Write a Custom PHPStan Rule to Enforce Your Laravel Conventions

Build a PHPStan custom rule for Laravel end to end: pick the AST node, add an error identifier, register it in phpstan.neon, and cover it with RuleTestCase.

Steven Richardson
Steven Richardson
· 11 min read

Every codebase I work on has a handful of rules that exist nowhere except in the heads of whoever reviews the PR. Jobs must set $tries. No dd() in app/. Actions get exactly one public method. Reviewers forget, the convention rots, and six months later half the jobs retry forever. A PHPStan custom rule fixes that permanently: write the check once, and CI enforces it on every commit. This walks the whole build for one real rule — including registration, scoping and tests, which is where the official docs go quiet.

I am assuming PHPStan is already installed and running. If it isn't, or if it's stuck below level 10, start with the five errors that block a Laravel app at level 10 and come back once analysis is green.

Pick a convention worth automating#

Choose a convention that is objective, cheap to check, and one you have actually commented on more than twice in review. Subjective preferences make bad rules — you will spend more time adding exceptions than you saved. The rule I'm building here is: any class implementing ShouldQueue must declare a $tries property, because a queued job with no retry limit will hammer a failing API until someone notices the queue depth.

Objective and cheap look like this:

  • Jobs implementing ShouldQueue declare $tries — yes, checkable from reflection alone.
  • Controllers are final — yes.
  • "Services should be cohesive" — no. Not a rule.

Write the convention as a sentence with no "should probably" in it. If you can't, it isn't ready to automate.

Find the AST node your PHPStan custom rule must inspect#

Don't guess the node type. Register a throwaway rule that listens to every node, dump what it receives, and read the answer off the output. Start with a sample file containing the exact situation you want reported:

<?php // app/Jobs/SendInvoice.php

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

final class SendInvoice implements ShouldQueue
{
    use Queueable;

    public function handle(): void
    {
        // ...
    }
}

Now a rule that reports nothing and prints everything:

<?php

declare(strict_types=1);

namespace App\PHPStan\Rules;

use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;

/** @implements Rule<Node> */
final class NodeDumpRule implements Rule
{
    public function getNodeType(): string
    {
        return Node::class; // the common interface: every node matches
    }

    public function processNode(Node $node, Scope $scope): array
    {
        echo get_class($node), PHP_EOL;

        return [];
    }
}

Register it under rules: and run with --debug, which is what lets a rule write to stdout:

vendor/bin/phpstan analyse --debug app/Jobs/SendInvoice.php

The interesting line in the output is PHPStan\Node\InClassNode. That is one of PHPStan's virtual nodes — it doesn't exist in PHP-Parser's AST. It matters here for two reasons. First, my rule needs to report something that is missing from the class, and absence has no node of its own. Second, on the raw PhpParser\Node\Stmt\Class_ node the scope has not entered the class yet, so $scope->getClassReflection() is null; InClassNode hands you the reflection directly.

That is the general trick: when you're checking for absence, or you need class reflection, reach for a virtual node from the PHPStan\Node namespace rather than a parser node.

Write the PHPStan custom rule class#

With the node type settled, the rule is a single class with two methods. Note the @implements Rule<InClassNode> tag — it's how PHPStan (and PhpStorm) know what $node actually is, and it replaces the old @param/@return PHPDoc on processNode().

<?php

declare(strict_types=1);

namespace App\PHPStan\Rules;

use Illuminate\Contracts\Queue\ShouldQueue;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Node\InClassNode;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;

/**
 * @implements Rule<InClassNode>
 */
final class QueuedJobMustDeclareTriesRule implements Rule
{
    /**
     * @param  list<string>  $ignoredNamespaces
     */
    public function __construct(
        private readonly ReflectionProvider $reflectionProvider,
        private readonly array $ignoredNamespaces = [],
    ) {}

    public function getNodeType(): string
    {
        return InClassNode::class;
    }

