Weave Code
Code Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Peststan Laravel Package

mrpunyapal/peststan

PestStan integrates PHPStan with the Pest testing framework, making static analysis fit naturally into your test workflow. Adds Pest-friendly configuration and helpers so you can run PHPStan on your codebase with minimal setup.

View on GitHub
Deep Wiki
Context7

PestStan

Latest Version on Packagist Total Downloads CI

PHPStan extension for Pest PHP testing framework. Provides type-safe expectations, proper $this binding in test closures, and accurate return types for all Pest functions.

Requirements

  • PHP ^8.2
  • PHPStan ^2.0
  • Pest PHP ^3.0, ^4.0, or ^5.0

Installation

composer require --dev mrpunyapal/peststan

If you have phpstan/extension-installer (recommended), the extension is registered automatically.

Otherwise, add it manually to your phpstan.neon or phpstan.neon.dist:

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

Features

Generic expect() Function

The extension provides generic type inference for Pest's expect() function, so PHPStan knows the exact type of the expectation value:

expect('hello');           // Expectation<string>
expect(42);                // Expectation<int>
expect(['a' => 1]);        // Expectation<array{a: int}>
expect($user);             // Expectation<User>
expect();                  // Expectation<null>

Type Narrowing Assertions

Type-checking assertion methods narrow the generic type parameter, so PHPStan tracks the type through assertion chains:

/** @var int|string $value */
$value = getValue();

expect($value)->toBeString();
// PHPStan now knows the expectation wraps a string

expect($value)->toBeInstanceOf(User::class);
// PHPStan now knows the expectation wraps a User

Supported type-narrowing assertions: toBeString, toBeInt, toBeFloat, toBeBool, toBeArray, toBeList, toBeObject, toBeCallable, toBeIterable, toBeNumeric, toBeScalar, toBeResource, toBeTrue, toBeFalse, toBeNull, toBeInstanceOf.

Type-Safe and() Chaining

The and() method properly changes the generic type parameter, enabling type-safe assertion chains:

expect('hello')
    ->toBeString()       // Expectation<string>
    ->and(42)            // Expectation<int>
    ->toBeInt()          // Expectation<int>
    ->and(['a', 'b'])    // Expectation<array{string, string}>
    ->toHaveCount(2);    // Expectation<array{string, string}>

$this Binding in Test Closures

The extension ensures $this is properly typed inside all Pest test closures and lifecycle hooks. It auto-detects your TestCase class from your Pest.php configuration file:

// tests/Pest.php
uses(Tests\TestCase::class)->in('Feature');

// tests/Feature/ExampleTest.php
it('can access test case methods', function () {
    $this->get('/');  // PHPStan knows $this is Tests\TestCase
});

beforeEach(function () {
    $this->assertTrue(true);   // Works in hooks too
});

Supported functions: it(), test(), describe(), beforeEach(), afterEach(), beforeAll(), afterAll().

Dynamic Properties in Test Closures

Pest allows setting properties on $this inside beforeEach hooks. The extension reads those assignments and infers the exact type — no @var annotation or extra local variable required:

beforeEach(function () {
    $this->post   = new Post;                    // Post
    $this->title  = 'Hello';                     // 'Hello' (constant string)
    $this->count  = 42;                          // 42 (constant int)
    $this->active = true;                        // true
});

it('knows the property types', function () {
    $this->post->title;          // PHPStan knows $this->post is Post — no "Cannot access property on mixed" error
    strlen($this->title);        // fine — PHPStan knows it is a string
});

For method-call chains such as factory calls, annotate the local variable with @var to guide inference:

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

If the same property is set by multiple hooks the type is unioned:

beforeEach(function () { $this->item = new Post; });
beforeEach(function () { $this->item = new Comment; });

it('sees the union', function () {
    $this->item;  // Post|Comment
});

Properties that are never set in a hook remain mixed.

Configuration

Automatic TestCase Detection

PestStan reads your Pest.php files to determine which TestCase class is used in each test directory. It supports the uses() pattern:

// uses(TestCase::class)->in('Feature');

No configuration needed — it discovers Pest.php files automatically from your PHPStan paths.

Manual TestCase Override

If auto-detection doesn't work for your setup, or you want a global default, set it in your phpstan.neon:

parameters:
    peststan:
        testCaseClass: App\Testing\TestCase

Explicit Pest.php Paths

If your Pest.php files aren't within PHPStan's analysis paths, you can specify them explicitly:

parameters:
    peststan:
        pestConfigFiles:
            - tests/Pest.php

Pest Function Return Types

Accurate return types for Pest's core global functions, plus newer helpers when they exist in the installed Pest version:

