Skip to content

Changelog

All notable changes to this project are documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

Runtime replay: the first feature that observes behaviour instead of inferring risk from source.

  • Reject incomplete chunked replay responses before assertions, including missing terminal chunks and truncated trailers. Validate and decode wire framing instead of relying on the PHP stream decoder.
  • Preserve object/array types in replay request JSON, including nested empty mappings and sequences.
  • worker-safety test — replays an ordered list of HTTP requests against an application that is already running and reports whether a later request could observe state an earlier one left behind.

    Static analysis asks whether code could retain state between requests. Replay asks whether request B did see what request A wrote. The two answers are not the same, and the package now ships a permanent regression test where they disagree: RequestContext in tests/Fixtures/Replay/static-safe/ is an ordinary object with a mutable instance property, the scanner correctly reports zero findings on it, and replaying two requests against a worker that constructs one instance outside its accept loop observes "alice" where null was expected. ReplayStaticGapRegressionTest starts a real second PHP process and makes real HTTP requests; nothing about it is mocked.

    The control is a second worker fixture differing by one line — where new RequestContext() sits relative to the accept loop. The same unmodified scenario fails against the leaky one and passes against the fixed one, so the difference is attributable to the object’s lifetime and nothing else. The older /reset scenario is kept but renamed to say what it actually proves: that an explicit reset between observations passes.

    The fixtures are small persistent PHP processes speaking real HTTP over a loopback socket. They are not FrankenPHP and not Octane, and the suite is not an automated integration with either runtime. Separate local release checks confirmed leak/fix outcomes with standalone FrankenPHP 1.12.7 (PHP 8.5.10) and Laravel 12.69.2 / Octane 2.19.1 using the FrankenPHP backend, one worker per server and stable worker-local identity across requests.

  • Scenario files (version: 1) describing steps, requests and expectations in YAML, validated as strictly as worker-safety.yaml: unknown keys are errors at every level, json and body may not both be set, and the YAML is parsed without object or custom-tag support.

  • Assertions: status equality, JSON equality at dot-paths (user, tenant.id, items.0.id), case-insensitive header equality, body_contains and body_not_contains. An absent path is reported as “no value at this path” rather than as null, because {"user": null} and {} are different answers to the question replay asks.

  • Console and JSON reports for replay. The JSON schema is versioned independently of the scan report. Neither reporter echoes request headers: scenarios carry tokens, and reports end up in CI logs.

  • A pure-PHP persistent fixture worker under tests/Fixtures/Replay/, so the acceptance test needs no FrankenPHP, no Octane and only loopback networking. CI is unchanged in its requirements.

  • Response completeness is verified before any expectation is judged. Stream metadata is read after the body, not before, so a stall partway through a response is no longer invisible; a read that fails is no longer turned into an empty successful body; and a body shorter than its Content-Length is a transport error. Each of these used to report passed: true with exit 0 on a response that never arrived — including for body_not_contains, which a truncated body satisfies for the wrong reason. HEAD, 1xx, 204, 304 and length-less responses remain supported, each with a regression test.
  • --timeout is documented as an inactivity timeout, matching what the implementation does. Help text and README agree.
  • JSON equality uses JSON’s type model. Bodies and YAML expectations both decode with objects and arrays kept distinct, and comparison is recursive: object key order is irrelevant, array order is not, {} never equals [], scalar types stay distinct, and the 1 == 1.0 policy applies at every depth. Previously {a:1,b:2} failed against {b:2,a:1} and [] matched {}.
  • Absence is a type, not a string. The missing-value marker was the literal __worker_safety_missing__, so a response containing that string was read as a missing field. It is now an enum case, which no JSON document can produce. The external schema is unchanged: actual_missing is still the boolean that distinguishes the two.
  • StreamHttpClient reads fopen() failures through error_get_last() rather than installing a temporary error handler. The self-scan flagged the handler as WS009, correctly — mutating the process-wide handler stack from a request path is the thing this package tells people not to do.
  • Replay requires a single worker. It demonstrates cross-request behaviour only when consecutive requests reach the same persistent process. Start the target with php artisan octane:start --workers=1, or a single FrankenPHP worker. Against a load-balanced deployment a passing run proves nothing, and the tool does not claim otherwise.
  • test never loads, autoloads or executes any of the target application’s code. It is an HTTP client, the same way scan is a parser.
  • Exit codes match scan: 0 pass, 1 a failed expectation, 2 an invalid scenario, 3 a request that could not be completed. 3 is deliberately not 1, because a request that never happened observed nothing.
  • Out of scope, and not implemented: memory or object-graph inspection, PHP extension instrumentation, automatic application bootstrap, route injection, worker pinning, queue-worker analysis, and mapping observed state back to source.
  • The static analyzer is untouched. Rules, findings, severities, fingerprints and baselines behave exactly as in 1.1.0.

