PestStan — Type-Safe Pest 4 Tests With PHPStan Generics in a Laravel App

PestStan adds PHPStan generics to expect() chains and fixes $this binding so Laravel Pest 4 suites pass static analysis at level 8 without false positives.

Steven Richardson
Steven Richardson
· 9 min read

Every Laravel team that gets Larastan green on app/ eventually opens a PR to extend the analysis to tests/ and watches PHPStan explode with hundreds of errors: Function expect not found, Call to an undefined method on mixed, Variable $this might not be defined. The usual fix is to add excludePaths: [tests/*] to phpstan.neon and lock in the gap forever. PestStan, the PHPStan extension by MrPunyapal, fixes that — and gets the test suite into the same static-analysis discipline as the production code.

This guide installs PestStan alongside Larastan, walks through the first errors it surfaces, and shows the exact phpstan.neon setup I use to run different analysis levels on app/ and tests/.

Install PestStan alongside Larastan#

PestStan is a PHPStan extension, not a replacement for Larastan. Larastan teaches PHPStan about Eloquent, container bindings, and the Laravel facade pattern. PestStan teaches it about Pest's global functions and closure binding. You need both, and you should install them in that order so you can confirm Larastan works on app/ before adding more moving parts. PestStan supports PHP ^8.2, PHPStan ^2.0, and Pest ^3.0, ^4.0, or ^5.0 — so it's fine on any modern Laravel 13 app.

composer require --dev mrpunyapal/peststan

If you already have phpstan/extension-installer in your composer.json, the extension is registered for you automatically when Composer finishes. That's the path I recommend — it means you never have to touch the includes: list in phpstan.neon as long as the package is in require-dev.

Register the PestStan extension in phpstan.neon#

Without phpstan/extension-installer you have to add the extension manually. Drop it into the includes: block in phpstan.neon or phpstan.neon.dist right alongside Larastan's. Order in the includes: list does not matter for correctness, but I keep Larastan first because it sets the Laravel-specific stub paths that PestStan's rules sometimes reach for.

includes:
  - vendor/larastan/larastan/extension.neon
  - vendor/mrpunyapal/peststan/extension.neon

parameters:
  level: 8
  paths:
    - app
    - tests

Once the extension is registered, PHPStan stops reporting Function expect not found because PestStan ships a stub that declares all of Pest's globals (it, test, expect, describe, beforeEach, afterEach, beforeAll, afterAll, todo) with their proper return types. The expect() stub returns Expectation<TValue> — a generic that propagates the wrapped value's type through chained assertions.

Analyse tests and triage the first errors#

Run PHPStan against the project once. The point of this pass is to look at what remains after PestStan handles the easy wins — you'll see real bugs and a small handful of test-specific issues that need PHPDoc, not exclusions.

./vendor/bin/phpstan analyse --memory-limit=2G

The errors that survive split into three buckets. Bucket one: missing @var on values returned from factories — User::factory()->create() returns Model in the IDE's view, so PHPStan reports Call to method on mixed. Bucket two: dataset rows typed as array<int, mixed> because the dataset was declared with no PHPDoc shape. Bucket three: real bugs — usually a renamed model method or a forgotten import that hides behind Pest's lazy evaluation. Fix the real bugs first, then move to dataset and factory typing.

Type Pest datasets correctly#

Datasets are the most common source of mixed errors after PestStan is installed. Pest passes each dataset row directly into the test closure as positional arguments, so PHPStan needs to know what those arguments are. The fix is a @param block on the closure signature combined with a typed dataset() definition.

<?php

declare(strict_types=1);

use App\Models\User;

dataset('valid_emails', [
    ['[email protected]', true],
    ['[email protected]', true],
    ['not-an-email', false],
]);

it(
    'validates email addresses',
    /** @param non-empty-string $email */
    function (string $email, bool $isValid): void {
        expect(filter_var($email, FILTER_VALIDATE_EMAIL))
            ->toBe($isValid ? $email : false);
    }
)->with('valid_emails');

