A domain action caught any exception and returned false, null, or an empty
array. Elsewhere, reusable services read the current request, tenant, and
locale from mutable framework context.
Both patterns can be intentional. A fallback may be the contract at a presentation boundary. Ambient context is normal in the code which receives a web request. The problem appears when domain failure becomes indistinguishable from a valid negative answer, or when a reusable service silently depends on which request happened to call it.
Two retained PHPStan rules turned those assumptions into diagnostics. They did not declare every broad catch or context read wrong. Each rule named a narrow question, ran only in configured directories, allowed known alternatives, and recorded existing exceptions by diagnostic identifier and exact path.
That is a more useful purpose for static analysis than making the error count reach zero.
A diagnostic should contain a design question
“Forbidden function call” tells a developer what syntax matched. It does not explain which contract is in doubt.
The retained rules asked:
exception.broadFallback
Can unexpected failure be represented as an ordinary domain result?
service.ambientRequestContext
Can this reusable service produce the same result without the active request?
service.ambientTenantContext
Which tenant and actor does this operation actually belong to?
service.ambientLocaleContext
Is locale an input, or an accident of process state?A finding does not answer those questions. It creates a place where the answer must become explicit.
For a broad catch, the valid answer may be “this boundary intentionally maps all provider failures to unavailable.” The code should then own that mapping, report what operations need, and return a domain result which distinguishes unavailability from invalid input.
For ambient context, the valid answer may be “this controller is the request boundary.” That is why the retained context rule allowed presentation code while restricting selected application and domain service directories.
Static analysis is strongest when it enforces where a decision belongs, not when it bans a token everywhere.
Scope is part of the rule
A custom rule is a production change. Its consumers are developers, editors, continuous integration, and every existing path it scans.
Turning it on for the entire repository can mix three problems:
- whether the proposed contract is correct;
- whether the rule detects that contract accurately; and
- how much existing code can be changed safely now.
The retained rollout separated them. Broad-fallback detection was piloted in one domain-action directory. Ambient-context detection covered two selected service areas. Files outside those scopes remained untouched.
That boundary was not a claim that the rest of the repository was safe. It made the first enforcement area reviewable.
Choose a pilot where:
- the architectural role is reasonably consistent;
- concrete examples already demonstrate the risk;
- legitimate exceptions can be named;
- the owners can respond to findings; and
- false positives will not drown out the question.
Expansion should be another decision. A rule which works in a pure domain service may be wrong for controllers, templates, framework adapters, tests, or one-off migration code.
Negative controls define what the rule means
Testing only rejected code proves that the analyzer can complain. It does not prove the diagnostic represents the intended contract.
The broad-fallback rule retained several allowed cases:
- catching a narrower expected exception;
- rethrowing the broad exception;
- returning a meaningful domain string rather than a generic empty value;
- using the same pattern outside the configured domain; and
- placing a return inside a nested function rather than the catch itself.
The ambient-context rule allowed:
- request and tenant access in a presentation boundary;
- an explicit context object;
- local methods which happened to share helper names; and
- files outside the configured service directories.
Those negative controls are the rule’s specification. They prevent the implementation from quietly becoming “grep for a name I dislike.”
The synthetic reproduction evaluates typed nodes rather than source text. It reports broad empty fallbacks and ambient service context in a configured domain, then proves that a narrow exception, rethrow, explicit context, and presentation-boundary access remain accepted.
A real analyzer must also resolve aliases, syntax variants, and type information. The fixture demonstrates the decision table, not PHP parsing.
Existing exceptions should stay visible
The retained rollout found existing violations in the pilot scopes. It did not weaken the rule until the repository became green. It recorded exact diagnostic identifiers against exact paths.
That distinction matters:
weakening
broad fallbacks are allowed everywhere
bounded exception
this known path may emit exception.broadFallback
suppression by disappearance
ignore every diagnostic in this fileThe bounded exception preserves pressure in both directions. A new violation in another file fails. If the known violation disappears, stale-baseline checking can require removing the exception.
Every exception should state:
- the diagnostic;
- the narrowest path or symbol;
- why it cannot be corrected in this change;
- what decision blocks correction; and
- when the exception should be reviewed.
A baseline without expiry can become a second source tree which silently defines the architecture. Ratcheting prevents new debt, but it does not pay the old balance by itself.
The rule must admit what it cannot see
The broad-fallback rule recognized broad exception catches and a limited set of fallback expressions. It deliberately did not infer meaning through helper methods or classify every scalar value.
The ambient-context rule recognized selected helpers, facades, and locale calls. It did not perform interprocedural data-flow analysis or detect every injected framework service which could expose mutable context.
Those limits are healthy when they are explicit.
A useful diagnostic has a precision boundary:
| Boundary | Question |
|---|---|
| syntax | Which forms are recognized? |
| type | Which aliases and subclasses resolve? |
| path | Where does the contract apply? |
| flow | Does the rule follow values across calls? |
| semantics | Which meanings are approximated? |
| suppression | Which existing exceptions remain? |
“PHPStan passes” does not mean no broad fallback or ambient dependency exists. It means the configured rules found no unaccepted instances within those boundaries.
That sentence is longer. It is also true.
Fix the contract, not the warning
Analyzer-driven work goes wrong when the objective is to silence a line.
An ambient tenant finding can be removed by wrapping the helper in another method. The dependency remains. A broad fallback can be moved into a helper which the rule does not inspect. The failure is still hidden.
Resolve the question instead:
before
priceForCurrentRequest()
after
priceFor(tenantId, actorId, locale, requestOptions)Explicit parameters are not automatically better. Passing eight unrelated scalars through every method may reveal that one context value object or boundary-specific command is missing. The analyzer should provoke that design conversation, not prescribe the final signature.
Likewise, replacing catch (Throwable) { return false; } with logging and the
same false preserves the ambiguous result. Reporting helps operations. It
does not decide whether false means invalid, unavailable, unauthorized, or
unexpectedly broken.
The fix is complete when the caller can act on the distinction the diagnostic exposed.
Measure a rule by decisions improved
Finding count is useful for rollout planning. It is a poor measure of value.
One diagnostic which prevents cross-tenant context leakage can matter more than five hundred style findings. A rule with zero findings may still be valuable if it guards a boundary which future changes are likely to cross. A noisy rule with thousands of suppressions may reduce trust in every analyzer message.
Review a proposed rule with four questions:
- Which unsupported assumption does the diagnostic name?
- Where is that assumption invalid, and where is it legitimate?
- Which accepted and rejected examples specify the boundary?
- How will existing exceptions shrink without blocking unrelated work?
Then inspect its consequences after adoption: false positives, ignored findings, time to understand a message, contract corrections, and exceptions which actually disappeared.
Static analysis cannot decide the architecture. It can make architecture claims executable enough that a change must confront them. The useful outcome is not a clean dashboard. It is a previously hidden assumption becoming a question the code, its tests, and its reviewers can answer.