php-standard-library/psalm-plugin
Psalm plugin for PHP Standard Library (PSL) that improves static analysis of PSL Type specifications. It gives Psalm precise inferred types for shapes and coerce results, such as exact array shapes instead of broad unions. Install with Composer as a dev dependency and enable via psalm-plugin.
Install as a dev dependency. The plugin only affects static analysis; it should never be in require:
composer require php-standard-library/psalm-plugin --dev
This assumes php-standard-library/psl and vimeo/psalm are already installed. The plugin does nothing on its own — it registers return-type providers that Psalm only uses when it can see PSL types in your code.
Enable it with the binary, not by hand. The recommended path is:
php vendor/bin/psalm-plugin enable php-standard-library/psalm-plugin
This writes <pluginClass vendor="php-standard-library/psalm-plugin" class="Psl\Psalm\Plugin"/> (or the equivalent plugins entry) into your psalm.xml. Check git diff on psalm.xml afterwards — that is your first confirmation the wiring is correct.
Where to look first. Open psalm.xml and confirm the plugin entry exists, then run Psalm on one file. The canonical smoke test from the README is a Psl\Type\shape() specification:
use Psl\Type;
$spec = Type\shape([
'name' => Type\string(),
'age' => Type\int(),
'location' => Type\optional(Type\shape([
'city' => Type\string(),
'state' => Type\string(),
'country' => Type\string(),
])),
]);
$input = $spec->coerce($_GET['user']);
/** @psalm-trace $input */
Without the plugin, Psalm widens the result to array<"age"|"location"|"name", array<...>|int|string>. With it enabled, @psalm-trace prints array{name: string, age: int, location?: array{city: string, state: string, country: string}}. If you see the precise shape, you are done.
First real use case. Pick the single payload boundary you trust least — an API request body, a webhook envelope, or a config array — and replace hand-rolled isset/is_string checks with a Type\shape() spec plus coerce()/parse(). You get one source of truth that is both a runtime validator and a static contract.
Pin the compatible versions. Match the README matrix before you start debugging anything:
| PSL | Plugin | Psalm |
|---|---|---|
| 2.x | ^2.1 | v5 |
| 2.x | ~2.0.0 | v4 |
| 1.x | 1.x | v4 |
A plugin that "doesn't do anything" is almost always a version mismatch, not a bug.
Shape specs at the boundary, plain arrays inside. The strongest pattern is to coerce() once at the edge of the application and let the inferred shape flow inward. Downstream functions can type-hint the exact shape (or accept the inferred array{...} via @param) and get full Psalm checks plus IDE autocomplete:
/** @param array{name: string, age: int, location?: array{city: string, state: string, country: string}} $user */
function register(array $user): void { /* ... */ }
register($spec->coerce($_POST['user'])); // Psalm now verifies the shape
coerce() vs parse() — choose per call site. Both benefit from the plugin's precise return types, but parse() throws on mismatch while coerce() is the lenient variant. Use parse() in strict service layers where a malformed payload is a bug, and coerce() at system edges (e.g. $_GET, $_POST) where you want to tolerate coercible input. The plugin resolves the spec in both cases, so the inferred type is identical — the choice is about failure semantics, not typing.
Optional keys and nullable values are different. Type\optional(Type\shape([...])) produces an optional key (location?: ...), while Type\nullish(...) (supported via the 2.4.0 return-type provider) produces a nullable value that is always present. Keep them distinct in your spec — mixing them up produces arrays where a key the caller expects may simply be absent, and Psalm will faithfully report that.
Compose shapes instead of flattening them. Nested Type\shape() and Type\optional() calls are modeled recursively. Build your schemas bottom-up (address shape → user shape → request shape) and reuse the PHP-side TypeInterface values across endpoints. This keeps the inferred array shapes readable and lets Psalm pinpoint exactly which branch drifted when a field changes.
In Laravel, treat shapes as an augmentation, not a replacement. Form Requests and Validator are excellent at producing user-facing 422s; PSL shapes are excellent at guaranteeing structure in typed code and in jobs/queues where no HTTP request exists. A clean split is: Validator for HTML-facing validation with error bags, Type\shape() for API payloads, queued job payloads, and webhook bodies. Do not try to make one cover the other.
CI as the enforcement point. Add vendor/bin/psalm --no-cache to CI on paths that touch PSL shapes. The payoff is catching schema drift at PR time: rename a field in the shape, and every consumer that still assumes the old key fails. Because it's a dev dependency, roll it out module-by-module (one high-risk module first, then broaden) without touching production code.
psalm.xml, or your Psalm/PSL versions do not match the compatibility matrix. Re-run psalm-plugin enable and diff psalm.xml before anything else.coerce() is not a validator with error messages. It will happily coerce "42" to 42 for Type\int(). If your API contract requires rejecting loose types, use parse() or tighten the spec — the plugin's precise typing will make a too-loose spec look correct.@psalm-trace is your debugging tool. Place /** @psalm-trace $var */ immediately after the coerce/parse call to see exactly what the plugin produced. If the trace shows array<...> instead of array{...}, the spec isn't statically resolvable (e.g. it's built from a variable that Psalm can't narrow).Type\shape([...]) structures. Assembling the spec in a foreach over config data will collapse back to a generic type. Keep shape definitions literal and static.composer install --no-dev in your deploy Dockerfile excludes it. It has no runtime presence and adds nothing to the deployed artifact.--no-cache after adding the plugin; otherwise Psalm may serve you a stale analysis cache and make the change appear ineffective.Psl\Type\shape, Type\optional, Type\nullish, plus the many Iter/Str providers landed across 1.1.x–2.4.x). If PSL adds a new Type combinator and the plugin lags, you can write your own ReturnTypeProviderInterface implementation and register it alongside — but check the release notes first, since the plugin has shipped these steadily (e.g. nullish() arrived in 2.4.0).^2.1) and let it move with your Psalm major, but don't jump a Psalm major without checking the matrix.How can I help you explore Laravel packages today?