- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You drew the diagram in the kickoff meeting. Domain in the middle. Application around it. Infrastructure on the outside. The arrows only point inward. Everyone nodded.
Six months later a junior dev needs an entity's created-at timestamp formatted for an email. The fastest path is a Carbon call inside the domain entity. It passes review because the reviewer is looking at the email logic, not the import list. Now your domain depends on a framework date library. The arrow points outward and nobody noticed.
That's the problem with architecture rules that live in a wiki. They're real until the first deadline. Then they're suggestions.
Deptrac turns the dependency rule into something a machine checks on every push. It reads your code, maps each class to a layer you define, and fails the build when a layer imports something it's not allowed to touch. The diagram stops being aspirational and starts being enforced.
What Deptrac actually does
Deptrac (deptrac/deptrac) is static analysis for architecture. It doesn't care about types or dead code. It cares about one thing: which layer depends on which.
You give it two inputs. First, a definition of your layers as regex over file paths or class names. Second, a ruleset saying which layers each layer may depend on. Deptrac parses every use statement, method call, and type reference, resolves each one to a layer, and checks it against the ruleset. Anything not allowed is a violation.
Install it as a dev dependency:
composer require --dev deptrac/deptrac
It ships a single binary at vendor/bin/deptrac and looks for a deptrac.yaml in the repo root by default.
Defining the three layers
A hexagonal PHP app usually organizes code by bounded context, then by layer inside each context:
src/
Ordering/
Domain/
Application/
Infrastructure/
Billing/
Domain/
Application/
Infrastructure/
The directory collector matches a regex against each file's path. That maps cleanly onto the structure above:
deptrac:
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/.*
Three layers, one regex each. The .* between src/ and the layer name lets the same rule cover every context. Ordering/Domain and Billing/Domain both land in the Domain layer.
If you organize by layer first and context second, flip the regex to src/Domain/.*. The collector doesn't care about your folder philosophy, only the path it matches.
The dependency rule as a ruleset
Here's the part that encodes the diagram. The ruleset section lists, per layer, the layers it's allowed to depend on. Leave a layer out and it may depend on nothing.
ruleset:
Domain: ~
Application:
- Domain
Infrastructure:
- Domain
- Application
Read it top to bottom:
-
Domain depends on nothing. The
~(YAML null) is explicit: no allowed targets. Your entities, value objects, and domain services import only each other and the language itself. - Application may depend on Domain. Use cases orchestrate domain objects. They know about ports (interfaces), not implementations.
- Infrastructure may depend on both. Adapters wire Doctrine, HTTP clients, and queues to the ports the inner layers declare.
What this forbids is the whole point. Domain importing Application is a violation. Domain importing Infrastructure is a violation. Application reaching into Infrastructure is a violation: the use case that news up a PDO connection directly breaks the rule. The arrows only point inward, and now that's a fact the build can prove.
Reading a violation
Run the analysis:
vendor/bin/deptrac analyse
When the domain entity reaches for a SystemClock that lives in your Infrastructure layer, you get output like this:
------ Report -------
Violations: 1
Skipped violations: 0
Uncovered: 0
Allowed: 214
------ ------------
Ordering\Domain\Order must not depend on
Ordering\Infrastructure\Clock\SystemClock
(Domain on Infrastructure)
That last line is the whole review comment you never have to write again. It names the offending class, the class it reached for, and the rule it broke. Deptrac exits with a non-zero status code when violations exist, which is the only thing CI needs to fail the job.
The Uncovered count matters too. It's the number of dependencies Deptrac saw but couldn't map to any layer — usually vendor classes you haven't assigned. A high uncovered count means your layers have blind spots. More on that below.
Failing CI on a violation
Because a violation is a non-zero exit, the CI job is almost nothing. Deptrac ships a formatter that turns violations into GitHub annotations so they show up inline on the PR diff:
# .github/workflows/deptrac.yml
name: Deptrac
on:
pull_request:
paths:
- 'src/**.php'
- 'deptrac.yaml'
jobs:
deptrac:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: none
- name: Install
run: composer install --no-progress --prefer-dist
- name: Run Deptrac
run: >
vendor/bin/deptrac analyse
--formatter=github-actions
--no-progress
When a violation lands, the job goes red and the offending use statement gets an annotation right on the line. The reviewer sees "Domain on Infrastructure" next to the import instead of finding it by hand.
For faster runs on a big codebase, point Deptrac at a cache file with --cache-file=.deptrac.cache and cache that path between builds. Parsing the whole src tree is the slow part, and the cache skips files that haven't changed.
The baseline strategy for legacy code
Turning this on for a greenfield project is easy: zero violations, and it stays that way. Turning it on for a codebase that's been ignoring the rule for three years is where teams give up. The first run reports 400 violations and the job is red forever. Nobody's going to fix 400 imports in one PR.
The baseline is the answer. It records every current violation as a known exception, so the build goes green on the existing debt while still failing on anything new.
Generate one with the baseline formatter:
vendor/bin/deptrac analyse \
--formatter=baseline \
--output=deptrac.baseline.yaml
That writes a deptrac.baseline.yaml listing each current violation. Reference it from your config:
deptrac:
baseline: deptrac.baseline.yaml
paths:
- ./src
# layers and ruleset as above
Commit the baseline. Now the run is green: the 400 existing violations are suppressed, but the moment someone writes a 401st, CI fails. The rule protects new code immediately, and you pay down the old violations on your own schedule.
Treat the baseline the way you'd treat any debt ledger. It's allowed to shrink, never to grow. When you fix a violation, regenerate the baseline and the entry drops out. A pre-merge check that the baseline didn't get bigger keeps the ratchet turning one direction.
Closing the blind spots
A baseline hides known violations. It doesn't help with dependencies Deptrac never mapped in the first place — the Uncovered count. If a class isn't in any layer, its dependencies go unchecked, and a gap in your regex becomes a gap in enforcement.
Two config switches tighten this. --fail-on-uncovered turns any unmapped dependency into a failure, forcing you to assign every class a layer. --report-uncovered lists them without failing, which is the gentler first step:
vendor/bin/deptrac analyse --report-uncovered
Run that once and you'll usually find a context you forgot to include or a folder that doesn't match the regex. Fix the layers until uncovered drops to zero, then flip on --fail-on-uncovered so it stays there. That's the difference between a rule that covers most of your code and one that covers all of it.
What you get when this lands
The dependency rule stops depending on reviewer vigilance. A framework import in the domain layer can't merge. A use case that news up a database connection can't merge. The diagram from the kickoff meeting is now the same thing as the build status, and neither one lies.
The deeper win is that the boundary becomes cheap to trust. Once Deptrac guards the edges, you can refactor the inside freely, knowing the arrows still point the right way. The architecture holds because a machine checks it on every push, not because everyone remembered.
Keeping the framework (Doctrine, Carbon, the HTTP kit) at arm's length behind ports your domain never imports is the whole premise of hexagonal PHP. Deptrac is the tool that proves you actually did it instead of just drawing it. That boundary, and how to shape the ports and adapters that hold it, is what Decoupled PHP is about.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)