DEV Community

Cover image for A PHPStan Custom Rule That Bans Framework Imports in Your Domain
Gabriel Anhaia
Gabriel Anhaia

Posted on

A PHPStan Custom Rule That Bans Framework Imports in Your Domain


You drew a clean boundary. App\Domain holds the business rules and nothing else. No framework, no HTTP, no ORM. Everyone agreed in the design doc.

Then someone needed a quick collection helper, typed use Illuminate\Support\Collection; at the top of an entity, and the CI stayed green. Six months later App\Domain imports Illuminate\Support\Str, a Symfony Request, and a Doctrine EntityManager. The boundary exists only in the README.

Code review can't hold that line. Reviewers miss one import in a 400-line diff, and the rule only has to fail once to rot. What holds the line is a machine that reads every use statement and fails the build when a banned namespace shows up inside your domain. PHPStan already parses every file you own. You can hook one small rule into that pass and gate it in CI so the boundary stops depending on anyone's attention.

What a PHPStan rule actually is

A rule is a class that implements PHPStan\Rules\Rule. It tells PHPStan which AST node it cares about, and PHPStan hands it every node of that type across your codebase.

The interface has two methods:

public function getNodeType(): string;
public function processNode(Node $node, Scope $scope): array;
Enter fullscreen mode Exit fullscreen mode

getNodeType() returns the php-parser node class you want. processNode() receives each matching node plus the Scope (which knows the current namespace, class, and file). It returns a list of errors, or an empty array when the node is fine.

For banning imports, the node you want is PhpParser\Node\Stmt\Use_ — the use statement itself. Every use Illuminate\...; at the top of a file is one of these.

The rule

Make it configurable from day one. Hardcoding App\Domain and Illuminate\ into the class means you can't reuse it, and you can't test it against fixtures with different names. Pass both as constructor arguments.

<?php

declare(strict_types=1);

namespace App\PHPStan;

use PhpParser\Node;
use PhpParser\Node\Stmt\Use_;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;

/**
 * @implements Rule<Use_>
 */
final class NoFrameworkImportsInDomainRule implements Rule
{
    /**
     * @param list<string> $domainNamespaces
     * @param list<string> $bannedPrefixes
     */
    public function __construct(
        private array $domainNamespaces,
        private array $bannedPrefixes,
    ) {}

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

    public function processNode(Node $node, Scope $scope): array
    {
        if ($node->type !== Use_::TYPE_NORMAL) {
            return [];
        }

        $namespace = $scope->getNamespace();
        if ($namespace === null
            || !$this->inDomain($namespace)) {
            return [];
        }

        $errors = [];

        foreach ($node->uses as $use) {
            $imported = $use->name->toString();

            foreach ($this->bannedPrefixes as $prefix) {
                if (!str_starts_with($imported, $prefix)) {
                    continue;
                }

                $errors[] = RuleErrorBuilder::message(sprintf(
                    'Domain code must not import %s. '
                    . 'Move this dependency behind a port.',
                    $imported,
                ))
                    ->identifier('domain.frameworkImport')
                    ->line($use->getStartLine())
                    ->build();
            }
        }

        return $errors;
    }

    private function inDomain(string $namespace): bool
    {
        foreach ($this->domainNamespaces as $prefix) {
            if (str_starts_with($namespace, $prefix)) {
                return true;
            }
        }

        return false;
    }
}
Enter fullscreen mode Exit fullscreen mode

Three decisions worth calling out.

The TYPE_NORMAL guard skips use function and use const imports. You're banning class imports, not a Symfony\Component\String\u function helper (though you can widen it later).

The $scope->getNamespace() check is what scopes the rule to your domain. A use statement in App\Http\Controllers returns early. Only files whose namespace starts with a configured domain prefix get inspected.

Every error carries an identifier('domain.frameworkImport'). PHPStan 2.x requires identifiers, and it lets you target this exact rule in ignore config or baselines without matching on the message text.

Register it in phpstan.neon

PHPStan finds rules through its DI container. Register the class as a service and tag it phpstan.rules.rule. The arguments block feeds the constructor.

services:
    -
        class: App\PHPStan\NoFrameworkImportsInDomainRule
        tags:
            - phpstan.rules.rule
        arguments:
            domainNamespaces:
                - 'App\Domain'
            bannedPrefixes:
                - 'Illuminate\'
                - 'Symfony\Component'
                - 'Doctrine\ORM'
                - 'Doctrine\DBAL'
