Understand How Laravel Context Facade works

Seamless Distributed Logging in Laravel

Understand How Laravel Context Facade works

Debugging issues in distributed or queue-heavy Laravel applications is very difficult. An HTTP request comes in, a user sees an error or triggers a background job, and minutes later the job is failing in your worker. If you head over to your log management tool (Datadog, AWS CloudWatch), how are you going to find which log line belongs to which user request? It'll require cross-referencing timestamps, user IDs, and IPs among thousands of entries.

In the past, manually attaching trace_id, tenant_id, or user_id to every single Log::info() call, or array payloads passed to queued job constructors was the way to go. But if you forgot to pass the metadata to a nested job, you lost your trace.

To eliminate this boilerplate and provide end-to-end tracing, modern Laravel provides the Context Facade (Illuminate\Support\Facades\Context). It gives you a shared metadata store that will automatically attach context to your log entries and silently pass that context along background queues. In this tutorial, we will learn how to capture request metadata, propagate trace IDs to queued jobs, filter sensitive data, and build end-to-end distributed tracing.

What is the Context Facade?

The Context facade is a request-scoped key-value storage. When you set values in the context, two things happen automatically:

  • Automatic log enrichment: All calls to Log::info(), Log::error(), and other log levels will have their payloads detailed context values.
  • Automatic propagation to queues: When you dispatch a queued job, Laravel will serialize the current context into the job's payload and restore it in the queue worker before calling the job's handle() method.

This means a unique trace ID generated during an HTTP request stays attached to logs written three jobs deep in your background queues without changing job constructor signatures.

Step 1: Capturing Metadata via Middleware

The cleanest place to capture request metadata is in an HTTP middleware. Let's create a middleware to record a unique request trace ID, the authenticated user ID, IP address, and tenant details:

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 CaptureRequestContext
{
    public function handle(Request $request, Closure $next): Response
    {
        // Check for an incoming trace ID from upstream gateways, or generate a new UUID
        $traceId = $request->header('X-Trace-Id') ?? (string) Str::uuid();

        // Populate context store
        Context::add([
            'trace_id'   => $traceId,
            'url'        => $request->path(),
            'ip'         => $request->ip(),
            'user_agent' => $request->userAgent(),
        ]);

        // Add user ID if already authenticated
        if ($request->user()) {
            Context::add('user_id', $request->user()->id);
        }

        $response = $next($request);

        // Echo the trace ID back in response headers for frontend debugging
        $response->headers->set('X-Trace-Id', $traceId);

        return $response;
    }
}

Register this middleware globally or in your web and api middleware groups. From this point forward, every log entry written during that request includes these context fields automatically.

How Context Enriches Your Logs

Consider a standard controller action where an order is placed:

namespace App\Http\Controllers;

use App\Models\Order;
use App\Jobs\ProcessOrderPayment;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class OrderController extends Controller
{
    public function store(Request $request)
    {
        Log::info('Order submission started.');

        $order = Order::create($request->validated());

        // Dispatch background job
        ProcessOrderPayment::dispatch($order);

        Log::info('Order record created, payment job dispatched.', ['order_id' => $order->id]);

        return response()->json($order, 201);
    }
}

Notice that we did not pass trace_id or ip to Log::info(). Yet, your log output (such as formatted JSON for Datadog or CloudWatch) will output:

{
    "message": "Order submission started.",
    "context": {
        "trace_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
        "url": "api/orders",
        "ip": "192.168.1.1",
        "user_agent": "Mozilla/5.0...",
        "user_id": 42
    },
    "level": 200,
    "level_name": "INFO",
    "channel": "production",
    "datetime": "2026-09-20T13:30:00.000000+00:00"
}

Seamless Propagation to Queued Jobs

The primary benefit of the Context facade is that queue dispatchers automatically serialize the current context payload when pushing jobs onto Redis, SQS, or database queues.

Here is our ProcessOrderPayment job:


namespace App\Jobs;

use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;