    public function processNode(Node $node, Scope $scope): array
    {
        // A shared rule package may run on projects with no queue contract.
        if (! $this->reflectionProvider->hasClass(ShouldQueue::class)) {
            return [];
        }

        $class = $node->getClassReflection();

        if (! $class->implementsInterface(ShouldQueue::class)) {
            return [];
        }

        // hasNativeProperty() walks the parent chain but ignores @property
        // PHPDoc and __get(), so only a genuinely declared $tries counts.
        if ($class->hasNativeProperty('tries')) {
            return [];
        }

        return [
            RuleErrorBuilder::message(sprintf(
                'Job %s implements ShouldQueue but does not declare a $tries property.',
                $class->getDisplayName(),
            ))
                ->identifier('richdynamix.jobMissingTries')
                ->tip('Add `public int $tries = 3;`, or set `public int $tries = 1;` for a job that must never retry.')
                ->line($node->getOriginalNode()->getStartLine())
                ->build(),
        ];
    }
}

Two details worth pausing on. implementsInterface() is real reflection, so it catches a job that inherits ShouldQueue through an abstract base class three levels up — a string comparison against $node->getOriginalNode()->implements would not. And hasNativeProperty() rather than hasInstanceProperty(): the latter resolves magic and @property annotations, which would let a job annotate its way out of the rule.

Return a rule error with an identifier and a tip#

processNode() returns an array. In PHPStan 2.x it must be an array of RuleError objects built through RuleErrorBuilder, and every one of them needs an identifier — returning plain strings was removed with the 2.0 native return type. Forget ->identifier() and the rule throws rather than reporting, which is a confusing first failure if you've ported a rule from 1.x.

The builder methods you'll actually use:

RuleErrorBuilder::message('Job App\Jobs\SendInvoice does not declare $tries.')
    ->identifier('richdynamix.jobMissingTries') // required; letters and dots only
    ->tip('Add `public int $tries = 3;`.')      // shown next to a 💡 on the CLI
    ->line(42)                                  // override the reported line
    ->nonIgnorable()                            // no baseline, no ignoreErrors
    ->build();

Identifiers are validated: ASCII letters with optional dots between them. No digits, no hyphens, no underscores. Namespace yours with a prefix you own (richdynamix. here) so nobody confuses them with PHPStan's built-ins, and so @phpstan-ignore richdynamix.jobMissingTries reads clearly at the call site.

Reach for ->nonIgnorable() sparingly. It also excludes the error from the baseline, which means you cannot adopt the rule incrementally — worth knowing if you're mid-way through burning down a PHPStan baseline on a legacy app.

Register the custom rule in phpstan.neon#

If every constructor argument can be autowired, the one-line rules: form is enough:

# phpstan.neon
rules:
    - App\PHPStan\Rules\QueuedJobMustDeclareTriesRule

My rule takes an array $ignoredNamespaces that PHPStan cannot autowire, so it needs registering as a service with the phpstan.rules.rule tag:

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

services:
    -
        class: App\PHPStan\Rules\QueuedJobMustDeclareTriesRule
        arguments:
            ignoredNamespaces:
                - App\Jobs\Legacy
        tags:
            - phpstan.rules.rule

The rule class also has to be autoloadable by PHPStan itself. Living in app/PHPStan/Rules/ under the app's PSR-4 namespace, it already is. If you extract the rules into a package later, add an autoload entry for them and ship an extension.neon that consumers include.

Test the rule with RuleTestCase#

A rule without a test is a rule you will be afraid to change. PHPStan\Testing\RuleTestCase runs the real analyser over a fixture file and asserts the exact errors, so both false positives and false negatives fail the build.

First the fixture. It is never autoloaded or executed, so give it its own throwaway namespace and put both a violating and a compliant class in it:

<?php // tests/PHPStan/Rules/data/queued-jobs.php

declare(strict_types=1);

namespace QueuedJobsData;

use Illuminate\Contracts\Queue\ShouldQueue;

final class SendInvoiceJob implements ShouldQueue
{
    public function handle(): void {}
}

