DEV Community

Cover image for Laravel Context: Request-Scoped Data Without the Globals
Gabriel Anhaia
Gabriel Anhaia

Posted on

Laravel Context: Request-Scoped Data Without the Globals


A support ticket comes in. A customer swears their export ran against another tenant's data. You pull the logs, find the request, and there's no way to prove them wrong, because your trace ID lived in a static property on a service class and that property got reused. Then you notice the export ran on a queue worker, where the static property was never even set. The log line that would have tied the HTTP request to the background job doesn't exist. You have two systems that ran the same user's work and no thread connecting them.

Cross-cutting request data has to travel through code that doesn't take it as an argument: a trace ID, the current tenant, the authenticated user's ID. It reaches loggers, jobs, event listeners, deep service methods. The tempting move is to stash it somewhere global and read it back later. That works right up until the two places it breaks: Octane and the queue.

Laravel's Context facade, added in Laravel 11 and steady since, is built for exactly this. Here's why it beats the global you were about to reach for.

The smuggling pattern, and why it rots

You've seen some version of this. A singleton that holds the request's identity so anything can read it:

<?php

namespace App\Support;

class RequestContext
{
    public static ?string $traceId = null;
    public static ?int $tenantId = null;
}
Enter fullscreen mode Exit fullscreen mode

Middleware fills it. A logger reads it. A job reads it. It feels clean because nothing has to pass the trace ID down five layers of method calls.

It rots in two places.

Under Laravel Octane, the PHP process stays alive between requests. Static properties are process state, so RequestContext::$tenantId keeps the value from the previous request until something overwrites it. Miss one reset and request N sees request N-1's tenant. That is the data-bleed in the support ticket.

On a queue worker, the static property was set inside the web request. The worker is a separate long-running process that never ran your middleware. RequestContext::$traceId is null there. The job that continues the user's work logs nothing you can correlate back to the request that started it.

A container singleton (app()->singleton(RequestContext::class, ...)) has the same two holes. The container is rebound per request under Octane if you remember to do it, and it does not cross into the worker at all.

What Context actually is

Context is a request-scoped key-value store that Laravel manages for you. Two properties make it different from the static you were using:

  1. It resets automatically at the end of each request and each job. Nothing bleeds between requests under Octane.
  2. Its contents are captured when a job is dispatched and restored when that job runs. The trace ID crosses the HTTP-to-worker boundary on its own.

You write to it with add, read with get, and the rest of the API mirrors what you'd expect:

use Illuminate\Support\Facades\Context;

Context::add('tenant_id', 42);
Context::add(['trace_id' => $id, 'region' => 'eu']);

Context::get('tenant_id');       // 42
Context::has('trace_id');        // true
Context::forget('region');

Context::push('breadcrumbs', 'validated', 'charged');
Context::pull('breadcrumbs');    // ['validated', 'charged']
Enter fullscreen mode Exit fullscreen mode

push and pull treat a key as a stack, which is handy for breadcrumbs you build up across a request. increment and decrement do the obvious thing for counters.

Attach the data at the edge

The right place to fill Context is middleware. That's the boundary where a request enters, and it keeps the concern out of your domain code:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Context;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;

class AttachRequestContext
{
    public function handle(
        Request $request,
        Closure $next,
    ): Response {
        $traceId = $request->header('X-Trace-Id')
            ?? (string) Str::uuid();

        Context::add('trace_id', $traceId);
        Context::add('tenant_id', $request->user()?->tenant_id);

        return $next($request);
    }
}
Enter fullscreen mode Exit fullscreen mode

Register it in bootstrap/app.php so every request runs through it:

->withMiddleware(function (Middleware $middleware) {
    $middleware->append(AttachRequestContext::class);
})
Enter fullscreen mode Exit fullscreen mode

If an upstream gateway already sends a trace ID, you read it from the header and keep the same value across services. If not, you mint one. Either way, the rest of the app reads Context::get('trace_id') and never knows which happened.

It shows up in your logs for free

This is the part that pays for itself the first day. Anything in Context is attached to every log entry's context automatically. No custom Monolog processor, no passing the trace ID into every Log::info call.

Log::info('charge succeeded', ['amount' => 4200]);
Enter fullscreen mode Exit fullscreen mode

The written record carries the merged context:

[2026-07-02 10:14:02] production.INFO: charge succeeded
{"amount":4200,"trace_id":"7f3c...","tenant_id":42}
Enter fullscreen mode Exit fullscreen mode

Every log line from that request now has the trace ID and tenant on it. Grep for the trace ID and you get the full path of one request across every log line it touched. That is the correlation the static property promised and never delivered on the worker side.

Queue propagation is the real win

Dispatch a job during the request and Context comes along. You write nothing extra:

<?php

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Context;
use Illuminate\Support\Facades\Log;

class GenerateExport implements ShouldQueue
{
    use Queueable;

    public function handle(): void
    {
        // Set in web middleware, still here on the worker.
        Log::info('export started', [
            'tenant' => Context::get('tenant_id'),
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

When you call GenerateExport::dispatch(), Laravel serializes the current Context into the job payload. When a worker picks the job up, it restores that Context before handle() runs. The log line the worker writes carries the same trace_id as the HTTP request that queued it. The thread from request to background work is now unbroken, which is the exact thing the static property could never do because the worker never ran your middleware.

It chains, too. If that job dispatches another job, the same Context rides along again. One trace ID spans the request and every job downstream of it.

Hidden context: propagate without logging

Sometimes a value has to reach the worker but should never land in a log line. An internal tenant token, a feature-flag payload, anything you don't want printed. That's what hidden context is for:

Context::addHidden('billing_token', $token);

// Not attached to log entries.
Context::getHidden('billing_token');

// Still restored inside the queued job.
Enter fullscreen mode Exit fullscreen mode

Hidden values propagate across the queue boundary exactly like visible ones, but they stay out of the log context. Visible for correlation, hidden for secrets. The split is the point.

Shape what crosses the boundary

By default Context serializes what you put in it, so keep the values small and scalar — a UUID string, an integer tenant ID, not a hydrated Eloquent model. If you need to transform values as they leave the request or arrive in a job, Context gives you two hooks you register in a service provider:

Context::dehydrating(function ($context) {
    // Runs before the job payload is serialized.
    $context->addHidden('locale', app()->getLocale());
});

Context::hydrated(function ($context) {
    // Runs on the worker after restore.
    app()->setLocale($context->getHidden('locale'));
});
Enter fullscreen mode Exit fullscreen mode

That pattern carries the request's locale into the worker so a queued email renders in the language the user was browsing in. The domain code stays unaware; the plumbing sits at the edge where it belongs.

Where this leaves you

Context is a small API, and the temptation is to spray Context::get() across the whole codebase until it becomes the same untyped global bag you were trying to escape. Resist that. Read it at the edges — middleware writes it, the logger and the job framework read it — and pass real, typed arguments through your domain the way you already do. Context is for the ambient concern that genuinely has nowhere to ride, not a shortcut around method signatures.

Keeping the trace ID and tenant at the boundary, and out of your business logic, is the same instinct that runs through clean and hexagonal architecture: the framework's request lifecycle is a detail, and your domain shouldn't reach into it. Decoupled PHP is about drawing that line on purpose, so the code that does the actual work stays readable long after Laravel's request plumbing changes underneath it.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)