Each row links to a per-test detail page with the source, expectations, and every analyzer’s raw output.
Results last updated:
The tools this page covers
PHP has long had tools called static analyzers or linters. For years that name meant a catalogue of heuristic rules — PHPMD is the familiar example. As PHP became an industrial language, checking that call arguments match and completing method names on the right type became more important, and people began trying real parsing and type analysis.
PhpStorm is the polished, integrated success of that turn. Phan, Psalm and PHPStan then specialized in type checking, and a new generation of static analysis had its foundation. What separates the two generations is a much stronger parser, a much stronger account of variable scope, and type inference.
This page compares that later generation, split by aim. A type checker exists to decide whether types are correct. An inferring linter carries its own, usually simpler, type engine so a rule catalogue can see types — and often so it can complete names or check a call’s arguments. Code intelligence infers types mainly to drive completion and navigation.
Verdict. Most rows ask whether the analyzer catches unsafe code.
Pass
The analyzer’s diagnostics match what the test expects.
Fail
They do not: an expected diagnostic is missing, or something unexpected is reported.
Not measured
The test is newer than the analyzer’s last run, so nothing has been measured for it yet. Only ever appears for Qodana, which is run by hand from PhpStorm rather than by this suite; re-run Inspect Code and the cell fills in.
Not reported (by design)
The analyzer declines to report the diagnostic and has said so upstream; the issue is linked in the cell notes. Hand-curated — the harness cannot tell this apart from a plain miss.
PHPDoc type handling. Tests that mark a declaration with // T ask a different question: not “is the code unsafe?” but “how is this type spelling treated?” That splits into two independent facets, and a single word cannot carry both:
Recognition — does the analyzer resolve the spelling? Read off the // T declaration lines. Level-independent.
Enforcement — does it then reject the values the spelling excludes? Read off the // E call sites. For PHPStan this is gated by the level; recognition never is.
The combinations:
Unrecognized
The spelling is not resolved at all: the analyzer reports an unresolvable type, an undeclared type, or a docblock parse error on the declaration. Expected whenever a test uses another analyzer’s dialect — not a defect.
Enforced
The spelling is resolved and every value the type excludes is rejected, without also rejecting values the type admits.
Incidental (n/m)
The violating lines fired, but so did valid controls: the analyzer also rejects values the type admits (class-name fallback, sealed where the test asked for unsealed, over-strict purity, …). Hits on the // E lines are then the wrong reason, not enforcement.
Partly enforced (n/m)
Resolved, and some but not all of the excluded values are rejected. Common in files that probe several related spellings at once.
Widened to X
Resolved, but widened to X, so nothing is rejected. Not an error — just a coarser type. The base type is hand-curated.
Not enforced
Resolved, nothing rejected, and the base type it widened to has not been pinned down yet.
Recognized (no probes)
The spelling resolves; this test has no rejection probes, so enforcement is not measured.
Not enforced (by design)
Resolved and reportable, but the analyzer deliberately declines; the upstream issue is in the cell notes.
Unsound (by design)
Resolved, and some excluded values are rejected, but the analyzer deliberately accepts the rest. PHPStan’s benevolent union is the case: array-key is not a sound int|string. Hand-curated status = By design on a partial row; the upstream write-up is in the cell notes.
Tags.
reported Lv.5+
PHPStan only. The lowest level whose rules report the diagnostic this test expects. PHPStan levels switch rule sets on and off — type inference is identical at every level — so this is a configuration threshold, not a type-support tier. Level 5 turns on argument-type checks, level 6 missing typehints, level 8 nullables, level 9 explicit mixed. Each diagnostic on the detail page carries its own [reported-from-level=N]; this tag shows the lowest one on an expected line. Omitted on Unrecognized cells: recognition is level-independent, and the number on those rows would usually be mixed-fallout, not the unresolvable-type diagnostic.
⚠ 3 false positives
Diagnostics on lines the test neither expects nor marks with // T. On a type-handling test these are the analyzer’s own false positives — Phan resolving number as a class name and then rejecting 1 for it, say. Hover for the line numbers.
(strict)
PHPStan only. Nothing is reported with the standard config; only phpstan-strict-rules catches it.
(pzoom≠)
Psalm only. pzoom, the Rust port of Psalm, flags a different set of lines than Psalm itself.
Soundness — potential runtime type errors
Positives here flag code that can actually fail at runtime — type mismatches, null access, invalid arguments, uninitialized reads. Pass means the analyzer agrees with the expected diagnostic; rows whose test marks a declaration with // T instead report recognition and enforcement of the type spelling. See the legend above.
Test
phan 6.0.7Phan 6.0.7 php-ast version 1.1.3 PHP version used to run Phan: 8.5.9
Style & opinionated rules — no runtime-safety impact
Lint-style opinions and advisories (PHPStan strict-rules, deprecations, doc conventions) that do not change whether the code runs. Cells show whether each analyzer opts into reporting the rule — not a pass/fail verdict.
Test
phan 6.0.7Phan 6.0.7 php-ast version 1.1.3 PHP version used to run Phan: 8.5.9
Dump/trace APIs used while debugging inference (PHPStan\dumpType(), @psalm-trace, @phan-debug-var, Mago\inspect(), …). Cells use the same recognition/enforcement vocabulary as type-spelling rows: does the analyzer accept this helper, and does it emit a diagnostic that reveals the inferred type?
Test
phan 6.0.7Phan 6.0.7 php-ast version 1.1.3 PHP version used to run Phan: 8.5.9
Reference metadata for each analyzer compared above; versions are those pinned by this suite. Analysis is the one column whose values are coined here rather than read off the project: a type checker aims at type correctness; an inferring linter aims at a rule catalogue, using its own (usually simpler) type engine; and code intelligence infers types mainly to drive completion and navigation, with diagnostics one feature among many.
Every cell in this table records what the project claims about itself — from its README, docs, changelog, release notes, package metadata, or its maintainer’s own blog — and no claim here has been verified by executing the tool. What has been measured lives elsewhere: Intelephense’s and Psalm’s diagnostics scores are in the matrix above, and the servers this machine can launch headless have their protocol behaviour recorded in the measured capability matrix below. Cells read Not stated where the project simply does not say.
Four rows also appear in the analyzer table above. Intelephense and Psalm are in both as the same tool — Psalm’s server runs the analyzer its CLI does, reached over the protocol instead of the command line. The other two are pairs: php-lsp drives mir, and phpy is the CLI frontend of devsense-php-ls. Each pair is versioned separately, so each artifact keeps its own row and its own dates. Phan ships a language server too and belongs here; it has simply not been researched yet.
These servers serve one framework or feature — PHPUnit test cases, Laravel helpers, Symfony integrations — rather than PHP as a language. Most have nothing to answer the suite’s typing probes with and are listed for reference only. Laravel LSP is the exception: it is launched against a Laravel-shaped workspace (an artisan file is required to initialize) and measured in the capability matrix and the framework-aware probes. The claims in the cells below are still the projects’ own.
Dead or superseded implementations, kept as a record of what came before — the parser each adopted and the PHP syntax line it reached. Like the function-specific table, they are listed for reference only and are never launched or measured; the latest release cell names the project’s final release.
Everything in this section comes from launching each server headless against a small fixture workspace: no cell repeats a claim from a README. Advertised is what the server’s own initialize response declared; the probe verdict says what happened when the suite then called the capability at a position prepared for it. A dash means the handshake did not advertise the capability. The two freemium servers gate paid features in opposite ways, and the matrix shows both faithfully: a free Intelephense omits them from its handshake (they render as dashes), while DEVSENSE advertises them and refuses the call by licence (they render as Gated). Hover any cell for what it does and does not claim.
Capability
intelephense intelephense 1.18.5
phpactor Phpactor 2026.07.22.0
psalm Psalm 6.16.1
devsense-php-ls devsense-php-ls 1.0.19213
phpantom phpantom_lsp 0.10.0
php-lsp php-lsp 0.25.1
phan Phan 6.0.7
laravel-lsp Laravel LSP v0.0.31
Diagnostics (push)
Publishes
Silent
Publishes
Publishes
Publishes
Publishes
Publishes
Silent
Diagnostics (pull)
—
—
—
—
—
Answered
—
—
Hover
Answered
Answered
Answered
Answered
Answered
Answered
Answered
Empty
Completion
Answered
Answered
Answered
Answered
Answered
Answered
Empty
Empty
Signature help
Answered
Answered
Answered
Answered
Answered
Answered
—
—
Go to definition
Answered
Answered
Answered
Answered
Answered
Answered
Answered
Empty
Go to declaration
—
—
—
—
—
Advertised
—
—
Go to type definition
—
Answered
—
Empty
Answered
Answered
Answered
—
Go to implementation
—
Answered
—
Empty
Answered
Answered
—
—
Find references
Answered
Answered
—
Answered
Answered
Answered
—
—
Document highlight
Answered
Answered
—
Answered
Answered
Answered
—
—
Document symbols
Answered
Answered
—
Answered
Answered
Answered
—
—
Workspace symbols
Answered
Answered
—
Answered
Answered
Answered
—
—
Code actions
—
Answered
—
Answered
Answered
Answered
—
Empty
Code lens
—
—
—
Advertised
Advertised
Advertised
—
—
Rename
—
Answered
—
Answered
Answered
Answered
—
—
Formatting
Answered
—
—
—
Answered
Answered
—
—
Range formatting
Advertised
—
—
Advertised
—
Advertised
—
—
Folding ranges
—
—
—
Empty
Answered
Answered
—
—
Selection ranges
—
Answered
—
Answered
Answered
Answered
—
—
Semantic tokens
—
—
—
Answered
Answered
Answered
—
—
Inlay hints
—
Answered
—
Empty
Answered
Answered
—
—
Call hierarchy
—
—
—
—
—
Answered
—
—
Type hierarchy
—
—
—
Empty
—
—
—
—
Hover type conformance
The typing question, asked over the protocol: hovering a variable whose type the annotation or the narrowing has already established, does the server show that type? Precise means the shown type matches the expected spelling; Widened / other means hover answered with something else — typically the declared type where the narrowed one was expected, or a widened fallback — and the cell’s hover note quotes it verbatim.
Feature
Expected type
intelephense
phpactor
psalm
devsense-php-ls
phpantom
php-lsp
phan
laravel-lsp
Native return type
int
Precise
Precise
Precise
Precise
Precise
Precise
Precise
No type shown
Union narrowed by is_int()
int
Precise
Precise
Precise
Precise
Precise
Precise
Precise
No type shown
Array shape from @return
array{name: string, age: int}
Precise
Precise
Precise
Precise
Precise
Precise
Precise
No type shown
Integer range from @var
int<1, 100>
Widened / other
Precise
Precise
Widened / other
Precise
Precise
Widened / other
No type shown
@template T resolved through a call
string
Precise
Widened / other
Precise
Precise
Precise
Precise
Widened / other
No type shown
Framework-aware probes
These probes ask a framework-specific question — Laravel env, config, route, view, and translation helper keys, plus the missing-key diagnostics — against a real Laravel app (the Gate imageboard) when the submodule is present, or a stub artisan workspace otherwise. Columns without a measurement were not asked; Laravel LSP is the server this layer exists for.
Probe
intelephense
phpactor
psalm
devsense-php-ls
phpantom
php-lsp
phan
laravel-lsp
env-hover
—
—
—
—
Precise
—
—
Precise
env-definition
—
—
—
—
Answered
—
—
Answered
env-completion
—
—
—
—
Answered
—
—
Answered
env-diagnostic
—
—
—
—
No answer
—
—
Precise
config-hover
—
—
—
—
Answered
—
—
Precise
config-diagnostic
—
—
—
—
No answer
—
—
Precise
route-hover
—
—
—
—
Answered
—
—
Answered
route-definition
—
—
—
—
Answered
—
—
Answered
route-diagnostic
—
—
—
—
Precise
—
—
Precise
route-blade-hover
—
—
—
—
Answered
—
—
Answered
view-hover
—
—
—
—
Answered
—
—
Answered
view-diagnostic
—
—
—
—
Precise
—
—
Precise
trans-hover
—
—
—
—
Precise
—
—
Precise
trans-dotted-hover
—
—
—
—
Precise
—
—
Precise
trans-replace-hover
—
—
—
—
Precise
—
—
Precise
trans-diagnostic
—
—
—
—
Precise
—
—
Precise
Navigating a real project’s dependency graph
The corpus is a real project — psy/psysh@0e8ec1af5990 from the steins-survey checkout — and each row is one symbol in it, chosen per kind. Go to definition jumps from a use site and must land on the declaration; find references asks from the declaration and is scored against the complete, grep-verified set of code references, with only the two files involved in each probe opened — everything else must come out of the server’s own index. A +n after the score counts locations beyond the expected set: the corpus contains same-name decoys (another info(), other getAll()s), so extras expose reference implementations that match by name rather than by symbol. The constructor row runs that test the other way — new ConfigPaths(…) contains the text __construct nowhere, so matching by name finds none of its fifteen sites rather than too many — and it is scored on references alone, because go-to-definition from a new expression has no single answer worth grading. Nothing in LSP requires references on a constructor to report instantiations; every server that advertises the capability does it anyway. The corpus defines no namespace-level constant that code references — true of much modern PHP — so that kind awaits a define()-era corpus.