Safeguard
Application Security

PHPStan vs. Psalm: setting up PHP static analysis to catch security bugs pre-commit

Psalm ships free taint analysis out of the box; PHPStan doesn't track data flow at all without extensions. Here's how to wire either one into pre-commit.

Safeguard Research Team
Research
6 min read

PHP still runs a large share of the web's server-side code, and its two dominant open-source static analysis tools solve very different problems. PHPStan, first released by Ondřej Mirtes in 2016, checks code against a 0–9 strictness scale — level: max corresponds to level 9 — but ships with no built-in concept of tainted data; catching SQL injection or XSS requires bolting on extensions. Psalm, open-sourced by Vimeo in 2018, took the opposite bet: it includes native, free taint analysis behind a single --taint-analysis flag, tracing untrusted input from sources like $_GET, $_POST, and $_COOKIE all the way to sinks like echo, include, header(), and SQL calls. Neither tool executes your code — both parse the AST and, for Psalm's taint mode, build a dataflow graph — which means both can run as a pre-commit hook in well under a second on a typical file and block a vulnerable line before it's ever pushed. This piece compares what each tool actually catches, how their strictness and taint models differ in practice, and walks through a working pre-commit + CI setup for both, including baseline files so you can adopt either on a legacy codebase without a multi-week cleanup blocking day one.

What does PHPStan actually check, and why doesn't it catch SQL injection by default?

PHPStan is fundamentally a type-checker and dead-code detector, not a security scanner, and that distinction shows up immediately once you set level: max in phpstan.neon. At its higher levels it flags calling methods on possibly-null values, mismatched return types, unreachable branches, and unused private methods — real bugs, but not the OWASP Top 10. PHPStan has no concept of a "tainted" variable in its core engine, so a call like $db->query("SELECT * FROM users WHERE id = $id") with $id sourced straight from $_GET passes cleanly at level 9 unless a dedicated security rule set is layered on top, such as community-maintained rule packages built on PHPStan's extension API. This makes PHPStan excellent at preventing an entire class of runtime TypeError and null-pointer crashes cheaply, but teams that rely on it alone for security review are checking correctness, not exploitability — the two are related but not the same audit.

How does Psalm's taint analysis actually trace an injection path?

Psalm's taint analysis, run with psalm --taint-analysis, builds a directed dataflow graph rather than pattern-matching function names, which is what lets it distinguish a genuinely exploitable call from a superficially similar safe one. Per Psalm's own documentation, the engine starts from a fixed set of default taint sources — $_GET, $_POST, and $_COOKIE — and walks assignments, function returns, and array access forward until it either reaches a registered sink (echo, include, header, PDO/mysqli query methods, shell_exec) or the value passes through something Psalm recognizes as a sanitizer. A call like echo htmlspecialchars($_GET['name']) is not flagged, because the taint is cleared at the htmlspecialchars() boundary; the unescaped version is. Sources and sinks are extensible through @psalm-taint-source and @psalm-taint-sink annotations, so teams can mark custom input boundaries (a JSON-RPC handler, a queue consumer) as taint sources without waiting on upstream Psalm releases. Output can be emitted as SARIF, which is what makes it wireable into GitHub code-scanning alerts and most CI security dashboards.

Which one should catch what in a real Laravel or Symfony codebase?

In practice the two are complementary rather than competing, and most security-conscious PHP teams that use static analysis run both rather than picking one. PHPStan with phpstan-strict-rules and framework-specific extensions (phpstan-doctrine, Larastan for Laravel) catches the type and logic bugs that cause 3 a.m. production incidents — a nullable User object dereferenced without a check, a Doctrine query built against the wrong entity. Psalm's taint mode is the one that actually stops a reflected-XSS or SQL-injection commit from landing, because it's the only one of the two tracking where request data goes. Neither tool, notably, would have caught the March 2021 compromise of the official git.php.net server, where attackers pushed two malicious commits impersonating Rasmus Lerdorf and Nikita Popov that added a backdoor triggered by a crafted User-Agent header — that was a source-control compromise caught by routine post-commit review within hours, not a code-level flaw a SAST tool scans for. It's a useful reminder that static analysis covers code-level injection risk, not supply-chain integrity of the repository itself.

How do you wire either tool into a pre-commit hook without blocking everyone on day one?

Both tools support the standard pre-commit framework as well as plain Composer scripts, and both ship a baseline mechanism specifically so adoption on an existing codebase doesn't require fixing every historical finding before the hook can go live. For Psalm, psalm --set-baseline=psalm-baseline.xml snapshots every current issue (including taint findings) into a file that's excluded from future failures, so the hook only fails on newly introduced problems. PHPStan's equivalent is phpstan analyse --generate-baseline, producing a phpstan-baseline.neon included from the main config. A minimal .pre-commit-config.yaml entry runs composer exec psalm -- --taint-analysis or composer exec phpstan -- analyse as a local hook, scoped to staged .php files for speed. Both projects also publish official GitHub Actions (vimeo/psalm-github-actions and PHPStan's own composer-based CI recipe), so the same check that blocks a local commit re-runs as a required PR status check, closing the gap for contributors who bypass hooks with --no-verify.

How does this fit alongside dependency and reachability scanning?

Static analysis of your own PHP source is only one layer — most PHP applications also carry dozens of Composer dependencies, and a code-level scanner like Psalm or PHPStan has no visibility into whether a vulnerable function inside vendor/ is ever actually called. That's where reachability analysis complements rather than replaces taint analysis: Safeguard's reachability engine currently supports PHP with Composer-aware, Symfony- and Laravel-aware dependency graph resolution, though static PHP reachability specifically is in preview while dynamic (runtime) PHP reachability is generally available. Safeguard doesn't run PHP SAST itself today — first-party SAST scanning currently covers JavaScript/TypeScript, Python, and Java — so Psalm and PHPStan remain the right tools for catching in-house injection bugs pre-commit. The practical split is: run Psalm's taint analysis and PHPStan's type checks on your own code at commit time, and let a reachability layer tell you which of your Composer dependencies' known CVEs are actually exercised by your call graph, so the CVE backlog your team triages reflects exploitable risk rather than every advisory that happens to match a composer.lock version string.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.