The Problem with Mass Database Writes in Laravel
Running a single UPDATE or DELETE across millions of rows is a common source of production pain. Long-held database locks, replicas falling behind, and deployment timeouts are all symptoms of the same root cause: trying to do too much in one query.
Queue-SQL, a Laravel package by Kamran Atayev, solves this by splitting large write operations into parallel queued jobs backed by Laravel's native Illuminate\Bus\Batch.
How Queue-SQL Works
The package registers a queue() macro on both the Query Builder and Eloquent, making it available anywhere you'd normally write a query. When you call dispatch(), Queue-SQL:
- Calculates the minimum and maximum primary key values for your query.
- Divides that ID range into bounded slices.
- Dispatches a
Bus\Batchwhere each job targets one slice independently.
Because each job targets a fixed key range, retrying an UPDATE or DELETE job is idempotent—it operates on the same rows every time.
Queueing a Write Query
Chain queue() before your write method and call dispatch() at the end. Nothing is queued until dispatch() is invoked:
use App\Models\Order;
Order::where('status', 'complete')
->queue(chunk: 25000, tries: 2, onQueue: 'maintenance', throttle: 4)
->update(['status' => 'completed'])
->then(fn (Batch $batch) => Log::info("Backfill finished in {$batch->totalJobs} jobs"))
->catch(fn (Throwable $e) => report($e))
->dispatch();
The throttle argument caps the batch at a given number of jobs per second, preventing worker saturation and replication lag during large backfills.
Deletes follow the same pattern:
PersonalAccessToken::where('expires_at', '<', now()->subMonths(6))
->queue(chunk: 10000)
->delete()
->dispatch();
Bulk inserts skip key-range planning and divide the records array across jobs instead. Note that insert jobs are not idempotent—retries can create duplicates. Use a unique index or upsert() when retry safety matters.
Previewing the Execution Plan
Replace dispatch() with dryRun() to inspect how the query will be batched without queuing anything:
Order::where('status', 'complete')
->queue(chunk: 25000)
->update(['status' => 'completed'])
->dryRun();
// ['operation' => 'update', 'table' => 'orders', 'jobs' => 412, 'ranges' => 412, 'estimatedRows' => 9842311]
If you'd rather cap the total number of jobs instead of specifying rows per job, use maxJobs. Note that chunk and maxJobs are mutually exclusive—passing both throws an InvalidArgumentException.
Monitoring Batches from the CLI
Queue-SQL ships with Artisan commands that query the framework's batch table directly:
php artisan queue-sql:status # list all batches
php artisan queue-sql:status {batch} # inspect a single batch
php artisan queue-sql:cancel {batch} # cancel a running batch
php artisan queue-sql:status --watch # live refresh every 2 seconds
The --watch flag works in interactive terminals; non-interactive environments print once and exit.
Installation
Queue-SQL requires PHP 8.1+ and Laravel 10–13, with support for SQLite, MySQL, and PostgreSQL.
composer require kamranata/queue-sql
php artisan queue:batches-table
php artisan migrate
php artisan vendor:publish --tag=queue-sql-config # optional
Key Takeaways
- Parallel execution replaces a single long-running query with many small, bounded jobs.
- Key-range chunking makes UPDATE and DELETE jobs idempotent and safe to retry.
dryRun()lets you preview the batching plan before committing.throttleprevents database connection saturation during large backfills.- Tables without an integer primary key fall back to a single queued job.
- Rows inserted after
dispatch()are not included in the current batch. - A benchmark on SQLite showed a single un-batched DELETE held a lock 3.6× longer than Queue-SQL's longest lock duration.
Learn more and view the source on the queue-sql GitHub repository.
Source: Laravel News – Queue-SQL: Run Mass Deletes and Updates Across Parallel Queue Jobs