final class SyncCustomerJob implements ShouldQueue
{
    public int $tries = 3;

    public function handle(): void {}
}

final class PlainCommand
{
    public function handle(): void {}
}

Then the test. getRule() returns the instance under test, createReflectionProvider() gives you the real provider, and analyse() takes the fixture paths plus the expected errors — message, line, and optionally the tip:

<?php

declare(strict_types=1);

namespace Tests\PHPStan\Rules;

use App\PHPStan\Rules\QueuedJobMustDeclareTriesRule;
use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;

/**
 * @extends RuleTestCase<QueuedJobMustDeclareTriesRule>
 */
final class QueuedJobMustDeclareTriesRuleTest extends RuleTestCase
{
    protected function getRule(): Rule
    {
        return new QueuedJobMustDeclareTriesRule(
            $this->createReflectionProvider(),
            ignoredNamespaces: ['App\Jobs\Legacy'],
        );
    }

    public function testItReportsAQueuedJobWithoutTries(): void
    {
        $this->analyse([__DIR__.'/data/queued-jobs.php'], [
            [
                'Job QueuedJobsData\SendInvoiceJob implements ShouldQueue but does not declare a $tries property.',
                9, // line of the `final class` keyword
                'Add `public int $tries = 3;`, or set `public int $tries = 1;` for a job that must never retry.',
            ],
        ]);
    }

    public static function getAdditionalConfigFiles(): array
    {
        // Needed so Larastan's Laravel-aware reflection is available.
        return [__DIR__.'/../../../phpstan.neon'];
    }
}

RuleTestCase extends PHPUnit's TestCase, so Pest runs it unchanged alongside your it() tests — no separate suite, no separate command. Keep the assertion on the whole expected error set: analyse() fails if an unexpected error appears too, which is how you catch a rule that quietly started firing on models. If you want the rule class itself type-checked to the same standard as your tests, PestStan is the piece that closes that loop.

The line number is the fiddly part. Get it wrong and the test failure tells you the actual line, so paste it in and move on — but re-check it whenever you edit the fixture.

Narrow the rule so it only fires on your own code#

A rule that reports on vendor code or test doubles gets ignored, then deleted. Narrow it in two places.

In phpstan.neon, keep paths: pointed at your source and exclude what you don't police:

parameters:
    paths:
        - app
    excludePaths:
        analyseAndScan:
            - app/Jobs/Legacy

In the rule itself, skip the class shapes that can't sensibly comply. Abstract base jobs legitimately leave $tries to their children, and an anonymous class implementing ShouldQueue inline is almost always a test fake:

if ($class->isAbstract() || $class->isAnonymous()) {
    return [];
}

foreach ($this->ignoredNamespaces as $namespace) {
    if (str_starts_with($class->getName(), $namespace)) {
        return [];
    }
}

Prefer reflection checks (isAbstract(), implementsInterface(), getFileName()) over matching on class names. Names change during a refactor; the reflection facts don't. Each exclusion earns its own test case in the fixture file — otherwise you'll re-introduce the false positive the next time you touch the rule.

Generalise the pattern to your next rule#

The same shape covers most conventions: pick a node, filter hard, build one error. Here is a second rule in condensed form, banning Carbon::now() so time comes from an injected clock and tests don't need Carbon::setTestNow():

<?php

declare(strict_types=1);

namespace App\PHPStan\Rules;

use PhpParser\Node;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;

/**
 * @implements Rule<StaticCall>
 */
final class NoCarbonNowRule implements Rule
{
    private const FORBIDDEN = [
        'Carbon\Carbon' => true,
        'Carbon\CarbonImmutable' => true,
        'Illuminate\Support\Carbon' => true,
    ];

    public function getNodeType(): string
    {
        return StaticCall::class;
    }