Report wording and false-positive handling, from feedback on real Laravel use. No rule was made quieter: nothing that was reported before is suppressed or downgraded now.

  • The console report no longer prints the selected runtimes as Runtime alongside detected facts. They are their own Analysis targets block, with a note saying they were chosen rather than detected and that the findings describe what the code would do under a persistent worker. Worker Safety performs no runtime detection, and the absence of a runtime package is not treated as evidence that no worker is used — queue workers, externally installed runtimes and deployment configuration all make that inference unreliable, and the tool has to stay usable as a pre-migration check.
  • A failing scan says Threshold exceeded: N finding(s) at or above the configured fail_on level, and states that this is a decision against the configured threshold rather than a fault observed in production. Failing because files could not be parsed is now printed as a separate Incomplete analysis: reason instead of sharing the wording.
  • WS001 distinguishes a conditional initializer (self::$x ??= …) from a slot that is overwritten per request, because the stories differ: ??= writes only into an empty slot, so a value stored there is reused by the requests that follow instead of being recomputed. The finding stops at what that establishes — it makes no claim that the initializer runs once, since an initializer returning null leaves the slot empty and a reset re-opens it — states which question decides the risk (where the value comes from), and points at the inline directive.
  • WS005 and WS006 no longer present singleton() → scoped() as a rename. It is a change of lifetime: the advice now asks which consumers resolve the class, and warns that anything outliving a request keeps the instance it already captured. WS006’s message says “is a candidate for” rather than “should use”.
  • project.analysis_targets and project.runtime_detected in the JSON report. project.runtimes keeps its name and values, so existing consumers and baselines are unaffected; fields are only added within a schema version.
  • Regression coverage for the memoization Laravel ships in every new application (static::$password ??= Hash::make('password')), together with the shapes that share its syntax but freeze request data (auth()->user(), request('tenant'), a caller-supplied password). All are reported, and the tests fail if a heuristic ever starts silencing them by class name, path or operator. Two further shapes pin the limits of what ??= establishes: an initializer that returns null, and a reset immediately before the assignment. Both recompute on every call — three computations in three calls when run — so the report must not describe either as computed once.
  • Documentation for that case in the README, with the exact inline directive to record the decision, and a note that such a suppression is anchored to the property declaration — so it keeps applying if the initializer later changes.
  • Whether a memoized value is a constant or request data is not determined, and neither is how long it survives. Hash::make('password'), request('tenant') and auth()->user() are the same shape to a parser, so the stock Laravel factory is reported and has to be accepted deliberately, via an inline directive or the baseline. Nothing was added that would silence it by guessing.

A compatibility fix for 1.0.0, which could not be installed alongside Symfony 8.

  • Allow symfony/console and symfony/yaml 8. 1.0.0 required ^6.4 || ^7.0, so composer require failed outright in any project that had already resolved Symfony 8 — which includes every Laravel 13 application, because Laravel 13 requires symfony/console ^7.4 or ^8.0. The constraint is now ^6.4 || ^7.0 || ^8.0.

    Nothing changes for projects on Symfony 6.4 or 7: Symfony 8 requires PHP 8.4, so Composer will not select it on PHP 8.2 or 8.3, and the committed lock file still resolves to Symfony 7.

  • A CI job that installs Symfony 8 on PHP 8.4 and runs the suite against it, so the compatibility claim is checked on every push rather than assumed. The job has to drop config.platform.php first — that pin targets the lowest supported PHP and would otherwise make the job silently re-test Symfony 7.
  • Laravel 13 to the verified support matrix: package discovery, all documented options, JSON and SARIF output, the baseline round-trip and every exit code, checked on PHP 8.5 with symfony/console 8.

First stable release, and the first release published at all: 0.1.0 below was a development version that was never tagged or distributed.

