Skip to content

Introduction

Laravel gives you schema migrations for structure and queues for background work. It gives you nothing for data backfills — filling a new column across 8M rows, recalculating values that were computed wrong, migrating a boolean into a roles table.

So every team hand-rolls the same chunk() loop. And that loop is wrong, in ways that only show up in production, on a table large enough that nobody notices for a week.

Why not just a migration?

This is the first question worth answering, because it is the first one everybody asks.

Migrations run synchronously during deploy, usually inside a transaction. Putting a multi-million row UPDATE there means:

  • The deploy pipeline blocks until the data change finishes. A four-hour backfill is a four-hour deploy.
  • A long transaction holds locks the whole time, and your statement timeout eventually kills it half way through.
  • If it dies, there is no cursor. The next attempt starts from the beginning, redoing everything it already did.
  • There is nowhere to put "skip this row and carry on", so one bad row rolls back the lot.

This package refuses to run inside a migration for exactly those reasons. Schema changes belong in migrations; data changes belong in something that can be paused, resumed, throttled and watched.

Why not just chunk()?

Because chunk() pages with LIMIT/OFFSET, and a backfill usually makes rows stop matching its own query.

php
// The bug, in five lines.
User::whereNull('slug')->chunk(1000, function ($users) {
    foreach ($users as $user) {
        $user->update(['slug' => Str::slug($user->name)]);
    }
});

Batch one takes rows at offset 0–999 and gives them all a slug. Those thousand rows now fail whereNull('slug'), so they leave the result set. Batch two asks for offset 1000–1999 of a result set that just shrank by a thousand — and skips a thousand rows that were shifted down into the gap.

Roughly half the table is silently missed. No error, no warning, and a count() afterwards that looks plausible enough to move on from.

Keyset pagination is the fix, and this package will not let you do it any other way.

The one invariant

Everything here is downstream of one sentence:

A backfill can be killed at any instant, restarted, and must arrive at the same end state — no duplicated side effects, no skipped rows.

When a design tradeoff comes up, that sentence is the tiebreaker. It is why the cursor is written after the work rather than before, why each row gets its own savepoint, why the run lock is a unique index rather than a cache key, and why ledger mode fails toward "missed a row" rather than "sent it twice".

It is also load-bearing in the test suite: the chaos test forks a process and has the child SIGKILL itself mid-batch, then asserts the resumed run matches an uninterrupted one exactly. Read more about the invariant.

What you get

Requirements

PHP8.2+
Laravel11, 12 or 13
DatabasesMySQL 8 / MariaDB, PostgreSQL 13+, SQLite
Optionallivewire/livewire for the dashboards, laravel/pulse for the card

Ready? Install it.

Released under the MIT Licence.