Function Return Type
expect($value) Expectation<TValue>
pest() Configuration
uses(...) UsesCall
it() / test() / todo() TestCall
describe() DescribeCall
beforeEach() BeforeEachCall
afterEach() AfterEachCall
fixture() string when available
beforeAll() / afterAll() null
dataset() / covers() / mutates() null

not() and each() Return Types

expect('hello')->not();    // OppositeExpectation<string>
expect([1, 2])->each();    // EachExpectation<array{int, int}>

TestCall Chaining

All TestCall methods are properly typed for fluent chaining:

it('does something', function () { /* ... */ })
    ->with(['a', 'b'])
    ->group('unit', 'feature')
    ->skip(false)
    ->depends('other test')
    ->throws(RuntimeException::class)
    ->repeat(3);

When you set peststan.testCaseClass to a custom class, PestStan also exposes that class's public helper methods on TestCall chains:

it('uses a custom helper')->publicHelper();

Architecture Testing Support

Architecture testing methods are fully supported:

expect('App\Models')
    ->toExtend('Illuminate\Database\Eloquent\Model')
    ->ignoring('App\Models\Legacy');

expect('App')
    ->classes()
    ->toBeFinal();

expect('App\Actions')->toBeInvokable();
expect('App\DTOs')->toBeReadonly();
expect('App')->toUseStrictTypes();

Static Analysis Rules

PestStan ships with rules that catch common Pest mistakes at static analysis time, before your tests run.

pest.test.emptyClosure — Empty test body

Detects tests whose closure contains no statements.

it('does something'); // fine — todo test
it('does something', function () {});
// ✘ Test 'does something' has an empty closure body. Did you forget to add assertions?

pest.test.staticClosure — Static test closure

Pest binds $this inside every test closure to the TestCase instance. Marking the closure static prevents that binding.

it('example', static function () {
// ✘ Test closure passed to it() must not be static.
    expect(true)->toBeTrue();
});

pest.lifecycle.beforeAllDisallowed / pest.lifecycle.afterAllDisallowed — Lifecycle hooks inside describe()

Pest does not support beforeAll() or afterAll() inside describe() blocks — calling them throws at runtime.

describe('suite', function () {
    beforeAll(function () { /* ... */ });
    // ✘ beforeAll() cannot be used inside describe() blocks.

    afterAll(function () { /* ... */ });
    // ✘ afterAll() cannot be used inside describe() blocks.
});

pest.execution.invalidRepeatValue — Invalid repeat() count

repeat() requires a positive integer greater than zero.

it('runs multiple times', function () { /* ... */ })->repeat(0);
// ✘ repeat() requires a value greater than 0, got 0.

pest.test.duplicateDescription — Duplicate test description

Two tests in the same file with the same description will collide at runtime.

it('does something', fn () => expect(1)->toBe(1));
it('does something', fn () => expect(2)->toBe(2));
// ✘ A test with the description 'it does something' already exists in this file.

pest.expectation.impossible — Assertion that always fails

When the static type already makes an assertion impossible, PestStan reports it.

expect(42)->toBeString();
// ✘ Calling toBeString() on Expectation<int>; assertion is impossible.

expect('hello')->toBeNull();
// ✘ Calling toBeNull() on Expectation<string>; assertion is impossible.

Covered assertions: toBeString, toBeInt, toBeFloat, toBeBool, toBeTrue, toBeFalse, toBeNull, toBeArray, toBeList, toBeObject, toBeCallable, toBeIterable, toBeNumeric, toBeScalar, toBeInstanceOf.

pest.expectation.redundant — Assertion that always passes

When the static type already guarantees an assertion will always succeed, the assertion is redundant and adds no value.

expect(true)->toBeTrue();
// ✘ Calling toBeTrue() on Expectation<true>; assertion is redundant.

expect('hello')->toBeString();
// ✘ Calling toBeString() on Expectation<string>; assertion is redundant.

expect(42)->toBeNumeric();
// ✘ Calling toBeNumeric() on Expectation<int>; assertion is redundant.

Covered assertions: toBeString, toBeInt, toBeFloat, toBeBool, toBeTrue, toBeFalse, toBeNull, toBeArray, toBeList, toBeObject, toBeCallable, toBeIterable, toBeNumeric, toBeScalar, toBeInstanceOf.

pest.expectation.requiresIterable / pest.expectation.requiresString — Incompatible value type

Some expectation methods require the value to satisfy a pre-condition.

expect(42)->each(fn ($e) => $e->toBeInt());
// ✘ Calling each() on Expectation<int>; matcher requires iterable.

