mrpunyapal/peststan
PHPStan extension for Pest PHP that adds type-safe expectations, proper $this binding in test closures, generic expect() inference, type-narrowing assertions, and accurate return types for all Pest functions. Requires PHP 8.2+, PHPStan 2, Pest 3-5.
app/ but leave tests/ unanalyzed. PestStan makes tests/ a viable analysis target by teaching PHPStan the Pest DSL, so the decision "do we gate CI on test-code quality?" becomes a concrete, low-cost yes/no rather than a vague aspiration.Expectation<T> inference, $this binding + dynamic property inference from beforeEach(), and the semantic expectation rules (pest.expectation.impossible / pest.expectation.redundant) — are the expensive parts to build. Re-implementing them in-house is a multi-sprint effort; adopting is a dev-dependency. Because the package is MIT and archived, a third option exists: vendor/fork a pinned copy and own it. That is a legitimate PM-level decision, not just an engineering one, and it should be made explicitly rather than by default.PHP ^8.2, PHPStan ^2.0, Pest ^3|^4|^5) map directly onto upgrade work you may already have planned. If a Pest 4→5 or PHPStan 1→2 migration is on the roadmap, this package belongs in the same workstream — its compatibility matrix becomes a checklist item, and it de-risks the migration by catching Pest API misuse that only surfaces at runtime today.beforeEach(fn () => $this->post = new Post) typed without @var annotations or extra locals eliminates the "Cannot access property on mixed" noise that normally pushes teams toward bloated baselines or annotation litter. That's a measurable DevEx win (annotations removed, baseline lines removed) you can put a number against.static test closures, beforeAll() inside describe(), duplicate descriptions, repeat(0), non-Throwable classes in throws(), $this in beforeAll(), empty describe() blocks. These are excellent codified standards for a team style guide and for reviewing junior contributors' PRs — the reviewer no longer has to be the linter.toExtend, classes()->toBeFinal(), toBeInvokable, toBeReadonly, toUseStrictTypes means architecture tests get the same type safety as unit tests. If "enforce hexagonal boundaries / final classes" is a stated engineering goal, this is a supporting capability rather than an incidental one.pest.expectation.impossible, baseline pest.expectation.redundant for a quarter. That turns a binary "adopt the tool" decision into a phased rollout with a dial.Consider adopting when:
tests/ is inside PHPStan's paths, or you're willing to add it (and can tolerate an initial error burst).expect(42)->toBeString()) is a genuine bug class that Pest silently accepts at runtime.Look elsewhere (or defer) when:
pest.expectation.redundant can flag intentional assertions in tests that exist to document intent. If the team can't absorb a baseline, phase it or ignore that identifier.To executives: A small, free, MIT-licensed developer tool that makes our test suite check itself. It adds no runtime dependency, ships nothing to production, and installs as a dev-only Composer package. The payoff is earlier detection of broken tests — tests that can never pass, duplicate test names, hooks placed where Pest forbids them — which today are only found when CI runs slowly or a release goes sideways. Cost is roughly an afternoon of integration plus a one-time cleanup of new findings; the only real caveat is that the upstream project is archived, so we should plan to pin the version and keep a fork option in our back pocket.
To engineering: PestStan is a PHPStan extension that types the Pest DSL properly. Three things you get immediately: expect() carries its generic type through the whole chain (Expectation<string>, Expectation<User>, narrowed by toBeString() / toBeInstanceOf()), $this is correctly typed inside it()/test()/describe() and lifecycle hooks by auto-reading your Pest.php uses() config, and properties assigned in beforeEach() are inferred (unioned across hooks) so you stop writing @var annotations and stop getting "property on mixed" errors. On top of that it ships ~15 analysis rules with stable identifiers, so you can turn them on one at a time and baseline what you don't want yet. Config is near-zero: install, let extension-installer register it, point PHPStan at tests/. Two things to know: it's read-only analysis (no runtime behavior, nothing to deploy), and upstream is archived — so we pin the version, and if a future Pest release breaks it, we either patch our fork or remove it. Recommendation: pilot on one service, measure the new-error yield in the first run, and decide fork-vs-drop within one quarter.
How can I help you explore Laravel packages today?