What a group costs
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:
<?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:
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:
> 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:
> $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:
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:
> 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:
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:
[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.
> 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:
> 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:
# fix the bug, then retry the failed head:
php artisan queue:retry all
> 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:
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:
> DB::table('failed_jobs')->count();
= 1
php artisan queue:flush
> 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:
<?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:
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:
> 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:
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:
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:
> strlen(DB::table('jobs')->value('payload'));
= 933
> 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'schainedproperty. 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:retrythe head resurrects them;queue:flushdiscards them. - A batch is a row in
job_batches. Each job carries only a batch id and decrements a shared counter;thenfires when it reaches zero. - The shared counter is locked. Decrementing goes through
SELECT ... FOR UPDATEso 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.
- 01 The minimal job
- 02 The model that never rode the queue
- 03 Beyond the minimal job
- 04 Where jobs go when they die
- 05 Queue the listener
- 06 The limits of patience
- 07 The door before the job
- 08 What a group costs
- 09 Workers in production