>Dudenkoff_
← Lab / Laravel · Queues

What a group costs

Laravel 13 · PHP 8.5 · Postgres · July 10, 2026 · part 8 of 9

So far every job has been a soloist. It carried its own state, its own limits, its own doormen. Real work comes in groups. An order needs charging, then stock reserved, then a receipt, in that order, each step only if the last one worked. A nightly run needs a thousand orders processed and one report when they are all done. Laravel gives you two tools for that, chains and batches, and they look like siblings. They are built on opposite ideas, and the difference is the most useful thing in this article.

Start with the ordered one. Three jobs that must run in sequence:

app/Jobs (the three links)
<?php

class Charge implements ShouldQueue  { use Queueable; public function __construct(public int $orderId) {} /* ... */ }
class Reserve implements ShouldQueue { use Queueable; public function __construct(public int $orderId) {} /* ... */ }
class Receipt implements ShouldQueue { use Queueable; public function __construct(public int $orderId) {} /* ... */ }

You chain them with Bus::chain , and while you are there you hang a handler off the end in case something breaks:

dispatch the chain
Bus::chain([new Charge(42), new Reserve(42), new Receipt(42)])
    ->catch(fn ($e) => Log::error('chain failed'))
    ->dispatch();

Three jobs dispatched. Look at the queue:

tinker
> DB::table('jobs')->count();
= 1

One row. You dispatched three jobs and the queue has a single job in it. The other two are not late, not lost, not on another queue. They are inside the first one. Open the letter the way we always do and read what the head is carrying:

