Tracing Failed Jobs With Laravel Context
September 25, 2026 • 6 min read
A support ticket comes in the next morning. The customer was charged for order #1042 but never got a confirmation email, and wants to know if the order went through. You check the log from around the time of the order and find this:
[2026-09-25 02:07:22] local.ERROR: Connection could not be established with host "127.0.0.1:2525": stream_socket_client(): Unable to connect to 127.0.0.1:2525 (Connection refused)That might be their email, or one of the other confirmations that failed while the mail relay was down. A queue worker wrote the line after the checkout request had already finished, so nothing on it says which order it belongs to.
Laravel’s Context lets you attach the customer and the order to every log line, including the ones queue workers write. It arrived in Laravel 11 following a pull request from @timacdonald. Everything below comes from a real run: two customers checking out, a separate queue:work process handling the jobs, and a mail relay that wasn’t accepting connections.
Tag the request once
final class AddRequestContext
{
public function handle(Request $request, Closure $next): Response
{
Context::add('trace_id', Str::uuid()->toString());
Context::add('user_id', $request->user()?->id);
Context::add('running_via', 'http');
return $next($request);
}
}Whatever you add() is appended to every log entry written for the rest of the request, without you passing it to Log::info() yourself.
Add what you learn along the way
The customer won’t quote a trace id at you. They’ll quote their order number, and that doesn’t exist until the checkout creates the order. So add it the moment it does:
public function store(CheckoutRequest $request): RedirectResponse
{
// create the order and take payment...
Context::add('order_id', $order->id);
Log::info('Order placed.');
ProcessOrder::dispatch($order);
return to_route('orders.show', $order);
}ProcessOrder reserves the stock and then sends the confirmation:
final class ProcessOrder implements ShouldQueue
{
use Queueable;
public function __construct(public Order $order) {}
public function handle(): void
{
Log::info('Processing order.');
// reserve stock, notify the warehouse...
Mail::to($this->order->user)->send(new OrderConfirmation($this->order));
}
}OrderConfirmation is an ordinary mailable that implements ShouldQueue, so send() puts it on the queue instead of sending it there and then. It doesn’t need any changes for this. Context is attached to every queued payload, including the ones Laravel creates for queued mailables and notifications.
The job still gets the order passed in, because it needs it to do the work. The order_id in context is there for the logs, and it’s the id rather than the model on purpose: everything in context is serialised into every job queued while it’s set, and the pull request that introduced Context recommends against putting Eloquent models in it.
Find the order, then everything else
The worker handled both customers’ jobs one after the other, so the raw log interleaves them. Search for the order number instead (timestamps and ids shortened to fit):
$ grep '"order_id":1042' storage/logs/laravel.log
[02:07:22] local.INFO: Order placed. {"trace_id":"5f8d3ab5-...","user_id":42,"running_via":"http","order_id":1042}
[02:07:22] local.INFO: Processing order. {"trace_id":"5f8d3ab5-...","user_id":42,"running_via":"queue","order_id":1042}
"} {"trace_id":"5f8d3ab5-...","user_id":42,"running_via":"queue","order_id":1042}The first line is the web request and the second was written by the worker, in a different process. The third is the failure from OrderConfirmation, which was queued from inside ProcessOrder and still carried the context, because the context was active when it was queued.
With the default line format, the context is written after the exception’s stack trace, so grep matches the end of that entry. The error message is directly above it.
In production you’d search wherever your logs end up rather than grep a file. Context is written as Monolog’s extra data, so a log service that stores JSON gets trace_id and order_id as fields you can filter on. Error trackers may need a line of setup, and Tim’s pull request shows one for Sentry.
Anything the checkout logged before the order existed, like a declined card, has no order_id but shares the trace.
running_via switched from http to queue because of a hydrated callback, which runs on the worker right after a job’s context is restored. It only overwrites that one key, and the docs ask you to use the $context it’s given rather than the facade:
Context::hydrated(function (Repository $context): void {
$context->add('running_via', 'queue');
});Fix it and retry
The failed confirmations ended up in failed_jobs. The context is serialised into a job’s payload when it’s queued, so the failed rows still have it:
$ select json_unquote(json_extract(payload, '$.displayName')),
json_unquote(json_extract(payload, '$."illuminate:log:context".data.trace_id')),
json_unquote(json_extract(payload, '$."illuminate:log:context".data.order_id'))
from failed_jobs;
App\Mail\OrderConfirmation s:36:"5f8d3ab5-..."; i:1042;
App\Mail\OrderConfirmation s:36:"ec830500-..."; i:1043;The failed job isn’t one you wrote. It’s the queued mailable, and the trace and order id came with it.
My first php artisan queue:retry all ran before the relay was back up, so it failed again. The second failure was logged under the same trace and order as the first:
"} {"trace_id":"5f8d3ab5-...","user_id":42,"running_via":"queue","order_id":1042}Once the relay was accepting connections again, the next retry sent both emails and emptied failed_jobs.
A scheduled command doesn’t start clean
Every web request starts with an empty context. A scheduled command that loops over records doesn’t get that. It’s one long process, and whatever you add for one record is still there when you reach the next:
Context::add('trace_id', Str::uuid()->toString());
Context::add('running_via', 'cli');
foreach (Invoice::query()->overdue()->with('user')->lazy() as $invoice) {
Context::add('invoice_id', $invoice->id);
Context::add('user_id', $invoice->user_id);
if ($invoice->reminders_sent >= 2) {
Context::add('final_notice', true);
}
Log::info('Sending overdue reminder.');
$invoice->user->notify(new InvoiceOverdue($invoice));
}
Log::info('Finished overdue reminders.');Invoice 5531 has already had two reminders, so this one is a final notice. Invoice 5532 is a day overdue and getting its first (trace ids removed):
Sending overdue reminder. {"running_via":"cli","invoice_id":5531,"user_id":42,"final_notice":true}
Sending overdue reminder. {"running_via":"cli","invoice_id":5532,"user_id":7,"final_notice":true}
Finished overdue reminders. {"running_via":"cli","invoice_id":5532,"user_id":7,"final_notice":true}invoice_id and user_id get overwritten on every pass, so they look right. final_notice is only set for some invoices, so nothing ever clears it. InvoiceOverdue is a queued notification, and it picked the value up too. I ran the worker with the log mailer, which writes each email into the log along with its context (subject and context shown):
Invoice #5531 is overdue {"running_via":"queue","invoice_id":5531,"user_id":42,"final_notice":true}
Invoice #5532 is overdue {"running_via":"queue","invoice_id":5532,"user_id":7,"final_notice":true}A customer one day late is on record as having received a final notice, in the command’s log and on the email itself.
Context::scope(), added in Laravel 12.1, runs a callback with extra context and puts everything back the way it was when the callback returns:
foreach (Invoice::query()->overdue()->with('user')->lazy() as $invoice) {
Context::scope(function () use ($invoice): void {
if ($invoice->reminders_sent >= 2) {
Context::add('final_notice', true);
}
Log::info('Sending overdue reminder.');
$invoice->user->notify(new InvoiceOverdue($invoice));
}, data: ['invoice_id' => $invoice->id, 'user_id' => $invoice->user_id]);
}Sending overdue reminder. {"running_via":"cli","invoice_id":5531,"user_id":42,"final_notice":true}
Sending overdue reminder. {"running_via":"cli","invoice_id":5532,"user_id":7}
Finished overdue reminders. {"running_via":"cli"}
Invoice #5531 is overdue {"running_via":"queue","invoice_id":5531,"user_id":42,"final_notice":true}
Invoice #5532 is overdue {"running_via":"queue","invoice_id":5532,"user_id":7}Each notification now carries only the context it was sent with. The callback runs inside a try/finally, so the context is restored even if it throws. What scope() doesn’t roll back is a change made to an object that was already in context, because it restores which values are stored rather than what’s inside them.