A backfill over ten million rows is fine until a deploy restarts the server four hours in, and now you're guessing which rows were already touched. Laravel Chores, by Amr Lotfy Saleh, wraps that kind of one-off data operation in batches that checkpoint progress to the database after every batch, so an interrupted run picks up where it stopped instead of starting over. The design takes its cue from Shopify's maintenance_tasks gem for Rails.
Here's what the package gives you:
- Checkpointed progress — the last processed ID is written to a
chore_runstable after each batch, so a crash or Ctrl+C loses at most one batch of progress - Keyset pagination — batches page by primary key rather than offset, which sidesteps the classic bug where updating rows shifts them out of the chunk you're iterating
- Failure isolation — a record that throws is logged to a failures table and skipped, and the run keeps going
- No extra infrastructure — state lives in your database, with no Redis, queue workers, or external services required
- Six Artisan commands — scaffold, run, list, pause, inspect failures, and retry
- CI-friendly output — a JSON output mode and distinct exit codes:
0for clean,1for completed with failures,2for fatal
Writing a Chore
A chore is a class with two methods: collection() returns the query for the records to touch, and process() handles one record. Scaffold one with php artisan make:chore:
namespace App\Chores; use AmrLotfy\Chores\Chore;use App\Models\User;use Illuminate\Contracts\Database\Eloquent\Builder; class NormalizePhoneNumbers extends Chore{ public int $batchSize = 500; public function collection(): Builder { return User::whereNotNull('phone') ->where('phone', 'not like', '+%'); } public function process($record): void { $record->update([ 'phone' => PhoneNumber::parse($record->phone, 'EG')->toE164(), ]); }}
That's the whole class. Batching, progress tracking, and failure logging happen around it, and the batch size defaults to 500 via the config file when you don't set it on the class.
Running, Pausing, and Resuming
chore:run executes the chore with a live progress display in the terminal:
php artisan chore:run NormalizePhoneNumbers
Progress checkpoints after each batch, so the same command resumes a run that was cut off by a deploy, a crash, or Ctrl+C. chore:pause stops a running chore at the next batch boundary, and chore:list shows the available chores along with their run history.
One caveat on the guarantees: the checkpoint is per batch, not per record, so records inside an in-flight batch can be re-examined after a resume. Write process() to be idempotent where you can — the phone number example above is safe because already-normalized numbers no longer match the collection() query.
For recurring work like purging expired records, compose the command with Laravel's task scheduler:
$schedule->command('chore:run PurgeExpiredRecords')->monthly();
Handling Failures
A record that throws an exception doesn't stop the run. The package logs the record and the exception to a failures table, skips it, and moves on. When the run finishes, inspect and reprocess the failures as their own operation:
php artisan chore:failures NormalizePhoneNumbers php artisan chore:retry NormalizePhoneNumbers
That split matters on long runs: a hundred malformed phone numbers out of ten million shouldn't kill a four-hour job, and retrying a hundred records after a fix is much cheaper than rerunning the whole thing.
Chores run in the foreground with a single worker per chore, and the collection needs an orderable primary key such as an auto-increment or ULID. Queued execution and parallel workers are on the roadmap; if you want to spread a mass update across queue workers today, Queue-SQL takes that approach.
Installation
Laravel Chores requires PHP 8.2+ and Laravel 12 or 13, and supports MySQL, PostgreSQL, and SQLite:
composer require amrlotfy/laravel-choresphp artisan vendor:publish --tag=chores-migrationsphp artisan migrate
The config file lets you change where chore classes live (app/Chores by default), the default batch size, the table names, and a sleep interval between batches for throttling load on a busy database.
You can find the source code, documentation, and roadmap on the Laravel Chores GitHub repository.