Read a finding
Worker Safety 1.1.0
Project /Users/dev/payment-api
Environment PHP 8.4.2 Framework Laravel 12 Config worker-safety.yaml
Analysis targets FrankenPHP, Octane
Selected for this scan, not detected — Worker Safety does not inspect how this application is deployed. Findings below describe what the code would do if it ran under one of these runtimes; they are not observations of its current behaviour.
284 PHP files analyzed in 1.42s.
Findings──────────────────────────────────────────────────────────────────────
HIGH WS005 Laravel container singleton with mutable state
app/Providers/AppServiceProvider.php:18 App\Support\UserContext::$user
$this->app->singleton(UserContext::class);
Laravel singleton UserContext contains potentially request-specific mutablestate ($user).
UserContext is registered with singleton(), so the container builds it onceand hands the same instance to every request. UserContext declares 1 mutableinstance property ($user) at app/Support/UserContext.php:12, which meansvalues written while serving one request are still set for the next one. Theproperty names read as request-specific state (user), so this is a concretecross-request leak rather than a theoretical one if the application runs ona persistent worker. $user is public, so any caller holding the sharedinstance can change it.
Affected runtimes: FrankenPHP, Octane
Recommendation: - Consider `$this->app->scoped()`: Octane discards scoped instances between requests. Check first what else resolves this class — anything that outlives a request and took it through the constructor keeps the instance it already has, so the binding changes while that consumer does not. - Or make the service immutable and pass the per-request values in as method arguments. That removes the question of lifetime instead of answering it. - If the binding must stay a singleton, reset its state in a RequestReceived listener.
──────────────────────────────────────────────────────────────────────
Summary
Critical 0 High 2 Medium 3 Low 0 Info 0
Result: FAILED
Threshold exceeded: 2 finding(s) at or above the configured fail_on level (HIGH).That is a decision about the findings above against the configuredthreshold. Each one still has to be read: it describes a risk under apersistent worker, not a fault observed in production.Every finding says what was found, why it is a risk under a worker, and what to do about it. That last part is the whole point: a list of line numbers is not actionable.
FAILED means the configured threshold was crossed — nothing more. It is not a
statement that any of the findings has caused a fault, and a scan can also fail
for the unrelated reason that some files could not be parsed; that is reported
separately as Incomplete analysis.
How findings are graded
Section titled “How findings are graded”“I saw a static, therefore it is a bug” would make this tool useless. So:
- A property that is never written is not mutable state. Writes are collected
across the whole project, so
Registry::$items[] = …in another file counts, and a scalarprivate static string $version = '1.0';produces nothing. - Naming is checked in both directions.
$currentUserandstatic ?User $umatch;$userTable,$defaultLocaleandUserRepositorydo not, because configuration and infrastructure vocabulary neutralises the match. - A release path lowers the severity instead of hiding the finding. A static
cache cleared only by a
flush()method isMEDIUM, and one with no release path at all isHIGH. - Silence requires a proof, and only one shape qualifies. WS008 goes quiet
for exactly one pattern: a statement that unconditionally resets the whole
collection, in the function that grows it, because then nothing can carry over
to the next call. It has to be a statement — an assignment nested inside
another expression is not accepted, because whether a sub-expression runs
depends on everything around it.
Everything else — including the
if (count($x) > N) array_shift($x)eviction idiom — is reported, atMEDIUMrather thanHIGH, with a message saying the removal is there but unproven. Certifying an eviction would mean knowing that it runs on every path through its guard, that it removes at least as much as was added, and that the limit is finite; matching a removal to an addition would mean knowing their order and the runtime value of the key. None of that follows from the shape of the code, so the tool does not claim it. Review the finding and, if the bound is real, record the decision with an inline// worker-safety-ignore WS008. - A conditional initializer is named as such, and no further.
self::$x ??= …writes only while the slot is null or unset, so WS001 says a stored value is reused rather than recomputed — a different story from a slot overwritten per request. It stops there:??=is not proof that the initializer runs once, because an initializer returning null leaves the slot empty and a reset re-opens it, so the finding claims no lifetime for the value. Nor does it decide whether the reuse is safe; see the standard Laravel factory below. - The more specific rule wins. A singleton’s instance holder is reported by
WS004alone, so one line never carries two contradictory severities. - Registration in a service provider is not reported. That is where it
belongs;
WS009targets controllers, middleware, jobs and helpers. - Top-level code is downgraded. Statements outside any function normally run
once per worker boot, which is exactly where
putenv()is acceptable.
If a rule is wrong about your code, that is a bug worth reporting — see CONTRIBUTING.md.