Enter fullscreen mode Exit fullscreen mode

Quote the namespace strings. NEON tolerates unquoted backslashes in most positions, but a value ending in \ (like Illuminate\) is the one that trips people up. Quoting removes the doubt.

Make sure App\PHPStan\ is autoloadable. PHPStan loads rule classes through Composer's autoloader, so the namespace has to be mapped there. If the rule lives outside your production paths, add it under autoload-dev in composer.json and run composer dump-autoload:

{
    "autoload-dev": {
        "psr-4": {
            "App\\PHPStan\\": "phpstan/"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Run PHPStan and point a domain file at a banned namespace to confirm:

 ------ -----------------------------------------------
  Line   src/Domain/Order/Order.php
 ------ -----------------------------------------------
  7      Domain code must not import
         Illuminate\Support\Collection. Move this
         dependency behind a port.
 ------ -----------------------------------------------
Enter fullscreen mode Exit fullscreen mode

Test it with RuleTestCase

Never ship an architecture rule without a test. A rule that silently stops firing is worse than no rule, because the team trusts a guard that quit. PHPStan gives you RuleTestCase for exactly this.

Write a fixture that PHPStan analyzes. It doesn't need to run — it needs to parse.

<?php

declare(strict_types=1);

namespace App\Domain\Order;

use Illuminate\Support\Collection;

final class Order
{
    public function __construct(
        private Collection $items,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

Then the test asserts the exact error and line:

<?php

declare(strict_types=1);

namespace App\Tests\PHPStan;

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

/**
 * @extends RuleTestCase<NoFrameworkImportsInDomainRule>
 */
final class NoFrameworkImportsInDomainRuleTest
    extends RuleTestCase
{
    protected function getRule(): Rule
    {
        return new NoFrameworkImportsInDomainRule(
            domainNamespaces: ['App\\Domain'],
            bannedPrefixes: ['Illuminate\\', 'Symfony\\'],
        );
    }

    public function testFlagsFrameworkImport(): void
    {
        $this->analyse(
            [__DIR__ . '/data/domain-order.php'],
            [
                [
                    'Domain code must not import '
                    . 'Illuminate\Support\Collection. '
                    . 'Move this dependency behind a port.',
                    7,
                ],
            ],
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Add a second fixture in a non-domain namespace that imports the same class, and assert zero errors. That test is the one that catches a broken getNamespace() scope check — the failure mode where your rule quietly flags nothing.

Wire it into CI

The rule now runs inside your normal PHPStan pass. No separate job, no new tool. If PHPStan already runs in CI, you get this for free.

A Composer script keeps the command in one place:

{
    "scripts": {
        "phpstan": "phpstan analyse --no-progress"
    }
}
Enter fullscreen mode Exit fullscreen mode

And the CI step:

      - name: Static analysis
        run: composer phpstan
Enter fullscreen mode Exit fullscreen mode

Two habits keep it honest. Run the rule's PHPUnit test in the same pipeline, so a refactor of the rule can't disable it unnoticed. And when you introduce the rule on an existing codebase that already has violations, generate a baseline instead of blocking the whole team:

vendor/bin/phpstan analyse --generate-baseline
Enter fullscreen mode Exit fullscreen mode

The baseline records today's leaks so CI stays green, while every new framework import in the domain fails immediately. Treat that baseline as a debt ledger: it's allowed to shrink, never to grow.

What this rule does not catch

Be honest about the edges. This rule reads use statements, so it misses two cases.

Inline fully-qualified names skip the import table entirely. \Illuminate\Support\Str::random() written directly in domain code has no use node to inspect. To close that, add a second rule on PhpParser\Node\Name\FullyQualified and apply the same prefix check.

Grouped imports are a different node. use Illuminate\Support\{Str, Arr}; parses to GroupUse, not Use_. If your codebase uses grouped imports, register the rule for GroupUse too and read $node->prefix alongside each item.

For a coarser boundary check across many layers at once, deptrac is the dedicated tool. The advantage of the PHPStan rule is that it lives inside a pass you already run, fails on the exact line, and speaks the same error format as the rest of your analysis.

Keeping the framework out of the domain is not a style preference you enforce with willpower. It's a boundary, and boundaries need a guard that never gets tired or distracted. A PHPStan rule is that guard. The whole point of a hexagonal design is that your business rules depend on ports you own, and the framework sits at the edge behind adapters — which is exactly what Decoupled PHP builds, layer by layer.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)