dave-liddament / phpstan-effect-system
PHPStan extension implementing a lightweight effect system: declare effects at the source, infer transitive propagation, enforce effect-free boundaries.
Package info
github.com/DaveLiddament/phpstan-effect-system
Type:phpstan-extension
pkg:composer/dave-liddament/phpstan-effect-system
Requires
- php: ~8.4.0 || ~8.5.0
- phpstan/phpstan: ^2.2
Requires (Dev)
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^13.2
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-15 09:02:50 UTC
README
A PHPStan extension that implements a lightweight effect system for PHP.
An effect is a label for something a method does that you care about, e.g.
slow.
For example, you can mark any method that makes an external request as slow,
then require that a controller never calls anything slow, however deep in its
call chain:
use DaveLiddament\PhpstanEffectSystem\Attributes\Effect; use DaveLiddament\PhpstanEffectSystem\Attributes\EffectFree; class ApiClient { #[Effect('slow')] // the source: this method IS slow public function fetch(): void { /* makes an HTTP request */ } } class OrderService { public function load(): void // nothing declared here... { (new ApiClient())->fetch(); } } class HomeController { #[EffectFree('slow')] // the sink: must never reach 'slow' (ERROR reported here) public function indexAction(): void { (new OrderService())->load(); } }
Method HomeController::indexAction() is #[EffectFree('slow')] but reaches effect 'slow':
HomeController::indexAction() -> OrderService::load() -> ApiClient::fetch() (declares #[Effect('slow')]).
To apply this to every controller without adding attributes to each one, use a pattern rule.
Methods declare effects (e.g. slow, io, db) via attributes at the source
only. The extension infers transitive effect propagation through the whole
call graph, with no annotations on intermediate callers, and enforces
#[EffectFree] contracts at the boundaries you declare.
Effect names are arbitrary strings: invent whatever taxonomy fits your
architecture (slow, io, http, db, nondeterministic, ...).
Stability: experimental. This package is pre-1.0. The attribute API, configuration schema and error identifiers may change in any release. Error identifiers end up in baselines and
ignoreErrorsentries, so pin an exact version until 1.0.
Installation
composer require --dev dave-liddament/phpstan-effect-system
With phpstan/extension-installer the extension registers itself. Otherwise include it manually:
includes: - vendor/dave-liddament/phpstan-effect-system/extension.neon
Then list every effect name you use. This is required: any other name is an error, so a typo can't silently create a contract that never fails.
parameters: effects: allowedEffects: ['slow', 'io']
The package requires phpstan/phpstan, so install it in require-dev. That
is safe even though the attributes appear in production code: the attribute
classes themselves have zero dependencies, and PHP never autoloads an attribute
class unless something reflects on it.
Requires PHP 8.4 or 8.5, and PHPStan 2.2.6 or later. Each new PHP version is added once it has been released and the test suite passes on it.
The attributes
All are repeatable. ExemptFromEffectRule targets methods only; the others
target methods and functions.
| Attribute | Meaning |
|---|---|
#[Effect('slow')] |
This method has the effect (a source). |
#[EffectFree('slow')] |
This method must not transitively reach the effect (a sink). Valid on interface/abstract methods; the contract applies to every implementation and override, and cannot be dropped. |
#[HandlesEffect('slow')] |
This method discharges the effect: callees' slow effects do not propagate through it (e.g. a caching wrapper). An #[Effect('slow')] declared on the same method still counts. |
#[ExemptFromEffectRule('slow', reason: '...')] |
This method is exempt from the slow contract an effects rule imposes on it. It cannot drop an own or inherited EffectFree, and the effect still propagates to callers. |
Handling effects: a worked example
#[HandlesEffect] is for boundaries that genuinely contain an effect. The
classic case is a cache in front of something slow:
use DaveLiddament\PhpstanEffectSystem\Attributes\Effect; use DaveLiddament\PhpstanEffectSystem\Attributes\EffectFree; use DaveLiddament\PhpstanEffectSystem\Attributes\HandlesEffect; class ExchangeRateApi { #[Effect('slow')] public function fetchRate(): float { /* HTTP call */ } } class CachedExchangeRates { #[HandlesEffect('slow')] // discharges 'slow': it stops here public function rate(): float { return $this->cache->get('rate', fn () => (new ExchangeRateApi())->fetchRate()); } } class PriceCalculator { #[EffectFree('slow')] public function total(int $qty): float { return $qty * (new CachedExchangeRates())->rate(); // OK: no violation } }
Without the #[HandlesEffect('slow')], total() would be reported: slow
would propagate from fetchRate() through rate() (the closure's calls count
as rate()'s calls). Note the handler only blocks effects arriving from
callees. If rate() itself also declared #[Effect('slow')] (say, a cold
cache is still slow enough to matter), that effect would still propagate.
Semantics
- Propagation: effects flow from callee to caller, transitively, through the inferred call graph. Recursion and mutual recursion are handled.
- HandlesEffect: blocks propagation of that effect from callees. It does
not cancel an
Effectof the same name declared on the same method. - EffectFree inheritance: a contract on an interface or parent method
applies to every implementation/override; there is no syntax to drop it.
Violations are reported at the implementation. This includes a method
inherited from a parent that is unrelated to the interface (
class Child extends Base implements Runner, withrun()declared only inBase): the error is reported onBase::run(), namingChildas the link. Private methods never inherit a contract: a same-named method in a subclass is not an override. Constructors do not inherit either:new Child()always names the concrete class, so nobody constructs aChildrelying on whatBase::__construct()promised, and PHP itself exempts constructors from compatibility checks. The exception is a constructor declared on an interface or asabstract: PHP enforces that signature on every implementation, and the contract is inherited with it. To keep a whole hierarchy cheap to construct, use a pattern rule instead (classPattern: 'App\Entity\*', methodPattern: '__construct'). Anew static()factory is still covered: the factory's own contract reaches subclass constructors through dispatch. - Contradiction: declaring
Effect('x')on a method whose own or inherited contracts includeEffectFree('x')is a dedicated error. - Dynamic dispatch: a call through an interface/abstract/parent type is
treated as the union of all known implementations (closed-world). Effects
declared on the interface method itself also count. Implementations in
namespaces matching
excludeImplementationsFrom(default:Tests\*,*\Tests\*) are excluded, so test doubles never pollute production dispatch. - Closures / arrow functions: calls inside inline closures count as calls of the enclosing method.
- First-class callables:
$obj->method(...)records an edge at the creation site (conservative: creating the callable counts as reaching it). - Traits: trait methods are analysed per using class; effects and contracts apply per class.
- Constructors:
new Foo()is an edge toFoo::__construct(). - Late static binding:
static::method(),new static(),$object::method(),$classString::method()andnew $classString()are expanded over all known subclasses, like any other dynamic dispatch.
Configuration
All configuration lives under parameters.effects. Only allowedEffects is
required. The neon schema is validated, so typos in the config fail fast.
parameters: effects: # Effects for code you don't own (vendor). Sources only; small list. stubs: - { method: 'GuzzleHttp\Client::request', effects: ['slow', 'http'] } - { function: 'file_get_contents', effects: ['io'] } # Pattern-based sinks: architectural sweeps without touching code. # Patterns are fnmatch-style, case-insensitive. rules: - { classPattern: 'App\Controller\*', methodPattern: '*Action', effectFree: ['slow'] } # Required: every effect name used in attributes, stubs and rules. # Any other name is an error, which catches typos. allowedEffects: ['slow', 'io', 'http', 'db'] # Implementations in these namespaces are ignored when expanding # dynamic dispatch (test doubles, fakes, mocks). excludeImplementationsFrom: - 'Tests\*' - '*\Tests\*'
Notes on stubs:
- The
methodmust name the class that declares the method (calls made through subclasses resolve to the declaring class). - Stubs apply only to unanalysed code; a stub for a method in your analysed paths is ignored (the analysed declaration wins).
Exempting methods from a pattern rule
A pattern rule is a policy imposed from outside the code, so it can have exceptions. There are two kinds:
-
Out of scope: whole classes the policy was never meant to cover, such as dev-only controllers. List them in the rule's optional
exclude(fnmatch-style class patterns, case-insensitive):rules: - classPattern: 'App\Controller\*' methodPattern: '*Action' effectFree: ['slow'] exclude: - 'App\Controller\DebugController' - 'App\Controller\Internal\*'
-
Justified exception: one method that is allowed to reach the effect, for example an endpoint only ever requested by a background job. Mark it in the code, with the reason:
final class ReportController { #[ExemptFromEffectRule('slow', reason: 'Only requested by the PDF renderer running in a queue job')] public function renderAction(): Response { /* ... */ } public function downloadAction(): Response { /* ... */ } // still checked }
Both only drop contracts that come from pattern rules: an #[EffectFree] on
the method or one it inherits always applies. Neither stops the effect
propagating, so callers of an exempt method are still checked.
Exemptions that no longer do anything are errors, like PHPStan's
reportUnmatchedIgnoredErrors: an exclude pattern that matches no method
the rule applies to, and an #[ExemptFromEffectRule] on a method no rule
covers for that effect, or that no longer reaches it.
Errors
| Identifier | Reported when | Location |
|---|---|---|
effects.violation |
An EffectFree method (attribute, inherited, or pattern rule) transitively reaches the effect. The message includes a shortest call path. |
Sink method's declaration |
effects.contradiction |
A method declares Effect('x') while also being contractually EffectFree('x'). |
The declaring method |
effects.invalidExemption |
A method has #[ExemptFromEffectRule('x')] but its EffectFree('x') contract is its own or inherited, which an exemption cannot drop. |
The declaring method |
effects.unusedExemption |
A method has #[ExemptFromEffectRule('x')] but no pattern rule requires it to be effect-free for x, or it doesn't reach x. |
The declaring method |
effects.unusedExclusion |
A pattern rule's exclude entry matches no method the rule applies to. |
No file (reported as N/A); suppress by identifier if needed |
effects.unknownEffect |
An attribute uses an effect name not listed in allowedEffects (with no allowedEffects configured, every name is reported). Unknown names in the neon config itself throw at startup instead. |
The declaring method |
All errors are ordinary PHPStan errors: baselines, ignoreErrors, and editor
integration work as usual.
How it works
PHPStan analyses file-by-file, so whole-program reachability uses the
collector pattern: per-file collectors record call edges, declarations
(with attribute data) and class hierarchy facts; a single rule then runs once
on the merged data, builds the call graph, expands dynamic dispatch, computes
a reachability fixpoint and checks every contract. The result cache works:
adding #[Effect] to a leaf re-reports at distant sinks on the next run
without a full re-analysis (covered by an end-to-end test).
Because the analysis is closed-world, results are only correct when the whole
project is analysed. When PHPStan is given individual files (e.g. single-file
editor runs, phpstan analyse src/Foo.php) the extension reports nothing
rather than under-reporting silently. It cannot detect a run restricted to
a sub-directory (phpstan analyse src/Controller): such a run only sees the
calls, implementations and effects inside that directory and will silently
miss violations. Always run it over the full configured paths.
Errors are reported on the first line of the sink's declaration, which is the
first attribute line when the method has attributes. That is where a
@phpstan-ignore effects.violation comment has to go (above the attributes,
not between them and the function keyword).
Comparison with adjacent tools
- spaze/phpstan-disallowed-calls
forbids calls to specific functions/methods, but only direct calls at
the call site. Use it for "never call
eval()anywhere"; use this extension when the thing you're forbidding may be buried arbitrarily deep in the call graph. - phpstan/phpstan-deprecation-rules
is the same shape: a marker (
@deprecated) enforced at direct usage sites, with no transitive propagation. - Psalm's taint analysis is
the closest semantic relative: sources, sinks and sanitizers map directly
onto
Effect,EffectFreeandHandlesEffect. The difference: taint analysis tracks the flow of data values for security (user input reaching SQL or HTML), while this extension tracks call reachability of arbitrary named behaviours. And it's Psalm, not PHPStan. - deptrac and phpat enforce architectural layer boundaries from static class dependencies. They answer "who may reference whom" (structural); this extension answers "what may a call transitively do" (behavioural). They compose well together.
- Outside PHP, the closest analogue is Google's Capslock for Go: transitive capability analysis over a whole-program call graph.
Known limitations (v1)
Not tracked (these are silent false negatives, by design):
- Variable, string and array callables (
$fn(),call_user_func('foo'),array_map([$obj, 'method'], ...)), container dispatch ($container->get(X::class)->run()). - Calls on receivers PHPStan has no class type for (untyped properties and
parameters,
mixed,object). The better typed the code, the more complete the call graph. - Implicit calls:
__invoke($obj()),__toString,__get/__set,__call/__callStatic(including@method-annotated magic methods),__destruct,__clone,ArrayAccess, iterators. newexpressions in parameter default values.- Vendor implementations of an interface: dispatch is only expanded over analysed classes, so a call through a vendor interface needs the stub on the interface method itself.
- Reflection-based invocation.
- Code in the global scope (outside any function/method).
- Property hooks (PHP 8.4).
- Attribute arguments that are not compile-time constant strings (class constants work; runtime expressions are skipped).
Conditional effects ("slow only on cold cache") have no path-sensitivity;
model the wrapper with #[HandlesEffect] instead.
License
MIT