Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Context Laravel Package

open-telemetry/context

Immutable, execution-scoped context propagation for PHP based on the OpenTelemetry specification. Activate contexts with scopes and detach them safely, with optional fiber support and guidance for event loops.

View on GitHub
Deep Wiki
Context7

Getting Started

open-telemetry/context is the storage primitive that the rest of OpenTelemetry PHP sits on. Nothing in it emits spans, exports data, or knows about Laravel — it is an immutable, copy-on-write key/value store with an activation stack, and everything interesting you'll do with it is get current → derive a new Context → activate → detach.

Install and orient yourself

composer require open-telemetry/context

Three files matter:

  • OpenTelemetry\Context\Context — getCurrent(), getRoot(), createKey(), storage()
  • ContextKeyInterface — opaque, identity-based keys (not string-keyed)
  • ScopeInterface — what activate() returns; detach() is mandatory

Read the upstream context spec once. It's short and it explains why contexts are immutable, which is the source of most beginner bugs.

First useful use case in a Laravel app

Don't start with tracing. Start with a context-carrying unit of work you already understand: a request-scoped correlation ID that survives into logs and into the job you dispatch from that request.

use OpenTelemetry\Context\Context;

final class RequestContext
{
    public static function key(): ContextKeyInterface
    {
        static $key;
        return $key ??= Context::createKey('app.request_id');
    }
}

// Placeholder for now — activate a context the rest of this file can read.
$context = Context::getCurrent()->with(RequestContext::key(), (string) Str::uuid());
$scope = $context->activate();

try {
    // everything in here sees the request id via Context::getCurrent()
} finally {
    $scope->detach();
}

That 8-line pattern is 80% of day-to-day usage. Everything else in this assessment is that pattern applied at the right seam.

Prerequisites worth checking before you commit

  • PHP 8.1+ for the split package; the current monorepo requires 8.2+, so target 8.2+ if you want a supportable upgrade path.
  • This package alone gives you propagation within a process. Cross-process propagation (HTTP, queues) is your job — see below. If you expected headers to appear automatically, they won't.
  • The repo is a read-only subtree split. Issues/PRs go to open-telemetry/php. Pin this and the SDK to compatible minor versions and upgrade them together.

Implementation Patterns

1. Middleware as the activation root

A single HTTP middleware should own the outermost activate()/detach() pair. Everything nested inside it then behaves correctly without knowing about scopes.

final class OpenTelemetryContextMiddleware
{
    public function handle(Request $request, Closure $next): Response
    {
        $parent = $this->propagator->extract($request->headers->all()); // W3C traceparent/tracestate
        $context = $parent->with(RequestContext::key(), $request->header('X-Request-Id') ?? (string) Str::uuid());

        $scope = $context->activate();
        try {
            return $next($request);
        } finally {
            $scope->detach(); // never omit, never reorder
        }
    }
}

Register it before anything that dispatches jobs, calls HTTP, or writes logs that you want correlated.

2. Baggage for log correlation (biggest quick win)

Log::withContext() in the same middleware is cheaper than reading Context from a Monolog processor, and it composes with the scope:

Log::withContext([
    'request_id' => $context->get(RequestContext::key()),
    'trace_id'   => $span->getContext()->getTraceId(),
]);

If you prefer pulling at write time (useful when the trace ID is only known mid-request), push a Monolog processor in a service provider:

Log::getLogger()->pushProcessor(function (array $record) {
    if ($id = Context::getCurrent()->get(RequestContext::key())) {
        $record['extra']['request_id'] = $id;
    }
    return $record;
});

The processor is the better pattern for long-running workers, because the value is read at log-write time rather than frozen in a logger instance.

3. Queue jobs: capture at push, restore at pop

Context does not survive serialization. You must stamp the payload on dispatch and rehydrate in a job middleware.

// AppServiceProvider::boot() — runs in the dispatching process
Queue::createPayloadUsing(function ($connection, $queue, array $payload) {
    $scope = Context::getCurrent()->activate();
    try {
        $carrier = [];
        TraceContextPropagator::getInstance()->inject($carrier);
        return $payload + ['otel' => $carrier];
    } finally {
        $scope->detach();
    }
});
final class RestoreTraceContext
{
    public function handle(object $job, Closure $next)
    {
        $carrier = $job->otel ?? json_decode($job->getRawBody(), true)['otel'] ?? [];
        $parent  = TraceContextPropagator::getInstance()->extract($carrier);

        $scope = ($parent ?: Context::getRoot())->activate();
        try {
            return $next($job);
        } finally {
            $scope->detach();
        }
    }
}

Register via $job->middleware([RestoreTraceContext::class]) or globally with Bus::pipeThrough(). Rehydrating from Context::getRoot() when the carrier is empty is important — otherwise a job dispatched by a tinker session inherits whatever stale context exists in the worker.

4. Outbound HTTP

Laravel's HTTP client is the seam where propagation is usually forgotten:

Http::withHeaders($carrier)->post(...);

Wrap it in a macro that injects traceparent from Context::getCurrent() so call sites stay clean:

