rasuvaeff / context
Deadline, cancellation and request-scoped values for PHP
Requires
- php: 8.3 - 8.5
Requires (Dev)
- ergebnis/composer-normalize: ^2.51
- friendsofphp/php-cs-fixer: ^3.95
- infection/infection: ^0.33
- maglnet/composer-require-checker: ^4.17
- rasuvaeff/property-testing-testo: ^1.0
- rasuvaeff/rector-named-literals: ^1.0
- rector/rector: ^2.4
- roave/backward-compatibility-check: ^8.0
- testo/bridge-infection: ^0.1.6
- testo/testo: ^0.10.25
- vimeo/psalm: ^6.16
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-11 09:24:52 UTC
README
Deadline, cancellation and request-scoped values for PHP
Using an AI coding assistant? llms.txt contains a compact API reference you can share with the model.
Requirements
- PHP 8.3+
- No runtime dependencies
Installation
composer require rasuvaeff/context
Usage
A Context is an immutable, read-only view passed down the call graph: a
deadline, a cancellation signal and request-scoped values. The owner of a
cancellable branch keeps the matching ContextController. Cancellation is
cooperative: code checks the context between steps; nothing is interrupted
mid-call.
Create and derive
use Rasuvaeff\Context\Context; $root = Context::background(); // never terminates by itself [$request, $requestController] = $root->withTimeout(3.0); // seconds [$step, $stepController] = $request->withCancel(); // cancellable branch [$capped] = $request->withDeadline($absoluteNs); // monotonic hrtime() ns $traced = $request->withValue(RequestId::class, new RequestId('r-42')); $traced->value(RequestId::class)?->value; // 'r-42' (nearest ancestor wins)
A child deadline is never later than its parent's: withTimeout() and
withDeadline() take the earlier of the two. A zero or negative timeout is
an immediate deadline; an oversized one saturates at PHP_INT_MAX instead of
overflowing.
Check
$request->assertActive(); // throws ContextCancelledException / ContextExpiredException $request->isTerminated(); // cancelled or expired $request->isCancelled(); // explicit cancellation $request->isExpired(); // deadline passed $request->remainingMs(); // null without a deadline; >= 1.0 while active, 0.0 once expired
Deadlines use monotonic hrtime() and are observed lazily: the next
assertActive(), remainingMs(), isTerminated() or isExpired() call
discovers an expired deadline. If the deadline belongs to an ancestor, the
ancestor and its whole subtree are terminated. A controller cancellation
propagates to every descendant immediately.
Cancel and clean up
$unsubscribe = $request->onTerminate(static function (TerminationCause $cause): void { // release a resource owned by this operation }); $unsubscribe(); // the resource was released early: drop the listener $requestController->cancel($reason); // $reason becomes the exception's previous
onTerminate() runs the listener once when the context terminates
(immediately if it already has) and returns a closure that unregisters it.
A terminated context detaches from its parent and releases its listeners, so
a long-lived parent does not accumulate per-request children. Always cancel
the controller you own in finally.
Queue workers
Create a context per message with the job's TTR as the deadline, check it between external calls and acknowledge only when the job completes:
[$jobContext, $jobController] = Context::background()->withTimeout($ttrSeconds); try { foreach ($steps as $step) { $jobContext->assertActive(); $step($jobContext); } $queue->ack($message); } catch (ContextExpiredException) { $queue->nack($message); // let the queue decide retry/dead-letter } finally { $jobController->cancel(); }
Crossing a process boundary
Deadlines are monotonic nanoseconds of the current process and mean nothing elsewhere. Send the remaining budget and rebuild it on the other side:
// sender: an HTTP header or a queue message attribute $headers['X-Request-Timeout-Ms'] = (string) (int) $context->remainingMs(); // receiver: never longer than its own limit [$context] = Context::background()->withTimeout(min($limitSeconds, $headerMs / 1000));
Validate the incoming value (an integer in a bounded range) before trusting
it. rasuvaeff/context-http implements this for PSR-15/PSR-18.
Testing
Inject a TimeSource to drive deadlines deterministically:
$clock = new FakeTimeSource(); // your TimeSource returning nowNs() [$context] = Context::background($clock)->withTimeout(1.0); $clock->advanceNs(2_000_000_000); $context->isExpired(); // true
Public API
| Class | Description |
|---|---|
Context |
Immutable view: background(), withCancel(), withTimeout(), withDeadline(), withValue(), value(), assertActive(), is*(), remainingMs(), deadlineNs(), onTerminate() |
ContextController |
Owner handle: cancel(?Throwable $reason = null) |
TimeSource |
Monotonic clock seam (nowNs(): int) for deterministic tests |
HrTimeSource |
Production hrtime() implementation |
TerminationCause |
Cancellation or deadline cause passed to listeners |
ContextException |
Base context exception (RuntimeException) |
ContextCancelledException |
Explicit cancellation detected by assertActive(); the reason is previous |
ContextExpiredException |
Deadline expiration detected by assertActive(); deadlineNs(), optional previous |
Security
Context values are request-scoped metadata, not a service locator; keys are
class strings and a value must be an instance of its key. The package never
interrupts a blocking syscall: transport adapters must map the remaining
budget to their own timeouts or register an onTerminate() cleanup
listener. A budget received from another process is untrusted input —
validate and cap it before building a context from it.
Examples
See examples/ for runnable scripts. Examples are expected to execute without fatal errors and stay aligned with the documented public API.
| Script | Shows | Needs server? |
|---|---|---|
basic.php |
Derivation, request-scoped values and cancellation | no |
Development
No PHP/Composer on the host — run in Docker via the composer:2 image:
docker run --rm -v "$PWD":/app -w /app composer:2 composer install docker run --rm -v "$PWD":/app -w /app composer:2 composer build docker run --rm -v "$PWD":/app -w /app composer:2 composer cs:fix docker run --rm -v "$PWD":/app -w /app composer:2 composer test docker run --rm -v "$PWD":/app -w /app composer:2 composer release-check
Or with Make:
make install
make build
make cs-fix
make test
make test-coverage
make mutation
make release-check
make test-coverage and make mutation bootstrap pcov inside the
composer:2 container because the base image has no coverage driver.