Worker Safety statically analyzes PHP source for state that survives a request when the application runs on a persistent worker — FrankenPHP worker mode, Laravel Octane, RoadRunner or Swoole — and reports it before it reaches production.

  • Ten rules (WS001–WS010) covering static properties, globals, environment mutation, singletons, Laravel container bindings, request-scoped static context, unbounded static collections, listener registration on request paths, and code that assumes the process dies with the request.
  • Four commands: scan, rules, init and baseline.
  • Three output formats: a human-readable console report, a stable JSON schema (version: "1") and SARIF 2.1.0 for GitHub code scanning.
  • Configuration through worker-safety.yaml, with per-rule enable/severity overrides, path-scoped suppression, inline // worker-safety-ignore comments and a #[WorkerSafetyIgnore] attribute.
  • A baseline so an existing codebase can adopt the tool without fixing everything first.
  • Framework awareness: Laravel detection with container-binding analysis, and a Laravel integration that registers php artisan worker-safety:scan through package discovery.
  • Documented CI exit codes: 0 pass, 1 findings at or above the threshold or a file that could not be analyzed, 2 invalid configuration, 3 internal error.
  • PHP 8.2, 8.3 or 8.4, with ext-json and ext-mbstring.
  • nikic/php-parser ^5.3, symfony/console and symfony/yaml ^6.4 or ^7.0.
  • Laravel 11 or 12 for the optional Artisan command. Laravel is never a runtime dependency; without it the CLI behaves identically.

Any baseline generated before this release must be regenerated. Finding identity changed during pre-release review: it is now derived from the whole reported construct, taken from the token stream with no length limit, instead of a single truncated display line. Old fingerprints will not match, so a stale baseline silently stops suppressing anything:

Terminal window
vendor/bin/worker-safety baseline

Identity is still independent of line numbers — moving code does not resurrect a baselined finding — and is now also insensitive to re-indentation and to comments inside the construct, while remaining sensitive to whitespace inside string literals and heredocs, which is content rather than formatting.

This is a risk analyzer, not a proof of safety. A clean scan means the known cross-request patterns are absent from the analyzed paths. Out of scope:

  • Dynamic property and variable access, reflection, and runtime-generated code.
  • Third-party packages: vendor/ is never scanned, so state retained inside a dependency is invisible.
  • Complex data flow, and inherited or trait-provided members, which are analyzed where they are declared rather than once per using class.
  • WS008 proves a bound for exactly one shape — a statement that unconditionally resets the whole collection in the function that grows it. Every other release path, including the if (count($x) > N) array_shift($x) eviction idiom, is reported at MEDIUM with a message saying the bound could not be proven. Review it and record the decision with an inline ignore if the trade-off is deliberate.
  • A namespaced user function that shadows a built-in cannot be told apart from the global one.

The README carries the full list.

  • Laravel integration. Installing the package in a Laravel application now registers php artisan worker-safety:scan through package discovery.

    The Artisan command subclasses the existing ScanCommand, so the arguments, options, output formats, baseline handling and exit codes are literally the same implementation rather than a second copy that could drift. The only difference is the default project root: Artisan can be run from anywhere, so the scan defaults to the application’s base_path() instead of the current working directory, with --project-dir still taking precedence and a relative value resolved against the application root.

    The command is registered only for console runs: outside the console the provider is still loaded and still adds its container binding, but no command is registered and nothing is scanned. Laravel remains a development-only suggestion rather than a dependency — in a project without it, vendor/bin/worker-safety is unchanged and nothing under WorkerSafety\Integration\Laravel is autoloaded.

    Verified against Laravel 11 and 12. Laravel 10 is not excluded by the package’s constraints but has not been tested, so it is not claimed as supported. See the README for the version table and for manual provider registration when package discovery is disabled.

WS008 now draws the line for a “provable reset” at the statement boundary instead of trying to enumerate the ways a sub-expression can be skipped.

A nullsafe chain that continues into a static call — $sink?->next()::consume(self::$items = []) — put the receiver in the class position rather than in ->var, so the chain walker added in the previous round stopped early and the reset counted as unconditional again. That was the fourth shape of the same question in as many rounds, so the question itself is gone:

  • A reset is accepted as proof only when the assignment is a statement, not when it sits inside another expression. Whether a sub-expression is evaluated depends on its surroundings — a nullsafe link anywhere in the chain, a short-circuit operator, arguments of a call that never happens — and that set has no closed enumeration.
  • The nullsafe chain walker is therefore deleted, along with its node-type special cases. Less code, and nothing left to extend shape by shape.

The cost is one deliberate over-report: a reset nested in another expression is now reported even when it does run. The existing scope, loop, early-exit and closure checks are unchanged and still required.

  • ?-> short-circuits the whole chain, not just its own link. A reset written further along one — $sink?->next()->consume(self::$items = []), and the property and array-dimension forms of the same shape — was still counted as unconditional, because the conditional context closed when the nullsafe node was left and the outer call is an ordinary MethodCall. The short-circuit is now modelled along the receiver chain, so anything reachable only past a nullsafe link is conditional. A chain with no nullsafe in it is unaffected and still counts as an unconditional reset.

