- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You ship a checkout flow. One click triggers six jobs: charge the card, reserve inventory, send the receipt, mint the invoice PDF, notify the warehouse, update the loyalty ledger. Friday at 4pm, one of those jobs fails on customer #8473. You open the queue UI. Six green ticks, one red one. No idea which checkout it belonged to. The user is asking on Twitter why their card was charged but no email arrived.
That's the problem both Laravel job batches and Symfony Messenger stamps solve. They attach metadata to a unit of work so when something blows up you can answer: which logical operation did this belong to, and what else was happening around it? Two frameworks, two completely different shapes, four patterns that translate cleanly between them.
Both, in 60 seconds
Laravel's API is a builder. You shove a list of jobs at Bus::batch() and get back a batch object with an ID:
use Illuminate\Support\Facades\Bus;
$batch = Bus::batch([
new ChargeCard($orderId),
new ReserveInventory($orderId),
new SendReceipt($orderId),
new MintInvoice($orderId),
])
->name("checkout-{$orderId}")
->allowFailures()
->dispatch();
// $batch->id is a uuid you can pass around
The batch is a first-class entity. Laravel stores it in job_batches, tracks pending/failed counts, lets you query it from anywhere via Bus::findBatch($id).
Symfony Messenger is the opposite shape. There is no batch. There is one envelope per message, and you stick stamps on it:
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
$bus->dispatch(new Envelope(new ChargeCard($orderId), [
new CorrelationStamp($correlationId),
new BusNameStamp('checkout'),
]));
A stamp is just a value object marker. Some come from the framework (DelayStamp, TransportMessageIdStamp, HandledStamp). The rest you write yourself.
LARAVEL: [Batch { id, jobs[] }] ← group is the thing
SYMFONY: [Envelope { msg, stamps[] }] × N ← stamps decorate each
Laravel encapsulates the group. Symfony decorates the individual. Same problems, opposite philosophies.
Pattern 1: Correlation IDs through fan-out
In Laravel, the batch ID is the correlation ID. Inject the batch into each job and the relationship is implicit:
class ChargeCard implements ShouldQueue
{
use Batchable, Queueable;
public function __construct(public string $orderId) {}
public function handle(PaymentGateway $gw): void
{
Log::withContext(['batch_id' => $this->batch()->id])
->info('charging card', ['order' => $this->orderId]);
$gw->charge($this->orderId);
}
}
Every log line from every job in the batch gets stamped with the same batch_id. Grep that ID in your log aggregator and you see the whole checkout.
Symfony needs you to build it. Write a stamp:
namespace App\Messenger\Stamp;
use Symfony\Component\Messenger\Stamp\StampInterface;
final class CorrelationStamp implements StampInterface
{
public function __construct(public readonly string $id) {}
}
Then a middleware that pulls it out and pushes it into the logger context:
namespace App\Messenger\Middleware;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
use Symfony\Component\Messenger\Middleware\StackInterface;
final class CorrelationMiddleware implements MiddlewareInterface
{
public function __construct(private LoggerInterface $logger) {}
public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
$stamp = $envelope->last(CorrelationStamp::class);
if ($stamp === null) {
return $stack->next()->handle($envelope, $stack);
}
// a monolog processor does the same thing more globally;
// this middleware shape stays handy when the stamp value
// needs to drive something other than logging.
return $stack->next()->handle($envelope, $stack);
}
}
Wire it through config/packages/messenger.yaml:
framework:
messenger:
buses:
messenger.bus.default:
middleware:
- 'App\Messenger\Middleware\CorrelationMiddleware'
More moving parts. The payoff is that stamps survive across buses, across transports, across re-dispatching. If ChargeCard's handler dispatches a follow-up WriteLedgerEntry message, you copy the stamp across:
public function __invoke(ChargeCard $msg, MessageBusInterface $bus): void
{
// ... charge the card ...
// propagate correlation to downstream messages
$bus->dispatch(new Envelope(new WriteLedgerEntry($msg->orderId), [
new CorrelationStamp($this->currentCorrelationId()),
]));
}
Laravel batches don't chain naturally. A job inside a batch can dispatch another job, but that new job isn't in the batch unless you call $this->batch()->add(...) explicitly, and even then the batch ID is what propagates, not arbitrary metadata.
Stamps win this one on flexibility. Batches win on ergonomics for the simple case.
Pattern 2: Retry budget per logical operation
Default queue retries are per-job. Three jobs in a batch with tries=3 each means up to nine attempts for one checkout. That's usually wrong. If the payment gateway is having a bad day, you want the whole checkout to give up, not keep hammering.
Laravel: implement a custom retry counter on the batch itself.
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Cache;
class RetryBudget
{
public function __construct(private int $max = 5) {}
public function consume(Batch $batch): bool
{
$key = "batch-retries:{$batch->id}";
$count = Cache::increment($key, 1);
// expire after 1h so we don't leak keys
Cache::put($key, $count, now()->addHour());
return $count <= $this->max;
}
}
Call it from the job's failed() hook:
class ChargeCard implements ShouldQueue
{
use Batchable, Queueable;
public int $tries = 1; // we manage retries at the batch level
public function failed(\Throwable $e, RetryBudget $budget): void
{
if (! $this->batch() || ! $budget->consume($this->batch())) {
$this->batch()?->cancel();
return;
}
// re-dispatch with a backoff
ChargeCard::dispatch($this->orderId)
->onQueue($this->queue)
->delay(now()->addSeconds(30));
}
}
Symfony: stamp the envelope with the remaining budget. The framework already has RedeliveryStamp tracking individual retries, so you ride alongside it.
final class RetryBudgetStamp implements StampInterface
{
public function __construct(public readonly int $remaining) {}
}
In config/packages/messenger.yaml, set the per-message retry limit to 1 so the framework doesn't double-count:
framework:
messenger:
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 1
Then a middleware that decrements the stamp and re-dispatches with the new value:
final class RetryBudgetMiddleware implements MiddlewareInterface
{
public function __construct(private MessageBusInterface $bus) {}
public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
try {
return $stack->next()->handle($envelope, $stack);
} catch (\Throwable $e) {
$budget = $envelope->last(RetryBudgetStamp::class);
if ($budget === null || $budget->remaining <= 0) {
throw $e; // give up; goes to failure transport
}
// strip the old stamp, add the new one
$next = $envelope
->withoutAll(RetryBudgetStamp::class)
->with(new RetryBudgetStamp($budget->remaining - 1));
$this->bus->dispatch($next);
return $envelope; // swallowed; we've re-queued
}
}
}
Gotcha: the order of middleware matters. RetryBudgetMiddleware has to run inside the failure-handling middleware, not outside it. Otherwise Symfony's built-in retry strategy fires first and you double-retry. Wire it explicitly above send_message in the middleware list.
framework:
messenger:
buses:
messenger.bus.default:
middleware:
- 'App\Messenger\Middleware\RetryBudgetMiddleware'
- 'App\Messenger\Middleware\CorrelationMiddleware'
A team adding middleware via service tags without setting priority will end up with it in the wrong slot. The result is nine retries instead of three.
Pattern 3: Progress reporting
Laravel does this out of the box. The batch tracks pendingJobs, processedJobs, failedJobs, and you query it:
$batch = Bus::findBatch($batchId);
return [
'total' => $batch->totalJobs,
'done' => $batch->processedJobs(),
'failed' => $batch->failedJobs,
'finished' => $batch->finished(),
];
Front-end polls /api/checkout/{batch_id}/status every 2 seconds. Done.
Symfony has nothing equivalent. There's no "group" concept to track against. You build it: a BatchStamp carrying a batch ID, plus a handler that writes progress to Redis on each completion:
final class BatchProgressMiddleware implements MiddlewareInterface
{
public function __construct(private \Redis $redis) {}
public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
$batch = $envelope->last(BatchStamp::class);
if ($batch === null) {
return $stack->next()->handle($envelope, $stack);
}
$result = $stack->next()->handle($envelope, $stack);
// only count successful handler completions
$handled = $result->all(HandledStamp::class);
if (count($handled) > 0) {
$this->redis->hIncrBy("batch:{$batch->id}", 'done', 1);
}
return $result;
}
}
When dispatching, set the total up front:
$redis->hMSet("batch:{$batchId}", ['total' => 6, 'done' => 0, 'failed' => 0]);
$redis->expire("batch:{$batchId}", 3600);
foreach ($messages as $m) {
$bus->dispatch(new Envelope($m, [new BatchStamp($batchId)]));
}
This is the place where stamps feel like more work for less function. Laravel's batch table gives you the same data without writing it. If progress reporting is core to your product, the Laravel shape is easier.
Pattern 4: Cancellation
You've charged the card. The fraud service flags the order. You want to cancel everything still pending (invoice generation, warehouse notification, loyalty update) without cancelling jobs that already ran.
Laravel: $batch->cancel(). Pending jobs check $this->batch()->cancelled() before running:
public function handle(): void
{
if ($this->batch()->cancelled()) {
return;
}
// ... actual work ...
}
Anything already in-flight finishes. Anything still queued sees the cancelled flag and exits early. Anything not yet picked up by a worker gets skipped.
Symfony: no native cancellation. You build it with a stamp + middleware that checks a kill-switch:
final class CancellableStamp implements StampInterface
{
public function __construct(public readonly string $groupId) {}
}
final class CancellationMiddleware implements MiddlewareInterface
{
public function __construct(private \Redis $redis) {}
public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
$stamp = $envelope->last(CancellableStamp::class);
if ($stamp !== null && $this->redis->sIsMember('cancelled-groups', $stamp->groupId)) {
// drop silently; could also dispatch a CancelledEvent
return $envelope;
}
return $stack->next()->handle($envelope, $stack);
}
}
Cancel by adding the group ID to the Redis set:
$redis->sAdd('cancelled-groups', $checkoutId);
$redis->expire('cancelled-groups', 86400);
Same semantic, more code. The advantage: stamps compose. Add an AuditStamp that writes the cancellation reason, and now every dropped message logs why without touching the cancellation middleware. Batches make you crack open the framework's job_batches table to add a cancelled_reason column.
Which abstraction is cleaner
Stamps win on composition. A stamp is a tagged value type. You can write five orthogonal stamps and combine them on one envelope. Each middleware reads exactly what it needs and ignores the rest. The mental model is closer to HTTP middleware than to Laravel's job lifecycle hooks.
Batches win on the simple case. Bus::batch([...])->dispatch() and a status endpoint give you 80% of the value with no setup. Symfony makes you build that 80% yourself before you get to the interesting 20%.
The pattern that holds: stamps are decorators, batches are aggregates. If your problem is "I have a group of work and I need to track the group," batches fit. If your problem is "I want to attach orthogonal metadata to individual units," stamps fit. Most real systems have both problems and you end up faking the missing half in whichever framework you picked.
Stealing patterns across the fence
You can do Bus::batch() semantics in Symfony. Wrap the four patterns above into a BatchEnvelopeFactory that adds BatchStamp, CorrelationStamp, CancellableStamp, RetryBudgetStamp in one call. Now you have a Symfony-flavoured batch.
You can do stamp semantics in Laravel. Middleware hooks via the Bus::pipeThrough([...]) API let you write middleware that reads from per-job metadata. Pass a metadata array into your job constructor and read it from a middleware that runs on every dispatch. You've just rebuilt stamps on top of Laravel.
The pattern transfers. The ergonomic cost is real. Symfony's stamp model is more powerful but ships less out of the box. Laravel's batch model is easier on day one but gets awkward when you need anything beyond "group of jobs with a progress bar."
Pick the framework first. Then pick the abstraction that fits the problem. And when you need the other framework's abstraction, build it. Both ports take less code than you think.
Which one have you reached for in production: batches, stamps, or something you rolled yourself? What's the case that broke your chosen abstraction?
If this was useful
This post is about the seam between framework infrastructure and your domain. If you've felt the friction of batches and stamps leaking into your business logic, Decoupled PHP is the architectural layer your codebase reaches for after it outgrows the framework defaults. It keeps checkout flows, payment logic, and order lifecycles out of Laravel jobs and Symfony handlers so the queue stays a transport detail.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)