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 payload
  • Context::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!