Skip to content

(feat) Request-scoped lifecycle and context propagation - #368

Merged
techmahedy merged 3 commits into
doppar:4.xfrom
techmahedy:techmahedy-4.x
Oct 16, 2026
Merged

techmahedy merged 3 commits into
doppar:4.xfrom
techmahedy:techmahedy-4.x

Conversation

@techmahedy

Copy link
Copy Markdown
Member

Pull Request Checklist

Q A
Branch? 4.x
Bug fix? no
New feature? yes
Deprecations? no
Issues #362
License MIT

Problem - Request-scoped lifecycle and context propagation

Doppar already drops resolved request-scoped services when a request terminates, but that cleanup is implicit and HTTP-only. It sweeps every service bound after boot, so the lifetime of a service depends on when it was registered, and console commands and workers get no equivalent. There was also no object that described the running request, command or job, so logs from one execution could not be tied together and metadata such as a tenant had nowhere to live.

What this adds

An immutable ExecutionContext (id, type, metadata, parent, start time), a stack of active contexts owned by the container, and a new execution binding scope.

// Bind a service to one execution
$this->app->bindExecution(TenantState::class, fn () => new TenantState());

// Read and enrich the active context
Execution::id();
Execution::get('path');
Execution::stamp(['tenant' => 'acme']);

// Run any unit of work in its own context
Execution::within('worker', ['job' => 'SendInvoice'], fn () => $job->handle());
  • Scopes: a binding is singleton, execution or transient. bindExecution() and bindWithScope() declare it, scopeOf() reads it back. An execution-scoped instance is shared inside one context, is never visible to the next request, command or job, and is dropped when its context ends.
  • Nesting: a nested context inherits its parent's metadata, may override keys, and gets its own scoped instances. Ending an outer context ends any open inner ones first.
  • Lifecycle hooks: contextBeginning() and contextEnding() (facade: Execution::beginning() and Execution::ending()), optionally filtered by context type. Ending listeners run before the scoped instances are dropped.
  • HTTP: dispatch() opens a request context with method, path and request_id (from X-Request-Id). It is suspended after the response is sent and resumed in terminate(), so terminating callbacks and defer() callbacks still see it. It ends last, after request cleanup, and also ends if the handler or the error page throws.
  • Console: every command runs in a console context carrying the command name, wrapped around its defer scope.
  • Logging: while a context is active, LoggerService adds an execution key (id, type, parent id, metadata) to each record's context. It is read from the active context at log time, so no logger state exists to reset. A caller-supplied execution key is never overwritten.
  • Facade, service and helper: Execution facade over ExecutionService, bound as execution, plus an execution() helper like defer() and signal().

Backward compatibility

The only signature changes are optional trailing parameters: Application::terminate() and DispatchResult::__construct() each gain a nullable ?ExecutionContext $context = null.

Documentation

Docs are in the docs repo under versions/4.x: a new execution-context.md, plus updates to container.md, logging.md, request-lifecycle.md, helpers.md, facades.md. Docs

Checklist

  • Tests have been added or updated
  • Documentation has been updated if necessary.
  • Code follows the project coding standards
  • All tests pass locally

@techmahedy
techmahedy merged commit e7563d6 into doppar:4.x Oct 16, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant