# arkitect: PHP architectural rules as PHPUnit style tests that run in CI

> PHPArkitect lets a PHP team write architectural constraints as plain PHP code, a selector, a check and a because message, and run them as a test suite in CI. It ships namespace, naming, inheritance, trait and type rules, a baseline mechanism for legacy code, and a debug command for checking which classes match an expression before you commit to a rule.

**phparkitect/arkitect** — Put your architectural rules under test!

- Repository: https://github.com/phparkitect/arkitect
- Stars: 936 · Forks: 53
- Language: PHP
- License: MIT
- Published: 2026-10-20 · Updated: 2026-10-20 · Language: en
- Canonical page: https://hysenlabs.com/projects/phparkitect-arkitect

## Rules are objects, and because() is the message your team reads

PHPArkitect describes itself as a way to write architectural rules for a PHP codebase as plain PHP code and verify them in CI, and the README compares it to a test suite for your architecture. The example that opens the documentation is short enough to hold in your head:

```php
Rule::allClasses()
    ->that(new ResideInOneOfTheseNamespaces('App\Domain'))
    ->should(new NotHaveDependencyOutsideNamespace('App\Domain'))
    ->because('the domain must not depend on infrastructure');
```

The three-part shape is the whole design. `that()` is a selector that picks classes, `should()` is a constraint those classes must satisfy, and `because()` is the reason. The README notes that the because string appears verbatim in the violation output, which is the detail that makes the tool usable in a pull request review. A failing check that says only that a rule was broken leaves the reader to work out why the rule exists, and then to argue about whether it still applies. A failing check that says the domain must not depend on infrastructure is a sentence someone can agree or disagree with immediately.

The primitives around that shape are small and composable. `ClassSet` is the set of PHP files to analyse, built with `ClassSet::fromDir(__DIR__.'/src')` for one or more directories. `Expression` is a single composable condition used in both the selector and the check position. `except()` removes specific classes from a selector and accepts wildcards, `andThat()` narrows a selector with additional conditions where all must match, and `runOnlyThis()` runs one rule in isolation during a check, which is the debugging escape hatch.

Written this way, the rules live in the same language and the same test style as the rest of a PHP codebase, with no separate DSL and no separate file format to learn.

## A config file is a closure returning rules for a class set

The configuration file is a PHP file that returns a static closure taking a `Config` object. That is the entire contract, and the minimal example in the README shows it:

```php
<?php
declare(strict_types=1);

use Arkitect\ClassSet;
use Arkitect\CLI\Config;
use Arkitect\Expression\ForClasses\HaveNameMatching;
use Arkitect\Expression\ForClasses\ResideInOneOfTheseNamespaces;
use Arkitect\Rules\Rule;

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

    $rules[] = Rule::allClasses()
        ->that(new ResideInOneOfTheseNamespaces('App\Controller'))
        ->should(new HaveNameMatching('*Controller'))
        ->because('we want uniform naming for controllers');

    $config->add($classSet, ...$rules);
};
```

A few things are worth noticing. The file uses `declare(strict_types=1)`, which is the norm for modern PHP projects and keeps the tool in step with a strict codebase. The class set is built from `__DIR__.'/src'`, so the analysed directory is relative to the config file, not the working directory, which means the check behaves the same whether it is run from the project root or from a CI runner that checked out elsewhere.

The config registers a class set and a set of rules together through `$config->add($classSet, ...$rules)`. That signature allows more than one class set in one configuration, so a project with several roots, for instance `src` plus a separate `tests` tree, can be described in one file.

One behaviour is enabled by default and worth knowing about. PHPArkitect parses custom DocBlock annotations such as `@Assert\NotBlank` by default, and `$config->skipParsingCustomAnnotations()` turns that off. The reason it is on by default is that annotations are part of the code being analysed, so a rule about DocBlock content needs them read; the switch exists for codebases where annotation parsing costs time or pulls in attributes the parser should not interpret.

## The built-in rule catalogue, and writing your own when it runs out

The README groups the built-in expressions into categories, and each category links into the full rules reference.

Namespace rules cover placement and dependency direction: `ResideInOneOfTheseNamespaces`, `NotHaveDependencyOutsideNamespace` and `DependsOnlyOnTheseNamespaces`. Naming rules cover `HaveNameMatching`, `NotHaveNameMatching` and `MatchOneOfTheseNames`. Inheritance and implementation rules cover `Extend`, `NotExtend`, `Implement`, `NotImplement`, `IsA` and `IsNotA`. Trait rules are `HaveTrait` and `NotHaveTrait`. Type checks include `IsFinal`, `IsAbstract`, `IsReadonly`, `IsInterface`, `IsEnum` and `IsTrait`. Doc block and attribute rules are `ContainDocBlockLike` and `HaveAttribute`.