expect(42)->toBeJson();
// ✘ Calling toBeJson() on Expectation<int>; matcher requires string.

Methods requiring an iterable: each, sequence.
Methods requiring a string: json, toStartWith, toEndWith, toBeJson, toBeDirectory, toBeFile, toBeReadableFile, toBeWritableFile, toBeReadableDirectory, toBeWritableDirectory.

pest.lifecycle.beforeAllThisUsage$this inside beforeAll()

beforeAll() runs once in a static context before any tests in the file. $this is not available.

beforeAll(function () {
    $this->db = new Database; // ✘ beforeAll() runs in static context — $this is not available. Use beforeEach() instead.
});

Use beforeEach() to run setup before each test with $this available.

pest.throws.classNotFound / pest.throws.invalidException — Invalid throws() argument

throws() accepts a class name that implements Throwable. Passing a non-existent class or a class that is not Throwable is caught at analysis time.

it('fails', function () { ... })->throws('App\NonExistentException');
// ✘ Class App\NonExistentException passed to throws() does not exist.

it('fails', function () { ... })->throws(stdClass::class);
// ✘ throws() expects a Throwable class, got stdClass.

pest.covers.classNotFound / pest.covers.functionNotFound — Non-existent symbol in coversClass()

coversClass(), coversTrait(), and coversFunction() reference symbols by name. PestStan verifies those symbols exist.

it('covers something', function () { ... })->coversClass('App\Nonexistent\Service');
// ✘ Class App\Nonexistent\Service referenced in coversClass() does not exist.

pest.describe.withoutTests — Empty describe() block

A describe() block that contains no it() or test() calls (only hooks, or nothing at all) is likely a mistake.

describe('UserService', function () {
    beforeEach(fn () => null);
    // ✘ describe() block 'UserService' contains no tests.
});

pest.group.invalidName — Empty group() name

group() requires at least one non-empty, non-whitespace string argument.

it('example', fn () => null)->group('');
// ✘ group() requires a non-empty string argument.

Ignoring rules

All rules use PHPStan identifiers, so you can suppress them selectively in your baseline or inline:

# phpstan.neon
parameters:
    ignoreErrors:
        - identifier: pest.test.emptyClosure
/** @phpstan-ignore pest.test.staticClosure */
it('example', static fn () => expect(true)->toBeTrue());

Canonical identifiers are emitted in diagnostics and rule errors. Legacy identifiers remain resolvable through src/Diagnostics/PestDiagnosticIdentifiers.php so downstream tooling can canonicalize older baselines or stored metadata safely.

Semantic Architecture

PestStan stays on the analysis side of the ecosystem boundary. src/Analysis/Expectation/ExpectationSemanticAnalyzer.php owns chain-aware reasoning, src/Analysis/Expectation/ExpectationChainStateResolver.php propagates expectation state through fluent chains, and src/Analysis/Expectation/ExpectationTypeNarrower.php applies conservative type narrowing without speculative inference.

Diagnostics are the interoperability contract surface. src/Diagnostics/PestDiagnostic.php is immutable and JSON-safe, src/Diagnostics/PestDiagnostics.php emits canonical identifiers plus machine-readable metadata, and src/Diagnostics/PestDiagnosticIdentifiers.php resolves legacy aliases to the canonical taxonomy.

The boundary with rector-pest is intentional: PestStan reports stable semantic facts, while Rector consumes those facts to decide whether an automated remediation is safe. PestStan does not apply fixes; it provides deterministic identifiers, semantic codes, matcher categories, and type reasoning that other tools can consume.

Testing

composer test        # Run all checks (lint + types + unit)
composer lint        # Apply code style fixes (Rector + Pint)
composer test:lint   # Check code style (dry-run)
composer test:types  # Run PHPStan analysis
composer test:unit   # Run Pest unit tests

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

License

MIT License. See LICENSE for more information.

Credits

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
daikazu/eloquent-salesforce-objects
unseen-codes/chat
romalytar/yammi-jobs-monitoring-laravel
kisame76/filament-db-table-state
nqxcode/laravel-lucene-search
dpfx/laravel-livewire-wizards
workos/workos-php-laravel
sofa/laravel-global-scope
nawasara/auth-primitives
adhocrat-io/arkhe-main
make-dev/orca-harpoon
itsemon245/lamet
baks-dev/dashboard
amoifr/pickle-panther-bundle
make-dev/orca
dmstr/symfony-system-resources-bundle
dmstr/symfony-job-queue-bundle
dmstr/openapi-json-schema-bundle
dmstr/keycloak-security-bundle
dmstr/doctrine-audit-log-bundle