Two things are happening here. The closure signature function (string $email, bool $isValid): void tells PHPStan the row shape. The @param non-empty-string narrows further so filter_var() is satisfied at level 8. If your dataset rows are richer — say [$user, $payload, $expected] — annotate each parameter on the closure and PestStan's generic expect() does the rest.

Handle higher-order chains and $this binding#

PestStan detects which TestCase class is in play by reading tests/Pest.php. As long as you use the standard uses(Tests\TestCase::class)->in('Feature') pattern, $this inside every it(), beforeEach(), and afterEach() closure resolves to your application's TestCase — which means $this->actingAs($user), $this->get('/dashboard'), and any custom helpers on the test case all type correctly.

<?php

use App\Models\User;

beforeEach(function () {
    /** @var User $user */
    $user = User::factory()->create();
    $this->user = $user;
});

it('shows the dashboard to authenticated users', function () {
    $this->actingAs($this->user)
        ->get(route('dashboard'))
        ->assertOk()
        ->assertSee($this->user->name);
});

PestStan reads the beforeEach() body and infers that $this->user is User — no @property annotation on the test case is needed. If the same property is assigned in multiple hooks, PestStan unions the types automatically, which is exactly how you'd write it manually.

The expect() chain narrows too. expect($user)->toBeInstanceOf(User::class) returns Expectation<User>, so the next link in the chain can call $user->orders() without PHPStan complaining. If you've felt the pain of PHPStan level 10 in Laravel for Eloquent calls, the same fixes apply inside tests.

Run different PHPStan levels per path#

A tests/ directory at the same level as app/ is rarely realistic on day one. The pragmatic move is to keep app/ at the level you've fought for and start tests/ two levels lower, then raise it as you clean up. The cleanest pattern is a second config file that includes the main one and overrides paths and level.

# phpstan.neon
includes:
  - vendor/larastan/larastan/extension.neon
  - vendor/mrpunyapal/peststan/extension.neon

parameters:
  level: 10
  paths:
    - app
# phpstan.tests.neon
includes:
  - phpstan.neon

parameters:
  level: 6
  paths:
    - tests

That keeps the production code at level 10 while you get tests/ to level 6, then 7, then 8 over a few PRs without holding up app-level discipline. Run ./vendor/bin/phpstan analyse for the production rules and ./vendor/bin/phpstan analyse -c phpstan.tests.neon for the test rules.

Wire the run into GitHub Actions CI#

Once it's green locally, the run belongs in CI alongside Pint and the Pest suite. Add a separate step to your existing static analysis workflow — don't bundle it into the test job, because you want a clean PHPStan error log even when tests fail. If you run a GitHub Actions matrix for Laravel, put the analysis on a single matrix entry to avoid duplicating work across PHP versions.

# .github/workflows/static-analysis.yml
name: Static analysis

on:
  pull_request:
  push:
    branches: [main, production]

jobs:
  phpstan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          coverage: none
      - run: composer install --no-progress --prefer-dist
      - name: PHPStan app/
        run: ./vendor/bin/phpstan analyse --memory-limit=2G --no-progress
      - name: PHPStan tests/
        run: ./vendor/bin/phpstan analyse -c phpstan.tests.neon --memory-limit=2G --no-progress

The two analyse steps run sequentially and surface their errors independently in the GitHub Checks UI. Pairing this with Laravel Pint on a git pre-commit hook means most style and type issues fail before a developer ever pushes — which is the cheapest place to catch them.

Gotchas and Edge Cases#

A few things still trip me up after running PestStan for a few months.

Custom TestCase paths need a manual override. If your application uses several base TestCase classes — say Tests\Feature\TestCase for HTTP tests and Tests\Unit\TestCase for pure unit tests — PestStan's auto-detection picks the first one it sees. Override it explicitly with parameters.peststan.testCaseClass: App\Testing\TestCase in phpstan.neon, or list explicit pestConfigFiles so PestStan reads every Pest.php in the project.

