vimeo/psalm
Psalm is a static analysis tool for PHP that finds type errors, bugs, and unsafe code before runtime. It offers configurable strictness, baseline support, and CI integration to help teams catch issues early and improve code quality.
Psalm is a dev-only dependency; it never ships to production.
composer require --dev vimeo/psalm:^6.0
./vendor/bin/psalm --init
Pick your major version deliberately. 7.0.0-rc2 is out and is a large taint-engine release (see below), but it is still an RC with heavy churn in taint and purity modelling. Pin ^6.0 for production CI today; install ^7.0@rc in a throwaway branch only if you specifically want the new taint sinks. Several fixes in this release are tagged [6.x] and land in the 6.x line too, so you are not choosing between "old" and "fixed" — you are choosing between stable and the new taint features.
--init runs an analysis pass and writes psalm.xml at the project root, auto-selecting a starting level (1 = strictest, 8 = loosest) based on how many errors it finds. Resist the urge to start at level 1 on an existing Laravel app — you will get hundreds of errors from Eloquent and facades alone. Level 5–6 is a realistic starting point for a mature codebase, then ratchet up over time.
<!-- psalm.xml -->
<psalm
errorLevel="5"
resolveFromConfigFile="true"
findUnusedBaselineEntry="false"
findUnusedCode="false"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://getpsalm.org/schema/config"
>
<projectFiles>
<directory name="app" />
<ignoreFiles>
<directory name="vendor" />
</ignoreFiles>
</projectFiles>
</psalm>
Where to look first:
psalm.xml — scope (projectFiles), errorLevel, and issue handlers live here. This is the file you will edit most.docs/running_psalm/ in the repo (mirrored at psalm.dev/docs) — specifically installation.md, configuration.md, deal_with_code_that_psalm_does_not_understand.md, and the taint docs. The taint docs are the section that changed most in 7.x; re-read them if you upgrade.psalm-baseline.xml) if you generate one — it is the snapshot of accepted debt.First use case: run psalm against a slice of your app and fix the cheap wins.
./vendor/bin/psalm --show-info=false app/Http/Controllers
The errors that matter on day one are UndefinedMethod, UndefinedClass, InvalidArgument (wrong argument type to a method), PossiblyNullReference, and UnusedVariable. These are real bugs — typos, wrong signatures, unhandled nulls — not stylistic noise. A typical Laravel controller run surfaces a handful of genuine null-dereference and wrong-argument bugs in the first pass.
Second use case, and now clearly the highest-value one for a Laravel app: taint analysis.
./vendor/bin/psalm --taint-analysis
This traces $_GET, $_POST, $_SERVER, $_FILES, request(), $request->input(), etc. through your code to sinks like echo, printf/var_dump/print_r, writes to php://output/php://stdout, DB::raw(), ->whereRaw(), exec(), DNS lookups, and file paths. It finds XSS, SQL injection, and SSRF-class bugs without you writing a single test. Run it before any security review.
The standard adoption pattern is: snapshot existing errors into a baseline, then never let the count grow.
./vendor/bin/psalm --set-baseline=psalm-baseline.xml
Commit the baseline. Every CI run then only fails on new errors. Once the backlog is stable, work through the baseline file by deleting entries and fixing what breaks.
<psalm errorLevel="4">
<projectFiles>
<directory name="app" />
<ignoreFiles>
<directory name="vendor" />
<directory name="app/Console/Commands/Generated" />
</ignoreFiles>
</projectFiles>
</psalm>
Fail the build on any new error, and use the GitHub output format so errors annotate the diff:
- name: Static analysis
run: ./vendor/bin/psalm --threads=4 --no-progress --output-format=github --shepherd
--shepherd uploads coverage to shepherd.dev, which gives you the coverage badge. Use --shepherd --stats if you want the full report. For a faster feedback loop in PRs:
./vendor/bin/psalm --diff --git-base=origin/main
--diff only reports errors in files touched by the branch — great for large teams slowly cleaning up.
Keep the taint job separate and non-blocking while you triage. 7.x expands the sink list substantially, so a codebase that was "clean" under 6.x taint analysis can produce a wall of new findings purely from newly-modelled builtins, not from new bugs:
- name: Taint analysis (report only)
run: ./vendor/bin/psalm --taint-analysis --output-format=github --no-progress || true
Promote it to blocking once the new-sink noise is triaged.
This is where day-to-day leverage actually appears. Psalm's value scales with the precision of your docblocks. The patterns Laravel devs use most:
/** @return array<int, User> */
public function activeUsers(): array { ... }
/** @param array{id: int, email: string} $payload */
public function createUser(array $payload): User { ... }
/** @var Collection<int, Order> */
$orders = Order::where('status', 'paid')->get();
/** @psalm-type UserRow = array{id: int, name: string} */
@psalm-type aliases let you define a shape once and reuse it, which is far cheaper than repeating array shapes across a dozen methods. Note that alias resolution is now independent of declaration order (fixed in this release), so aliases defined at the bottom of a file or in a later @psalm-import-type no longer silently degrade.
For nullable chains and mixed coming out of json_decode/request()->input(), @psalm-var on the assignment is the pragmatic fix — it narrows without changing runtime code, so it costs nothing in production but eliminates the downstream MixedAssignment cascade.
Two newer typing features worth adopting in Laravel code:
/** @throws ValidationException<int> */ // generic exception classes in @throws
final class UserBuilder
{
/** @return self<static> */ // constructor @return drives the constructed type
public function __construct() { ... }
}
The constructor-@return rule lets your fluent query/builder/factory objects (common in Laravel domain code) keep their precise type through new static(...), instead of collapsing to the declared class.
Annotate custom sanitizers and escape hatches. For a validator or a value object that guarantees input safety:
/** @psalm-taint-escape html */
public function toEscapedString(): string { ... }
/** @psalm-taint-escape sql */
public function toSqlIdentifier(): string { ... }
Annotate anything that deliberately emits output, including logging and debug helpers:
/** @psalm-taint-sink html $content */
public function render($content): void { ... }
Three things changed materially in 7.0 that affect how you use this:
html/sql/shell. When upgrading, re-check your @psalm-taint-escape annotations against the taint docs — an escape written for the old kinds may no longer cover the new ones, which shows up as new findings in previously clean code.printf/vprintf/print_r/var_dump/var_export/debug_zval_dump, fprintf/vfprintf, highlight_file/highlight_string, gzpassthru, implode/join over tainted arrays, and stream writes to php://output/php://stdout are all sinks now. Iterating a generator or a container object also propagates taint through the yielded values.$_SERVER and $_FILES entries are treated as client-controlled; exception getMessage() values carry the taint of their constructor argument; catch blocks inherit taint; session_unset() resets $_SESSION. This closes real holes ($request->header('X-Foo') echoed into a view) but also invalidates assumptions your suppressions were written under.Run --taint-analysis in CI as a separate job, not alongside normal analysis — it is slower and its output is a distinct class of issue.
/** @psalm-suppress UndefinedMagicPropertyFetch */
Prefer a targeted @psalm-suppress with the exact issue name over a config-wide <ignoreFiles>. If the same issue fires more than a handful of times in one class, it usually means a missing stub or plugin rather than something to suppress.
./vendor/bin/psalter --dry-run --issues=UnusedVariable,UnusedParam app/
./vendor/bin/psalter --issues=UnusedVariable app/
The set of auto-fixable issues is deliberately conservative. Use --dry-run first and review the diff — UnusedParam in particular can mangle callback signatures that are dictated by an interface.
Psalm ships generic PHP stubs but knows nothing about Eloquent's magic, facades, or the container. The ecosystem plugin (psalm/plugin-laravel, from the Laravel org) provides the stubs and extensions. If you're not using it, expect to write your own Psalm\Plugin\PluginEntryPointInterface implementations for the dynamic parts of your app — the plugin API is stable and well-documented, and it's the correct extension point for things like dynamic facade resolution or your own service locator. If you write a plugin that connects taint to a sink, 7.x now reports those flows even when the source node has no file location, which makes plugin-driven taint findings considerably less mysterious to debug.
Eloquent and facades will produce false positives. $model->some_attribute is a magic __get; User::where() is a facade forward. Without the Laravel plugin you'll drown in UndefinedMagicPropertyFetch and UndefinedMagicMethod. Install the plugin first, then add targeted suppressions for what's left.
Don't run Psalm and PHPStan on the same codebase. They duplicate effort and neither will reach full coverage. Pick one.
findUnusedCode is on by default in Psalm 6+. This is a behaviour change from v5 and it will surface a wave of UnusedClass / UnusedMethod errors in a Laravel app, because routes, console commands, event listeners, and jobs are all invoked dynamically. Set <findUnusedCode="false"/> unless you have a plugin that understands your dispatch mechanisms — or scope it to a subdirectory where you know it's accurate.
Memory and time. Default runs can be slow and memory-hungry on larger apps. Add these flags to CI and local runs:
./vendor/bin/psalm --threads=$(nproc) --memory-limit=2G
The cache lives in .psalm/ (with --cache on by default). If you see stale results after editing composer autoload or stubs, --clear-cache is the first thing to try. Cache directory in Docker/CI should be persisted or cleared consistently — a cache written on a different PHP version will produce odd results.
Upgrading to 7.x invalidates caches, baselines, and taint expectations — in that order. Clear the cache first, then regenerate the baseline in a review-only commit so the diff shows exactly what the new engine thinks changed. Some of that diff will be newly modelled sinks in code that was always vulnerable; some will be modelling fixes. Read it before you accept it.
The baseline format changed. Code samples in baseline entries are now kept as a list. If you commit a regenerated baseline, expect formatting churn in psalm-baseline.xml even where the errors themselves are unchanged — put this in its own commit or reviewers will assume every entry is new.
Terminal report now goes to STDOUT. If any script of yours captured or parsed Psalm's summary, or piped the exit-code-plus-report combination into a formatter, verify it after upgrading. It no longer uses echo for the report because it is not HTML output.
Expect new taint findings from debug code, not just production code. dd(), dump(), var_dump(), printf, and print_r are all sinks now, and so is anything written to a stream opened on php://output/php://stdout. A stray dump($request->all()) or a debug helper that writes to stdout will be flagged. That's correct behaviour — but it means your first 7.x taint run will report a lot of code you'd never ship.
$_SERVER taint is broader than it looks. Because $_SERVER entries are now client-sourced, framework plumbing and your own middleware that read $_SERVER['HTTP_*'] and later render or log them will start appearing in taint flows. Prefer $request->header() with an explicit type annotation and a real sanitizer at the boundary rather than chasing each flow.
Baselines are line-sensitive. The baseline keys entries on file path + line + issue snippet. Renaming a file invalidates its entries; shifting lines can shift which occurrence is matched. Regenerate with --set-baseline after large refactors, but review the diff — regeneration can silently drop entries for issues that were fixed, which is fine, or add new ones, which is not. Keep findUnusedBaselineEntry="true" during cleanup work so you notice stale entries.
Error levels are not linear in effort. Going from 6 → 5 costs a day. 5 → 4 costs a week. 4 → 3 can cost a month on a large codebase. Budget accordingly and don't treat the level number as a target in itself; the taint analysis and the Undefined*/InvalidArgument checks at level 5 catch most real bugs.
Suppression is scoped to the next statement or docblock target. @psalm-suppress on a method docblock suppresses the whole method body; on a variable assignment it only covers that expression. Misplacing it is a common source of "why is this still failing."
--alter / Psalter rewrites your source. Always run on a clean working tree. There is no undo beyond git.
Pin the major version and the Laravel plugin in lockstep. "vimeo/psalm": "^6.0" rather than tracking HEAD or consuming an RC in shared CI. Plugin/PHP version compatibility is the single most common installation failure, and 7.x's new taint kinds in particular require a plugin version that emits the annotations the engine now understands.
Psalm reads composer.json for autoloading but not for runtime. If you rely on dynamic includes or a custom autoloader (e.g. a legacy app/ namespace not in PSR-4), you need <extraFiles> or <autoloader> entries in psalm.xml, otherwise you'll get spurious UndefinedClass. Autoload behaviour around class_implements() on class-name strings is now modelled, so a few previously-clean UndefinedClass cases in code that relies on implicit autoload may resolve — or start resolving to a different type.
Treat MixedAssignment findings as a signal, not noise. In Laravel, mixed almost always comes from request(), config(), json_decode(), or a model attribute. Narrowing those to real types with @psalm-var both silences Psalm and documents the actual contract — arguably the most valuable thing Psalm pushes you toward in a Laravel codebase.
Start the language server early. ./vendor/bin/psalm --language-server gives you errors in the editor rather than at CI time. Fixing a null-dereference while writing the code is a 30-second fix; fixing it after CI red is a context switch. Nullsafe short-circuiting across whole chains is now modelled correctly, so editor feedback on ?-> chains is trustworthy enough to act on while typing.
How can I help you explore Laravel packages today?