Introduction
Debugging production applications is rarely about a single log line.
Usually, what we really need is the story around that log line:
- which request triggered it
- which tenant, team, or account was involved
- which user was authenticated
- which job continued the work later
- which important steps happened before the error
For a long time, we solved this by manually passing arrays to Log::info(), adding request IDs to
middleware, forwarding metadata to jobs, or building our own small correlation system.
That works, but it gets repetitive very quickly.
Laravel's Context facade gives us a framework-level place to store execution metadata during a request, command, or queued job. That metadata can then follow the application flow and be attached to logs automatically.
In this article, let's do a deep dive into the Context facade: what it is, how to use it, how it works internally, how it crosses the request and queue boundary, and when it is the right tool for real Laravel applications.
The Mental Model
At a high level, Laravel Context is a per-execution key-value repository.
You add data once:
use Illuminate\Support\Facades\Context;
use Illuminate\Support\Str;
Context::add('trace_id', Str::uuid()->toString());
Context::add('url', request()->url());
Then any log written later in the same execution can include that data automatically:
use Illuminate\Support\Facades\Log;
Log::info('Payment authorized.', [
'payment_id' => $payment->id,
]);
Conceptually, the final log has two different kinds of information:
Payment authorized. {"payment_id": 10} {"trace_id":"...","url":"https://example.com/checkout"}
The first JSON object is the context passed directly to that log call. The second one is the shared Laravel Context data.
That separation matters.
The log call describes what happened right there. The Context repository describes the wider execution around it.
So the mental model is simple:
Context is not a replacement for log context arrays. It is shared execution metadata that Laravel can carry and attach for you.
Where Context Fits in the Lifecycle
If you read my article about the Laravel Request Lifecycle, you already know that your controller is not the beginning of the request. A lot has happened before your action runs.
Context fits nicely into that lifecycle because it can be filled early and read later.
A common HTTP flow looks like this:
flowchart TD
A[Incoming HTTP Request] --> B[Laravel captures Request]
B --> C[Global middleware]
C --> D[Add request context]
D --> E[Router matches route]
E --> F[Route middleware]
F --> G[Controller or action]
G --> H[Logs are written]
H --> I[Context is appended to log extra data]
G --> J[Job is dispatched]
J --> K[Context is dehydrated into queue payload]
K --> L[Worker processes job]
L --> M[Context is hydrated again]
M --> N[Job logs include original context]
Laravel Context can be captured early, used in logs, and carried into queued jobs.
That is the main reason the feature exists.
It gives you one place to say:
Context::add('trace_id', $traceId);
And then Laravel can keep that trace ID visible across the rest of the execution.
The Basic API
Let's start with the public API because it is intentionally small and practical.
You can add one value:
use Illuminate\Support\Facades\Context;
Context::add('tenant_id', $tenant->id);
Or many values at once:
Context::add([
'tenant_id' => $tenant->id,
'user_id' => $request->user()?->id,
'route' => $request->route()?->getName(),
]);
If the key already exists, add() replaces it.
When you only want to set a value if the key is still missing, use addIf():
Context::add('trace_id', 'first');
Context::addIf('trace_id', 'second');
Context::get('trace_id');
// first
You can retrieve values with get():
$traceId = Context::get('trace_id');
And you can provide a default:
$traceId = Context::get('trace_id', 'missing-trace');
The has() and missing() methods check whether the key exists:
if (Context::has('tenant_id')) {
// ...
}
if (Context::missing('tenant_id')) {
// ...
}
An important detail from the implementation: has() uses array_key_exists(), not a truthy check.
That means a key with a null value still exists:
Context::add('user_id', null);
Context::has('user_id');
// true
You can also retrieve subsets:
$publicContext = Context::only(['trace_id', 'tenant_id']);
$safeContext = Context::except(['debug_payload']);
And remove values:
Context::forget('debug_payload');
Context::forget(['tenant_id', 'user_id']);
When you want to read and remove in one operation, use pull():
$temporaryValue = Context::pull('temporary_value');
There is also a global helper:
context(['trace_id' => $traceId]);
$traceId = context('trace_id');
$repository = context();
The helper is just a small convenience around Illuminate\Log\Context\Repository.
A Practical Middleware Example
The most common place to add HTTP context is middleware.
For example, imagine that we want every log line in a request to include a trace ID, route name, URL, and authenticated user ID.
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
{
$traceId = $request->headers->get('X-Trace-Id', Str::uuid()->toString());
Context::add([
'trace_id' => $traceId,
'url' => $request->url(),
'route' => $request->route()?->getName(),
'user_id' => $request->user()?->id,
]);
$response = $next($request);
$response->headers->set('X-Trace-Id', $traceId);
return $response;
}
}
Now, any code deeper in the request can write normal logs:
Log::warning('Invoice payment failed.', [
'invoice_id' => $invoice->id,
'reason' => $reason,
]);
You do not need to remember to pass trace_id, url, route, and user_id every time. The log
entry stays focused on the local event, and Context carries the wider request story.
This is the first big benefit: you can separate local log details from global execution details.
Context Is Not Dot Notation Config
Context keys are simple array keys.
This means a key like payment.gateway is just a string key. It does not create a nested array:
Context::add('payment.gateway', 'stripe');
Context::get('payment');
// null
Context::get('payment.gateway');
// stripe
That is a small detail, but it is useful when naming keys.
I like using flat, explicit keys for Context:
Context::add([
'trace_id' => $traceId,
'tenant_id' => $tenant->id,
'payment_gateway' => 'stripe',
]);
You can use dotted keys if they match your logging conventions, but do not expect config-style nested retrieval.
Stacks
Sometimes a single value is not enough.
You may want to record a list of things that happened during a request: executed steps, breadcrumbs, queries, external calls, state transitions, or domain events.
That is where stacks help.
Context::push('checkout_steps', 'cart_validated');
Context::push('checkout_steps', 'payment_authorized');
Context::push('checkout_steps', 'order_created');
Context::get('checkout_steps');
// ['cart_validated', 'payment_authorized', 'order_created']
You can push several values at once:
Context::push('checkout_steps', 'stock_reserved', 'notification_queued');
And you can pop the latest value:
$lastStep = Context::pop('checkout_steps');
Internally, Laravel only allows a stack if the key is missing or already contains a list array. If
you previously stored a scalar or associative array under that key, push() and pop() will throw a
runtime exception.
So this works:
Context::push('breadcrumbs', 'started');
But this does not:
Context::add('breadcrumbs', 'started');
Context::push('breadcrumbs', 'continued');
// RuntimeException
You can also check if a stack contains a value:
if (Context::stackContains('checkout_steps', 'payment_authorized')) {
// ...
}
Or use a closure for more control:
use Illuminate\Support\Str;
$hasPaymentStep = Context::stackContains(
'checkout_steps',
fn (string $step): bool => Str::startsWith($step, 'payment_'),
);
Stacks are useful, but use them with care. They can grow quickly if you push too much data, especially when that context later gets serialized into a queued job payload.
Counters
Context also has small counter helpers:
Context::increment('products_imported');
Context::increment('products_imported', 10);
Context::decrement('remaining_attempts');
Internally, increment() reads the current value, casts it to an integer, adds the amount, and stores
the new value.
This can be useful in importers, jobs, and command flows where you want a simple count attached to the final logs:
foreach ($records as $record) {
import_record($record);
Context::increment('records_imported');
}
Log::info('Import finished.');
The log can then include records_imported without every log call needing to know about that count.
Hidden Context
Not all execution metadata should appear in logs.
Sometimes you need data to travel through Context, but you do not want it automatically appended to log records. Laravel calls this hidden context.
Context::addHidden('internal_user_id', $user->id);
Context::getHidden('internal_user_id');
// 123
Context::get('internal_user_id');
// null
Hidden context has a parallel API:
Context::addHidden('key', 'value');
Context::addHiddenIf('key', 'value');
Context::pushHidden('steps', 'first');
Context::getHidden('key');
Context::pullHidden('key');
Context::popHidden('steps');
Context::onlyHidden(['key']);
Context::exceptHidden(['key']);
Context::allHidden();
Context::hasHidden('key');
Context::missingHidden('key');
Context::forgetHidden('key');
Context::hiddenStackContains('steps', 'first');
The important behavior is this:
regular context is appended to logs; hidden context is not.
That makes hidden context useful for internal coordination.
Laravel itself uses this idea for unique queued jobs. When dispatching a unique job, framework code stores hidden context values like the unique job cache store and lock key. If the worker cannot unserialize the job because a model is missing, Laravel can still use that hidden context to release the unique job lock.
That is a great example of what hidden context is for: data that should move with the execution, but should not become public log metadata.
Scoped Context
There are cases where you want to add context only for a small block of code.
For example, maybe a command processes many tenants in sequence. You want every log line inside a tenant operation to include the tenant ID, but you do not want that tenant ID to leak into the next iteration.
Context::scope() gives you that behavior:
foreach ($tenants as $tenant) {
Context::scope(
callback: function () use ($tenant): void {
Log::info('Starting tenant sync.');
sync_tenant($tenant);
Log::info('Finished tenant sync.');
},
data: ['tenant_id' => $tenant->id],
);
}
Inside the callback, tenant_id exists. After the callback finishes, the previous context is
restored.
This restoration happens in a finally block inside the repository, so it also runs when the callback
throws an exception.
You can also scope hidden data:
Context::scope(
callback: fn () => run_sensitive_operation(),
data: ['operation' => 'customer_export'],
hidden: ['export_id' => $export->id],
);
There is one subtle warning from the documentation: if you store an object in context and mutate that object inside the scope, the mutation can still be visible outside the scope. The repository restores the arrays, but it does not deep clone every object you placed inside them.
So for Context, prefer scalars, small arrays, enums, IDs, and immutable values.
Conditional Context
Because the repository uses Laravel's Conditionable trait, you can use when() and unless():
Context::when(
$request->user()?->isAdmin(),
fn ($context) => $context->add('actor_role', 'admin'),
fn ($context) => $context->add('actor_role', 'user'),
);
This is nice when the value depends on a condition but you want to keep the context setup readable.
I still prefer normal if statements when the setup becomes more complex, but for small conditional
additions, this API is clean.
Dependency Injection With the Context Attribute
The facade and helper are not the only ways to read context.
Laravel also has a container attribute:
Illuminate\Container\Attributes\Context
You can use it to inject a context value into a class resolved by the container:
namespace App\Actions;
use Illuminate\Container\Attributes\Context;
final readonly class CreateAuditEntry
{
public function __construct(
#[Context('trace_id')]
private ?string $traceId,
) {}
public function handle(string $message): void
{
// Use $this->traceId...
}
}
You can also read hidden context:
use Illuminate\Container\Attributes\Context;
final readonly class ProcessInternalOperation
{
public function __construct(
#[Context('operation_id', hidden: true)]
private ?string $operationId,
) {}
}
Under the hood, the attribute resolves Illuminate\Log\Context\Repository from the container and
calls either get() or getHidden().
This is useful when the value is part of an object's execution environment, but I would not overuse it. If every class starts secretly depending on Context, your code becomes harder to reason about.
Use it for infrastructure and cross-cutting metadata, not for core business input.
How Context Is Registered Internally
Now let's go behind the facade.
The facade accessor is:
protected static function getFacadeAccessor()
{
return \Illuminate\Log\Context\Repository::class;
}
So the Context facade points directly to Illuminate\Log\Context\Repository.
Laravel registers that repository in Illuminate\Log\Context\ContextServiceProvider:
$this->app->scoped(Repository::class);
That scoped() binding is important.
In normal HTTP requests, it behaves like a shared instance for the current lifecycle. In long-running Laravel processes, scoped services can be flushed between lifecycles so request-specific state does not accidentally live forever.
This is exactly the kind of lifetime Context needs.
It is not a transient object that should be rebuilt every time you call Context::get(). It is also
not global application state that should live forever across unrelated requests and jobs.
It is state for the current execution scope.
The repository itself is simple. It has two arrays:
protected $data = [];
protected $hidden = [];
Most facade methods are small operations around those arrays: add, read, forget, push, pop, and filter.
The power does not come from a complicated data structure. The power comes from how Laravel connects that repository to logging, queues, scheduled commands, and the container.
How Context Is Added to Logs
Laravel integrates Context with logging through a Monolog processor.
When the log manager creates a logger channel, it pushes Laravel's context log processor when the underlying logger supports processors:
$loggerWithContext->pushProcessor($this->app->make(ContextLogProcessor::class));
The default processor is Illuminate\Log\Context\ContextLogProcessor.
Its job is small:
public function __invoke(LogRecord $record): LogRecord
{
$app = Container::getInstance();
if (! $app->bound(ContextRepository::class)) {
return $record;
}
return $record->with(extra: [
...$record->extra,
...$app->get(ContextRepository::class)->all(),
]);
}
The key detail is that Context data is added to the log record's extra data, not used as message
placeholder context.
That means this:
Context::add('name', 'James');
Log::info('My name is {name}', [
'name' => 'Tim',
]);
Still uses Tim for the message placeholder. The Context value does not override the per-log context
array.
That is the right behavior. Shared execution metadata should not change the meaning of a specific log message.
How Context Crosses the Queue Boundary
The queue integration is where Context becomes more than just automatic log metadata.
When a job is dispatched, Laravel creates a queue payload. The Context service provider hooks into
that process using Queue::createPayloadUsing().
Conceptually, it does this:
Queue::createPayloadUsing(function ($connection, $queue, $payload) {
$context = Context::dehydrate();
return $context === null ? $payload : [
...$payload,
'illuminate:log:context' => $context,
];
});
So, when context is not empty, Laravel serializes it into the job payload under this key:
illuminate:log:context
Later, when the worker begins processing the job, Laravel listens for JobProcessing and hydrates
the context back:
Context::hydrate($event->job->payload()['illuminate:log:context'] ?? null);
The flow looks like this:
flowchart TD
A[HTTP Request] --> B[Context has trace_id]
B --> C[Dispatch queued job]
C --> D[Context::dehydrate]
D --> E[Store serialized context in payload]
E --> F[Queue worker receives job]
F --> G[JobProcessing event]
G --> H[Context::hydrate]
H --> I[Job handle method]
I --> J[Job logs include trace_id]
Laravel dehydrates context into the queue payload and hydrates it before the job runs.
This is very useful for observability.
Imagine a controller does this:
Context::add([
'trace_id' => $traceId,
'order_id' => $order->id,
]);
ProcessOrderReceipt::dispatch($order);
Then the job can simply log:
public function handle(): void
{
Log::info('Sending order receipt.');
}
And the log can still include the original trace_id and order_id from the request that dispatched
the job.
This solves a real production problem: connecting an asynchronous job back to the request or command that created it.
Dehydrating and Hydrated Events
Laravel exposes two hooks around that serialization process:
Context::dehydrating()runs before context is serialized into a payloadContext::hydrated()runs after context is restored
The documentation uses locale as a great example.
During the request, maybe your middleware sets app.locale from the Accept-Language header. If a
notification is queued, you may want the queued job to use the same locale.
You can capture it during dehydration:
use Illuminate\Log\Context\Repository;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Context;
public function boot(): void
{
Context::dehydrating(function (Repository $context): void {
$context->addHidden('locale', Config::get('app.locale'));
});
}
And restore it during hydration:
use Illuminate\Log\Context\Repository;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Context;
public function boot(): void
{
Context::hydrated(function (Repository $context): void {
if ($context->hasHidden('locale')) {
Config::set('app.locale', $context->getHidden('locale'));
}
});
}
Notice the important detail: the callbacks receive a Repository $context instance.
Inside these callbacks, you should modify the repository passed to the callback, not the Context
facade. This is especially important for dehydrating() because Laravel creates a separate repository
copy before serialization. Changing that copy lets you affect what is sent to the queued job without
mutating the current process context.
Serialization Details
When Laravel dehydrates context, it serializes each value.
The implementation creates a fresh repository, copies normal and hidden data into it, dispatches the dehydrating event, and then serializes each value into two arrays:
return $instance->isEmpty() ? null : [
'data' => array_map($serialize, $instance->all()),
'hidden' => array_map($serialize, $instance->allHidden()),
];
Because the repository uses Laravel's SerializesModels trait, Eloquent models inside Context are
handled like queued job models: Laravel stores a model identifier and restores the model later.
That is powerful, but I recommend being conservative.
For most applications, Context should contain:
- strings
- integers
- booleans
- IDs
- route names
- tenant identifiers
- trace or correlation IDs
- small arrays
- enums
Avoid putting large objects, full request payloads, full models, uploaded files, or service objects in Context.
Context may be serialized into queue payloads and written to logs. Treat it as lightweight metadata, not as a transport layer for your domain state.
Scheduled Commands and Console Context
Context is also used when Laravel runs scheduled command events as subprocesses.
Before executing the shell command, Laravel dehydrates the current context and passes it through the process environment as:
__LARAVEL_CONTEXT
When the application boots in console mode, the Context service provider checks that environment value and hydrates the repository if it exists.
This means Context can survive the boundary between the scheduler process and the scheduled command process.
Again, this is not something you need to think about every day, but it shows the design intent:
Context is execution metadata that Laravel can carry across framework boundaries.
Context and Exceptions
Because exception reporting uses Laravel's logging system, Context can also appear in reported exceptions.
This is very useful when debugging errors:
Context::add([
'trace_id' => $traceId,
'tenant_id' => $tenant->id,
]);
throw new RuntimeException('Payment gateway unavailable.');
When the exception is reported, the contextual metadata can travel with the log entry, making it much easier to answer "which tenant did this affect?" or "which request produced this failure?".
This does not replace custom exception context. If a specific exception knows important local details, those details still belong on the exception or in the log call. Context is for the shared execution data around it.
When to Use Context
Context is a great fit for cross-cutting metadata.
Use it for things like:
- trace IDs and correlation IDs
- request URLs and route names
- tenant, account, or team identifiers
- authenticated user IDs
- job origin information
- feature flag snapshots
- locale or timezone values that need to cross queue boundaries
- small breadcrumbs for critical flows
- import or batch counters
It is especially valuable when you want logs from different layers to share the same metadata without passing the same array through every method call.
For example, this is a good use:
Context::add([
'trace_id' => $traceId,
'tenant_id' => $tenant->id,
]);
This is not:
Context::add('checkout_dto', $checkoutData);
Your application services should still receive their real input explicitly. Context should support observability and execution coordination, not become a hidden parameter bag for business logic.
When Not to Use Context
Context can become dangerous if it turns into global state with a nicer API.
Avoid using it for:
- required business inputs
- authorization decisions that should depend on explicit users or policies
- large arrays or full request payloads
- secrets that could accidentally be logged
- replacing method parameters
- replacing DTOs, value objects, or action input
- passing data between unrelated services because it feels convenient
This is the rule I use:
If the code cannot correctly run without the value, pass it explicitly. If the value helps explain the execution, Context may be a good fit.
That keeps Context in the observability and lifecycle-support lane, where it shines.
Testing Code That Uses Context
Testing Context usage is straightforward because it is just a repository behind a facade.
For middleware, you can assert that the response works and then inspect the context during the request through a route or fake log.
For smaller units, you can flush and set context explicitly:
use Illuminate\Support\Facades\Context;
beforeEach(function (): void {
Context::flush();
});
it('creates an audit message with the current trace id', function (): void {
Context::add('trace_id', 'trace-123');
$entry = app(CreateAuditEntry::class)->handle('Order created.');
expect($entry->trace_id)->toBe('trace-123');
});
If you are testing logs, remember the difference between log context and Laravel Context. A value
passed directly to Log::info() is not the same thing as a value added through Context::add().
That distinction is exactly why the feature is useful.
A Practical Pattern I Like
For real applications, I like starting with one middleware responsible for request-level context:
final class AddRequestContext
{
public function handle(Request $request, Closure $next): Response
{
Context::add([
'trace_id' => $request->headers->get('X-Trace-Id', Str::uuid()->toString()),
'route' => $request->route()?->getName(),
'user_id' => $request->user()?->id,
'ip' => $request->ip(),
]);
return $next($request);
}
}
Then, in specific domain flows, add only the metadata that helps connect the dots:
Context::scope(
callback: fn () => $this->checkout->handle($cart),
data: [
'cart_id' => $cart->id,
'checkout_attempt_id' => Str::uuid()->toString(),
],
);
And in jobs, trust Laravel to hydrate the context instead of manually rebuilding everything:
final class CapturePayment implements ShouldQueue
{
use Queueable;
public function handle(): void
{
Log::info('Capturing payment.');
// ...
}
}
This gives you a clean balance:
- middleware captures request-wide metadata
- domain flows add scoped metadata
- jobs inherit useful origin metadata
- logs stay focused and readable
That is where Context feels most valuable.
Conclusion
Laravel's Context facade is one of those features that looks small on the surface, but becomes very powerful once you understand where it sits in the framework.
It is a scoped repository for execution metadata. It integrates with Monolog so your logs can include shared context automatically. It dehydrates and hydrates data across queued jobs. It supports hidden values, stacks, counters, scoped changes, lifecycle hooks, and even container attribute injection.
The most important thing is to use it for the right job.
Do not turn Context into a hidden dependency system for your business logic. Use it to describe the execution: trace IDs, tenants, route names, job origins, small breadcrumbs, and other metadata that make production behavior easier to understand.
When used like that, Context gives your logs and asynchronous flows a much better story without polluting every method signature with observability details.
I hope that you liked this article and if you do, don't forget to share this article with your friends!!! See ya!