Two more ways a WS008 finding could vanish entirely:

  • Sub-expressions that may never be evaluated are now treated as conditional: the arguments of a nullsafe call ($sink?->consume(self::$items = []) skips them when the receiver is null) and the right-hand side of ??=. A reset written in either position was being taken as an unconditional full reset.
  • array_push(self::$items['bucket'], $v) is growth again. The dimension path was overwriting the function’s effect with a keyed write, and the fixed outer key then made it look bounded — but a constant key limits how many keys the property has, not how large the array under one of them grows. The function now decides the effect and the dimension only says which collection it lands on.

Four holes in the one remaining WS008 exception, the unconditional full reset:

  • A write through an array dimension is never a reset of the collection. self::$items['last'] = [] (and the null variant) assigns into a key and can even add one; it was being classified as a full clear, which silenced the finding entirely. Both are now HIGH.
  • A reset written inside a closure no longer counts for the function that declares it. $reset = function () { self::$items = []; }; is never executed on its own — declaration scope is not execution scope, so nothing lexically inside a closure is treated as guaranteed.
  • goto joins return/throw/exit as an early exit, so a reset that is jumped over is no longer taken as unconditional.
  • Severity no longer depends on method declaration order. Every growing function is graded and the worst one decides, so moving a method within its class can no longer flip a finding between MEDIUM and HIGH — or the exit code between 0 and 1.

WS008 no longer claims bounds it cannot prove. Two suppression paths are gone rather than tightened again, because each needed information that the shape of the code does not carry:

  • The size-guarded eviction path is removed. Verifying the guard’s target, direction and limit still left it blind to an eviction behind a second condition, to a removal that takes out a key that was never added, to one write site that adds two elements, and to INF as a “finite” limit.
  • The same-key add/remove path is removed. Matching the key text cannot see that the removal runs before the addition, or that the variable holding the key was reassigned in between.

What remains is the one provable shape: an unconditional reset of the whole collection in the function that grows it. Everything else is reported — HIGH when there is no removal at all, MEDIUM when removals exist but bound nothing verifiable, with a message that says so and points at the inline ignore for a trade-off you have reviewed. The examples/leaky-app LRU now carries such an ignore, which is the workflow this is meant to have.

This makes the rule noisier on correct bounded caches and no longer silent on six broken ones. The guard-tracking machinery (sizeGuardedKeys, keyExpression, the guard stack and the finite-limit check) is deleted, so there is less code and nothing left to mis-verify.

  • WS008: a size guard is now verified rather than detected. The condition has to measure this collection with count()/sizeof(), compare it against a finite limit, and put the eviction in the branch taken when the limit is exceeded. count([]) > 10 and count(self::$x) < 0 no longer silence the rule, and an else branch no longer inherits its own if’s guard.
  • WS008: removals are only counted as undoing an addition when they target the same array key, so unset(self::$items['never']) after an append no longer passes as a bound. Write sites are never treated as element counts: array_push($x, $a, $b) paired with one array_pop() is reported.
  • The right operand of &&, ||, and, or and ?? counts as conditional, so $evict && array_pop(...) no longer reads as guaranteed to run.
  • WS005/WS006: alias-based suppression removed. forgetScopedInstances() unsets $instances[$key] with no alias resolution and bind() deletes $aliases[$abstract] for the key it registers, so scoping an alias never flushes the binding it aliased. Only an exact key match suppresses now, and the round-two test that asserted the opposite has been corrected.
  • WS008: a fixed outer key no longer bounds a nested collection. self::$items['bucket'][] = $x grows without bound, and the whole dimension chain is now checked — a key counts as fixed only when every dimension is a compile-time constant. Applies to function-scoped statics too, where the case previously produced no finding at all.
  • WS008: a bound must now be proven, not inferred from one removal being present. Three shapes silence the rule — an unconditional full reset, a set of additions and removals that are all guaranteed to run with at least as many removals as additions, and a single addition paired with a size-guarded eviction. Growth inside a loop, behind a condition, or after an early return proves nothing and keeps the warning, so the +2/−1 net-growth case is reported.
  • unset($static[$key]) is recorded as removing one entry, unset($static) as removing all of them; the two are no longer conflated.
  • Finding identity has no length limit. It was capped at the first 20 lines, so two calls differing only past that line shared a fingerprint and a baseline accepted the changed one. The identity is now derived from the token stream: whitespace between tokens collapses and comments are dropped (re-indenting or annotating code keeps a baseline valid), while the text of string literals and heredoc bodies is preserved byte for byte.
  • Laravel container keys are compared the way the container compares them — byte for byte, after following alias() chains. A scoped 'shared' no longer suppresses a singleton 'Shared', and a scoped alias correctly suppresses the binding it aliases. This key identity is deliberately separate from PHP class-name identity, which stays case-insensitive.
  • WS008’s description no longer claims the PHP-FPM process exits; the earlier lifecycle correction had not reached that string.

