Problem
When analyzing a Laravel project that uses Pest for testing, psalm/plugin-laravel emits errors that are false positives in the Pest DSL. Pest test files are not classes. The framework rewrites them into anonymous classes at runtime, and the DSL exposes helpers (expect(), uses(), test(), it()) that internally call methods marked @internal to the Pest package.
Two error classes dominate:
1. InvalidScope — $this inside test() / it() closures
Pest binds the closure passed to test() / it() to the underlying test case, so $this is valid at runtime. Psalm sees a closure in the root namespace and reports:
ERROR: InvalidScope
Invalid reference to $this in a non-class context (see https://psalm.dev/013)
$this
Minimal repro:
// tests/Feature/ExampleTest.php
test('user can do a thing', function (): void {
$this->get('/');
});
2. InternalMethod — calls to Pest\PendingCalls\* and Pest\Mixins\Expectation
The Pest DSL is implemented via methods on Pest\PendingCalls\TestCall, Pest\PendingCalls\UsesCall, and Pest\Mixins\Expectation. These are tagged @internal, but the public Pest API (uses()->in(...), test(...)->with(...), expect(...)->toBe(...), ->toContain(...)) is the intended way to call them.
ERROR: InternalMethod
The method Pest\PendingCalls\UsesCall::in is internal to Pest but called from root namespace (see https://psalm.dev/175)
uses(TestCase::class)->in('Unit', 'Feature');
ERROR: InternalMethod
The method Pest\PendingCalls\TestCall::with is internal to Pest but called from root namespace (see https://psalm.dev/175)
})->with([...]);
ERROR: InternalMethod
The method Pest\Mixins\Expectation::toContain is internal to Pest but called from root namespace
expect($output)->toContain('...');
ERROR: InternalMethod
The method Pest\Mixins\Expectation::toBe is internal to Pest but called from root namespace
expect($count)->toBe(1);
Environment
psalm/plugin-laravel: dev-master
vimeo/psalm: 7.0.0-beta19
pestphp/pest: v4.7.0
- PHP: 8.4
Proposal
Add first-class Pest awareness, similar to how psalm/phpunit-plugin handles PHPUnit. Two pieces:
A. Resolve $this inside Pest test closures
When a closure is passed as the second argument to test(), it(), describe(), beforeEach(), afterEach(), beforeAll(), afterAll(), treat its body as bound to the relevant Tests\TestCase (or the class registered via uses()->in(...) for that path). This eliminates the InvalidScope error and unlocks accurate type info for $this->get(...), $this->assertX(...), Laravel test traits, etc.
The mapping of paths to base classes can come from parsing tests/Pest.php's uses(...)->in(...) calls, falling back to Tests\TestCase if present.
B. Treat the Pest DSL surface as public
Suppress InternalMethod for calls into:
Pest\PendingCalls\TestCall (e.g. ->with, ->group, ->skip, ->throws, ->depends)
Pest\PendingCalls\UsesCall (e.g. ->in, ->group)
Pest\PendingCalls\BeforeEachCall / AfterEachCall
Pest\Mixins\Expectation (all toX expectations, not, and, each, sequence, etc.)
Pest\Mixins\HigherOrderExpectation
…when invoked from a file under the configured Pest test directories (or from any file that calls uses(...), which marks it as a Pest test).
Discoverability
Following the spirit of #788, psalm-laravel init could detect pestphp/pest in composer.json and either enable this behavior automatically or print a hint pointing to it.
Workarounds today
Users currently have to either:
- Exclude the entire
tests/ directory from Psalm, losing all coverage of test code.
- Litter test files with
@psalm-suppress InvalidScope and @psalm-suppress InternalMethod.
- Use a separate
psalm-pest config with errorLevel="suppress" for these issues, which also masks real bugs.
A dedicated Pest integration in psalm/plugin-laravel (or a sibling psalm/pest-plugin it auto-suggests) would let Laravel + Pest users get real Psalm coverage on their test suite.
Problem
When analyzing a Laravel project that uses Pest for testing,
psalm/plugin-laravelemits errors that are false positives in the Pest DSL. Pest test files are not classes. The framework rewrites them into anonymous classes at runtime, and the DSL exposes helpers (expect(),uses(),test(),it()) that internally call methods marked@internalto the Pest package.Two error classes dominate:
1.
InvalidScope—$thisinsidetest()/it()closuresPest binds the closure passed to
test()/it()to the underlying test case, so$thisis valid at runtime. Psalm sees a closure in the root namespace and reports:Minimal repro:
2.
InternalMethod— calls toPest\PendingCalls\*andPest\Mixins\ExpectationThe Pest DSL is implemented via methods on
Pest\PendingCalls\TestCall,Pest\PendingCalls\UsesCall, andPest\Mixins\Expectation. These are tagged@internal, but the public Pest API (uses()->in(...),test(...)->with(...),expect(...)->toBe(...),->toContain(...)) is the intended way to call them.Environment
psalm/plugin-laravel:dev-mastervimeo/psalm:7.0.0-beta19pestphp/pest:v4.7.0Proposal
Add first-class Pest awareness, similar to how
psalm/phpunit-pluginhandles PHPUnit. Two pieces:A. Resolve
$thisinside Pest test closuresWhen a closure is passed as the second argument to
test(),it(),describe(),beforeEach(),afterEach(),beforeAll(),afterAll(), treat its body as bound to the relevantTests\TestCase(or the class registered viauses()->in(...)for that path). This eliminates theInvalidScopeerror and unlocks accurate type info for$this->get(...),$this->assertX(...), Laravel test traits, etc.The mapping of paths to base classes can come from parsing
tests/Pest.php'suses(...)->in(...)calls, falling back toTests\TestCaseif present.B. Treat the Pest DSL surface as public
Suppress
InternalMethodfor calls into:Pest\PendingCalls\TestCall(e.g.->with,->group,->skip,->throws,->depends)Pest\PendingCalls\UsesCall(e.g.->in,->group)Pest\PendingCalls\BeforeEachCall/AfterEachCallPest\Mixins\Expectation(alltoXexpectations,not,and,each,sequence, etc.)Pest\Mixins\HigherOrderExpectation…when invoked from a file under the configured Pest test directories (or from any file that calls
uses(...), which marks it as a Pest test).Discoverability
Following the spirit of #788,
psalm-laravel initcould detectpestphp/pestincomposer.jsonand either enable this behavior automatically or print a hint pointing to it.Workarounds today
Users currently have to either:
tests/directory from Psalm, losing all coverage of test code.@psalm-suppress InvalidScopeand@psalm-suppress InternalMethod.psalm-pestconfig witherrorLevel="suppress"for these issues, which also masks real bugs.A dedicated Pest integration in
psalm/plugin-laravel(or a siblingpsalm/pest-pluginit auto-suggests) would let Laravel + Pest users get real Psalm coverage on their test suite.