PHPArkitect: turning architectural rules into executable code
1. Introduction: why architecture dies silently
Architecture in PHP projects rarely dies from one fatal decision — it dies from a thousand small ones. A controller reaches into a repository directly — “just this once”. A service pulls in a dependency from another module — “it’s right there”. The Domain layer starts knowing about infrastructure — “we’ll sort it out later”. Then a new developer arrives, sees that “this is allowed”, and does the same. Now it’s not an exception, it’s the norm — and the project slowly turns into a tangled ball of coupling that nobody dares to change.
The problem isn’t that architecture goes undocumented. It gets documented: ADRs, diagrams, a README section. The problem is that documentation doesn’t execute. The sentence “the Domain layer must not depend on Infrastructure” doesn’t stop anyone from writing use App\Infrastructure\Mailer; inside a domain class. Code review won’t catch it either: a human can’t hold every boundary in their head and gets tired after twenty minutes. By the time you have a dozen violations, it’s too late to fix them cheaply — dependencies are intertwined and untangling them is a project of its own. The later a violation is found, the more it costs.
The only way to keep architecture alive is to check it on every change, automatically. In the Java world this has long been the standard: ArchUnit appeared in 2014 and became part of CI in thousands of projects. PHP got its equivalent in June 2020 — PHPArkitect. This article is about it: what it is, how it looks on a real project, and how to adopt it without pain.
2. What is PHPArkitect
PHPArkitect is a library that runs in CI and checks your code against architectural rules. The key idea: rules are written in PHP — the same language as the project itself. No DSLs, no YAML schemas — the config reads like a test and gets reviewed like code.
A few numbers to gauge the scale (verified 2026-08-17):
- created June 30, 2020, inspired by ArchUnit from the Java world;
- GitHub: ~920 stars (924 on Packagist), 52 forks, MIT license;
- Packagist: 4,625,927 installs, 21 dependent packages;
- current version 1.3.0 (July 31, 2026), requires PHP ^8.0.
Installation is one command:
composer require --dev phparkitect/phparkitect
A minimal config — a phparkitect.php file in the project root:
<?php
use Arkitect\ClassSet;
use Arkitect\CLI\Config;
use Arkitect\Expression\ForClasses\ResideInOneOfTheseNamespaces;
use Arkitect\Expression\ForClasses\NotHaveDependencyOutsideNamespace;
use Arkitect\Rules\Rule;
$src = ClassSet::fromDir(__DIR__.'/src');
return static function (Config $config) use ($src): void {
$config->add(
$src,
Rule::allClasses()
->that(new ResideInOneOfTheseNamespaces('App\Domain'))
->should(new NotHaveDependencyOutsideNamespace('App\Domain'))
->because('the domain layer is isolated from the rest of the world')
);
};
ClassSet::fromDir() is the set of files to analyse — you can pass several directories. Then come the rules.
3. Anatomy of a rule: that / should / because
Each rule is a chain of three parts — and this is the best thing about the tool: rules read like sentences.
that(...)— who the rule applies to: classes in certain namespaces, classes with a certain name, subclasses of a base class.should(...)— what they must or must not do: not depend on foreign namespaces, extend the right base class, end with a suffix, be final, carry an attribute.because(...)— why. This text ends up in the violation report, so the developer who broke the rule immediately sees which convention they crossed and why it exists.
Running the check:
vendor/bin/phparkitect check
A convenient composer script:
"scripts": {
"arkitect": "phparkitect check"
}
There are helper commands too: init scaffolds a starter config with example rules; debug:expression shows which classes match an expression before you add it to the config. The latter saves many iterations: you can test a selector against live code without touching the config.
4. What it can do out of the box
The expressions cover the architectural conventions you meet in every other project:
- Naming: controllers end with
*Controller, services with*Service, no junk classes like*Helperappear. - Inheritance: all classes in a namespace are
final, or conversely extend a specific base class. - Dependencies: classes in
App\Domaindon’t depend on anything outside the namespace;App\Controllernever touchesApp\Infrastructure. - Attributes and DocBlocks: a class carries a given PHP attribute or annotation (or conversely — doesn’t carry deprecated ones).
- Components: namespaces are grouped into “components” with a strict dependency matrix: component A may depend on B and C, but not on D.
Three typical scenarios: must not (forbidden dependencies), must (inheritance, attributes, naming), and only this way (component boundaries). For Laravel there is a ready-made wrapper with preconfigured rules — smortexa/laravel-arkitect — and a demo project with examples — phparkitect/arkitect-demo.
5. Case study: 19 rules for a modular monolith
Let’s see the tool in action. My side project — Tender Platform — is a modular Symfony monolith: 13 modules — 9 business modules, 3 platform modules and a policy plugin — sharing a common Shared kernel. The key architectural invariant: a module never reaches into another module’s internals, only into its public contracts. Breaking this invariant is the first step toward a concrete monolith where modules exist in name only.
The phparkitect.php config holds 7 rule groups that expand into 19 checks. Example — controllers don’t call infrastructure directly:
$config->add(
$src,
Rule::allClasses()
->that(new ResideInOneOfTheseNamespaces(
'App\Controller',
'App\Iam\Controller',
'App\Tender\Controller',
// ... remaining modules
))
->should(new NotDependsOnTheseNamespaces([
'App\Infrastructure',
]))
->because('controllers call UseCases/services, not infrastructure directly')
);
The most interesting technique — generating rules in a loop. Module boundaries aren’t written out 13 times by hand: they are assembled from a list of modules and a list of “internals” — Controller, Command, Entity, Repository, Form, Input, Presenter, Exception, Storage, Rules, State, Stream, Timer, Step, Timeline, Service:
$moduleNamespaces = ['App\Iam', 'App\Tender', /* ... */];
$moduleInternals = ['Controller', 'Command', 'Entity', /* ... */];
foreach ($moduleNamespaces as $module) {
$forbidden = [];
foreach ($moduleNamespaces as $other) {
if ($other === $module) {
continue;
}
foreach ($moduleInternals as $internal) {
$forbidden[] = $other.'\\'.$internal;
}
}
$config->add(
$src,
Rule::allClasses()
->that(new ResideInOneOfTheseNamespaces($module))
->should(new NotDependsOnTheseNamespaces($forbidden))
->because('module boundary: '.$module.' must not reach into internals of other modules')
);
}
13 module boundaries come from ten lines of code. Intentional exceptions — public contracts, read models, enums used as value types — are declared in an explicit whitelist: cross-module access to internals without a whitelist entry automatically becomes a violation.
The run against current code: 646 classes in src/, the check took 14.35 seconds, violations — 0.
6. How it lives in CI
The check is wired into the GitHub Actions quality pipeline and runs on every PR — after PHPStan, before tests:
- name: PHPArkitect (architecture)
run: composer arkitect
A boundary violation no longer reaches main: the PR simply doesn’t pass. Let’s look at the report. Minimal example: a domain class Order starts using Mailer from infrastructure. PHPArkitect processes two classes in 0.28 seconds and reports:
⚠️ 1 violations detected!
App\Domain\Order has 1 violations
depends on App\Infrastructure\Mailer, but should not depend on these namespaces:
App\Infrastructure because the domain layer is isolated from infrastructure (on line 11)
The violating class, the concrete dependency, the text from because(...) and the line number. No guessing needed: the rule explains itself. CI fails with exit code 1 — the merge is blocked.
14 seconds of overhead for 646 classes is a fair price for the guarantee that boundaries don’t erode. If you want it faster or stricter:
--stop-on-failure— fail on the first violation;--format=json/--format=gitlab— machine-readable reports: JSON for GitHub Actions and SonarQube, GitLab code quality format for GitLab CI;--target-php-version=8.0..8.5— set the PHP version to parse for, if it differs from the runtime version.
7. Legacy: the baseline strategy
The first question from anyone with a production codebase: “we already have 500 violations — adding the check is pointless.” That’s what the baseline is for:
# snapshot the current violations as "historical"
vendor/bin/phparkitect check --generate-baseline
# from now on, a regular run: old violations don't block, new ones do
vendor/bin/phparkitect check
The tool writes the current list of violations into phparkitect-baseline.json. The rule from then on: existing violations don’t fail CI, new ones do. The team fixes legacy debt at its own pace while regression is impossible: reverting a fixed violation makes it “new” again and breaks the build.
It’s the same strategy as PHPStan (--generate-baseline) and Psalm: not “all or nothing”, but a controlled transition. Nuances:
--ignore-baseline-linenumbers— compare without line numbers; handy when the code moves a lot, but then the tool won’t notice a repeated violation of the same rule in the same file;--skip-baseline— temporarily disable the baseline, e.g. for an honest full check before a release.
8. Gotchas and limitations
The tool is honest, but a few things are worth knowing upfront.
False positives on attributes. PHPArkitect analyses class dependencies, and checks can latch onto technical constructs — for example, Doctrine attributes on entities. In Tender Platform this is solved by how the rules are phrased: entities are forbidden from depending on App\Controller and App\Infrastructure, while framework attributes stay outside the rules. If you hit a false positive, refine the rule first — don’t add an exception. On annotation-heavy projects (Doctrine, Symfony) you can disable custom annotation parsing — $config->skipParsingCustomAnnotations() — which speeds up the run.
PHAR instead of composer. If your project’s dependencies conflict with PHPArkitect’s, there’s a self-contained phparkitect.phar from GitHub releases. For custom rules, running via PHAR requires the --autoload=vendor/autoload.php flag.
Static analysis is not runtime. PHPArkitect inspects source, not behavior: a class created dynamically via reflection or eval may fall outside the analysis. Fine for architectural boundaries; not fine for runtime invariants.
Rules need maintenance. The config is code: boundary changes go through PRs and review, not silent editing. That’s a feature: a change to an architectural decision becomes a visible event in project history.
Overhead on large codebases. On hundreds of thousands of lines, a run can take minutes. Solutions: baseline, running the check only on changed paths, or moving it to a separate job that doesn’t slow down local iterations.
9. Alternatives: deptrac, phpat and PHPStan
PHPArkitect isn’t the only tool in the niche. Comparison across the key criteria:
| Criterion | PHPArkitect | Deptrac | PHPat | PHPStan (custom rules) |
|---|---|---|---|---|
| Rule format | PHP code, that/should chains |
YAML: layers, dependency rules | PHP code (PHPStan extension) | PHP classes at AST level |
| Classic checks | naming, inheritance, attributes, dependencies | dependency graph only | dependencies, inheritance, traits | anything, by hand |
| Baseline | ✅ --generate-baseline |
✅ | ✅ (via PHPStan) | ✅ --generate-baseline |
| Entry barrier | low | low | medium | high |
| Best for | “tests for your architecture” | visualizing and controlling layers | teams already on PHPStan | one-off invariants with type info |
Deptrac excels at graph analysis and dependency visualization; PHPat — if you already live on PHPStan and don’t want another tool; PHPStan with custom rules — when a rule must account for types. There’s also the niche PHPArch, where rules are written as PHPUnit tests. For typical architectural conventions, PHPArkitect offers the shortest path: rules in the same language as the project, readable as tests, no DSL to learn.
10. Conclusion: where to start
Good practices become mandatory checks. PHPArkitect doesn’t replace the architect or code review — it takes the mechanical work off review: “don’t reach into another module”, “don’t drag infrastructure into the domain”, “don’t spawn Helper classes”. Humans review what requires thinking; machines check what requires remembering.
Getting started takes one evening:
composer require --dev phparkitect/phparkitect;- write 3–5 rules for your own pain points — naming, layer boundaries, forbidden dependencies;
check --generate-baselineif legacy debt has already accumulated;- add the step to CI on every PR.
In a month, your project’s architecture stops depending on whether every developer remembers the boundaries. They become code.
The project is open: github.com/alex-frolov/tender, MIT. The full 19-rule config — app/phparkitect.php in the repository; CI running the check on every PR — there too. The modular monolith series continues: sealed bids with encryption before opening and an outbox with 63 JSON Schema events are next.