Laravel error
Laravel: Serialization of 'Closure' is not allowed
The error
Serialization of 'Closure' is not allowedThe queue writes your job to a row or a Redis key before a worker reads it back. A closure cannot survive that trip, and the exception names the closure rather than the property holding it.
The error
Exception
Serialization of 'Closure' is not allowed
Usually thrown from Illuminate\Queue\Queue::createObjectPayload, and usually
at the moment you dispatch rather than while the job is running.
What it means
Dispatching a job does not run it. It writes it down - into a database row, a
Redis key, an SQS message - so a separate process can read it back later and
reconstruct the object. That writing down is PHP's serialize(), and
serialize() cannot represent a closure. A closure is compiled code plus a
bound scope; there is no text form that survives a round trip through a
database column.
So the exception is not about the queue being fussy. It is the queue telling you that part of your job has no way to exist outside this process.
Finding it
The message names the type, not the property, which is why this error eats afternoons. Reproduce it where the stack trace is useful:
it('is serializable', function () {
serialize(new ProcessOrder($order, $options));
})->throwsNoExceptions();The failure now points at the line that built the object rather than at the framework internals, and it fails in CI instead of in production.
The usual sources
A closure passed as a constructor argument. The obvious case, and the rarest, because it is visible at the call site.
An object holding a closure. The common one. A configuration object with a
formatter callback, a query builder captured before dispatch, a PDF or Excel
exporter configured with a column mapper - none of them look like closures
from where you are standing.
// the closure is two levels down from the dispatch
$report = new ReportDefinition(columns: [
'total' => fn ($row) => $row->total / 100,
]);
ProcessReport::dispatch($report);A model with a closure attached. Anything registered on an instance rather than on the class - a local scope bound at runtime, a relation resolved with a callback - rides along when the model is serialized.
A container-resolved dependency. Injecting a service into a job's
constructor serializes that service and everything it holds, which can be a
whole graph including a client configured with a retry callback. Resolve it
inside handle() instead.
The fixes, in the order worth trying
Pass data, not behaviour. Send the arguments the closure needed and put the closure back on the other side. A job's constructor should take scalars and models; anything else deserves a moment's thought.
// before
ProcessReport::dispatch(new ReportDefinition(columns: [
'total' => fn ($row) => $row->total / 100,
]));
// after
ProcessReport::dispatch(reportId: $report->id, format: 'minor-units');Name the behaviour. Where the callback really is the point, replace it with a class name - a string that serializes fine and that you resolve when the job runs:
ProcessReport::dispatch($reportId, formatter: MinorUnitsFormatter::class);Rebuild it in handle(). Dependencies that come from the container do not
need to travel:
public function handle(PdfRenderer $renderer): void
{
// resolved on the worker, never serialized
}Drop the property, deliberately. __sleep(), or a #[\AllowDynamicProperties]-free
explicit list, will exclude it - but the property is null on the worker, and
that is a decision to write down rather than to discover.
The rule that prevents it
A queued job is a message, not a callback. If you can write the job's constructor arguments into a JSON object without losing anything, the queue can carry it. If you cannot, the queue is telling you something true about the design rather than being difficult.
A job that cannot be serialised and a job that runs twice teach the same thing from two sides: the queue carries a message, and a message can be delivered again. If the whole queue layer is what you cannot trust, we take it on as an engagement.