Because the fingerprint algorithm changed, baselines generated before this change no longer match. Regenerate with worker-safety baseline.

Scan integrity:

  • An unreadable or vanished file is reported as an analysis failure instead of being treated as a successfully analyzed empty file.
  • A scan that could not analyze a file now fails (exit 1) rather than reporting a clean result, and SARIF no longer sets executionSuccessful: true for it. Opt out with --allow-parse-errors or fail_on_parse_error: false. A syntax error php-parser recovers from does not count.
  • baseline refuses to record findings from an incomplete scan.
  • baseline: false in the configuration is honoured; it previously fell back to loading the default baseline file.
  • Finding identity is derived from the whole reported construct, so changing a multi-line argument no longer keeps a baselined fingerprint. Line movement still does not change it.

Rule accuracy:

  • WS005/WS006: a scoped() binding under a different key no longer hides a real singleton — the container flushes scoped instances by abstract key.
  • WS005/WS006: container factories resolve to the type they return, not to the first object they allocate; ambiguous factories are skipped.
  • WS005/WS006: free-form service ids ('auth.context') and already-built instances (instance('ctx', new Ctx())) are recognised as bindings.
  • WS005/WS006: a fully qualified class name that is not declared in the analyzed paths no longer falls back to an unrelated local short-name match.
  • readonly class marks every property, promoted or not, as immutable.
  • WS008: array_slice() is no longer treated as a release path (it copies); a bound in one method no longer covers growth in another; release paths are matched by class and method; a literal array key bounds the collection.
  • WS008: unset($static[$key]) inside a function-scoped static stays a clearing write.
  • WS003: aliased imports (use function putenv as changeEnv) are resolved; setlocale($category, 0) is recognised as a query, not a mutation.
  • WS009: only static registries are reported; an instance property on an object created per request is not assumed to outlive the request.

Accuracy of what the tool claims:

  • Remediation no longer suggests octane.flush for static state: it calls forgetInstance() on container bindings and never touches a static property.
  • WS002/WS003 no longer claim cross-request persistence for superglobals the runtimes rebuild. FrankenPHP documents $_GET, $_POST, $_COOKIE, $_FILES, $_SERVER and $_REQUEST as reset and $_ENV as the exception, so $_SERVER writes drop to LOW, request-superglobal writes drop to LOW, and $_ENV stays HIGH.
  • The PHP-FPM lifecycle is described accurately: the engine ends the request context, the OS worker process is reused.

Repository:

  • SARIF artifact and base URIs are percent-encoded, so a path containing a space or # is a valid URI.
  • The committed lock resolves against the minimum supported PHP (8.2) via config.platform, so the CI jobs pinned to 8.2/8.3 can install from it.
  • The consumer workflow templates moved to examples/workflows/; as active workflows in this repository they invoked a vendor/bin/worker-safety that does not exist here.
  • PHP-CS-Fixer runs sequentially, so composer lint does not depend on being able to bind a local TCP socket.

Development version. Never tagged or published; its contents ship as part of 1.0.0.

  • worker-safety scan — static analysis of PHP source for state that survives a request under a persistent worker (FrankenPHP, Laravel Octane, RoadRunner, Swoole).
  • Ten built-in rules:
    • WS001 mutable static property
    • WS002 mutable global variable
    • WS003 runtime environment mutation
    • WS004 mutable singleton
    • WS005 Laravel container singleton with mutable state
    • WS006 Laravel scoped binding candidate
    • WS007 static request/user context
    • WS008 static collection growth
    • WS009 persistent event/listener registration
    • WS010 shutdown/runtime lifecycle assumption
  • worker-safety rules — list the rule set, or show one rule in detail.
  • worker-safety init — write a commented configuration file.
  • worker-safety baseline — record current findings so only new ones fail the build.
  • Console, JSON and SARIF 2.1.0 output formats.
  • Configuration via worker-safety.yaml, with per-rule enable/severity overrides and path-scoped rule suppression.
  • Inline suppression through // worker-safety-ignore WS001 comments and the #[WorkerSafetyIgnore] attribute.
  • Framework detection from Composer metadata, with a Laravel adapter that contributes container-binding analysis.
  • Runtime targeting through --runtime, including runtime-specific guidance.
  • Documented CI exit codes: 0 pass, 1 findings above threshold, 2 invalid configuration, 3 internal error.