tinker
> $cmd = unserialize(json_decode(DB::table('jobs')->value('payload'))->data->command);
= App\Jobs\Charge {#7511
    +orderId: 42,
    +job: null,
    +connection: null,
    +queue: null,
    +messageGroup: null,
    +deduplicator: null,
    +debounceOwner: "",
    +delay: null,
    +afterCommit: null,
    +middleware: [],
    +chained: [
      "O:16:\"App\\Jobs\\Reserve\":1:{s:7:\"orderId\";i:42;}",
      "O:16:\"App\\Jobs\\Receipt\":1:{s:7:\"orderId\";i:42;}",
    ],
    +chainConnection: null,
    +chainQueue: null,
    +chainCatchCallbacks: [
      Laravel\SerializableClosure\SerializableClosure {#7513},
    ],
  }

There they are. In the head's chained property, Reserve and Receipt sit serialized, and just below them chainCatchCallbacks holds the catch() closure, wrapped in a SerializableClosure so it too can ride in the payload. A chain is not a structure the framework keeps somewhere; it is a job carrying the other jobs, error handler and all. Hold on to that picture, because it is the whole article: in a chain, the state of the group rides inside the jobs.

The baton#

Run one worker and watch the head go:

output
2026-07-10 13:19:10 App\Jobs\Charge .. RUNNING
2026-07-10 13:19:10 App\Jobs\Charge .. 12.28ms DONE

Charge finished, and the queue is not empty again. There is a new row. Open it:

tinker
> unserialize(json_decode(DB::table('jobs')->value('payload'))->data->command)->chained;
= [
    "O:16:\"App\\Jobs\\Receipt\":1:{s:7:\"orderId\";i:42;}",
  ]

The new head is Reserve, and its chained now holds only Receipt. When a chained job finishes, it takes the next one off its own chained list, hands it the rest, and dispatches it (Queueable::dispatchNextJobInChain). The chain moves the way a relay moves: each runner carries the baton to the next and drops out. Nobody is holding the whole race.

A corpse with a tail inside#

The baton only passes on success (the handoff runs after handle() , so a job that throws never reaches it). Make the middle link fail. Reserve has no #[Tries] , so the worker's default of one try means it dies on the first throw:

output
2026-07-10 13:19:10 App\Jobs\Reserve .. RUNNING
2026-07-10 13:19:10 App\Jobs\Reserve .. 14.46ms FAIL

The chain stopped there, and the catch() closure fired:

storage/logs/laravel.log
[2026-07-10 13:19:10] local.ERROR: chain failed

That handler was serialized into the head's payload at dispatch and handed down the chain link by link, the same way the jobs were. When Reserve died, it ran out of the dead job's own pocket. State travels with the work, and code is state too.

tinker
> DB::table('jobs')->count();
= 0
> DB::table('failed_jobs')->count();
= 1

Charge ran, Reserve failed, and Receipt never happened. But look for Receipt and it is nowhere obvious: the queue is empty, and there is one corpse, not two. Read the corpse's tail, and its tag:

tinker
> unserialize(json_decode(DB::table('failed_jobs')->value('payload'))->data->command)->chained;
= [
    "O:16:\"App\\Jobs\\Receipt\":1:{s:7:\"orderId\";i:42;}",
  ]
> DB::table('failed_jobs')->value('uuid');
= "9ef21490-bec3-4b9b-bfb2-756226877796"

Receipt is inside Reserve, where it was riding when Reserve died. The failed link went to failed_jobs with its tail still in its pocket. So the trace is there, but it is not Receipt's own. Receipt has no uuid , no row, no death certificate; the corpse has one, and it belongs to Reserve. A dashboard counting queued jobs shows one where you dispatched three. Horizon would show one failed job where a second never ran at all. The tail is not lost, it is buried, and it comes back the moment you exhume the head:

terminal
# fix the bug, then retry the failed head:
php artisan queue:retry all
tinker
> unserialize(json_decode(DB::table('jobs')->value('payload'))->data->command)->chained;
= [
    "O:16:\"App\\Jobs\\Receipt\":1:{s:7:\"orderId\";i:42;}",
  ]

Reserve is back in the queue, still carrying Receipt. Let it run and the chain finishes from where it broke:

output
reserved stock for order #42
receipt sent for order #42

Charge does not run again. There is no charged line: the retry picked the chain up at the broken link, carrying only its remaining tail, and finished from there. No double charge. Retrying one row resurrected the chain from where it stopped.

The opposite move is queue:flush . Break the chain once more to put the corpse back, then clear the failed table instead of retrying it:

tinker
> DB::table('failed_jobs')->count();
= 1
terminal
php artisan queue:flush
tinker
> DB::table('failed_jobs')->count();
= 0

One row deleted, and the buried Receipt went with it. Two jobs discarded by removing a single failed_jobs row.

How does a group know it is done?#

Here is a question the chain answers without breaking a sweat: how does the group know it has finished? The last link runs, looks at its own chained , finds it empty, and there is no one left to call. Done. A job answered a question about the group by looking only at itself, because the group's state was in its pocket the entire time.

Now change the shape. Not three steps of one order, but a thousand orders that have nothing to do with each other, each its own job, and you want to know when all thousand are through so you can send one summary email. Take five to keep it on screen:

app/Jobs/ProcessOrder.php
<?php

class ProcessOrder implements ShouldQueue
{
    use Batchable, Queueable;

    public function __construct(
        public int $orderId,
        public string $customerEmail,
        public array $items,
        public array $shippingAddress,
    ) {}

    public function handle(): void { /* ... */ }
}

Ask one of these the finishing question and it has no answer. No ProcessOrder knows whether the other four have run; their state is not in its payload, because they are not chained to it. No single ProcessOrder can answer that; it is a fact about all of them at once. Answering it needs something the chain never had: a place outside the jobs where the group is counted. That place is a batch.

The ledger#

Dispatch the five as a batch, with the finishing callback the chain could not give you:

dispatch the batch
Bus::batch([new ProcessOrder(1, ...), /* ... */ new ProcessOrder(5, ...)])
    ->then(fn (Batch $batch) => Log::info("BATCH THEN FIRED total={$batch->totalJobs}"))
    ->name('orders')
    ->dispatch();

This time the group has a home of its own, a row in job_batches with a counter:

tinker
> DB::table('job_batches')->value('pending_jobs');
= 5

Five jobs, one ledger, counting down. Each job, as it finishes, decrements that number; when it reaches zero, the batch fires then . Now run it under two workers at once, so they land on the last few jobs together, and log which process handled what:

output
processed order #1 by pid 1613867
processed order #2 by pid 1613866
processed order #3 by pid 1613867
processed order #4 by pid 1613866
processed order #5 by pid 1613867
BATCH THEN FIRED total=5

Two processes shared the work, and then fired once. Not twice, not zero times: once. That "once" is not luck. Two workers decrementing the same counter is a race, and Laravel closes it with a pessimistic lock. Turn on statement logging and Postgres shows the exact shape of it every time a job updates the ledger:

postgres log
select * from "job_batches" where "id" = $1 limit 1 for update

for update locks the batch row, so the two workers take turns on the counter and exactly one of them sees it hit zero. (That is a driver fact, not a framework one: Postgres and MySQL emit a real SELECT ... FOR UPDATE ; on SQLite the same call compiles to nothing, and a whole-file write lock stands in for it.) The batch keeps a ledger, and the ledger has to be locked. So the question the chain shrugged off costs the batch a row and a lock. Why does the batch have to pay when the chain did not?

Because the two groups are answering different questions. In a chain, "are we done?" is local: the last runner finds its own chained empty. Nothing is shared, so nothing needs locking. In a batch, the jobs are independent and none can see the others, so "are we done?" is a question about all of them at once, and that needs an answer kept somewhere they can all reach. The batch did not build that ledger for your progress bar. It built it for itself, to know when to call then ; the progress you can read off it is a byproduct of the bookkeeping it already needed.


Both tools remember a group, and neither does it for free. The chain pays in payload: the state rides with the work, so there is no ledger to lock, but also no progress to read, no row of its own for the tail, and a head that gets heavier with every link. Weigh a single ProcessOrder , then the head of a fifty-link chain of them:

tinker: one job
> strlen(DB::table('jobs')->value('payload'));
= 933
tinker: fifty, chained
> strlen(DB::table('jobs')->value('payload'));
= 28503

Nine hundred bytes becomes twenty-eight kilobytes, about five hundred and sixty a link, all of it riding in the head's one message. The batch pays in a lock: the state lives in a shared row, which gives you progress and a finishing signal, at the cost of every job queuing up for FOR UPDATE to touch it.

There is no free way to remember a group. The only question is which currency you spend, and it follows from the work: ordered and dependent, or independent and counted. Six lines worth keeping:

  • A chain is a job carrying the other jobs. The tail, and the catch() closure, are serialized into the head's chained property. No central record.
  • The baton passes on success only. Each link dispatches the next when it finishes; a link that throws stops the chain with the rest still riding inside it.
  • A broken chain buries its tail in the corpse. The failed link is in failed_jobs ; the jobs behind it have no row of their own. queue:retry the head resurrects them; queue:flush discards them.
  • A batch is a row in job_batches . Each job carries only a batch id and decrements a shared counter; then fires when it reaches zero.
  • The shared counter is locked. Decrementing goes through SELECT ... FOR UPDATE so two workers cannot both fire the callback (a real lock on Postgres and MySQL, a no-op on SQLite).
  • You always pay to remember a group. A chain pays in a fattening payload; a batch pays in a contended lock. The work decides the currency.

That lock on the batch row is not the only FOR UPDATE in play. On the database driver, the queue reserves jobs with a lock too, but with two extra words: FOR UPDATE SKIP LOCKED . A worker that meets a row another worker already holds steps over it and takes the next one; the workers fan out across the queue instead of waiting. The batch row cannot do that. It is a single row, so there is nothing to skip, and it is the one lock in this article where workers actually wait their turn. Why the jobs table gets to skip and the batch row cannot is where workers in production begin. That is next.


Next
Workers in production
The series
  1. 01 The minimal job
  2. 02 The model that never rode the queue
  3. 03 Beyond the minimal job
  4. 04 Where jobs go when they die
  5. 05 Queue the listener
  6. 06 The limits of patience
  7. 07 The door before the job
  8. 08 What a group costs
  9. 09 Workers in production