class ProcessOrderPayment implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public Order $order
    ) {}

    public function handle(): void
    {
        // This log executes inside an asynchronous worker process!
        Log::info('Processing payment gateway charge for order.', [
            'order_id' => $this->order->id,
            'amount'   => $this->order->total_amount,
        ]);

        // logic for payment charge...
    }
}

When this job runs inside your background queue worker, the log output still contains the original trace_id, ip, and user_id from the web request:

{
    "message": "Processing payment gateway charge for order.",
    "context": {
        "order_id": 105,
        "amount": 250.00,
        "trace_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
        "user_id": 42
    },
    "level": 200,
    "level_name": "INFO"
}

Searching for trace_id: 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d in your log viewer returns every log event that occurred across the HTTP request and its background queue workers in chronological order.

Managing Hidden or Sensitive Context

Sometimes you want data to propagate through queues and be available in code, but you don't want it leaked into your logs (like access tokens, API secrets, or personally identifiable information).

Laravel provides Context::addHidden() for exactly this case:

use Illuminate\Support\Facades\Context;

// Visible in logs and propagated to queues
Context::add('tenant_id', $tenant->id);

// Hidden from logs, but still propagated to queued jobs
Context::addHidden('tenant_api_token', $tenant->decrypted_api_token);

To read hidden context inside your queued job or service class:

// Retrieve a value regardless of whether it was stored normally or hidden
$token = Context::getHidden('tenant_api_token');

The token stays available across all queue execution boundaries without risk of being accidentally written to your log storage.

Stacking Context with Context::push()

In addition to standard key-value pairs, the Context facade allows you to push items onto array stacks. This is useful for collecting breadcrumbs, executed third-party services, or performance checkpoints:

use Illuminate\Support\Facades\Context;

// Push checkpoints onto an array stack
Context::push('breadcrumbs', 'Loaded user profile');
Context::push('breadcrumbs', 'Validated checkout cart');
Context::push('breadcrumbs', 'Contacted payment processor');

// Logs will render: "breadcrumbs": ["Loaded user profile", "Validated checkout cart", ...]
Log::info('Checkout step finished.');

Controlling Queue Serialization (Opting Out)

If you have particular jobs queued you don't want to carry context with, such as generic maintenance jobs or jobs carrying large payloads where you'd rather keep your redis queue messages smaller, you can disable context propagation in the queue configuration or skip the context middleware entirely.

Or, you can modify or clear the context before dispatching:

use Illuminate\Support\Facades\Context;

// Check if a key exists
if (Context::has('trace_id')) {
    $currentTrace = Context::get('trace_id');
}

// Remove a specific key
Context::forget('user_agent');

// Completely wipe context
Context::flush();

Context Methods Comparison

Method Target Data Appears in Logs? Propagates to Queues?
Context::add($key, $val) Standard key-value data ✅ Yes ✅ Yes
Context::addHidden($key, $val) Sensitive tokens/secrets ❌ No (Hidden) ✅ Yes
Context::push($key, $val) List/Breadcrumb array ✅ Yes ✅ Yes
Context::flush() All active context Clears all Clears all

Things to Remember

  • 📌 Contexts are reset after each request in PHP-FPM setups. In Laravel Octane, Laravel automatically flushes the Context store between requests to avoid leaking state between different users.
  • ⚡ Keep your context payloads lean and clean As context is stored as a serialized string in the queue driver (Redis/SQS/etc), you should avoid storing large strings, base64 binary data, or nested model arrays within your Context object. Use IDs, UUIDs, or other reduced representations of your data instead.
  • ⚠️ Job chains and batches: Context is preserved during entire Bus::chain([...]) job chains, as well as job batching callbacks, preserving traceability from the initial dispatch to the final callback.

Conclusion

Laravel's new Context facade addresses one of the perennial headaches of distributed web applications by solving the problem of disconnected logs. By establishing a shared trace id in middleware and letting Laravel forward it through your queue infrastructure, you can trace an entire user's flow from initial HTTP request all the way through to background jobs with a single query in your logging dashboard.

Thank you for reading this article 😊

For any query, do not hesitate to comment 💬


Previous Post Next Post

Contact Form