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.
1.2.0 - 2026-09-15
Section titled “1.2.0 - 2026-09-15”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:
RequestContextintests/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"wherenullwas expected.ReplayStaticGapRegressionTeststarts 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/resetscenario 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 asworker-safety.yaml: unknown keys are errors at every level,jsonandbodymay 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_containsandbody_not_contains. An absent path is reported as “no value at this path” rather than asnull, 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.
Changed
Section titled “Changed”- 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-Lengthis a transport error. Each of these used to reportpassed: truewith exit 0 on a response that never arrived — including forbody_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. --timeoutis 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 the1 == 1.0policy 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_missingis still the boolean that distinguishes the two. StreamHttpClientreadsfopen()failures througherror_get_last()rather than installing a temporary error handler. The self-scan flagged the handler asWS009, 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. testnever loads, autoloads or executes any of the target application’s code. It is an HTTP client, the same wayscanis a parser.- Exit codes match
scan:0pass,1a failed expectation,2an invalid scenario,3a request that could not be completed.3is deliberately not1, 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.
1.1.0 - 2026-09-11
Section titled “1.1.0 - 2026-09-11”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.
Changed
Section titled “Changed”- The console report no longer prints the selected runtimes as
Runtimealongside detected facts. They are their ownAnalysis targetsblock, 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 separateIncomplete 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_targetsandproject.runtime_detectedin the JSON report.project.runtimeskeeps 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.
Known limitations
Section titled “Known limitations”- 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')andauth()->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.
1.0.1 - 2026-09-11
Section titled “1.0.1 - 2026-09-11”A compatibility fix for 1.0.0, which could not be installed alongside Symfony 8.
-
Allow
symfony/consoleandsymfony/yaml8.1.0.0required^6.4 || ^7.0, socomposer requirefailed outright in any project that had already resolved Symfony 8 — which includes every Laravel 13 application, because Laravel 13 requiressymfony/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.phpfirst — 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/console8.
1.0.0 - 2026-09-11
Section titled “1.0.0 - 2026-09-11”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.
What is in it
Section titled “What is in it”- 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,initandbaseline. - 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-ignorecomments 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:scanthrough package discovery. - Documented CI exit codes:
0pass,1findings at or above the threshold or a file that could not be analyzed,2invalid configuration,3internal error.
Requirements
Section titled “Requirements”- PHP 8.2, 8.3 or 8.4, with
ext-jsonandext-mbstring. nikic/php-parser^5.3,symfony/consoleandsymfony/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.
Upgrading a baseline
Section titled “Upgrading a baseline”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:
vendor/bin/worker-safety baselineIdentity 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.
Known limitations
Section titled “Known limitations”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.
WS008proves 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 theif (count($x) > N) array_shift($x)eviction idiom, is reported atMEDIUMwith 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:scanthrough 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’sbase_path()instead of the current working directory, with--project-dirstill 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-safetyis unchanged and nothing underWorkerSafety\Integration\Laravelis 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.
Changed — eighth release review
Section titled “Changed — eighth release review”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.
Fixed — seventh release review
Section titled “Fixed — seventh release review”?->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 ordinaryMethodCall. 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.
Fixed — sixth release review
Section titled “Fixed — sixth release review”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.
Fixed — fifth release review
Section titled “Fixed — fifth release review”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 thenullvariant) assigns into a key and can even add one; it was being classified as a full clear, which silenced the finding entirely. Both are nowHIGH. - 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. gotojoins 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
MEDIUMandHIGH— or the exit code between 0 and 1.
Changed — fourth release review
Section titled “Changed — fourth release review”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
INFas 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.
Fixed — third release review
Section titled “Fixed — third release review”WS008: a size guard is now verified rather than detected. The condition has to measure this collection withcount()/sizeof(), compare it against a finite limit, and put the eviction in the branch taken when the limit is exceeded.count([]) > 10andcount(self::$x) < 0no longer silence the rule, and anelsebranch no longer inherits its ownif’s guard.WS008: removals are only counted as undoing an addition when they target the same array key, sounset(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 onearray_pop()is reported.- The right operand of
&&,||,and,orand??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 andbind()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.
Fixed — second release review
Section titled “Fixed — second release review”WS008: a fixed outer key no longer bounds a nested collection.self::$items['bucket'][] = $xgrows 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.
Fixed — release review
Section titled “Fixed — release review”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 setsexecutionSuccessful: truefor it. Opt out with--allow-parse-errorsorfail_on_parse_error: false. A syntax error php-parser recovers from does not count. baselinerefuses to record findings from an incomplete scan.baseline: falsein 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: ascoped()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 classmarks 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.flushfor static state: it callsforgetInstance()on container bindings and never touches a static property. WS002/WS003no longer claim cross-request persistence for superglobals the runtimes rebuild. FrankenPHP documents$_GET,$_POST,$_COOKIE,$_FILES,$_SERVERand$_REQUESTas reset and$_ENVas the exception, so$_SERVERwrites drop toLOW, request-superglobal writes drop toLOW, and$_ENVstaysHIGH.- 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 avendor/bin/worker-safetythat does not exist here. - PHP-CS-Fixer runs sequentially, so
composer lintdoes not depend on being able to bind a local TCP socket.
[0.1.0] - 2026-09-10
Section titled “[0.1.0] - 2026-09-10”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:
WS001mutable static propertyWS002mutable global variableWS003runtime environment mutationWS004mutable singletonWS005Laravel container singleton with mutable stateWS006Laravel scoped binding candidateWS007static request/user contextWS008static collection growthWS009persistent event/listener registrationWS010shutdown/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 WS001comments 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:
0pass,1findings above threshold,2invalid configuration,3internal error.