boundwize/structarmed

Configurable PHP architecture guards — define your layers and rules, then keep them enforced

Maintainers

Package info

github.com/boundwize/structarmed

pkg:composer/boundwize/structarmed

Fund package maintenance!

samsonasik

Statistics

Installs: 1 249

Dependents: 4

Suggesters: 0

Stars: 7

Open Issues: 0

0.4.0 2026-05-11 15:48 UTC

README

Image

Latest Version ci build Code Coverage PHPStan Downloads

Configurable PHP architecture guards — define your layers and rules, then keep them enforced.

Turn architecture rules into executable checks

  • Make architecture decisions executable, not just documented.
  • Catch boundary violations before they quietly become conventions.
  • Tune, override, or skip individual preset rules in native PHP code.

Installation

composer require --dev boundwize/structarmed

Quick start

# defaults to --preset=psr4
vendor/bin/structarmed init

# verify source paths match composer.json PSR-4 mappings
vendor/bin/structarmed init --preset=psr4

# enforce basic coding standard (tags, StudlyCaps, camelCase)
vendor/bin/structarmed init --preset=psr1

# extends PSR-1: require explicit visibility on all members
vendor/bin/structarmed init --preset=psr12

# thin controllers, model/view/service layer rules
vendor/bin/structarmed init --preset=mvc

# layer isolation, entity/VO/repository/event/service conventions
vendor/bin/structarmed init --preset=ddd

# enable all presets at once
vendor/bin/structarmed init --preset=all

Generates a structarmed.php in your project root. Edit it to match your structure, then run:

vendor/bin/structarmed analyse

If violations are found, the output reports each one:

Image

If everything passes, you get a clean summary:

Image

Configuration

Default

<?php

// structarmed.php
use Boundwize\StructArmed\Architecture;
use Boundwize\StructArmed\Preset\Preset;

return Architecture::define()
    ->withPreset(Preset::PSR4());

Multiple presets

->withPresets(Preset::PSR4(), Preset::PSR1(), Preset::PSR12(), Preset::MVC(), Preset::DDD())

Cache directory

StructArmed stores analysis cache in the system temp directory by default. You can configure a project cache directory:

<?php

use Boundwize\StructArmed\Architecture;
use Boundwize\StructArmed\Preset\Preset;

return Architecture::define()
    ->cacheDirectory('var/cache/structarmed')
    ->withPreset(Preset::PSR4());

Relative cache directories are resolved from the project root. --config also controls the cache directory used by analyse and --clear-cache.

Custom layers and rules

<?php

use Boundwize\StructArmed\Architecture;
use Boundwize\StructArmed\Preset\Preset;
use Boundwize\StructArmed\Preset\Presets\DddPreset;
use Boundwize\StructArmed\Rule\Rules\Layer\MayNotDependOnRule;
use Boundwize\StructArmed\Rule\Rules\Method\MustHaveReturnTypeRule;

return Architecture::define()
    ->layer('Domain', 'src/Domain/')
    ->layer('Application', 'src/Application/')
    ->layer('Infrastructure', 'src/Infrastructure/')
    ->skip([
        'tests/Fixtures/',
        'var/cache/*',
        DddPreset::DOMAIN_NO_DATETIME,
        DddPreset::ENTITY_MUST_BE_FINAL => ['src/Legacy/'],
    ])
    ->withPreset(Preset::DDD())
    ->rule(
        'domain.must_not_depend_on_infrastructure',
        new MayNotDependOnRule(from: 'Domain', to: 'Infrastructure', toPath: 'Infrastructure')
    )
    ->rule(
        'domain.public_methods_must_have_return_types',
        new MustHaveReturnTypeRule(layer: 'Domain')
    );

Inside skip(), string entries skip files or directories unless they match a registered rule key, keyed entries skip paths for one specific rule, and rule key constants skip that rule entirely. You can also use skipPath() / skipPaths() and skipRule() / skipRules() when you prefer the explicit methods.

Custom presets

A custom preset is a class that implements Boundwize\StructArmed\Preset\PresetInterface. Inside apply(), add the layers and rules you want to reuse:

<?php

use Boundwize\StructArmed\Architecture;
use Boundwize\StructArmed\Preset\PresetInterface;
use Boundwize\StructArmed\Rule\Rules\Method\MustHaveReturnTypeRule;

