A tenant-provisioning flow grew organically: create the schema, seed the defaults, warm a few caches, sync the billing record, send the welcome email. Eight jobs in one Bus::chain, each taking a model or two. Then it started failing on SQS with a message about size, and on Redis the queue memory climbed faster than the number of jobs could explain.

The chain is the reason, and it is worth knowing exactly why.

A chain is stored inside its first job

When you build a chain, Laravel does not create eight independent messages. It serializes every job after the first and stores those serialized strings on the first job:

Bus::chain([
    new CreateSchema($tenant),      // this one goes on the queue
    new SeedDefaults($tenant),      //  serialized inside it
    new SyncBilling($tenant),       //  serialized inside it
    new SendWelcomeEmail($tenant),  //  serialized inside it
])->dispatch();

Only CreateSchema is pushed. Its payload contains the serialized form of the other three. When it finishes, the worker unpacks the next one, pushes it carrying the remaining two, and so on.

So the messages shrink as the chain progresses, and the very first message is the largest thing you will ever put on that queue.

Why it bites

SQS has a hard limit. A single message cannot exceed 256KB. A chain of a dozen jobs, each holding a couple of models and some arrays, gets there more easily than you would guess, and the failure arrives at dispatch rather than at run time.

Redis just quietly grows. There is no limit to hit, so instead the queue uses more memory than expected. If you dispatch thousands of these, the first-job payloads dominate your Redis footprint.

Everything is serialized twice over. Each job in the chain is serialized when the chain is built, and the remainder is re-serialized at every step. Long chains of fat jobs make the queue do avoidable work.

Keep the constructor lean

The single biggest win is the usual one: pass identifiers, not objects. This matters more in a chain than anywhere else, because the cost is multiplied by the chain length.

// each job drags a hydrated model into every payload ahead of it
new SyncBilling($tenant, $tenant->subscription, $tenant->owner)

// far lighter, and the worker loads what it needs
new SyncBilling($tenant->id)

SerializesModels already stores only the class and key for models, which helps, but arrays of data, collections, and DTOs are stored in full. Those are what push a chain over a limit.

Do not chain what does not need ordering

A chain exists to express dependency. If four of your eight steps are independent, they do not belong in the chain at all. Put the ordered work in the chain and let the rest run as a batch, which dispatches each job as its own message with no nesting.

Build the chain as you go

For genuinely long sequences, a job can add the next step when it finishes rather than the whole sequence being packed up front:

public function handle(): void
{
    // ... do this step

    $this->appendToChain(new SyncBilling($this->tenantId));
}

Now each message carries only what is still ahead of it at that moment, and the first payload stays small. There is a matching prependToChain() when the new work has to happen before whatever is already queued.

Or just dispatch the next job

The oldest trick still works, and for a long pipeline it is often the clearest:

public function handle(): void
{
    // ... do this step

    SyncBilling::dispatch($this->tenantId);
}

You lose the chain's automatic stop-on-failure, so you take on that handling yourself. In exchange every message is small and independent, and the flow is obvious to read.

Wrapping up

A chain trades message count for message size: fewer, larger payloads with everything downstream nested inside the first one. Keep chain constructors down to identifiers, chain only the steps that genuinely depend on each other, and for long sequences either grow the chain with appendToChain() or have each job dispatch the next.

Related