Skip to content

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 mutable
state ($user).
UserContext is registered with singleton(), so the container builds it once
and hands the same instance to every request. UserContext declares 1 mutable
instance property ($user) at app/Support/UserContext.php:12, which means
values written while serving one request are still set for the next one. The
property names read as request-specific state (user), so this is a concrete
cross-request leak rather than a theoretical one if the application runs on
a persistent worker. $user is public, so any caller holding the shared
instance 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 configured
threshold. Each one still has to be read: it describes a risk under a
persistent 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.

“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 scalar private static string $version = '1.0'; produces nothing.
  • Naming is checked in both directions. $currentUser and static ?User $u match; $userTable, $defaultLocale and UserRepository do 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 is MEDIUM, and one with no release path at all is HIGH.
  • 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, at MEDIUM rather than HIGH, 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 WS004 alone, so one line never carries two contradictory severities.
  • Registration in a service provider is not reported. That is where it belongs; WS009 targets 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.