Architecture Testing in PHP with Deptrac and PHPArkitect

Architecture Testing in PHP with Deptrac and PHPArkitect

Architecture decisions decay silently. A developer in a hurry imports a database model directly into a controller action. Another adds a framework dependency to a domain class. Each violation seems small, but they compound into a codebase where "clean architecture" is a historical fiction.

Architecture testing tools make these violations visible — and blockable in CI. Two tools dominate the PHP landscape: Deptrac for dependency layer enforcement, and PHPArkitect for class-level architectural rules. This guide covers both.

Deptrac: Layer Dependency Rules

Deptrac analyzes class dependencies between defined layers and fails if a dependency violates your configured rules.

Install:

composer require --dev qossmic/deptrac

Configuration (deptrac.yaml):

parameters:
  paths:
    - ./src

layers:
  - name: Domain
    collectors:
      - type: directory
        value: src/Domain/.*
  - name: Application
    collectors:
      - type: directory
        value: src/Application/.*
  - name: Infrastructure
    collectors:
      - type: directory
        value: src/Infrastructure/.*
  - name: Presentation
    collectors:
      - type: directory
        value: src/Presentation/.*

ruleset:
  Domain:
    # Domain has no dependencies — it's the core
  Application:
    - Domain              # Application may use Domain
  Infrastructure:
    - Domain              # Infrastructure implements Domain interfaces
    - Application         # Infrastructure may use Application services
  Presentation:
    - Application         # Controllers call Application services only
    # NOT Infrastructure — Presentation must not know about DB/ORM

Running Deptrac:

./vendor/bin/deptrac analyse

If a controller imports an Eloquent model or a Doctrine entity directly, Deptrac will report a violation like:

Presentation\Http\OrderController must not depend on Infrastructure\Persistence\OrderRepository

Deptrac with Namespace Collectors

For projects that use namespaces rather than directories as layer boundaries:

layers:
  - name: Domain
    collectors:
      - type: className
        value: ^App\\Domain\\.*
  - name: Application
    collectors:
      - type: className
        value: ^App\\Application\\.*
  - name: Infrastructure
    collectors:
      - type: className
        value: ^App\\Infrastructure\\.*

You can also collect by interface implementation or attribute:

layers:
  - name: Commands
    collectors:
      - type: implements
        value: App\Application\Command\CommandInterface
  - name: Handlers
    collectors:
      - type: implements
        value: App\Application\Command\CommandHandlerInterface

Deptrac: Allowing Specific Exceptions

Sometimes a violation is intentional. Whitelist it rather than weakening the rule:

ruleset:
  Presentation:
    - Application
    
skip_violations:
  Presentation\Http\LegacyController:
    - Infrastructure\Persistence\LegacyRepository  # TODO: migrate in HEL-456

Document the reason. The skip list becomes a technical debt register.

PHPArkitect: Class-Level Rules

PHPArkitect operates at a finer grain — it can enforce naming conventions, inheritance rules, and dependency constraints per class type. It's excellent for enforcing framework-specific patterns.

Install:

composer require --dev phparkitect/phparkitect

Configuration (phparkitect.php):

<?php

use Arkitect\ClassSet;
use Arkitect\CLI\Config;
use Arkitect\RuleBuilders\Architecture\Architecture;
use Arkitect\Rules\Rule;

return static function (Config $config): void {
    $srcClassSet = ClassSet::fromDir(__DIR__ . '/src');

    $layerRules = Architecture::withComponents()
        ->component('Domain')->definedBy('App\Domain\*')
        ->component('Application')->definedBy('App\Application\*')
        ->component('Infrastructure')->definedBy('App\Infrastructure\*')
        ->component('Presentation')->definedBy('App\Presentation\*')
        ->where('Domain')->shouldNotDependOnAnyComponent()
        ->where('Application')->mayDependOnComponents('Domain')
        ->where('Infrastructure')->mayDependOnComponents('Domain', 'Application')
        ->where('Presentation')->mayDependOnComponents('Application')
        ->rules();

    $controllerRules = [
        Rule::allClasses()
            ->that()->resideInOneOfTheseNamespaces('App\Presentation\Http')
            ->should()->haveNameMatching('.*Controller')
            ->because('HTTP handlers should be named *Controller for discoverability'),

        Rule::allClasses()
            ->that()->haveNameMatching('.*Controller')
            ->should()->extendClass('App\Presentation\Http\BaseController')
            ->because('All controllers should use shared middleware setup'),
    ];

    $domainRules = [
        Rule::allClasses()
            ->that()->resideInOneOfTheseNamespaces('App\Domain')
            ->should()->notDependOnTheseNamespaces('Illuminate', 'Doctrine', 'Symfony')
            ->because('Domain must remain framework-agnostic'),
    ];

    $serviceRules = [
        Rule::allClasses()
            ->that()->haveNameMatching('.*Service')
            ->should()->notHavePublicClassConstants()
            ->because('Services should not expose constants — use value objects or enums'),
    ];

    $config->add($srcClassSet, ...$layerRules, ...$controllerRules, ...$domainRules, ...$serviceRules);
};

Running PHPArkitect:

./vendor/bin/phparkitect check

CI Integration

Both tools should run in CI on every pull request:

# .github/workflows/architecture.yml
name: Architecture

on: [push, pull_request]

jobs:
  deptrac:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with: { php-version: '8.3' }
      - run: composer install --no-interaction
      - run: ./vendor/bin/deptrac analyse --no-progress

  phparkitect:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with: { php-version: '8.3' }
      - run: composer install --no-interaction
      - run: ./vendor/bin/phparkitect check

Starting on an Existing Codebase

If you're adding architecture tests to an existing project with violations, don't try to fix everything at once:

  1. Run Deptrac in --report-uncovered mode to see all current violations.
  2. Add all current violations to the skip_violations whitelist.
  3. Set up CI so new violations are blocked immediately.
  4. Remove whitelist entries as you fix the underlying violations.

This "baseline and ratchet" approach lets you get value from architecture testing immediately without requiring a big-bang refactor.

What to Enforce

Good first rules for any project:

  • Domain must not import framework classes — keeps domain logic portable.
  • Presentation must not import persistence classes — forces you to go through services.
  • All request handlers must be named *Controller or *Handler — makes code navigation predictable.
  • All value objects must be immutablereadonly in PHP 8.2+, or no setters.
  • No circular dependencies between modules — Deptrac catches these automatically.

Architecture tests don't replace code review, but they catch the mechanical violations automatically — freeing review bandwidth for the judgment calls that tools can't make.


Architecture tests keep your codebase's structure honest over time. For end-to-end confidence that your application flows keep working as the architecture evolves, HelpMeTest offers 24/7 test monitoring — sign up free and catch regressions before they reach your users.

Read more

Start now free