    public function processNode(Node $node, Scope $scope): array
    {
        // `new $class()`-style dynamic calls have an Expr here, not a Name.
        if (! $node->class instanceof Name || ! $node->name instanceof Identifier) {
            return [];
        }

        if ($node->name->toLowerString() !== 'now') {
            return [];
        }

        // PHPStan resolves imported aliases to the FQN before rules run.
        if (! isset(self::FORBIDDEN[$node->class->toString()])) {
            return [];
        }

        return [
            RuleErrorBuilder::message('Do not call '.$node->class->toString().'::now() directly.')
                ->identifier('richdynamix.carbonNow')
                ->tip('Inject Psr\Clock\ClockInterface and call $clock->now() so tests can control time.')
                ->build(),
        ];
    }
}

Two limits to know before you over-invest. Rules run per file, across parallel processes, so a rule cannot answer "is this class used anywhere?" — cross-file questions need a Collector plus a rule listening on CollectedDataNode. And for coarse structural checks like "controllers must not import the domain layer", Pest's arch() helper expresses the same intent in one line. My split: arch tests for boundaries and naming, PHPStan rules when the check needs type information or a precise AST condition. Reaching for a custom rule to assert something arch() already does is wasted effort.

Wire the rule into CI#

The rule ships the moment PHPStan runs in CI — no extra job, because it's part of the same analysis. Use the GitHub error format so violations annotate the diff instead of hiding in a log:

# .github/workflows/ci.yml
      - name: Static analysis
        run: vendor/bin/phpstan analyse --error-format=github --no-progress

Then do the one thing that decides whether the rule survives: run it over the whole existing codebase before you merge. Either fix the hits in the same PR or add them to the baseline with --generate-baseline and burn them down deliberately. A rule merged red teaches the team to ignore PHPStan output.

From here, the next two things I'd reach for are running Pint, PHPStan and Rector behind one command so nobody has to remember the incantation, and a CI matrix across PHP and database versions so the rule is exercised everywhere the app actually runs.

FAQ#

How do I write a custom PHPStan rule?

Create a class implementing PHPStan\Rules\Rule, return the AST node type you want to inspect from getNodeType(), and return an array of RuleErrorBuilder-built errors from processNode(). Add an @implements Rule<TheNodeType> PHPDoc tag so PHPStan knows the concrete node type. Register the class in phpstan.neon and it runs on every analysis.

What is getNodeType() in a PHPStan rule?

getNodeType() tells PHPStan which AST node should trigger your rule. Return a PHP-Parser class such as PhpParser\Node\Expr\StaticCall, or one of PHPStan's virtual nodes like PHPStan\Node\InClassNode when you need class reflection or want to detect something that is missing. Every time the analyser walks a node of that type, processNode() is called with the node and the current Scope.

How do I test a custom PHPStan rule?

Extend PHPStan\Testing\RuleTestCase, return your rule from getRule(), and call $this->analyse() with a fixture file and the errors you expect. Each expected error is an array of message, line number, and optionally the tip. The test fails if an expected error is missing or if the rule reports anything extra, so it catches false positives as well as false negatives.

How do I register a custom rule in phpstan.neon?

If every constructor argument is autowirable, list the class under the rules: key. If the rule takes configuration PHPStan cannot resolve — an array of ignored namespaces, a threshold — register it under services: with class:, arguments:, and the phpstan.rules.rule tag. Both forms require the class to be autoloadable by PHPStan.

Can PHPStan enforce architecture rules in Laravel?

Yes, within one file at a time. Reflection gives you inheritance, interfaces, attributes and member declarations, which covers rules like "jobs must set $tries" or "controllers must be final". Questions that span files — dead classes, unused public methods — need a Collector feeding a rule registered on CollectedDataNode, because rules are executed in isolated parallel processes and cannot share state directly.

Should I use a PHPStan rule or a Pest architecture test?

Start with a Pest arch() test. It is one readable line for coarse structural rules like namespace boundaries, naming conventions and banned functions, and it needs no AST knowledge. Move to a PHPStan custom rule when the check needs type information, has to inspect a specific expression shape, or should report on the exact line of the violation rather than the class as a whole.

Steven Richardson
Steven Richardson

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