dave-liddament/phpstan-rule-test-helper
A lightweight test helper for PHPStan custom rules. It provides utilities to run rules against sample code and assert expected errors, reducing boilerplate when validating your static analysis extensions.
This is a dev-only convenience package: it removes the fixture-file ceremony from testing custom PHPStan rules. If you already write rules with PHPStan\Testing\RuleTestCase, you know the pain — a data/ directory full of .php fixtures, brittle expected line numbers, and exact-match error strings. This helper lets you put the analysed snippet inline in the test.
Install (Laravel app or package):
composer require --dev dave-liddament/phpstan-rule-test-helper phpstan/phpstan phpunit/phpunit
Where to look first:
README.md — it is short and the usage example is the whole story.src/ — the library is tiny; read it end-to-end (10 minutes) so you know exactly which methods exist and what they assert. This matters more than usual for a 1-star package: you are effectively adopting a pattern, not a framework.tests/ — they double as copy-paste examples.Smallest useful first use case: take one rule you already test (e.g. a custom Laravel rule that bans DB:: in App\Domain) and rewrite its test to declare the bad code inline and assert the expected errors, without creating a fixture file.
The shape you will end up with (confirm exact method names against src/ — the API surface is small but version-sensitive):
use DaveLiddament\PhpstanRuleTestHelper\RuleTestHelper;
use PHPUnit\Framework\TestCase;
final class NoFacadeInDomainRuleTest extends TestCase
{
use RuleTestHelper;
protected function getRule(): Rule
{
return new NoFacadeInDomainRule();
}
public function testFlagsFacadeUsage(): void
{
$this->assertErrorsAreReported(
<<<'PHP'
<?php
namespace App\Domain;
use Illuminate\Support\Facades\DB;
DB::table('users')->get();
PHP,
['Do not use facades in the domain layer.'],
);
}
}
Run it with vendor/bin/phpunit --filter NoFacadeInDomainRuleTest. If you get your error, you are done — from that point the helper is just a cheaper way to write the next fifty rule tests.
One test class per rule, mirroring the rule's namespace. Keep it boring:
src/Rules/NoFacadeInDomainRule.php
tests/Rules/NoFacadeInDomainRuleTest.php
Use PHPUnit data providers aggressively. This is the single biggest win over RuleTestCase. Inline snippets make it natural to cover ten edge cases where fixtures made you cover two:
public static function validCodeProvider(): array
{
return [
'plain class' => ['<?php class A {}'],
'eloquent model' => ['<?php namespace App\Models; class User extends Model {}'],
'repository via DI' => ['<?php public function __construct(private UserRepository $r) {}'],
];
}
/** @dataProvider validCodeProvider */
public function testValidCodeProducesNoErrors(string $code): void
{
$this->assertNoErrorsAreReported($code);
}
Test both directions for every rule. A rule that never fires is the most common silent failure in a PHPStan ruleset. Always have at least one "clean code → zero errors" case per rule alongside the "bad code → this exact message" case. Do not let the happy-path tests drift into "it produced an error" — assert the message.
Keep snippets minimal but realistic. Include the <?php tag, the namespace, and the use statements your rule actually inspects. Snippets that omit namespaces hide bugs where your rule's namespace logic is wrong. Name things like real application code (UserRepository, not Foo).
Coexist with RuleTestCase rather than replacing it. The helper is for rules whose logic is single-file and AST-local. Keep a RuleTestCase-based test for anything that legitimately needs multiple files on disk (rules that inspect composer.json, follow ->extends across files, read Laravel config, or rely on reflection against a bootstrapped autoloader).
Extract a project base test case. After the third rule test, add tests/Rules/AbstractRuleTestCase.php with your common setup: getRule() declared abstract, a shared assertNoErrorsAreReported() alias, your typical bootstrap/config file, and a helper for building snippets from a template. All rules inherit from that, not from the package class directly — it gives you a single place to adapt if the helper is abandoned or PHPStan's API shifts.
In a Laravel context, drive rule development from the codebase. The typical workflow: write a rule to enforce a house convention (no facades in domain, no env() outside config, mandatory declare(strict_types=1), no queries in Blade), test it with the helper, then add it to phpstan.neon and run the whole thing in CI. The test file is your spec and your regression safety net when you tighten the rule.
Wire it into CI as a normal test suite. Add vendor/bin/phpunit tests/Rules to the pipeline next to vendor/bin/phpstan analyse. Rule tests are cheap and fast; there is no reason to gate them behind a separate job.
Pin the version. "dave-liddament/phpstan-rule-test-helper": "^1.0" with a committed composer.lock, and treat PHPStan upgrades as a coordinated bump across both packages. Do not use dev-main for something your whole ruleset's test suite depends on.
Error messages are matched exactly — including PHPStan's type formatting. PHPStan renders types as Foo|null, array<int, string>, list<App\Models\User>, and errors usually end with a period. If your rule builds a message with sprintf, test the formatted output, not just the prefix. Most "the rule is broken" reports are actually "the message string changed by one character."
Line numbers are the recurring trap. Even with inline snippets, if the API asks for expected errors with line numbers, those numbers are relative to the snippet — and heredoc <<<PHP makes line 1 the line after the marker (or the <?php line, depending on how the helper counts). Adding one use statement at the top shifts every assertion below it. If the helper supports message-only assertions, prefer those; if not, keep snippets short so the blast radius of an edit is small. For <<<'PHP' with flexible heredoc indentation, the indentation is stripped from the code but line counting still starts at the first line of the body — verify with a deliberately failing test before writing ten of them.
"Class not found" instead of your expected error is almost always autoloading. Rules that resolve types via reflection need the referenced classes to be autoloadable from the test process. In a Laravel app this usually works because vendor/autoload.php brings in the app; in a package's own test suite it does not, and PHPStan silently reports a different error (or nothing) than you expect. Assert on the absence of errors in a clean-code case to catch this early.
Silent zero-errors is the default failure mode. If a test expecting an error passes with no errors, your first suspects are: the rule returned by getRule() is not the one you think, the snippet doesn't actually contain the construct (typo in the namespace, missed use), or PHPStan skipped the file. Dump the analysed errors array once and look at it; don't reason about it.
Some rules need config or bootstrap. If your rule depends on a PHPStan parameter, custom bleedingEdge setting, or bootstrap file (Larastan's extension setup is a common one), check whether the helper exposes a way to pass additional config files — RuleTestCase has getAdditionalConfigFiles(), and the helper may or may not surface it. If it doesn't, that rule belongs in a RuleTestCase test.
Debugging checklist when a test misbehaves: run with --filter to isolate one test; comment out the assertion and var_dump() the collected errors; temporarily simplify the snippet to the single offending line; confirm getRule() returns an instance of the rule you just edited (get_class()).
Treat the low adoption as a known risk, not a blocker. One star and zero dependents means no community buffer. Mitigations, in order of preference: read src/ so you could fork it; keep all usage funnelled through your own AbstractRuleTestCase so a swap is one file; keep the package dev-only so it never ships to production. The library is small enough that vendoring the relevant trait into tests/ is a legitimate fallback if it ever breaks against a PHPStan major.
Extension points are in your code, not theirs. Don't subclass the helper to add project behaviour — wrap it in your base test case. That keeps the boundary between "third-party" and "our conventions" explicit and makes the PHPStan-upgrade diff readable.
Don't test PHPStan. Assert that your rule fires with your message at the right place. Avoid asserting on PHPStan's internal error ordering or on messages produced by PHPStan itself — those are not yours to guarantee and they change between minors.
How can I help you explore Laravel packages today?