Search by

dave-liddament / phpstan-effect-system

DaveLiddament

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

Statistics

Installs: 244

Dependents: 0

Suggesters: 0

Stars: 26

Open Issues: 1

0.2.0 2026-10-09 13:30 UTC

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 ignoreErrors entries, 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 Effect of 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, with run() declared only in Base): the error is reported on Base::run(), naming Child as 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 a Child relying on what Base::__construct() promised, and PHP itself exempts constructors from compatibility checks. The exception is a constructor declared on an interface or as abstract: 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'). A new 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 include EffectFree('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 to Foo::__construct().
  • Late static binding: static::method(), new static(), $object::method(), $classString::method() and new $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 method must 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, EffectFree and HandlesEffect. 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.
  • new expressions 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