Architecture tests need PestStan too. Pest's arch() block uses the same expect() syntax. If you've leaned into Pest architecture testing for Laravel apps, PestStan also types the toExtend(), toBeInvokable(), and classes()->toBeFinal() chains so PHPStan doesn't flag the predicate composition as mixed.

Browser tests work but need --headed debugging. Pest 4's browser plugin generates closures with the same $this binding rules. PestStan types them correctly, so if you're running the Pest 4 browser plugin with Playwright, the analyser handles visit()->click('Sign In')->assertSee('Dashboard') chains without complaint.

Snapshot tests still need shape hints. If you're using Pest 4 snapshot testing for Laravel API responses, PHPStan can't peek inside the snapshot file on disk. Annotate the value you pass to toMatchSnapshot() with @var or a return type on the helper that builds it, so the analyser knows the shape that's being asserted.

Don't bump the level until you've fixed the dataset PHPDoc. Going to level 8 with hundreds of array<int, mixed> dataset errors creates a noise wall that hides the real issues. Fix dataset typing first, then bump the level. PHPStan baselines (./vendor/bin/phpstan analyse --generate-baseline phpstan-baseline.neon) are fine as a temporary measure — just put a date in a comment so future-you remembers to clean it up.

Wrapping Up#

PestStan is a single composer require that takes test files from "excluded from analysis" to "first-class participants in your type discipline" — and it does it without forcing you to rewrite a single test. Install it, run one PHPStan pass, fix the real bugs it surfaces, type your datasets, and you're done. If you want to widen the net further, the full picture of how PestStan fits with Pint, Rector, and the rest of the static toolbelt is in the Laravel developer toolchain for 2026.

FAQ#

What is PestStan and why do I need it?

PestStan is a PHPStan extension by MrPunyapal that teaches the analyser about Pest's global functions and closure semantics. Without it, PHPStan flags expect(), it(), and $this inside test closures as errors, and most Laravel teams give up and exclude tests/ from analysis. PestStan makes the analyser understand the framework, so the test suite can participate in the same level of static checking as production code.

Why does PHPStan fail on Pest test files without PestStan?

PHPStan does not natively understand Pest's global helpers, the lazy way $this is bound inside test closures, or the generic chain of expect()->toBe()->and(). With no stubs, PHPStan sees expect() as an undefined function and reports Function expect not found. It also can't tell that the closure passed to it() runs with $this bound to the TestCase, so $this->actingAs() looks like an undefined method on Closure.

How do I install PestStan alongside Larastan?

Install Larastan first and confirm it analyses app/ cleanly at your target level — usually 8 or higher. Then run composer require --dev mrpunyapal/peststan. If you have phpstan/extension-installer you're done. Otherwise, add vendor/mrpunyapal/peststan/extension.neon to the includes: block in phpstan.neon. PestStan does not conflict with Larastan; they extend different parts of PHPStan and run side by side.

Can PestStan analyse Pest datasets and higher-order tests?

Yes. Datasets need a @param block on the test closure so PHPStan knows the row shape — PestStan can't infer that on its own. Higher-order test chains like it('...')->with(...)->group(...)->throws(...) are typed end to end because PestStan ships return type annotations for every method on TestCall. The expect()->each() and expect()->sequence() chains also retain their generic parameter, so the inner closure type-checks properly.

What PHPStan level should I run on my tests directory?

Start at one or two levels below your app/ level. If app/ is at level 8, run tests/ at level 6 first, fix what surfaces, then raise it. Use a separate phpstan.tests.neon config that includes the main file so you can run different levels in CI without splitting the rest of the configuration. Once tests/ is clean at level 8, drop the second config and analyse everything together.

Does PestStan work with Pest 4 browser tests?

Yes. PestStan supports Pest ^3.0, ^4.0, and ^5.0, which covers the browser plugin shipped in Pest 4. Closures inside visit() and the chained browser methods bind $this to the same TestCase as a feature test, so PestStan applies the same typing rules. The browser plugin's fluent API (visit('/login')->click('Sign In')->assertSee('Dashboard')) is typed, so PHPStan tracks the value through the chain.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.