DEV Community

Cover image for Laravel Context: Share Data Across Logs and Queued Jobs
devTalk
devTalk

Posted on Originally published at dev-talk.com

Laravel Context: Share Data Across Logs and Queued Jobs

You know which tenant a request belongs to in the middleware. Then the request writes a log line, dispatches a job, and the job writes more log lines on a worker with no idea who the tenant was. People usually fix this by adding $tenantId to every job constructor and every Log::info() call. Laravel's Context facade does it for you: set the value once, and it is attached to every log entry and carried into every job you dispatch.

The quick answer

use Illuminate\Support\Facades\Context;

Context::add('tenant_id', 42);   // in middleware, once per request

// anywhere later, including inside a queued job:
$tenantId = Context::get('tenant_id');   // 42
Enter fullscreen mode Exit fullscreen mode

Every log entry written after that carries tenant_id as metadata, and every job dispatched after that carries it to the worker.

Step 1: set it in middleware

<?php

namespace App\Http\Middleware;

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

class AddTenantContext
{
    public function handle(Request $request, Closure $next): Response
    {
        if ($tenantId = $request->user()?->tenant_id) {
            Context::add('tenant_id', $tenantId);
        }

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

This assumes your users table has a tenant_id column. Use whatever identifies the tenant in your app, such as the subdomain or a route parameter. Register the middleware where the user is already available. In a Laravel 11+ app, bootstrap/app.php is the place:

->withMiddleware(function (Middleware $middleware) {
    $middleware->appendToGroup('web', \App\Http\Middleware\AddTenantContext::class);
})
Enter fullscreen mode Exit fullscreen mode

Adding it to the web group rather than the global stack matters here: the session, and with it $request->user(), is only available after the web group's session middleware has run. If you take the tenant from the subdomain instead, ordering does not matter.

Step 2: read it inside the job

<?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 ProcessOrder implements ShouldQueue
{
    use Queueable;

    public function __construct(public int $orderId) {}

    public function handle(): void
    {
        $tenantId = Context::get('tenant_id');   // 42, set in the request

        Log::info('Processing order', ['order_id' => $this->orderId]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Dispatch it from a controller as usual with ProcessOrder::dispatch($order->id). The job constructor does not mention the tenant at all.

What the log looks like

I ran this against the Laravel 13 context classes and a real database queue. The request logged a line, dispatched a job, and a separate PHP process (the "worker") started with an empty context and ran the job:

Order created {"order_id":7} {"tenant_id":42,"request_id":"req-abc"}      ← written in the request
context before job: []                                                    ← worker starts empty
Processing order {"order_id":7} {"tenant_id":42,"request_id":"req-abc"}   ← written in the worker
tenant_id read inside the job: 42
Enter fullscreen mode Exit fullscreen mode

The first {...} is the log context you passed to the call. The second is the shared Context data, which Laravel adds as log metadata. In a default file log both appear after the message, as in this example and in the Laravel documentation. Your log formatter may place the second part differently.

How it travels to the worker

There is no magic beyond two hooks that Laravel registers itself, both visible in Illuminate\Log\Context\ContextServiceProvider:

  1. On dispatch, Laravel adds the current context to the job's payload under the key illuminate:log:context. Each value is PHP-serialized.
  2. When a worker starts a job, it reads that key and loads the data back into the worker's context before your handle() runs. The existing context is cleared first, so one job's data does not leak into the next job on a long-running worker.

Here is the payload that was stored in the jobs table in my test:

{"data":{"tenant_id":"i:42;","request_id":"s:7:\"req-abc\";"},"hidden":{"api_token":"s:10:\"secret-xyz\";"}}
Enter fullscreen mode Exit fullscreen mode

A job dispatched with an empty context has no illuminate:log:context key at all.

Hidden context

Some data should travel with the request but stay out of the logs. Use addHidden():

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

Context::getHidden('api_token');   // the token
Context::get('api_token');         // null
Enter fullscreen mode Exit fullscreen mode

Hidden values are not added to log entries and are not returned by get() or all(). They are still carried to queued jobs. Note the payload above: secret-xyz is readable in the stored job. In my test with the database queue it was plain serialized text, not encrypted. "Hidden" means hidden from logs, not protected from anyone who can read your queue storage, so do not put real secrets in context.

Things to know

  • The context is captured when you dispatch. A value added after dispatch() does not reach that job.
  • Values must be serializable. The context is serialized into the payload. Store IDs, strings and numbers, not closures or large objects. Eloquent models are stored by identifier, and if the record is deleted before the job runs, the value comes back as null, so an ID is simpler and clearer.
  • Keep it small. It is added to every job payload you dispatch, so a large value is stored and transferred once per job.
  • It does not replace explicit job data. If a job cannot do its work without a value, such as the order it must process, pass it through the constructor. Use context for ambient data: tenant, trace ID, locale, request ID.
  • It is not a security boundary. Scope your queries by tenant as you do today. A shared value in a payload is data your app wrote, not proof of who may see what.
  • Console commands and schedulers have no request. Nothing sets the tenant there. Set it explicitly where the command starts. Context::scope() sets values for the duration of a callback and restores the previous state afterwards.

Common questions

Does Context work with every queue driver?

The hook is registered with Queue::createPayloadUsing() on the base Queue class, and the built-in database, Redis, SQS, Beanstalkd and sync queues all extend that class, so they build their payloads the same way. I only ran the database driver.

How do I add a request ID or trace ID to every log line?

Add it in the same middleware with Context::add('trace_id', (string) Str::uuid()). The Laravel documentation uses exactly this pattern for a URL and trace ID.

What is the difference between Log::withContext() and Context::add()?

Log::withContext() adds data to the log entries of the current process. Context::add() also reaches the log entries, as metadata, and in addition travels with the jobs you dispatch.

Can I change what is carried to jobs?

Yes. Laravel provides Context::dehydrating() and Context::hydrated() callbacks. Inside them, change only the repository passed to your callback, not the Context facade, as the documentation warns.

Sources


Originally published on DEV Talk.

Top comments (0)