That is a wider surface than a first-time user will use, and the sensible order is to start with one dependency rule per layer boundary, since a namespace dependency rule catches the mistakes that compound. Naming rules are cheap to add and give an immediate sense of how the output reads. The type checks are useful for codebases moving towards stricter PHP, since `IsFinal` and `IsReadonly` express policies that are otherwise enforced only by review comments.

When the catalogue does not cover a case, the README points to a custom rules document, and the design supports it: since a rule is a selector plus a check, a custom check is an object that can be dropped into the `should()` position.

The repository also ships two example write-ups, `dependency-inversion-principle.md` and `naming-conventions.md`, which show the two most common rule families worked through.

## The baseline is the difference between a tool people use and a tool people disable

Any architectural checker applied to an existing codebase fails on day one, because the codebase already contains the violations. PHPArkitect's answer is the baseline.

```bash
phparkitect generate-baseline
```

This writes `phparkitect-baseline.json`, and subsequent `check` runs pick it up automatically. You can generate a baseline under a different name with `phparkitect generate-baseline my-baseline.json`, point `check` at it with `--use-baseline=my-baseline.json`, or skip it entirely with `--skip-baseline`.

The reason this design matters is that it lets a team adopt the tool without a rewrite. The baseline records what is already wrong, so new violations are the ones that fail. Over time the baseline shrinks.

The pruning command exists for that shrinkage, and the README is careful about what it does. `phparkitect prune-baseline` only removes entries that no longer match a current violation, and it never adds anything. Regenerating snapshots the entire current state, which would silently legitimise violations that appeared after the original baseline was taken. The distinction is easy to get wrong by accident and the tool refuses to guess.

There is a subtlety in how the baseline matches. The configuration table lists `--ignore-baseline-linenumbers` as deprecated, with the note that it has no effect because baseline matching already tolerates moved violations. That matters in practice: a file that shifts lines as other code is edited above it will not produce a spurious new violation, which is the kind of noise that makes teams turn a linter off.

## Three report formats, and CLI options that beat the config file

Every setting can be passed as a command line option or set through the corresponding `Config` method, and when both are set the CLI option wins. That precedence rule is worth internalising, because it means a CI job can override what the repository config says without editing it.

The options cover more than you would expect from a small tool. `--target-php-version` (`-t`) sets the PHP version the parser targets, and the README lists the supported range as 8.0 through 8.6, with the default being the current runtime version. `--stop-on-failure` (`-s`) stops at the first violation instead of collecting them all, which is the faster mode for a local run and the wrong mode for a CI run where you want the full list. `--format` (`-f`) selects the report format: `text` by default, or `json` or `gitlab`. The `gitlab` format exists so that a pipeline can annotate a merge request diff directly, which is the practical reason to bother with a machine-readable format at all.

`--autoload` (`-a`) names an autoload file to load before running, and the README marks it as required for all Phar runs. That is the single most important operational detail in the table, because the Phar distribution has no vendor directory to autoload from, so a Phar based CI setup fails without it.

`--verbose` (`-v`) prints every parsed file instead of the progress bar, which is what you want when a run is slow and you need to see what it is chewing through. `--config` (`-c`) points at a different configuration file, with `phparkitect.php` in the current directory as the default.

## Debugging a rule before you commit to it

Writing a rule blind is a bad idea, because a selector that matches nothing passes silently. PHPArkitect gives you a command to find out what a selector actually matches:

```bash
phparkitect debug:expression ResideInOneOfTheseNamespaces App
```

The argument is the short class name of any expression under `Arkitect\Expression\ForClasses`, and the remaining arguments match its constructor. So for the namespace example above, the namespace string is a positional argument, and for a different expression the arguments would differ. The README describes this as handy for testing a rule before adding it to your config.

The command has its own options. `--from-dir` (`-d`) sets the directory to search for classes, defaulting to the current directory. `--target-php-version` (`-t`) sets the PHP version the parser targets, the same knob as on `check`.

This is the feature that turns the tool from a gate into a design aid. Before committing to a rule that says controllers must live in a namespace and be named accordingly, you can list the classes that satisfy the expression and see whether the selection is what you intended. If it matches only three classes when you expected fifty, the rule is wrong in a way that would otherwise go unnoticed until someone noticed the missing coverage.

