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/deptracConfiguration (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/ORMRunning Deptrac:
./vendor/bin/deptrac analyseIf 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\OrderRepositoryDeptrac 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\CommandHandlerInterfaceDeptrac: 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-456Document 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/phparkitectConfiguration (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 checkCI 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 checkStarting on an Existing Codebase
If you're adding architecture tests to an existing project with violations, don't try to fix everything at once:
- Run Deptrac in
--report-uncoveredmode to see all current violations. - Add all current violations to the
skip_violationswhitelist. - Set up CI so new violations are blocked immediately.
- 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
*Controlleror*Handler— makes code navigation predictable. - All value objects must be immutable —
readonlyin 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.