Looking to hire Laravel developers? Try LaraJobs

laravel-chores maintained by amrlotfy

Description
Batched, checkpointed, resumable data operations for Laravel. Write only the per-record logic.
Last update
2026/08/14 22:00 (dev-main)
License
Links
Downloads
0

Comments
comments powered by Disqus

Laravel Chores

Tests Latest Version on Packagist License

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 own WHERE clause 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_failures and never blocks the run. Retry just the failures later with chore: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-run mode

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.