Queue-SQL: Run Mass Deletes and Updates Across Parallel Laravel Queue Jobs
Laravel Composer Pacakge #Laravel #Queue #Database #Performance #Packages

Queue-SQL: Run Mass Deletes and Updates Across Parallel Laravel Queue Jobs

2 min read Mohamed Said Mohamed Said

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:

  1. Calculates the minimum and maximum primary key values for your query.
  2. Divides that ID range into bounded slices.
  3. Dispatches a Bus\Batch where 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.
  • throttle prevents 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

Found this useful?

Frequently Asked Questions

3 questions
Q01 Are Queue-SQL update and delete jobs safe to retry?
Yes. Each UPDATE and DELETE job targets a fixed slice of primary key IDs, so retrying a job executes against the same row range, making those operations idempotent. Bulk insert jobs do not share this property—retries can create duplicate rows, so you should use a unique index or upsert() for insert operations that require retry safety.
Q02 What happens if my table does not have an integer primary key?
Queue-SQL relies on an incrementing integer primary key to calculate key ranges and divide work across jobs. Tables without an integer primary key fall back to running the entire query in a single queued job rather than splitting it into parallel slices.
Q03 How can I preview how Queue-SQL will batch my query before dispatching it?
Replace dispatch() with dryRun() on your query chain. This returns a breakdown including the operation type, target table, estimated job count, and estimated row count without queuing any jobs.

Continue reading

More Articles

View all