final class MyPreset implements PresetInterface
{
    public const METHODS_MUST_HAVE_RETURN_TYPES = 'source.methods_must_have_return_types';

    public function apply(Architecture $architecture): void
    {
        $architecture
            ->layer('Source', 'src/')
            ->rule(
                self::METHODS_MUST_HAVE_RETURN_TYPES,
                new MustHaveReturnTypeRule(layer: 'Source')
            );
    }
}

Register it in structarmed.php:

<?php

use App\Architecture\MyPreset;
use Boundwize\StructArmed\Architecture;

return Architecture::define()
    ->withPreset(new MyPreset());

Override preset rules

Use rule key constants — never raw strings:

<?php

use Boundwize\StructArmed\Architecture;
use Boundwize\StructArmed\Preset\Preset;
use Boundwize\StructArmed\Preset\Presets\DddPreset;
use Boundwize\StructArmed\Rule\Rules\Class_\MustBeFinalRule;
use Boundwize\StructArmed\Rule\Rules\Class_\NamingConventionRule;

return Architecture::define()
    ->layer('Domain',         'src/Domain/')
    ->layer('Application',    'src/Application/')
    ->layer('Infrastructure', 'src/Infrastructure/')
    ->withPreset(Preset::DDD())

    // Replace a rule with a different configuration
    ->replaceRule(
        DddPreset::ENTITY_MUST_BE_FINAL,
        new MustBeFinalRule(layer: 'Domain', classNamePattern: '/Entity$|Aggregate$/')
    )

    // Add your own custom rule
    ->rule(
        'our.handlers.must_be_in_application',
        new NamingConventionRule(
            classNamePattern: '/Handler$/',
            mustBeInLayer: 'Application'
        )
    );

Preset constructor parameters

->withPreset(Preset::DDD(
    maxComplexity:        3,     // default: 5
    maxMethodLength:      15,    // default: 20
    enforceFinalEntities: false, // default: true
))

->withPreset(Preset::MVC(
    controllerMaxComplexity:   3,  // default: 5
    controllerMaxDependencies: 4,  // default: 5
    viewMaxComplexity:         2,  // default: 3
))

->withPreset(Preset::PSR1(
    sourcePaths: ['src/', 'tests/'], // default: read composer.json PSR-4 paths
))

->withPreset(Preset::PSR12(
    sourcePaths: ['src/', 'tests/'], // default: read composer.json PSR-4 paths
))

->withPreset(Preset::PSR4(
    sourcePaths: ['src/', 'tests/'], // default: read composer.json PSR-4 paths
))

Available presets

Preset Rules
Preset::PSR1() Basic Coding Standard checks: PHP tags, UTF-8 without BOM, symbols vs side effects, PSR-4 class placement, StudlyCaps class names, upper-case class constants, camelCase methods
Preset::PSR12() Extends PSR-1: all methods, constants, and properties must declare explicit visibility
Preset::PSR4() Verifies configured source paths exist in composer.json autoload or autoload-dev PSR-4 mappings
Preset::DDD() Layer isolation, entity/VO/repository/event/service conventions
Preset::MVC() Layer isolation, thin controllers, model/view/service rules

PHPUnit extension

Run architecture checks as part of your test suite:

<!-- phpunit.xml -->
<extensions>
    <bootstrap class="Boundwize\StructArmed\PHPUnit\StructArmedExtension"/>
</extensions>

Violations cause the test run to fail before any tests execute.

CLI analyse commands

# Analyse with default config discovery
vendor/bin/structarmed analyse
vendor/bin/structarmed analyze

# Analyse only specific paths
vendor/bin/structarmed analyse src
vendor/bin/structarmed analyze src tests

# Custom config path
vendor/bin/structarmed analyse --config=path/to/structarmed.php
vendor/bin/structarmed analyze --config=path/to/structarmed.php

# JSON output (for CI tools)
vendor/bin/structarmed analyse --report=json
vendor/bin/structarmed analyze --report=json

Layer resolution

Layers are resolved by file path — no attributes needed on classes:

src/Domain/     → 'Domain'
src/Application/ → 'Application'
src/Infrastructure/ → 'Infrastructure'

Rule key constants

Every preset rule has a public constant. Use constants, never raw strings:

// ✅ correct — caught by IDE and static analysis
DddPreset::ENTITY_MUST_BE_FINAL

// ❌ wrong — typo silently does nothing
'ddd.entity.must_be_fnal'