Http::macro('traced', function () {
    $carrier = [];
    TraceContextPropagator::getInstance()->inject($carrier);
    return Http::withHeaders($carrier);
});

5. Event loops, callbacks and fibers

For anything that defers execution, bind the context at scheduling time. The README's helper is the canonical shape:

function bindContext(Closure $closure): Closure {
    $context = Context::getCurrent();
    return static function (mixed ...$args) use ($closure, $context): mixed {
        $scope = $context->activate();
        try { return $closure(...$args); }
        finally { $scope->detach(); }
    };
}

Use this around queue:work daemons' per-job wrappers, websocket message handlers, and any Redis/pubsub subscriber. Without it, the callback runs in whatever context happens to be current.

6. Artisan and scheduled tasks

Same middleware logic, different entry point. For php artisan commands, activate in handle() and detach in a finally. For the scheduler, wrap each event closure with bindContext() — scheduled tasks run inside a long-lived schedule:run process where a leaked scope will silently apply to the next task.

7. Testing

Assert the invariant rather than the internals:

public function test_middleware_restores_context(): void
{
    $root = Context::getRoot();
    $this->get('/')->assertOk();

    $this->assertSame($root, Context::getCurrent());
}

For unit tests of context-dependent code, Context::storage() is swappable — replace it with a trivial storage implementation in setUp() if you need deterministic isolation from the fiber storage.

Gotchas and Tips

Contexts are immutable — with() returns a new object. $context->with($key, $value); on its own is a no-op with no warning. Always assign: $context = $context->with(...). This is the single most common bug.

Scopes must detach LIFO. If you activate A then B, detach B then A. Out-of-order detaches leave the current context pointing at a node that's gone or, worse, at the wrong ancestor. In PHP-FPM you often get away with it because the process dies; in Octane, queue workers and Swoole this silently corrupts the next request's trace.

Debug scopes warn loudly, and dd()/exit trip them. A scope alive at process shutdown produces notices. Laravel's dd(), die() and hard exit calls will do this in local dev. Set OTEL_PHP_DEBUG_SCOPES_DISABLED=1 in .env.local if the noise is unmanageable — but disabling it in CI removes your safety net, so keep it on there. The warnings are the only feedback you get for a leaked scope.

Nothing propagates across a process boundary for free. Context is per-process memory. HTTP headers, queue payloads, and log shipping all require explicit inject/extract. If traces look "broken at the queue", the bug is almost always a missing inject() at dispatch, not a missing activate() at consume.

Context keys are identity-based, not string-based. Two packages both doing Context::createKey('tenant.id') get different keys and will not see each other's values. Share a single static key (or a small Keys class) across your app. This is also why you can't override or accidentally clobber another library's value — by design.

Fiber support is opt-in and environment-sensitive. It requires an NTS build, ext-ffi, OTEL_PHP_FIBERS_ENABLED=true, and vendor/autoload.php preloaded for non-CLI SAPIs. It is explicitly unsafe on ZTS. Laravel Octane with Swoole uses coroutines, not fibers, so fiber propagation does not cover it — treat Octane as a case where you must manually reset context per request and per tick, and test under load.

Baggage is untrusted input. Anything extracted from headers or job payloads can be attacker-controlled. Never put secrets, tokens or raw PII in baggage, and strip/replace incoming baggage at the edge rather than trusting X-Correlation-Id verbatim. Correlation is an enabler for audit and incident response, not a compliance control in itself.

Don't hold a Context in a singleton. Storing a Context (or worse, a Scope) on a bound singleton pins the whole linked list for the life of the worker and defeats the garbage collection improvements shipped in 1.2.1. Read Context::getCurrent() at the point of use; pass primitives (trace ID strings) across boundaries.

Scope is cheap but not free. Activating inside a tight loop (per-row in an import, per-attribute in a serializer) adds up in profile traces. Hoist the activate/detach outside the loop and mutate via with() inside.

Version alignment matters more than usual. This package is a split of one monorepo along with open-telemetry/api, open-telemetry/sdk and open-telemetry/sem-conv. Mixing minor versions across them produces confusing type errors on ContextInterface. Upgrade as a set, and check the monorepo's CHANGELOG/releases, not the split repo's.

Extension point: Context::storage(). The default storage is fiber-aware in recent versions; swapping in a custom ContextStorageInterface is how you'd integrate a non-fiber async runtime. That's an advanced, invasive lever — exhaust bindContext() wrappers first, because a custom storage is global and applies to every library in the process.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
roundly-consulting/kubernetes-api-for-laravel
roundly-consulting/plausible-for-laravel
gvp/gvp-laravel-template
metasyncsite/laravel-metasync-client
roundly-consulting/onboarding-for-laravel
andydefer/laravel-notification
adachsoft/cli-process-tester
jhaskell13/laraflow
sebastienheyd/boilerplate-media-manager
ccast/tagixo-primix
pretendtrue/laravel-builder
sawmainek/laravel-scout-tntsearch-driver
tomshaw/laravel-calendar-table
dibakar/laravel-ownership
steadrun/laravel-monitor
labyrinthe/connect-laravel
elmogy/layan
drewdan/sentinel-client
dskripchenko/laravel-llm
fatryst/nuonuo