The Purity Model
Psalm describes what a function, method or closure may do as a set of capabilities:
permissions to perform particular side effects, such as writing a property or printing. The
model comes from Hack's contexts and capabilities.
It replaces the three fixed levels of Psalm 6 (@psalm-pure, @psalm-mutation-free and
@psalm-external-mutation-free), which are now three points in a larger space.
This page explains how the model works. The annotations are listed in Supported Annotations. The examples only mark the issues they are about: Psalm also suggests annotations for the unannotated code in them (see Inferring annotations).
Capabilities
| Capability | Allows |
|---|---|
read-props |
reading properties of mutable objects, including $this (immutable objects never need it, nor objects the caller created with new from a class whose contract needs at most read-props\|write-this-props\|write-refs, or whose constructor can't keep a reference to them, see Method calls) |
write-this-props |
writing or unsetting properties of $this |
write-props |
writing or unsetting properties of any other object |
read-globals |
reading static properties and superglobals, and binding global variables |
write-globals |
writing them, including through a bound global variable, and using static variables |
write-refs |
writing through by-reference parameters and other references into another scope |
io |
echo, print, exit with a message, and builtins with side effects (random_int, file_put_contents, …) |
time |
reading the clock (time, microtime, hrtime, date and getdate without a timestamp, …) |
Builtins touching process-wide state (mt_rand, ini_set, spl_autoload_register, …) need
write-globals instead of io.
Two names stand for the extremes: pure is the empty set, a
pure function whose result depends only on its
arguments, and impure is every capability. A set is written with | or commas:
read-props|write-this-props.
A function-like may only do something, or call something, when its own set contains every
capability that needs. Code without an annotation is impure: it may do anything, and nothing
inside it is checked.
Each name means that one capability only. Writing does not include reading, so $this->n++
needs read-props|write-this-props, and code that writes both $this and other objects needs
write-this-props|write-props. write-props does not include write-this-props, and
write-globals does not include read-globals. A plain assignment only needs the write
capability: $this->n = 0 needs write-this-props, Counter::$count = 0 needs write-globals.
Using $this at all needs read-props or write-this-props, so a setter returning $this may be
write-this-props.
<?php
final class Counter {
public static int $count = 0;
private int $n = 0;
/** @psalm-capabilities read-props|write-this-props */
public function next(): int {
return ++$this->n;
}
/** @psalm-capabilities write-this-props */
public function nextWrong(): int {
return ++$this->n; // ImpurePropertyFetch: The context is write-this-props but accessing a property on a mutable object requires read-props
}
}
/** @psalm-capabilities read-globals */
function total(): int {
return Counter::$count;
}
/** @psalm-capabilities read-globals */
function resetTotal(): int {
Counter::$count = 0; // ImpureStaticProperty: The context is read-globals but writing a static property requires write-globals
return 0;
}
Global state
Values reached from global state stay bound to it. An object read from a static property, a
superglobal or a global variable, returned by a function that may read globals, or fetched from
such an object, can only have its properties written, its mutating methods called, or be passed
to a function that may write properties, by code that has write-globals. This makes
read-globals a read-only view of global state, like Hack's readonly values:
<?php
final class Config {
public string $env = "prod";
public static ?Config $instance = null;
}
/** @psalm-capabilities read-globals */
function config(): ?Config {
return Config::$instance;
}
/** @psalm-capabilities read-globals|write-props */
function tamper(): void {
$config = config();
if ($config !== null) {
$config->env = "dev"; // ImpurePropertyAssignment: The context is write-props|read-globals but property assignment to Config::$env on an object reached from global state requires write-props|write-globals
}
}
A new object the caller created (see Method calls) is not global state itself,
even when its constructor read global state, or when it is a clone of an object reached from
global state: writing its own properties is free. What it holds may be global state, so writing
another object through it, or calling a method that does, costs write-globals too.
Annotations
Every purity annotation is a name for a capability set:
| Annotation | Used on | Capabilities |
|---|---|---|
@psalm-pure |
function, method, class | pure; on a class, properties can't be used at all |
@psalm-capabilities <set> |
function, method, closure, class | <set> |
@psalm-impure |
function, method | impure, written out |
@psalm-immutable |
class | read-props for every method, and every property is readonly |
@psalm-mutable |
class | impure, written out |
@psalm-mutation-free |
function, method, class | read-props (legacy) |
@psalm-external-mutation-free |
function, method, class | read-props\|write-this-props\|write-refs (legacy) |
| none | impure |
@pure, @phpstan-pure, #[Psalm\Pure] and #[JetBrains\PhpStorm\Pure] mean the same as
@psalm-pure; #[Psalm\Immutable] and #[JetBrains\PhpStorm\Immutable] the same as
@psalm-immutable; #[Psalm\ExternalMutationFree] the same as @psalm-external-mutation-free.
Capability aliases
A capability set can be named once with a type alias and used in @psalm-capabilities, in
closure types and as a purity argument, like Hack's context constants. Aliases of other classes
are imported with @psalm-import-type:
<?php
/** @psalm-type Storage = write-props|io */
final class Repo {
/** @psalm-capabilities Storage */
public function save(): void { echo "saved"; }
}
/** @psalm-import-type Storage from Repo */
final class Service {
/**
* @psalm-capabilities Storage
* @param Closure[Storage](): void $after
*/
public function run(Repo $repo, Closure $after): void {
$repo->save();
$after();
}
}
Conditional capabilities
What a call requires may depend on its arguments, like a conditional return type. Each call requires the branch its arguments select. Where the arguments may go either way, or there is no call (a callable taken from the function), it requires both. The body may do what either branch allows.
<?php
/** @psalm-capabilities ($seed is null ? io : pure) */
function makeId(?int $seed = null): int {
return $seed ?? random_int(0, PHP_INT_MAX);
}
/** @psalm-pure */
function fromSeed(int $seed): int {
return makeId($seed); // fine: $seed can't be null
}
/** @psalm-pure */
function fresh(): int {
return makeId(); // ImpureFunctionCall: The context is pure but function call on makeid requires io
}
The constructor of MongoDB\BSON\ObjectId is stubbed this way: without an id it generates one
from the clock and a counter of the process, while given an id it only validates it.
Closures
Closures don't need an annotation. Psalm infers their capabilities from their bodies and keeps
them in their type, for example Closure[io](int): void (see
callable types). Creating a closure is not an
effect: a pure function may build and return an impure closure. Calling it, or passing it to a
parameter that expects fewer capabilities, is checked.
A closure that captures a variable by reference (use (&$x)) shares it with the enclosing
scope: reading it needs read-props and writing it write-this-props, as if it were a property
of the closure, so such a closure is not pure. The enclosing scope may still call it freely,
since the variable is its own, and only needs a capability when the variable is shared further:
write-refs for a by-reference parameter, write-globals for a global.
<?php
/**
* @psalm-pure
* @param list<int> $numbers
*/
function sum(array $numbers): int {
$total = 0;
$add = function (int $value) use (&$total): void { $total += $value; };
foreach ($numbers as $number) {
$add($number); // fine: $total belongs to sum()
}
return $total;
}
/**
* @psalm-pure
* @param Closure[pure](int): void $callback
*/
function callWithOne(Closure $callback): int {
$callback(1);
return 1;
}
/** @psalm-pure */
function leak(): int {
$total = 0;
return callWithOne(function (int $value) use (&$total): void { $total += $value; }); // ArgumentTypeCoercion: Argument 1 of callWithOne expects Closure[pure](int):void, but parent type Closure[read-props|write-this-props](int):void provided
}
Likewise, an object the enclosing scope created and the closure captures, by value or by
reference, is state of the closure, as an object a class instance creates is its own. So are the
objects in a captured array the enclosing scope created, and the state of a closure that a closure
created in it captures. Writing that state, or calling a method that writes it, needs
write-this-props, and the enclosing scope may call the closure freely. An object the enclosing
scope did not create, like a parameter, belongs to someone else: writing it needs write-props.
Class-level contracts
@psalm-capabilities on a class or interface is a contract for the class and for everything that
extends or implements it. Every method of the class gets those capabilities, and may declare fewer
but not more:
<?php
/** @psalm-capabilities io */
final class Logger {
public static int $level = 0;
public function log(string $message): void {
echo $message; // fine: the class allows io
}
/** @psalm-capabilities io|write-globals */
public function setLevel(int $level): void { // ImpureFunctionCall: setLevel is marked @psalm-capabilities write-globals|io but its containing class allows fewer capabilities, @psalm-capabilities io
self::$level = $level;
}
}
A class extending or implementing one with a contract must have a contract that fits inside it;
otherwise Psalm reports ImmutableDependency.
A marker interface, declaring no method or property and inheriting none, is pure without an
annotation, as it does nothing; unlike an interface marked @psalm-pure, it caps none of its
implementers.
Abstract methods, and the methods of interfaces, have no body to infer anything from, so each of
them needs its own annotation, even inside a class with a contract
(MissingAbstractPureAnnotation); use
@psalm-impure for one that may do anything.
How calls are charged
A caller normally needs every capability of the function-likes it calls. Two kinds of calls are charged more precisely.
Method calls
A method that needs write-this-props writes its own $this, which may or may not be the
caller's. The call is charged according to the receiver:
- the caller's own
$this, also when a call on it of a method returning its receiver gives it back (see Fluent calls):write-this-props; - an object the caller created with
new, from a class whose contract needs at mostread-props|write-this-props|write-refs, or whose constructor can't keep a reference to it: nothing, as nobody else can see it change; - any other object, including a parameter, a property of
$this, or an object returned by another call:write-props.
<?php
/** @psalm-capabilities read-props|write-this-props */
final class Counter {
private int $n = 0;
public function inc(): void {
$this->n++;
}
public function incTwice(): void {
$this->inc(); // fine: costs write-this-props
$this->inc();
}
public function get(): int {
return $this->n;
}
}
/** @psalm-pure */
function countTwice(): int {
$counter = new Counter();
$counter->inc(); // fine: $counter was created here
$counter->inc();
return $counter->get();
}
/** @psalm-capabilities read-props */
function bump(Counter $counter): int {
$counter->inc(); // ImpureMethodCall: The context is read-props but method Counter::inc requires write-props
return $counter->get();
}
Reading or unsetting a property gets the same waiver: it is free on an object the caller created
with new, from a class whose contract needs at most read-props|write-this-props|write-refs, as
nobody else can change it. Reading a property of any other mutable object, including $this, a
parameter or an object held in a property, costs read-props, whatever the class's contract:
<?php
/** @psalm-capabilities read-props|write-this-props */
final class Tally {
public int $n = 0;
public function inc(): void {
$this->n++;
}
}
/** @psalm-pure */
function tallyTwice(): int {
$tally = new Tally();
$tally->inc();
$tally->inc();
return $tally->n; // fine: $tally was created here
}
/** @psalm-pure */
function currentTally(Tally $tally): int {
return $tally->n; // ImpurePropertyFetch: The context is pure but accessing a property on a mutable object requires read-props
}
Without a class-level contract, calling a method on an object the caller created with new, and
reading or writing its properties, is still free, as long as the constructor can't keep a reference
to the new object anywhere. Only
three things can hand $this to someone else: writing another object (write-props), global state
(write-globals) or a by-reference parameter (write-refs). So a constructor that needs none of
these qualifies, and so does a class without a constructor:
<?php
final class Greeting {
/** @var list<string> */
private array $words = [];
/** @psalm-capabilities read-props */
public function __construct(string $first) {
$this->words[] = $first;
}
/** @psalm-capabilities read-props|write-this-props */
public function add(string $word): void {
$this->words[] = $word;
}
/** @psalm-capabilities read-props */
public function text(): string {
return implode(" ", $this->words);
}
}
/** @psalm-pure */
function hello(): string {
$greeting = new Greeting("hello");
$greeting->add("world"); // fine: the constructor can't hand $greeting to anyone
return $greeting->text();
}
A constructor without a purity annotation qualifies too when it is trivial: it does nothing, or only
assigns its parameters (or constant values) to properties its class declares, promoted ones included.
Such a constructor needs write-this-props without being annotated (nothing at all if it is empty), so
neither new nor its class needs an annotation for the waiver. Any other statement makes it non-trivial:
a call (parent::__construct() included), a by-reference parameter, a default value that creates an
object or calls something, a property with hooks or one the class doesn't declare before the
constructor, or $this stored anywhere. A non-trivial constructor without an annotation may do
anything, as any unannotated method. So may a trivial constructor run for a class that redeclares one
of the properties it assigns with a hook (PHP 8.4), or by new static() / new $class() where the
class may be extended: the hook may do anything.
<?php
final class Point {
public int $z = 0;
public function __construct(public int $x, public int $y) {}
}
/** @psalm-pure */
function lift(int $x, int $y): Point {
$point = new Point($x, $y);
$point->z = 1; // fine: Point's constructor is trivial
return $point;
}
A constructor that may store $this somewhere loses the waiver. Here the constructor registers
every new object in a static property, so calling a method on the new object costs what it would
on any other one. The object is also reached from global state now, which adds write-globals:
<?php
final class Registered {
/** @var list<Registered> */
public static array $all = [];
public int $uses = 0;
/** @psalm-capabilities read-globals|write-globals */
public function __construct() {
self::$all[] = $this;
}
/** @psalm-capabilities read-props|write-this-props */
public function use(): void {
$this->uses++;
}
}
/** @psalm-capabilities read-globals|write-globals */
function register(): Registered {
$registered = new Registered();
$registered->use(); // ImpureMethodCall: The context is read-globals|write-globals but method Registered::use requires write-props|write-globals
return $registered;
}
A constructor that writes another object, such as linking the new object to a parent passed to it, or that writes a by-reference parameter, loses it the same way.
A clone is a new object too, by the same rule: the copy is free to mutate unless the class's
__clone needs write-props, write-globals or write-refs, and so may hand the copy to someone
else. A class-level contract keeps the waiver, for a clone as for a new object:
<?php
final class Tracked {
/** @var list<Tracked> */
public static array $copies = [];
public int $uses = 0;
/** @psalm-capabilities read-props|read-globals|write-globals */
public function __clone() {
self::$copies[] = $this;
}
/** @psalm-capabilities read-props|write-this-props */
public function use(): void {
$this->uses++;
}
}
/** @psalm-capabilities read-props|read-globals|write-globals */
function copyTracked(Tracked $tracked): Tracked {
$copy = clone $tracked;
$copy->use(); // ImpureMethodCall: The context is read-props|read-globals|write-globals but method Tracked::use requires write-props
return $copy;
}
When the class isn't known exactly, there is no waiver: new static() in a class that may be
extended, and new $class(), may run the constructor of a subclass that keeps a reference:
<?php
/** @psalm-consistent-constructor */
class Item {
public int $count = 0;
/** @psalm-capabilities read-props|write-this-props */
public function bump(): void {
$this->count++;
}
}
/** @psalm-capabilities read-props|write-this-props */
function makeItem(): Item {
$item = new Item();
$item->bump(); // fine: an Item, whose constructor can't keep $item
return $item;
}
/**
* @param class-string<Item> $class
* @psalm-capabilities read-props|write-this-props
*/
function makeAny(string $class): Item {
$item = new $class();
$item->bump(); // ImpureMethodCall: The context is read-props|write-this-props but method Item::bump requires write-props
return $item;
}
This waiver covers calling methods, and writing and reading properties: reading a property of
such an object directly is free too, as calling a read-props method on it is. What the property
holds is another matter: reading a property of an object stored in it costs read-props, as that
object may be shared.
The write-this-props that a class purity template of the receiver is bound to
(Doer[write-this-props], see Purity templates) is charged the same way,
except that it is never waived for a new object. A receiver reached from
global state also costs write-globals.
Fluent calls
A method returns its receiver if it declares @return $this, or if no override can replace it
(it is private, final or in a final class) and each of its returns is return $this;. A call on
$this of such a method gives back $this, so a fluent chain on $this only mutates $this,
and so does writing a property of what the chain returns. @return static is not enough: the
method may return another instance of the class.
@return $this is a promise, checked like a return type: the method and every override of it may
only return $this, or a call on it of another method returning its receiver; any other return is
an InvalidReturnStatement.
<?php
abstract class Query {
/** @var list<string> */
private array $conditions = [];
/**
* @return $this
* @psalm-capabilities read-props|write-this-props
*/
public function where(string $condition): static {
$this->conditions[] = $condition;
return $this;
}
/** @psalm-capabilities read-props|write-this-props */
public function whereBetween(string $from, string $to): static {
return $this->where($from)->where($to); // fine: costs write-this-props
}
/** @psalm-capabilities read-props */
public function copy(): static {
return clone $this;
}
/** @psalm-capabilities read-props|write-this-props */
public function copyWhere(string $condition): static {
return $this->copy()->where($condition); // ImpureMethodCall: The context is read-props|write-this-props but method Query::where requires write-props
}
}
By-reference arguments
A call to a function-like with write-refs does not cost write-refs itself. Each argument
passed by reference costs what writing it directly would cost: nothing for a local variable,
write-refs for one of the caller's own by-reference parameters, write-this-props for a property
of $this, write-props for another object's property, and write-globals for global state.
The callee itself still only has write-refs. This includes builtins such as sort(), so a pure
function may sort a local array.
<?php
final class Stats {
public static int $n = 0;
}
/** @psalm-capabilities write-refs */
function inc(int &$i): void {
$i++;
}
/** @psalm-capabilities write-refs */
function update(int &$ref): void {
$local = 0;
inc($local); // fine: $local belongs to update()
inc($ref); // fine: like writing $ref directly, costs write-refs
// both reported on the next line:
// ImpureStaticProperty: The context is write-refs but reading a static property requires read-globals
// ImpureFunctionCall: The context is write-refs but function call on inc requires write-globals
inc(Stats::$n);
}
Implicit calls
Everything a function-like does is checked, including what happens implicitly: clone calls
__clone, string interpolation and casts call __toString, $object() calls __invoke, array
access on objects calls the ArrayAccess methods, throw new and new $className call the
constructor (an unknown class may do anything), and foreach over an object calls its iteration
methods (see Iterators and generators). A callable string or array
whose target is not known may do anything, so a pure function may not call one.
Destructors
Destroying an object calls its destructor, where the object dies. So a pure function may not
hold, in a local variable, an object it created with new whose __destruct has effects, unless
the object leaves the function (returned, stored, passed on, captured). Nor may it unset() a
variable, property or array element holding such an object, overwrite one by assigning it, or
discard one (new Guard();).
Only the declared destructors of the known classes of the dropped value count: a mixed,
object or template value is not charged. A property is assumed to hold nothing yet in its
class's constructor (and to be kept by the original in __clone), and a variable first assigned
in a loop body to hold a value like the one assigned when it is assigned again, the previous
iteration's.
Parameter default values
Parameter default values are evaluated like in Hack: with no capabilities at all, or with
read-globals|write-globals when the function-like has write-globals, whatever else it may do.
Only function-likes without a purity annotation may use anything in a default value, so new in
the defaults of unannotated code stays free.
Purity templates
Sometimes what a function does depends on what it is given: array_map() is pure when its
callback is pure. A purity template is a template parameter whose value is a capability set
instead of a type. It is declared with
@psalm-purity-template.
Binding a purity template
Purity arguments are written in square brackets, before any type arguments, as in Hack:
Closure[P](int): int,callable[pure](): void: the purity of a callable;Handler[pure],Box[io]<int>: the purity argument of a class with a purity template;@extends Handler[pure],@implements Iterator[pure]<int, string>: a subclass binding it.
The purity templates of a class come after its type templates, whatever the order of the
@template and @psalm-purity-template tags. A class with @template T and
@psalm-purity-template P is used as Box[pure]<int>, as Box<int> for its default purity, or
as Box[pure] for its default type arguments. Several purity arguments are separated by commas
(Pair[pure, io]<int>), and each is a capability set (write-props|io) or a purity template.
An empty set is written pure; Handler[] is an array of Handler.
Purity templates are covariant: a Handler[pure] can be used where a Handler[io] is expected.
The bounds of type templates may use purity templates, which are then inferred with them: a
class with @template TIterator as Traversable[P]<K, V> and @psalm-purity-template P,
constructed with a Generator[pure]<int, string, mixed, void>, is a
Foo[pure]<int, string, Generator[pure]<int, string, mixed, void>>.
@psalm-purity-from-template
@psalm-purity-from-template P says that
each call needs the function-like's own capabilities plus whatever P is bound to at that call.
Inside the body, calling a closure whose purity is P is not an effect: each caller pays for it,
like with Hack's [ctx $f] contexts. It works for functions, methods, static methods and constructors. P may be a purity template of
the function-like or of its class, or a type template bound to a closure or callable type.
With a class-level template, subclasses decide the purity:
<?php
/** @psalm-purity-template P */
abstract class Handler {
/**
* @psalm-pure
* @psalm-purity-from-template P
*/
abstract public function handle(string $input): string;
}
/** @extends Handler[pure] */
final class Upper extends Handler {
/** @psalm-pure */
public function handle(string $input): string {
return strtoupper($input);
}
}
/** @extends Handler[io] */
final class Printer extends Handler {
/** @psalm-capabilities io */
public function handle(string $input): string {
echo $input;
return $input;
}
}
/** @psalm-pure */
function viaUpper(Upper $handler): string {
return $handler->handle('a'); // fine: Upper binds P to pure
}
/**
* @psalm-pure
* @param Handler[pure] $handler
*/
function viaPureHandler(Handler $handler): string {
return $handler->handle('a'); // fine
}
/**
* @psalm-pure
* @psalm-purity-template Q
* @param Handler[Q] $handler
* @psalm-purity-from-template Q
*/
function viaSomeHandler(Handler $handler): string {
return $handler->handle('a'); // fine: pure when given an Upper, io when given a Printer
}
/** @psalm-pure */
function viaAnyHandler(Handler $handler): string {
return $handler->handle('a'); // ImpureMethodCall: The context is pure but method Handler::handle requires impure
}
With a function-level template, the closures passed in decide the purity:
<?php
/**
* @psalm-pure
* @psalm-purity-template P
* @param Closure[P](int): int $callback
* @psalm-purity-from-template P
*/
function apply(Closure $callback): int {
return $callback(1);
}
/** @psalm-pure */
function usePure(): int {
return apply(fn(int $x): int => $x + 1); // fine: the closure is pure
}
/** @psalm-capabilities io */
function useIo(): int {
return apply(function (int $x): int { echo $x; return $x; }); // fine: the call costs io
}
/** @psalm-pure */
function usePrinting(): int {
return apply(function (int $x): int { echo $x; return $x; }); // ImpureFunctionCall: The context is pure but function call on apply requires io
}
A type template bound to a closure type carries the closure's purity, so it works with
@psalm-purity-from-template too:
<?php
/**
* @template T of callable(): void
*/
final class Deferred {
/** @var T */
private $callback;
/**
* @param T $callback
* @psalm-capabilities write-this-props
*/
public function __construct($callback) {
$this->callback = $callback;
}
/**
* @psalm-capabilities read-props
* @psalm-purity-from-template T
*/
public function run(): void {
($this->callback)();
}
}
/**
* @psalm-capabilities read-props
* @param Deferred<pure-Closure(): void> $deferred
*/
function runPure(Deferred $deferred): void {
$deferred->run(); // fine: T is a pure closure
}
The _ shorthand
The common case, a function whose purity follows one closure parameter, needs no named template:
Closure[_](...) or callable[_](...) in a @param type declares one, and the function takes
its purity from that parameter, like Hack's (function()[_]: T) $f. This is the same apply() as
above:
<?php
/**
* @psalm-pure
* @param Closure[_](int): int $callback
*/
function apply(Closure $callback): int {
return $callback(1);
}
_ may appear anywhere in a parameter's type (list<Closure[_](int): int>,
Traversable[_]<int, string>). Every _ in one parameter's type stands for the same template,
which has no name of its own, so it never clashes with a template declared in the docblock. _
can only be used in @param types: in @return, @param-out, @psalm-assert, @var,
@property or @template bounds it has no parameter to stand for.
On a promoted constructor parameter, _ makes new take the purity of the closure passed for
it, as for any other parameter. The property, though, outlives that call, so its type uses the
template's bound: here $f is an impure-Closure(): int, and calling it needs every capability,
whatever closure was passed.
<?php
final class Holder {
/**
* @psalm-pure
* @param Closure[_](): int $f
*/
public function __construct(public Closure $f) {}
}
To keep the closure's purity in the property, use a class purity template instead
(@psalm-purity-template P on the class, @param Closure[P](): int $f on the constructor): then
new Holder(fn(): int => 1) is a Holder[pure], whose $f is a pure closure.
The parameters of an @method may use _ too. A call to such a method needs what __call (or
__callStatic) needs, plus the capabilities of the closures passed for its _ parameters:
<?php
/**
* @method int run(Closure[_](): int $callback)
*/
final class Runner {
/** @psalm-pure */
public function __call(string $name, array $args): int { return 1; }
}
/** @psalm-pure */
function usePure(Runner $runner): int {
return $runner->run(fn(): int => 1); // fine: the closure is pure
}
Defaults and bounds
The bounds of a purity template are written as a chain, lower <= Name(default) <= upper, where
every part but the name is optional:
- the upper bound is the most a value may need (
impurewhen left out):P <= write-props|io; - on a class, the lower bound is what every value needs, which the methods depending on the
template may then use unconditionally:
write-this-props <= C; - the default, in parentheses, applies to subclasses that don't bind the template:
C(pure).
Type templates take their default after = instead (@template T of Bound = Default), and it also
applies to generic types that leave the template out, see Template defaults.
Several templates can be declared in one tag, separated by commas (P <= io, Q). In a chain with
a single bound, the side that is a capability is the bound: io <= C is a lower bound and
C <= io an upper bound. A type alias used as a lower bound needs the upper bound written out
as well (Alias <= C <= impure). In Hack these are the bounds and default of a context
constant, with the keywords reversed as contexts are types:
abstract const ctx C super [write_props, io] as [write_this_props] = [write_this_props].
A binding must include the lower bound itself: Doer[io] is rejected, write Doer[write-this-props|io].
<?php
/** @psalm-purity-template write-this-props <= C(write-this-props) <= write-this-props|write-props|io */
abstract class Doer {
public int $runs = 0;
/**
* @psalm-capabilities read-props
* @psalm-purity-from-template C
*/
public function run(): int {
$this->runs++; // fine: every C includes write-this-props
return $this->runs;
}
}
/** @extends Doer[write-this-props|io] */
final class LoudDoer extends Doer {} // fine: between the bounds
/** @extends Doer[write-globals] */
final class GlobalDoer extends Doer {} // InvalidTemplateParam: Extended template param C expects type write-this-props|write-props|io, type write-globals given
/** @extends Doer[pure] */
final class PureDoer extends Doer {} // InvalidTemplateParam: Extended template param C must include at least write-this-props, pure given
final class DefaultDoer extends Doer {} // C is write-this-props
Constructors
A constructor may take its purity from a class purity template. new Sub() then costs what Sub
binds the template to, or its default or upper bound when Sub doesn't bind it, and
new static() costs the template itself, as it may instantiate any class extending the class.
new can also bind a class purity template from what the constructor is given. A parameter
typed Closure[P] binds it from the purity of a closure. To bind it from the kind of argument
instead, give the constructor a @return: the type of the new expression, which is the class
itself with its template and purity arguments. Like any return type, it may be conditional.
ArrayObject and ArrayIterator are declared this way: their mutators only write the object
itself when it stores an array, but also write the object it wraps when given one. A class like
them can say so:
<?php
/**
* @template TValue
* @psalm-purity-template TStorage(write-props) <= write-props
* @psalm-capabilities read-props|write-this-props
*/
final class Store {
private int $writes = 0;
/**
* @param array<TValue>|object $storage
* @return ($storage is array ? Store[pure]<TValue> : Store[write-props]<TValue>)
* @psalm-pure
*/
public function __construct(array|object $storage = []) {}
/**
* @param TValue $value
* @psalm-purity-from-template TStorage
*/
public function add($value): void {
$this->writes++;
}
}
/** @psalm-pure */
function fromArray(): int {
$store = new Store([1]); // Store[pure]<int>
$store->add(2); // fine: TStorage is pure, and $store was created here
return 1;
}
/** @psalm-pure */
function fromObject(stdClass $object): int {
$store = new Store($object); // Store[write-props]<mixed>
$store->add(1); // ImpureMethodCall: The context is pure but method Store::add requires write-props
return 1;
}
An argument that may be either an array or an object gives the union of both types, so calling
add() on the result costs write-props. A @return without a condition binds type templates
the same way: with @return Box<int> on its constructor, new Box() is a Box<int>.
A condition can also be given to each purity argument, inside the brackets, and a branch may be a
purity template of the constructor, bound from the argument. With
@param array<K, V>|Container[PP]<K, V> $param and @return self[$param is array ? pure : PP]<K, V>,
new Wrapper($container) takes the purity argument of $container, io for a
Container[io]<string, int>. Such conditional purity arguments are resolved per call in the
@return of any function or method, also nested (list<Box[$x is array ? pure : io]<int>>), and in
@psalm-self-out.
The @return of a constructor may only name its own class (or self or static); anything else
is an InvalidDocblock. Since it names the class itself, a subclass inheriting the constructor,
and new static() in a class that isn't final, get the type new would give without it.
Iterators and generators
Traversable, Iterator, IteratorAggregate and Generator have a purity template besides
their key and value templates, TPurity: what iterating over them may do.
Iterator[pure]<int, string> can be iterated by a pure function, and
Generator[io]<int, int, mixed, void> prints when consumed. It defaults to impure, so
Iterator<int, string> still means an iterator about which nothing is known.
Iterating over a value known only by one of these types costs its TPurity and nothing else: a
pure function may consume any Iterator[pure]<int, string> or
Generator[pure]<int, int, mixed, mixed>, including one it was given (where a generator is
paused is internal engine state). Iterating over an object of an iterator class calls that
class's own methods (rewind, valid, current, key, next, or getIterator() and then the
methods of the iterator it returns, which counts as new), so it costs what they need like any
other method call.
iterable has a purity too: iterable[pure]<int, string> accepts arrays (iterating over an array
does nothing) and Traversable[pure]<int, string>, and a pure function may iterate over it. A
plain iterable<int, string> is iterable[impure]<int, string>, since it may be any
Traversable. The purity of an iterable parameter may be a purity template
(iterable[P]<int, string>), bound by each call to what iterating over the argument does.
A generator function-like with a purity annotation binds the TPurity of the Generator (or
Iterator, Traversable) it returns, or the purity of the iterable it returns, to its own
capabilities and purity templates, which every call resolves: consuming the generator costs what
running its body costs.
<?php
/**
* @psalm-pure
* @param Closure[_](): int $f
* @return Generator<int, int>
*/
function map(Closure $f): Generator { yield $f(); return 0; }
/** @psalm-pure */
function sum(): int {
$total = 0;
foreach (map(fn(): int => 1) as $x) { // fine: a Generator[pure]<int, int, mixed, mixed>
$total += $x;
}
return $total;
}
/**
* @psalm-pure
* @param Generator[io]<int, int> $generator for example map(function (): int { echo "x"; return 1; })
*/
function printAll(Generator $generator): int {
foreach ($generator as $x) {} // ImpureMethodCall: The context is pure but iterating over Generator[io]<int, int, mixed, mixed> requires io
return 0;
}
A class implementing Iterator or IteratorAggregate may bind TPurity in its @implements
(@implements Iterator[pure]<int, string>); its iteration methods (or its getIterator() and
what that returns) must then fit the binding. A class that doesn't bind it gets it from those
methods, as whoever iterates over it sees them: a method writing the iterator's own properties
makes it write-this-props|write-props. So MyIterator is accepted where
Iterator[pure]<int, string> is expected exactly when its iteration methods are pure.
The SPL iterators wrapping another one (IteratorIterator, FilterIterator,
CallbackFilterIterator, LimitIterator, NoRewindIterator, InfiniteIterator) have a
TPurity too, bound at construction to what iterating over the inner iterator (and calling the
callback of a CallbackFilterIterator) does: new CallbackFilterIterator(gen(), fn(int $v): bool => $v > 0)
is a CallbackFilterIterator[pure]<...> when gen() returns a Generator[pure]<...>. A subclass of
FilterIterator that doesn't bind TPurity iterates impurely, whatever its accept() does.
Builtins taking callbacks
The builtins that call the closures they are given work the same way: array_map, usort,
preg_replace_callback, ArrayObject::uasort, Ds\Vector::map, … need what their callbacks
need and nothing else, and iterator_to_array and iterator_count what iterating over their
argument does. Fiber has a purity template for what its callback does, which starting or
resuming the fiber costs. Closure::bind() and bindTo() keep the type of the closure they
rebind, and $closure->call($newThis, ...) is a call of the closure that writes $newThis
where the closure writes $this.
Overrides
An override may need fewer capabilities than the method it overrides, never more. This holds for every capability, and for interface methods as well as parent class methods:
<?php
/** @psalm-immutable */
interface Shape {
/** @psalm-capabilities read-props */
public function area(): float;
}
/** @psalm-immutable */
final class Square implements Shape {
public function __construct(private float $side) {}
public function area(): float { // fine: read-props, like Shape::area
return $this->side * $this->side;
}
}
/** @psalm-pure */
final class Unit implements Shape {
public function area(): float { // fine: pure needs less than read-props
return 1.0;
}
}
// both reported on the class name:
// ImmutableDependency: Shape is marked with @psalm-immutable, but LoggedUnit is not
// ImmutableDependency: Shape::area is read-props, but LoggedUnit::area additionally requires io
/** @psalm-capabilities io */
final class LoggedUnit implements Shape {
public function area(): float {
echo 'area';
return 1.0;
}
}
ImmutableDependency is a level 1 issue, so it is reported as info at the default level 2.
A subclass may bind a class purity template of its parent to one of its own
(@psalm-purity-template P with @extends Filter[P]), which each new then binds, possibly to
pure. So an override of a method with @psalm-purity-from-template may use the template only as
the parent does: what it needs unconditionally must fit what the parent's method needs
unconditionally, plus the template's lower bound. This is like overrides in Hack, which may only
use an abstract context constant through this::C.
Property refinements after calls
By default Psalm remembers what it knows about object properties across calls. With
rememberPropertyAssignmentsAfterCall="false",
it forgets refinements after a call, but only those the call could invalidate:
| The callee may use | Psalm forgets refinements of |
|---|---|
write-this-props or write-props |
properties of mutable objects |
write-globals |
static properties and superglobals |
| none of these | nothing |
This applies to function calls, method calls, static calls and new. With
rememberPropertyAssignmentsAfterCall="false", this code reports one issue:
<?php
final class Order {
public ?int $total = null;
public static ?string $currency = null;
}
/** @psalm-capabilities read-globals */
function currencyIsSet(): bool {
return Order::$currency !== null;
}
/** @psalm-capabilities write-props */
function clear(Order $order): void {
$order->total = null;
}
function kept(Order $order): int {
if ($order->total === null) {
return 0;
}
currencyIsSet();
return $order->total; // fine: currencyIsSet() can't write properties
}
function forgotten(Order $order, Order $other): int {
if ($order->total === null) {
return 0;
}
clear($other);
return $order->total; // NullableReturnStatement: clear() may have written $order->total
}
Inferring annotations
Psalm infers the capabilities of unannotated code from its body and the callees it uses. It does
so as a fixpoint over the call graph, after the whole codebase has been analysed: a function-like
that only calls pure function-likes, annotated or inferred, is pure itself, whatever the order in
which they are declared, and mutual recursion, recursive closures and closures assigned to a
variable are handled. When suggestPurityAnnotations
is enabled, it reports code that could be annotated:
- MissingPureAnnotation: a function or method
that could have
@psalm-pureor@psalm-capabilities; - MissingImmutableAnnotation: a class
that could have
@psalm-pure,@psalm-immutableor@psalm-capabilities; - MissingAbstractPureAnnotation: an abstract method with no annotation;
- MissingInterfaceImmutableAnnotation: an interface with no annotation.
The suggestions are off by default. Enable them in psalm.xml:
<psalm suggestPurityAnnotations="true">
The first two can be fixed automatically, with or without the flag in the config:
vendor/bin/psalm --alter --issues=MissingPureAnnotation,MissingImmutableAnnotation
The suggestions and taint analysis (on by default) both need the inferred purity of unannotated code, so it is computed when either is on. Capability checks never use it: a call is checked against what the callee declares, with or without the suggestions.
<?php
function slug(string $title): string { // inferred pure, but not declared
return strtolower(trim($title));
}
/** @psalm-pure */
function heading(string $title): string {
return ucfirst(slug($title)); // ImpureFunctionCall: The context is pure but function call on slug requires impure
}
Taint analysis can only rely on inference for functions and for methods that cannot be overridden, so an annotation still adds precision to an overridable method, an abstract or interface method, and a constructor.
Given this code:
<?php
function slug(string $title): string { // MissingPureAnnotation: slug could be marked @psalm-pure to aid security analysis, run with --alter --issues=MissingPureAnnotation to fix this
return strtolower(trim($title));
}
final class Cart { // MissingImmutableAnnotation: Cart could be marked @psalm-capabilities read-props|write-this-props|write-refs to aid security analysis, run with --alter --issues=MissingImmutableAnnotation to fix this
private int $items = 0;
public function addItem(): void { // MissingPureAnnotation: addItem could be marked @psalm-capabilities read-props|write-this-props|write-refs to aid security analysis, run with --alter --issues=MissingPureAnnotation to fix this
$this->items++;
}
public function count(): int {
return $this->items;
}
}
--alter adds @psalm-pure to slug(), and @psalm-capabilities read-props|write-this-props|write-refs
to Cart and addItem(). Suggestions use the four named levels only: pure, read-props,
read-props|write-this-props|write-refs and impure (@psalm-immutable for a class at
read-props). Write a narrower set by hand if you want one.
A class-level contract binds every subclass too, so the one suggested for a class is the smallest of these levels that allows:
- what the class's own methods need;
- the contract of its parent class;
- what each known subclass needs, or what its contract allows if it has one;
- for a class but not an interface, the upper bounds of its purity templates, up to which subclasses may bind them.
So an abstract base class whose subclasses do more, or a class with a purity template whose upper
bound is left as impure, gets no class-level suggestion. A subclass without an annotation is
suggested a contract on the next run, once its parent has one.
<?php
abstract class Shape { // fine: no suggestion, as only impure covers Circle
/** @psalm-pure */
public function name(): string {
return 'shape';
}
}
final class Circle extends Shape {
public function draw(): void {
echo 'o';
}
}
class Base { // MissingImmutableAnnotation: Base could be marked @psalm-capabilities read-props|write-this-props|write-refs to aid security analysis, run with --alter --issues=MissingImmutableAnnotation to fix this
/** @psalm-pure */
public function name(): string {
return 'base';
}
}
final class Child extends Base {
private int $count = 0;
/** @psalm-capabilities read-props|write-this-props */
public function increment(): void {
$this->count++;
}
}
Psalm doesn't suggest an annotation for an unannotated method that is overridden somewhere, nor
for the code calling it, since the annotation would restrict its overrides. Annotate it yourself
when you want a contract. It doesn't suggest one either when the function-like's
parameter default values need capabilities the annotation wouldn't
give them. A function-like marked @psalm-impure is never reported.
Once enabled, these issues are reported as errors. To adopt them gradually, silence the ones you
are not ready for with issueHandlers:
<psalm suggestPurityAnnotations="true">
<issueHandlers>
<MissingAbstractPureAnnotation errorLevel="suppress" />
<MissingInterfaceImmutableAnnotation errorLevel="suppress" />
</issueHandlers>
</psalm>
Migrating from Psalm 6
Existing annotations keep their meaning:
@psalm-pureand@psalm-immutableare unchanged.@psalm-mutation-freemeans@psalm-capabilities read-props.@psalm-external-mutation-freemeans@psalm-capabilities read-props|write-this-props|write-refs. It no longer allows static properties orstaticvariables: useread-globalsorwrite-globalsfor those.
@psalm-mutation-free and @psalm-external-mutation-free are still accepted but no longer
documented or suggested. mutation-free and external-mutation-free are not capability names,
so @psalm-capabilities mutation-free is an InvalidDocblock.
What changes for your code:
- Calls on objects of the same class are no longer free. In Psalm 6, a method could call a
mutating method on any object of its own class, and a
@psalm-mutation-freemethod could call one on$this. Now a call on$thiscostswrite-this-propsand a call on any other object costswrite-props(see Method calls). - Overrides are checked at every level. Psalm 6 only reported impure overrides of
@psalm-external-mutation-freemethods. Now any override that needs more capabilities is reported, asImmutableDependency. MissingImmutableAnnotationhas a new meaning. In Psalm 6 it reported a mutable class inheriting from an immutable one; that is nowImmutableDependency.MissingImmutableAnnotationnow reports a class that could be annotated. Review existing suppressions of it.- New suggestions are opt-in:
MissingPureAnnotation,MissingImmutableAnnotation,MissingAbstractPureAnnotationandMissingInterfaceImmutableAnnotationare only reported withsuggestPurityAnnotations="true". Run--alteronce to adopt them (see Inferring annotations). - More is checked. Implicit calls (
__toString,__clone,__invoke,ArrayAccess, destructors),throw new,new $classNameandcall_user_funcare now checked for purity. Builtins with side effects need specific capabilities:iofor I/O and random numbers,timefor reading the clock,write-globalsfor process-wide state such asmt_rand()orini_set(). - Iteration has a purity.
foreachover an object costs the iterator's methods, or theTPurityofTraversable,Iterator,Generatoranditerable, instead of always being impure (see Iterators and generators). - Callable types:
pure-callableandpure-Closurestill work, and any capability set can now be written, as inClosure[io](int): void. The capability names (pure,impure,read-props, …) are now reserved type names.
The full list of changes is in UPGRADING.md.