About six months ago, I created a new GitHub organization named Boundwize, as a home for developer tools, practices, and ideas that make code clearer, calmer, and easier to evolve.
The first tool I created was StructArmed, a configurable PHP architecture guard. It turns architecture decisions into executable checks. You can start with an existing preset for PSR, MVC, or DDD projects, then tune or extend the rules in native PHP.
It is already used by PHP frameworks:
| Project | Configuration |
|---|---|
| CakePHP | https://github.com/cakephp/cakephp/blob/5.4.4/structarmed.php |
| CodeIgniter 4 | https://github.com/codeigniter4/CodeIgniter4/blob/v4.7.6/structarmed.php |
| Mezzio Framework | https://github.com/mezzio/mezzio/blob/3.29.0/structarmed.php |
| Spiral Framework | https://github.com/spiral/framework/blob/3.18.2/structarmed.php |
and by testing frameworks:
| Project | Configuration |
|---|---|
| Mockery | https://github.com/mockery/mockery/blob/1.6.x/structarmed.php |
| Testo | https://github.com/php-testo/testo/blob/0.10.56/tools/structarmed/structarmed.php |
You can find other PHP packages that use it on the Packagist dependents page:
https://packagist.org/packages/boundwize/structarmed/dependents?order_by=downloads
An architecture guard runs again and again, locally and in CI, so it has to be fast. This post is about how StructArmed got there.
Here is the latest version, 0.21.8, running on the Spiral Framework:
It analyses 2223 files in 0.28 seconds.
In the early releases, I did not focus on speed, because I was collecting use cases for the features first. Since version 0.5.x, I have worked on speed step by step, while still adding features and fixing bugs. Here is how.
Extract Once, Then Check the Data
Since StructArmed focuses on architecture, its rules don't need to read the PHP-Parser nodes directly. Instead, it extracts the data once into serializable objects, grouped by scope: ClassLike, FunctionLike, and File.
Each scope has its own rule interface. A rule implements the interface for the scope it cares about, and StructArmed passes it only the data for that scope. ClassLike and FunctionLike are represented by StructArmed *Node objects, and File by a FileAnalysis record. Rules that check the project as a whole have their own interface too, but they read the project directly instead of the extracted data.
The ruleset works on the same extracted data, so it needs no extra parsing. Each class is resolved to its layer. Then, for every class it depends on, StructArmed checks whether that dependency's layer is in the allowed list of the class's own layer. If it is not, it is a violation.
The Parallel Process
To analyse many files quickly on a multi-core CPU, running in parallel is the key. The first version split the files across workers started with proc_open.
It was already much faster, and I liked it a lot. But I did not stop there.
Balancing the Workers with LPT
I found that the LPT (Longest Processing Time First) algorithm makes it faster. The files are sorted from the largest to the smallest, with file size used as a cheap estimate of how long a file takes to parse.
Before LPT starts, the first worker takes the largest files until it reaches an even share of the total bytes (the total bytes divided by the number of workers). This way, only that one worker pays the memory peak of parsing the big files, instead of every worker getting one big file.
Then each remaining file is assigned to the worker that has the smallest total bytes so far. Every worker gets a nearly equal amount of bytes to parse, including the first one: it has fewer files, but bigger ones. So all workers finish at about the same time, and none of them sits idle waiting for the others.
Waiting Smarter
At first, the coordinator polled the workers: read what each one has written, sleep 5ms, repeat. When the progress bar is disabled, the only event the coordinator cares about is a worker that has finished. So it now blocks on stream_select(), and the operating system wakes it up exactly when a worker has something to say, instead of waking up every 5ms to find nothing.
An Even Faster Parallel Process
proc_open still had one bottleneck: every worker starts a new PHP interpreter and has to bootstrap from zero.
Since StructArmed 0.21.8, the workers are created with pcntl_fork instead. A forked worker is a copy of the already initialized PHP process, so it can start working right away. pcntl_fork is not available everywhere, for example on Windows, so the parallel process falls back to proc_open when it is not supported.
Parallelism is the biggest win, but it is not the only one. The rest of the speed comes from giving each process less work to do.
The Cache
The fastest file to parse is the one that is not parsed at all. StructArmed has two levels of cache, and both rely on content hashes with xxh128, a very fast non-cryptographic hash that has been built into PHP since 8.1.
The first level is the result cache. It stores the final violations, keyed by everything that can change them: the config file and the files it imports, composer.json, the installed StructArmed version, the scan paths, and the content hash of every analysed file. When nothing has changed since the last run, the violations are read back: no file is parsed and no rule is run.
The second level is the per-file node cache. Because the extracted *Node objects and FileAnalysis records are plain serializable data, each file's extraction is stored in its own cache entry, together with the content hash of the file it was built from:
$payload = $this->read($this->analysisNodesKey($file, $namespace)); if ($payload === null || ($payload['metadata'] ?? null) !== $this->fileHashProvider->hash($file) ) { return null; } return $payload; So when you change one file in a project of thousands, only that one file is parsed again. The rest are hydrated from the cache, and the rules run on the same data as a cold run.
The cache entry name also includes a hash of the config and composer.json, because the stored layer of a class depends on both: changing a layer definition or a PSR-4 mapping must not reuse stale layers.
Some details keep the cache itself cheap:
- Reading thousands of cache entries back into objects is also work, so it is split across the parallel workers, the same way as parsing.
- The entries are compact. Most files have no anonymous classes, no functions, and no top-level references, so empty lists are left out of the entry entirely. Class members are stored as positional tuples instead of repeating the key names for every member.
- Each file is hashed once per run. The hash is memoised, so the result cache and the node cache share it.
- Invalidation after a config change, a
composer.jsonchange, or aStructArmedupgrade is an O(1) check of a single marker file, not a scan of the whole cache directory. - Workers write each entry right after the file is extracted, instead of holding all results to write at the end.
For CI that starts fresh on every run, there is also --no-cache, which skips hashing, reading, and writing entirely.
Collecting Only What the Rules Need
Extraction is driven by the registered rules. Before any file is parsed, the analyser already knows which rule scopes are active, so the collector can skip what nobody will read:
$withFileAnalysis = $fileAnalysisRules !== []; - When no File-scope rule is active, the file-level analysis is not built at all, and numeric literals and keyword constants are not collected.
- When no ClassLike or FunctionLike rule is active, for example when the config defines only a ruleset, resolving recursive parents and function fallbacks is skipped, because layer dependency checks don't need them.
Some data costs extra to compute, so it is opt-in. A rule asks for it by implementing a marker interface:
| Interface | Provides |
|---|---|
ExtendedClassAwareRuleInterface | whether a class is extended |
UsedInterfaceAwareRuleInterface | whether an interface is implemented |
UsedTraitAwareRuleInterface | whether a trait is used |
UsedFunctionAwareRuleInterface | whether a function is used |
SignatureTypesAwareRuleInterface | the class-like types named in member signatures |
The analyser checks the registered rules for these interfaces before any file is parsed:
if ($rule instanceof SignatureTypesAwareRuleInterface) { $withSignatures = true; } So this data is computed only when an active rule asks for it. Cheap data, such as the @api and @internal tags of a class, is always collected.
The ruleset check itself is memoised as well. Whether a dependency is allowed depends only on the layer of the class that uses it, and the same dependencies recur across many classes. So each allowed "layer may depend on this class" pair is resolved once and then looked up, instead of being re-checked for every class.
Halving the Coordinator Memory with __unserialize()
Each worker sends its extracted nodes back to the coordinator through serialize(). By default, unserialize() builds a property hash table on every object it creates and keeps it, which roughly doubles the memory of the whole node graph. Assigning the properties in __unserialize() avoids that table:
trait PropertyUnserializeTrait { /** @param array<string, mixed> $data */ public function __unserialize(array $data): void { foreach ($data as $property => $value) { $this->{$property} = $value; } } } On a large project like Magento, this cut the coordinator's memory roughly in half. Less memory to allocate also means less time.
Disabling the Garbage Collector
PHP frees most memory by reference counting, and that always stays on. On top of that, PHP has a cycle collector: an extra pass that looks for objects that reference each other in a loop. It runs every time its buffer of 10,000 possible roots is full, and each run walks the live objects to check whether any of them is garbage.
For StructArmed, that walk is wasted work. The extracted *Node objects and FileAnalysis records are all needed until the last rule finishes, so there is nothing to collect.
So the analyser disables the cycle collector during the analysis:
$isGcEnabled = gc_enabled(); gc_disable(); try { return $this->doAnalyse( $architecture, $scanPaths, $progressHandler, $analyserOptions, $files ); } finally { if ($isGcEnabled) { gc_enable(); } } The previous state is saved and restored in finally, because the analyser may be called from other code in the same process, for example a PHPUnit test, and it should not change the collector setting of its caller.
Final Words
None of these changes made StructArmed fast on its own. The speed comes from many small steps added together: extract once, split the work evenly, reuse what has not changed, collect only what the rules need, and keep the memory low. Each step was measured on real projects before it was kept, and the ideas that showed no measurable gain were dropped.
Speed is also never allowed to change the result. A cached run, a parallel run, and a cold sequential run all give the same violations, because the rules always read the same extracted data. That is why you can rely on it, both in CI and on your own machine.
If you want to guard the architecture of your project, give it a try:
composer require --dev boundwize/structarmed vendor/bin/structarmed init vendor/bin/structarmed analyze Then read the documentation at https://boundwize.github.io/structarmed/ to pick a preset or write your own rules. If you find a bug, a false positive, or a use case that is not covered yet, please open an issue at https://github.com/boundwize/structarmed/issues. Feedback from real projects is what has shaped the tool so far.
Pull requests are welcome too. But if yours is not approved, please don't take it personally. I maintain StructArmed myself, and every change that is merged is code I have to keep working, tested, and fast in every release after it. That takes a lot of effort, so sometimes I have to say no, even to a good idea.
Thank you to everyone who has already tried it, reported issues, and added it to their projects.





