Skip to content
LaravelBlog

framework #61816: [13.x] Fix `chunkById()` and `lazyById()` looping forever on models with a cast primary key

LaravelBlogBot
LaravelBlogBot

Contributed by xurshudyan

Part of the framework v13.35.0 release

Laravel Framework 13.x includes a small but important database-layer fix: when a model casts its primary key, chunkById() and lazyById() could loop forever because the pagination cursor was built from the cast value instead of the raw value stored in the column. Pull request #61816 makes keyset pagination re-bind the exact value the database returned, so pages always advance.

What Changed

In Illuminate\Database\Concerns\BuildsQueries, the two internals that page by ID — orderedChunkById() (backing chunkById(), chunkByIdDesc(), and eachById()) and orderedLazyById() (backing lazyById() and lazyByIdDesc()) — previously read the last ID like this:

$lastId = data_get($results->last(), $alias);
// or, in lazyById():
$lastId = $results->last()->{$alias};

Both now delegate to a new protected helper:

protected function getLastIdFromChunk($result, $alias)
{
    return $result instanceof Model
        ? $result->getRawOriginal($alias)
        : data_get($result, $alias);
}

When the chunked results are Eloquent models, the raw, as-stored attribute becomes the cursor. Plain query builder results (stdClass rows) still go through data_get() exactly as before, so non-Eloquent usage is untouched.

Why the Loop Never Ended

Consider the canonical setup from the AsBinary cast introduced with #58254:

class Session extends Model
{
    public $incrementing = false;
    protected $keyType = 'string';

    protected function casts(): array
    {
        return ['id' => AsBinary::uuid()]; // backed by a binary(16) column
    }
}

With this cast, $session->id is a 36-character UUID string in PHP, while the database stores 16 raw bytes. Keyset pagination works by repeatedly running where id > ? with the last ID of the previous page. Because the last result was a model, the attribute was read through the cast, so a formatted UUID string got bound against the binary column:

-- Before this fix
where "id" > '00000000-0000-0000-0000-000000000002'  -- 36 chars vs. 16 bytes

The comparison no longer lines up with the stored values. In SQLite, for instance, BLOBs always sort after TEXT, so every row matches the condition again and the same first page comes back. Either way, the cursor never advances and the loop spins indefinitely.

Because the fix reads getRawOriginal(), the next page is bound with the same bytes the previous query returned:

-- After this fix
where "id" > x'00000000000000000000000000000002'

The bug also reached beyond direct chunking: php artisan model:prune pages through prunable models by ID, so pruning a model with a cast key could hang or never finish.

Who Is Affected

  • Anyone upgrading from v13.34.0 who calls chunkById(), eachById(), or lazyById() on a model with a cast primary key — most visibly AsBinary::uuid(), but any cast that transforms the key: encrypted casts, custom cast classes, and similar.
  • Teams running model:prune against prunable models with cast keys.
  • Not affected: plain DB::table(...)->chunkById() calls and non-cast integer keys, which were already correct.

Upgrade Impact

This is a straight bug fix with no public API change:

  • No signatures changed. getLastIdFromChunk() is a new protected method on the trait; it only matters if you override orderedChunkById() or orderedLazyById() in a custom builder, in which case you should call the helper rather than data_get() yourself.
  • If you worked around the bug — a manual orderBy('id')->take() loop, chunking without the cast, or a raw query-builder pipeline — you can remove that workaround and return to idiomatic keyset pagination.
  • The existing guard is preserved: if the alias column is missing from the result, you still get the same RuntimeException.
  • One subtle behavioral note: bindings for model results are now the raw stored value. If you assert on query-log bindings in tests, expect raw bytes rather than cast values.

The PR ships with regression tests that build a SQLite table with a blob primary key and assert the second page binds the 16-byte value:

$this->assertSame(
    [[], [hex2bin(str_replace('-', '', $ids[1]))]],
    $this->pagedBindings($connection)
);

Takeaways

  • chunkById(), lazyById(), and eachById() now advance their cursor via the model's raw key attribute (getRawOriginal()) instead of the cast value.
  • Fixes infinite pagination loops for models with cast primary keys, such as 'id' => AsBinary::uuid().
  • php artisan model:prune benefits automatically, since it chunks prunable models by ID.
  • No code changes are required to upgrade from v13.34.0 — behavior is simply corrected.
  • Non-Eloquent (query builder) chunking keeps its previous data_get() behavior.

Sources

More from this release

Related Articles