alies-dev/psalm-tester
Run Psalm assertions and traces as phpt test files inside PHPUnit. Write --FILE--/--EXPECT-- fixtures, use EXPECTF for flexible matching, pass custom Psalm arguments globally or per test via --ARGS--, and skip tests conditionally with --SKIPIF--.
Architecture fit
require --dev dependency that bridges PHPUnit and Psalm: .phpt fixtures carry --FILE-- / --EXPECT-- / --EXPECTF-- sections, PsalmTester shells out to Psalm, and results are asserted through a PHPUnit constraint. Nothing ships to runtime, so there is no blast radius on application code, request lifecycle, or deployment artifacts.--no-progress --no-diff --config=src/psalm.xml, overridable globally via PsalmTester::create(defaultArguments: ...) or per fixture via --ARGS--. For a Laravel codebase that means the analysis contract stays in your psalm.xml + psalm-plugin-laravel; the package only adds fixture-level assertions on top.Integration feasibility
composer require --dev, one PHPUnit TestCase (~15 lines for the single-fixture path), fixtures as files. The 0.4.0 line adds PsalmPhptTestCase with phptDirectory(), which collapses the whole thing to fixture discovery + batched run + --filter narrowing — no custom test class plumbing.vimeo/psalm (^6.10 || ^7.0.0-beta16) and composer-runtime-api ^2 are required, not suggested. Any project pinned below Psalm 6.10, or running a Psalm 7 pre-beta-16, will hit a Composer conflict. This is the single most likely first-hour blocker.#[TestWith], #[DataProvider]) in the documented examples imply PHPUnit 10+.run() / runOne() API returning Result with Outcome and Issue objects is the real integration lever: gating, reporting, and trend capture become ordinary code rather than log parsing.Technical risk
--EXPECT*_EXTERNAL--, changed --ARGS-- from replace to append, made withArguments() one-arg-per-parameter, removed the shell, and turned repeated sections into hard errors. Every one of those is a silent-or-loud CI break. The existence of UPGRADING.md is a good sign; the cadence is not.^7.0.0-beta16 means your harness version is coupled to Psalm's pre-release track. Psalm 7 stable could shift inference and your expectations simultaneously.--EXPECTF-- wildcards (%d, %s) are convenient and also the easiest way to write an assertion that can never fail meaningfully — an expectation-drift risk that needs review discipline.runBatch() shares one global symbol table across fixtures in an argument group, so duplicate class/function names produce DuplicateClass / DuplicateFunction noise. This is documented, but it is a design constraint teams will hit at scale.Key questions
psalm-plugin-laravel pin today, and does it satisfy ^6.10 || ^7.0.0-beta16?EXPECTF wildcards and --XFAIL-- expiry, or will this rot like a baseline file?Stack fit
TestCase, so no new CI stage, no new runner, no new reporting surface — phpunit --filter becomes the way to run a single type fixture.psalm-plugin-laravel's type suite (758 fixtures) is the reference workload, and runBatch() exists specifically because booting a full Laravel application per fixture is prohibitively expensive. If we maintain Laravel-facing stubs or a plugin, this is close to a purpose-built tool.psalm.xml and a baseline, the fit is weak. Application type errors are already covered by psalm in CI; fixture-level inference assertions solve a problem we don't have.Migration path
.phpt fixtures asserting the inference we care about (e.g. an Eloquent relation accessor resolving to a concrete model type), run via PsalmPhptTestCase with phptDirectory().--update-baseline): these are not equivalent and should not be conflated. A baseline suppresses known errors in application code; a fixture suite pins expected analyzer behavior. Baselines tend to grow monotonically and are invisible in review; fixtures are explicit and reviewable. Migrating a handful of high-value baseline entries into fixtures is a legitimate cleanup, not a mechanical port.Result / Outcome / Issue removes parsing code that we would otherwise own forever.Compatibility
^6.10 || ^7.0.0-beta16, composer-runtime-api ^2, PHPUnit 10+ attributes. Lock the Psalm version in lockfile and avoid floating to ^7 until we've run the fixture suite against it deliberately.--FILE--, --EXPECT--, --EXPECTF--, --ARGS--, --SKIPIF--, --XFAIL--, --CONFLICTS-- supported; --CLEAN--, --ENV--, --INI-- explicitly rejected with a clear message, as are --EXPECT_EXTERNAL-- / --EXPECTF_EXTERNAL--. Fixtures ported from php-src may need edits.--ARGS-- appends to configured arguments, and a --config inside it overrides the configured one. A team upgrading from 0.3 with per-test --config will get different behavior than they expect.Sequencing
PsalmTester::create() + runBatch() behave under our config. Do not touch CI yet.runBatch() and withConcurrency().psalm-tester: N phpt files (S skipped), G Psalm runs on STDERR as the run signature; keep results advisory long enough to judge flake rate.withTimeout() and withConcurrency() explicitly; rely on per-run isolated cache dirs; ensure a crashed or unexpected-exit Psalm errors rather than passes (this is a fixed behavior in 0.4.0 — verify it in our environment).--XFAIL-- for known upstream Psalm bugs so "expected failure" flips to a failure when fixed — a scheduled reminder, not a permanent skip.UPGRADING.md, the same way we'd treat a framework minor.Maintenance
--ARGS-- semantic flip (our configs may silently point elsewhere), withArguments() signature change (our test bootstrap compile-errors — the good case), rejected --EXPECT*_EXTERNAL-- (our fixtures must inline expectations).Support
TestCase subclass plus PsalmTester::create() calls), and avoid depending on internal helpers like runGroup() documented as "see the source code." Anything we build on internals is ours to maintain.Scaling
--filter on one test — are the relevant benchmark and are the strongest single argument for adoption over a naive loop.withConcurrency() plus per-run cache directories (--no-cache, isolated per run) bounds resource use and removes cross-run cache contamination. --CONFLICTS-- (php-src semantics) gives conflicting fixtures their own run, prevents overlapping runs sharing a key, and lets all run alone — that is what makes parallelism safe rather than flaky.runBatch()'s shared symbol table is the scaling ceiling on the authoring side: names must be globally unique per argument group or you get DuplicateClass / DuplicateFunction. Plan fixture naming conventions up front; retrofitting them across hundreds of fixtures is unpleasant.Failure modes
| Failure | Behavior | Operational consequence |
|---|---|---|
| Psalm crashes / unexpected exit / non-issue-list output | Tests error (fixed in 0.4.0) | Loud, correct. Previously could pass silently — verify this in our environment during the pilot. |
| Hung Psalm run | withTimeout() kills process and children; Linux/macOS only, Windows kills Psalm only |
Windows CI can leak orphans. Constrain to Linux runners or accept the gap. |
| Duplicate symbol across fixtures in a batch | DuplicateClass / DuplicateFunction |
Misdiagnosed as a real type regression. Naming convention is load-bearing. |
| Issues reported in other files | Errors the affected tests | Prevents false passes from config drift; can be noisy if psalm.xml includes more than intended. |
Expectation drift via EXPECTF (%d, %s) |
Test passes but asserts little | Silent erosion of coverage. Needs review convention: prefer exact --EXPECT--, use EXPECTF only for line numbers. |
| Known-bad expectation left in place | --XFAIL-- reports incomplete, and failure once fixed |
Self-expiring by design — good. Requires someone to notice the newly-red test and act. |
| Upstream package abandonment | No fix path except fork | Fork cost ~1–2 weeks, MIT-licensed, small surface. |
Config drift (--config in --ARGS-- overriding configured config) |
Analysis runs against the wrong config | Fixtures pass while the real config regresses. Enforce one config source. |
Ramp-up
.phpt section semantics, EXPECT vs EXPECTF, SKIPIF returning a reason with the leading skip token stripped, and the batch-mode constraint on global names. A competent PHP engineer is productive in under a day; writing good expectations (exact enough to catch regressions, loose enough not to churn on every Psalm patch) takes a few iterations.EXPECTF, and action --XFAIL-- flips. Without that owner, this becomes a green checkmark that no longer means anything.Recommendation
Adopt if we maintain Psalm-facing type behavior — a plugin, stub set, or framework integration — and our Psalm version satisfies ^6.10 || ^7.0.0-beta16. The buy-vs-build case is strong: fixture parsing, batched execution, issue diffing, cache isolation, conflict scheduling, and timeout handling are weeks of undifferentiated work, and the Result/Issue API makes downstream gating trivial.
Do not adopt if we are a consumer of Psalm rather than an extender of it. Application type errors are already covered by psalm in CI; a 1-star, 0-dependent, pre-1.0 dev dependency with a beta upstream is not worth the version-conflict and upgrade-burden risk for coverage we already have.
Either way, resolve the metadata question — the listed 2026-09-30 release date — against Packagist before any pinning decision.
How can I help you explore Laravel packages today?