A user reports “my order didn’t sync”. You open the log, find forty crm sync done lines from that minute, and none of them says which order, which user or which request. The job that did the work ran in a queue worker, three seconds and one process away from the request that started it.
Laravel has shipped a fix for this since 11.0, and most apps still pass ids around by hand. It is called Context.
TL;DR
Context::add('request_id', $id)in a middleware, and every log line in that request ends with{"request_id":"…"}. NoLog::withContext(), no passing ids into helpers.- When you dispatch a job, the context is serialised into the job payload and restored in the worker. The job’s log lines carry the same ids.
Context::addHidden()keeps a value out of the logs but still available in code (and in the job).Context::scope()adds data for one closure,Context::push()builds stacks,dehydrating()/hydrated()carry things like the locale into jobs.
What Context is and why people miss it#
Context arrived in Laravel 11.0 (March 2024), in the same release as the slim application skeleton, health routes and per-second rate limiting. It got one paragraph in the release notes and its own docs page, “Context”, which sits between Concurrency and Contracts in the sidebar. Most people never open it.
The idea: a request-scoped key-value store that Laravel’s logger reads on every write, and that the queue serialises with every dispatched job. Set a value once, early, and it follows the request everywhere, including into the worker.
The usual way vs the gem#
Before Context, the honest options were Log::withContext() (works for the request, but a queued job starts with a clean logger) or passing $requestId into every job constructor and every Log::info() call.
Log::withContext(), then Context:
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
public function handle(Request $request, Closure $next)
{
$requestId = $request->header('X-Request-Id') ?? (string) Str::uuid();
Log::withContext(['request_id' => $requestId]);
// ...and remember to pass $requestId into every job you dispatch.
return $next($request);
}use Illuminate\Support\Facades\Context;
use Illuminate\Support\Str;
public function handle(Request $request, Closure $next)
{
Context::add([
'request_id' => $request->header('X-Request-Id') ?? (string) Str::uuid(),
'user_id' => $request->user()?->id,
'route' => $request->path(),
]);
return $next($request);
}Register it once for the web and api groups:
->withMiddleware(function (Middleware $middleware) {
$middleware->append(\App\Http\Middleware\AddRequestContext::class);
})That is the whole gem. Everything below is what you get for free.
Real-world example: an Inertia checkout with a queued CRM sync#
An Inertia form posts to /orders. The controller logs, saves, and queues a sync to the CRM:
use App\Jobs\SyncOrderToCrm;
use Illuminate\Support\Facades\Log;
public function store(StoreOrderRequest $request)
{
$order = $request->user()->orders()->create($request->validated());
Log::info('order placed', ['order_id' => $order->id]);
SyncOrderToCrm::dispatch($order);
return to_route('orders.show', $order);
}use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
class SyncOrderToCrm implements ShouldQueue
{
use Queueable;
public function __construct(public Order $order) {}
public function handle(): void
{
// ...talk to the CRM...
Log::info('crm sync done', ['order_id' => $this->order->id]);
}
}Neither file mentions a request id. This is what the log gets, in the exact layout our test app produced (the second line written by php artisan queue:work after the request had finished and the context had been flushed):
[2026-10-17 06:56:04] local.INFO: order placed {"order_id":42} {"request_id":"req-123","user_id":1,"route":"orders"}
[2026-10-17 06:56:04] local.INFO: crm sync done {"order_id":42} {"request_id":"req-123","user_id":1,"route":"orders"}Grep for req-123 and you have the full story of that request, across processes. If your front door (Cloudflare, nginx, a load balancer) already sets a request id header, use it and the same id shows up in your proxy logs too.
Pro tip: Inertia partial reloads and polling
Add 'inertia' => $request->header('X-Inertia') === 'true' and 'partial' => $request->header('X-Inertia-Partial-Data') to the same Context::add() call. Noisy log lines from a polling component become filterable in one go.
Hidden values: in the job, not in the log#
Some things are useful in a job but must not be logged: an API token, a card’s last four, a raw email. addHidden() stores them in a second bucket that the logger never sees:
Context::addHidden('api_token', $request->bearerToken());$token = Context::getHidden('api_token'); // available here
Context::get('api_token'); // null: hidden keys are invisible to get()In our test, Log::info('crm sync done', ['hidden_in_job' => Context::getHidden('api_token')]) printed the token because we asked for it explicitly, while the shared part of the line ({"request_id":…}) never contained it. Every method has a Hidden twin: hasHidden(), forgetHidden(), pushHidden(), getHidden().
Scopes and stacks#
scope() adds data for the duration of one closure and restores the previous context afterwards. The data and hidden arguments are merged in for that closure only:
Context::scope(function () use ($order) {
Context::add('step', 'payment');
Log::info('charging card'); // {"request_id":"req-1","breadcrumbs":["cart","checkout"],"gateway":"stripe","step":"payment"}
}, data: ['gateway' => 'stripe'], hidden: ['card_last4' => $order->card_last4]);
Log::info('after scope'); // {"request_id":"req-1","breadcrumbs":["cart","checkout"]}
Context::get('step'); // nullpush() appends to a list, which is handy for breadcrumbs or for collecting the queries a request ran:
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\Context;
use Illuminate\Support\Facades\DB;
DB::listen(function (QueryExecuted $event) {
Context::push('queries', [$event->time, $event->sql]);
});Context::stackContains('breadcrumbs', 'checkout') checks a stack, and accepts a closure for anything fancier.
Carry the locale into the job with dehydrating() and hydrated()#
Two events let you change what travels with a job and what happens when it arrives. The classic use is the locale, so a queued notification is rendered in the language of the request that queued it:
use Illuminate\Log\Context\Repository;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Context;
public function boot(): void
{
Context::dehydrating(function (Repository $context) {
$context->addHidden('locale', Config::get('app.locale'));
});
Context::hydrated(function (Repository $context) {
if ($context->hasHidden('locale')) {
Config::set('app.locale', $context->getHidden('locale'));
}
});
}We dispatched a job with the locale set to fr, switched the process back to en, flushed the context and ran queue:work. Inside the job app.locale was fr again, and because it was hidden, no "locale" key appeared in the job’s log line.
Gotcha: use the $context argument, not the facade
Inside dehydrating() and hydrated(), write to the Repository you are given. Calling Context::add() there changes the context of the current process, not the one being packed for the job.
Gotchas#
Hidden means hidden from logs, not secret. The queue payload carries both buckets. This is the illuminate:log:context key of our job in the jobs table:
{"data":{"request_id":"s:7:\"req-123\";","user_id":"i:1;","route":"s:6:\"orders\";"},"hidden":{"api_token":"s:10:\"secret-abc\";"}}Anyone who can read your queue store (Redis, the jobs table, the Horizon dashboard’s payload view, a failed_jobs row) can read hidden context. Keep real secrets in config and put only a reference in context.
Your Log::info() array comes first, the shared context second. A line reads message {your array} {context}. Dashboards that parse “the JSON at the end of the line” will pick up the shared context; make sure your parser handles two objects.
Tests share one process. Within a test, context persists across $this->get() and $this->post() calls (each test boots a fresh application, so it does not leak between tests). Call Context::flush() when a later assertion needs a clean slate. We had to, to prove the worker took the ids from the job payload and not from the process.
Objects are shared by reference. Mutating an object you put in context inside a scope() closure mutates it outside the scope too. Scopes restore keys, not object state.
Workers start each job clean. Before a job runs, the worker calls hydrate() with that job’s payload, and hydrate() flushes first. A job dispatched from a scheduled command therefore starts with an empty context, and nothing leaks from the previous job. We didn’t test Octane; keys your middleware sets on every request are overwritten each time, so the risk there is limited to keys you only set sometimes. Prefer scope() for those.
Models travel as ids. Put an Eloquent model in context and the payload stores its class and key, like a queued job does. It is re-fetched on hydrate, and a deleted model comes back as null (the ModelNotFoundException is reported, not thrown).
Context::remember() is per request, not a cache. Context::remember('permissions', fn () => $user->permissions) saves the second lookup inside one request and does nothing across requests. For that you still want Cache::remember or Cache::flexible.
Try it yourself#
See the id flow into a job in tinker
php artisan tinkeruse Illuminate\Support\Facades\Context;
use Illuminate\Support\Facades\Log;
Context::add('request_id', 'demo-1');
Context::addHidden('api_token', 'secret');
Log::info('from the request');
// storage/logs/laravel.log: ... from the request {"request_id":"demo-1"}
dispatch(fn () => Log::info('from the job', ['token' => Context::getHidden('api_token')]));
// sync queue: ... from the job {"token":"secret"} {"request_id":"demo-1"}Switch QUEUE_CONNECTION to database or redis, run php artisan queue:work --once in a second terminal, and the second line still carries demo-1.
How useful is this?#
| Score | |
|---|---|
| Time saved per use | ★★★★★ One middleware, then never pass a request id again |
| How often you’ll reach for it | ★★★★☆ Every app with a queue and a log you actually read |
| Risk if misused | ★★☆☆☆ Only if you treat hidden values as secrets |
| Overall | 4.5 / 5: the cheapest observability win in Laravel |
If you already log in controllers and jobs, add the middleware today and your next bug report comes with a request id.
Recap#
Context::add()once in a middleware, and every log line in the request ends with your keys.- Dispatched jobs carry the context in their payload and restore it in the worker.
addHidden()keeps values out of logs but not out of the queue store.scope(),push()andstackContains()cover per-closure data and breadcrumbs;dehydrating()/hydrated()cover things like the locale.- Log lines read
message {your array} {context}. Parse accordingly.
FAQ#
How do I add a request id to every Laravel log line?
Call Context::add('request_id', $id) in a middleware. Laravel appends the context to every log entry written during that request, and to log entries written by jobs the request dispatches.
Does Laravel Context work with queued jobs?
Yes. When a job is dispatched the current context is serialised into the job payload under illuminate:log:context, and the worker restores it before handle() runs. The job's log lines show the same values.
What is the difference between Context::add and Log::withContext?
Log::withContext() only affects the logger in the current process. Context is a separate store that the logger reads from and that also travels with queued jobs and can hold hidden values.
Are hidden context values secure?
They are kept out of log lines and out of Context::get(), but they are serialised into the queue payload in plain text. Treat hidden as "not logged", not as "encrypted".
Which Laravel version added Context?
Laravel 11.0, released March 12, 2024. It is unchanged in spirit on Laravel 12 and 13; this post was tested on 13.37.
Next steps#
- Laravel defer(): run code after the response, the other 11.x helper most apps skip
- queue:work vs queue:listen, for the worker that will hydrate your context
- Log stacks: one channel for the file, one for Slack
- The failed_jobs table, explained
Sources#
- Laravel docs: Context (capturing, stacks, hidden context, events)
- Laravel 11 release notes (Context introduced in 11.0)
Illuminate\Log\Context\RepositoryandIlluminate\Log\Context\ContextServiceProvider, where the queue payload and log integration live



