Tracing Failed Jobs with Laravel Context
September 25, 2026 • 4 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. Laravel 11 added it in a pull request from @timacdonald.
Let’s add both to every log line, and then find this failure again.
Adding context to every request
Start with a middleware that puts a trace id and the logged-in user into the context:
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Context;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
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);
}
}Then add it to the web middleware group in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: [AddRequestContext::class]);
})Whatever you add() is appended to every log entry written for the rest of the request. You don’t pass it to Log::info() yourself.
Adding the order id at checkout
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 straight away. It doesn’t need any changes. Context is attached to every queued payload, including the ones Laravel creates for queued mailables and notifications.
Store the id rather than the model. Everything in context is serialised into every job queued while it’s set, and the Context pull request recommends against putting Eloquent models in it.
Searching the logs by order
I checked out as two customers while the mail relay was refusing connections, with a separate queue:work process handling the jobs. The worker ran both customers’ jobs one after the other, so the raw log mixes them together. Searching for the order number pulls out one customer’s lines (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 comes from the web request. The second was written by the worker, in a different process. The third is the failure from OrderConfirmation, which was queued from inside ProcessOrder while the context was still active, so it carried the context too.
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.
running_via changed from http to queue because of a hydrated callback, registered in a service provider. It runs on the worker right after a job’s context is restored, and only overwrites that one key. 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');
});In production, a log service that stores JSON gets trace_id and order_id as fields you can filter on, since Context is written as Monolog’s extra data.
Retrying the failed jobs
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;Both failed rows are OrderConfirmation, the queued mailable, and they still carry the trace and the order id.
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.
So as soon as your app creates something a customer will quote, like an order number, add its id to the context. Every log line after that can be found by it, whether the request or a queue worker wrote it.