laravel-chores maintained by amrlotfy
Laravel Chores
Batched, checkpointed, resumable data operations for Laravel.
Every Laravel app eventually needs to run something over a big table: backfill a new column, normalize legacy phone numbers, anonymize expired records. You write a quick loop, run it on the server... and then a deploy kills it at row 180,000 of 340,000 — with no idea where it stopped, whether re-running is safe, or which rows failed along the way.
Chores solves the plumbing once. You write only the per-record logic — batching, progress, checkpointing, safe resume, failure isolation, pause, and retry are handled for you. No Redis, no queue workers, no external services: state lives in your database, which makes it a natural fit for on-prem and air-gapped deployments too.
class NormalizePhoneNumbers extends Chore
{
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()]);
}
}
$ php artisan chore:run NormalizePhoneNumbers
187240/341882 [▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░] 54%
Ctrl+C it. Deploy over it. Reboot the server. Then run the same command again — it resumes from the last checkpoint, and no row is processed twice.
Installation
composer require amrlotfy/laravel-chores
php artisan vendor:publish --tag=chores-migrations
php artisan migrate
Requires PHP 8.2+ and Laravel 12 or 13. Works on MySQL, PostgreSQL, and SQLite.
Quick start
Scaffold a chore:
php artisan make:chore BackfillInvoiceTotals
Fill in the two methods:
namespace App\Chores;
use AmrLotfy\Chores\Chore;
use App\Models\Invoice;
use Illuminate\Contracts\Database\Eloquent\Builder;
class BackfillInvoiceTotals extends Chore
{
// Optional tuning
public int $batchSize = 500;
public int $sleepBetweenBatches = 0; // seconds; throttle if the DB needs breathing room
/** Which records still need work. */
public function collection(): Builder
{
return Invoice::whereNull('total_cached');
}
/** Handle ONE record. Throwing marks it failed — the run continues. */
public function process($record): void
{
$record->update(['total_cached' => $record->lines()->sum('amount')]);
}
}
Run it:
php artisan chore:run BackfillInvoiceTotals
For recurring chores (retention purges, cleanups), compose with Laravel's scheduler:
$schedule->command('chore:run AnonymizeExpiredAppointments')->monthly();
The guarantee, stated honestly
- The runner iterates by keyset pagination (
WHERE id > cursor), so it is immune to the classic bug where processing rows out of your ownWHEREclause makes offset-based chunking skip records. - Progress is checkpointed to the database after every batch. Kill the process any way you like — worst case, the rows of the single in-flight batch are re-examined on resume.
- That means: exactly-once processing beyond the current batch, at-least-once within it. Write
process()so that handling the same record twice is harmless (most update-style operations already are), and you're fully covered. - A record that throws is logged to
chore_failuresand never blocks the run. Retry just the failures later withchore:retry.
Commands
| Command | What it does |
|---|---|
make:chore {name} |
Scaffold a chore class in app/Chores |
chore:run {name} |
Run a chore — or resume its open run — with live progress |
chore:list |
Available chores + recent run history |
chore:pause {name} |
Pause a running chore at its next batch boundary (from another terminal) |
chore:failures {name} |
List failed records with their exceptions |
chore:retry {name} |
Re-process only the failed records |
chore:run and chore:failures accept --json for machine-readable output — handy in CI pipelines and for AI coding agents. Exit codes: 0 clean, 1 completed with failures, 2 fatal.
Configuration
php artisan vendor:publish --tag=chores-config
| Key | Default | Meaning |
|---|---|---|
path |
app_path('Chores') |
Directory scanned for chore classes |
namespace |
App\Chores |
Namespace matching that directory |
default_batch_size |
500 |
Used when a chore doesn't set $batchSize |
table_names |
chore_runs / chore_failures |
Bookkeeping tables — rename before running the migration |
Limitations (v1)
- Iteration requires an orderable primary key: auto-increment integers and ULIDs work; random UUIDv4 keys are not supported.
- Runs execute in the foreground artisan process (run inside
tmux/screen, or via the scheduler). Queue-based execution is on the roadmap. - One worker per chore — no parallel processing yet.
Roadmap
- Queued execution mode for very long runs
- Self-hosted web dashboard (runs, progress, failures, retry button)
- Parallel workers with range partitioning
--dry-runmode
Testing
composer test
The suite covers the guarantees above directly: crash-resume with exactly-once assertions, shrinking-predicate immunity, failure isolation, pause/SIGTERM at batch boundaries, ULID cursors, and counter-only mode.
Credits & license
Built by Amr Lotfy Saleh.
Inspired by Shopify's excellent maintenance_tasks for Rails.
MIT — see LICENSE.md.