The `init` command fills the other gap. It scaffolds a `phparkitect.php` in the current directory so you do not write the closure boilerplate yourself, and if a `phparkitect.php` already exists the command leaves it untouched. It also accepts an optional destination directory, `phparkitect init [--dest-dir=<path>]`, which matters for monorepo layouts where each package needs its own config.

## A PHP package with a Phar build, static analysis and a maintenance cadence to check

PHPArkitect is installed as a development dependency with Composer, and the repository is a normal PHP library with a `src/` directory, a `tests/` directory, `phpunit.xml` for the test runner and `psalm.xml` for static analysis. There is a `Makefile` with targets for running the tests, running a single filtered test, building the Phar, checking for outdated Composer dependencies, running php-cs-fixer and running psalm, and a `build` target that runs the whole sequence.

The Phar target is the interesting part of that Makefile. It copies `src`, `bin-stub`, `box.json`, `README.md`, `composer.json`, `phparkitect-stub.php`, `bin` and `scoper.inc.php` into a temporary directory, installs production dependencies there, and runs the Box tool to compile a scoped Phar. The `scoper.inc.php` file is what scopes the dependencies so the Phar does not collide with the host project's vendor tree. That is the same reason the `--autoload` option exists on the CLI.

Maintenance is current. The last push was on 2026-10-19, and the release history shows 1.3.1 on 2026-09-22, 1.3.0 on 2026-07-31 and 1.2.0 on 2026-07-02. The README points to UPGRADE.md for breaking changes when coming from an older version, which is worth reading before you bump the constraint in `composer.json`, since the project is at 1.x and the format of rules and configuration has moved across versions.

The licence is MIT. The repository also carries a CLAUDE.md file, a php-cs-fixer configuration, a scrutinizer configuration and a codecov configuration, which together suggest a project with continuous integration, static analysis and coverage reporting wired in from the start.

One thing the documentation does not state is a compatibility promise across PHP versions beyond the parser target range, or a backward compatibility guarantee for the rule API between 1.x minors. Check UPGRADE.md rather than assuming.

## Conclusion

arkitect suits PHP teams that have already agreed on a layering convention and want CI to refuse a commit that breaks it, and it is the wrong tool for a codebase with no architectural intent yet, since a rule set with no agreed rules protects nothing. Verify three things before you adopt it: that your PHP runtime is inside the supported parser range of 8.0 through 8.6, since the default target is the current runtime version, that you generate a baseline with `phparkitect generate-baseline` before the first CI run so existing violations do not block every pull request, and that you read UPGRADE.md, because the README points there for breaking changes between versions and the current release line is 1.3.1.

## FAQ

### How do I install PHPArkitect and run it for the first time?

Install it with `composer require --dev phparkitect/phparkitect`, scaffold a configuration file with `vendor/bin/phparkitect init`, then run `vendor/bin/phparkitect check`. The init command creates phparkitect.php in the current directory and leaves an existing file untouched.

### What is the difference between a selector and a check in PHPArkitect?

A rule has three parts: a selector in that(), a check in should(), and a reason in because(). The because string appears verbatim in the violation output, so the reason reaches whoever reads the failing check.

### How do I deal with existing architectural violations in my codebase?

Generate a baseline with `phparkitect generate-baseline`, which writes phparkitect-baseline.json that later check runs pick up automatically. Use `phparkitect prune-baseline` as you fix violations, since pruning only removes entries and never adds any.

### Which report formats does PHPArkitect support?

The `--format` option accepts `text`, which is the default, `json` or `gitlab`. The GitLab format exists so a pipeline can report violations into a merge request.

### Can I check which classes match a rule before adding it to the config?

Yes, use `phparkitect debug:expression` with the short class name of an expression under Arkitect\Expression\ForClasses plus its constructor arguments, for example `phparkitect debug:expression ResideInOneOfTheseNamespaces App`.

### Which PHP versions does PHPArkitect support?

The `--target-php-version` option accepts 8.0 through 8.6, and the default is the current runtime version. The option also matters for Phar runs, where `--autoload` is required.

## Sources

- [Issues](https://github.com/phparkitect/arkitect/issues)
- [License: MIT](https://github.com/phparkitect/arkitect/blob/main/LICENSE)
- [phparkitect/arkitect on GitHub](https://github.com/phparkitect/arkitect)
- [README](https://github.com/phparkitect/arkitect/blob/main/README.md)
- [Releases](https://github.com/phparkitect/arkitect/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/phparkitect-arkitect
