structuraphp/structura

Architectural testing for PHP

0.4.0 2025-09-21 19:55 UTC

README

License PHP from Packagist

About

Structura is an architectural testing tool for PHP, designed to help developers maintain a clean and consistent code structure.

Requirements

PHP version

Version PHP Structura 0.x
<= 8.1 ✗ Unsupported
8.2 / 8.3 / 8.4 ✓ Supported

Installation

Using Composer

composer required --dev structuraphp/structura

Usage

Configuration

Create the configuration file required for running the tests:

php structura init

At the root of your project, add the namespace and directory for your architecture tests:

return static function (StructuraConfig $config): void {
    $config->archiRootNamespace(
        '<MY_NAMESPACE>\Tests\Architecture', // namespace
        'tests/Architecture', // test directory
    );
};

Make test

After creating and completing your configuration file, you can use the command to create architecture tests:

php bin/structura make

Here's a simple example of architecture testing for your DTOs:

use StructuraPhp\Structura\Asserts\ToExtendNothing;
use StructuraPhp\Structura\Attributes\TestDox;
use StructuraPhp\Structura\Expr;
use StructuraPhp\Structura\Testing\TestBuilder;
use Symfony\Component\Finder\Finder;

final class TestDto extends TestBuilder
{
    #[TestDox('Asserts rules Dto architecture')]
    public function testAssertArchitectureRules(): void
    {
        $this
            ->allClasses()
            ->fromDir(
                'app/Dto',
                fn(Finder $finder) => $finder->depth('== 0')
            )
            ->that($this->conditionThat(...))
            ->except($this->exception(...))
            ->should($this->should(...));
    }

    private function that(Expr $expr): void
    {
        // The rules will only apply to classes (ignore traits, enums, interfaces, etc.)
        $expr->toBeClasses();
    }

    private function exception(Except $except): void
    {
        // These classes will be ignored in the tests
        $except
            ->byClassname(
                className: [
                    FooDto::class,
                    BarDto::class,
                ],
                expression: ToExtendNothing::class
            );
    }

    private function should(Expr $expr): void
    {
        $expr
            ->toBeFinal()
            ->toBeReadonly()
            ->toHaveSuffix('Dto')
            ->toExtendsNothing()
            ->toHaveMethod('fromArray')
            ->toImplement(\JsonSerializable::class);
    }
}

toBeClasses() and allScripts()

There are two types of analysis:

// Analysis of classes. A class MUST be present, otherwise an exception is raised.
$this->allClasses()

// Analysis of all PHP scripts.
$this->allScripts()

If you choose script analysis, all PHP code can be analysed, but only rules can be used.

fromDir() and fromRaw()

Start with the fromDir() method, which takes the path of the files to be analysed. It can take a second closure parameter to customise the finder:

->fromDir(
    'src/Dto',
    static fn(Finder $finder): Finder => $finder->depth('== 0')
)

fromRaw() method can be used to test PHP code in the form of a string:

->fromRaw('<?php
            
    use ArrayAccess;
    use Depend\Bap;
    use Depend\Bar;
            
    class Foo implements \Stringable {
        public function __construct(ArrayAccess $arrayAccess) {}

    public function __toString(): string {
        return $this->arrayAccess['foo'] ?? throw new \Exception();
    }
}')

that()

Specifies rules for targeting class analysis, optional functionality:

// with allClasses()
->that(static fn(Expr $expr): Expr => $expr->toBeClasses())
// with allScript()
->that(static fn(ExprScript $expr): ExprScript => $expr->toBeClasses())

except()

Ignores class rules, can be used as a baseline, optional functionality:

->except(static fn(Except $except): Except => $except
    ->byClassname(
        className: [
            FooDto::class,
            BarDto::class,
        ],
        expression: ToExtendNothing::class
    )
)

should()

List of architecture rules, required functionality:

// with allClasses()
->should(static fn(Expr $expr): Expr => $expr
    ->toBeFinal()
    ->toBeReadonly()
    ->toHaveSuffix('Dto')
    ->toExtendsNothing()
    ->toHaveMethod('fromArray')
    ->toImplement(\JsonSerializable::class)
)
// with allScript()
->should(static fn(ExprScript $expr): ExprScript => $expr)

First run

To run the architecture tests, execute the following command:

php bin/structura analyze

Assertions

toBeAbstract()

$this
  ->allClasses()
  ->fromRaw('<?php abstract class Foo {}')
  ->should(
    static fn (Expr $assert): Expr => $assert->toBeAbstract(),
  );

toBeAnonymousClasses()

$this
  ->allClasses()
  ->fromRaw('<?php new class {};')
  ->should(
    static fn (Expr $assert): Expr => $assert->toBeAnonymousClasses(),
  );

toBeClasses()

$this
  ->allClasses()
  ->fromRaw('<?php class Foo {}')
  ->should(
    static fn (Expr $assert): Expr => $assert->toBeClasses(),
  );

toBeEnums()

Must be a valid Unit Enum or Backed Enum.

$this
  ->allClasses()
  ->fromRaw('<?php enum Foo {}')
  ->should(
    static fn (Expr $assert): Expr => $assert->toBeEnums(),
  );

toBeBackedEnums()

Must be a backed enumeration, if ScalarType is not specified, int and string are accepted.

https://www.php.net/manual/en/language.enumerations.backed.php

use StructuraPhp\Structura\Enums\ScalarType;

$this
  ->allClasses()
  ->fromRaw('<?php enum Foo: string {}')
  ->should(
    static fn (Expr $assert): Expr => $assert->toBeBackedEnums(ScalarType::String),
  );

toBeFinal()

$this
  ->allClasses()
  ->fromRaw('<?php final class Foo {}')
  ->should(
    static fn (Expr $assert): Expr => $assert->toBeFinal(),
  );

toBeInterfaces()

$this
  ->allClasses()
  ->fromRaw('<?php interface Foo {}')
  ->should(
    static fn (Expr $assert): Expr => $assert->toBeInterfaces(),
  );

toBeInvokable()

$this
  ->allClasses()
  ->fromRaw('<?php class Foo { public function __invoke() {} }')
  ->should(
    static fn (Expr $assert): Expr => $assert->toBeInvokable(),
  );

toBeReadonly()

$this
  ->allClasses()
  ->fromRaw('<?php readonly class Foo {}')
  ->should(
    static fn (Expr $assert): Expr => $assert->toBeReadonly(),
  );

toBeTraits()

$this
  ->allClasses()
  ->fromRaw('<?php trait Foo {}')
  ->should(
    static fn (Expr $assert): Expr => $assert->toBeTraits(),
  );

toBeAttribute()

$this
  ->allClasses()
  ->fromRaw('<?php #[\Attribute(\Attribute::TARGET_CLASS_CONSTANT)] class Foo {}')
  ->should(
    static fn (Expr $assert): Expr => $assert->toBeAttribute(\Attribute::TARGET_CLASS_CONSTANT),
  );
<?php

[\Attribute(\Attribute::TARGET_CLASS_CONSTANT)] // OK
class Foo {

}

#[Custom] // KO
class Bar {

}

(new ReflectionClass(Bar::class))->getAttributes()[0]->newInstance();
// Fatal error: Uncaught Error: Attribute class "Custom" not found

dependsOnlyOn()

You can use regexes to select dependencies.

$this
  ->allClasses()
  ->should(fn(Expr $expr) => $expr
    ->dependsOnlyOn(
        names: [ArrayAccess::class, /* ... */],
        patterns: ['App\Dto.+', /* ... */],
    )
  );

dependsOnlyOnAttribut()

If you use the rule classes toHaveAttribute(), they are included by default in the permitted dependencies.

$this
  ->allClasses()
  ->should(fn(Expr $expr) => $expr
    ->dependsOnlyOnAttribut(
        names: [\Attribute::class, /* ... */],
        patterns: ['Attributes\Custom.+', /* ... */],
    )
  );

dependsOnlyOnImplementation()

If you use the rule classes toImplement() and toOnlyImplement() they are included by default in the permitted dependencies.

$this
  ->allClasses()
  ->should(fn(Expr $expr) => $expr
    ->dependsOnlyOnImplementation(
        names: [\ArrayAccess::class, /* ... */],
        patterns: ['Contracts\Dto.+', /* ... */],
    )
  );

dependsOnlyOnInheritance()

If you use the rule classes toExtend() they are included by default in the permitted dependencies.

$this
  ->allClasses()
  ->should(fn(Expr $expr) => $expr
    ->dependsOnlyOnInheritance(
        names: [Controller::class, /* ... */],
        patterns: ['Controllers\Admin.+', /* ... */],
    )
  );

dependsOnlyOnUseTrait()

If you use the rule classes toUseTrait() and toOnlyUseTrait() they are included by default in the permitted dependencies.

$this
  ->allClasses()
  ->should(fn(Expr $expr) => $expr
    ->dependsOnlyOnUseTrait(
        names: [\HasFactor::class, /* ... */],
        patterns: ['Concerns\Models.+', /* ... */],
    )
  );

toNotDependsOn()

$this
  ->allClasses()
  ->should(fn(Expr $expr) => $expr
    ->toNotDependsOn(
        names: [ArrayAccess::class, /* ... */],
        patterns: ['App\Dto.+', /* ... */],
    )
  );

You can use regexes to select dependencies

dependsOnFunction()

$this
  ->allClasses()
  ->should(fn(Expr $expr) => $expr
    ->dependsOnlyOnFunction(
        names: ['strtolower', /* ... */],
        patterns: ['array_.+', /* ... */],
    )
  );

You can use regexes to select dependencies

toNotDependsOnFunction()

Prohibit the use of function.

$this
  ->allClasses()
  ->should(fn(Expr $expr) => $expr
    ->dependsOnlyOnFunction(
        names: ['goto', /* ... */],
        patterns: ['.+exec', /* ... */],
    )
  );

You can use regexes to select dependencies

toExtend()

$this
  ->allClasses()
  ->fromRaw('<?php class Foo extends \Exception {}')
  ->should(fn(Expr $expr) => $expr->toExtend(Exception::class));

toExtendsNothing()

$this
  ->allClasses()
  ->fromRaw('<?php class Foo {}')
  ->should(fn(Expr $expr) => $expr->toExtendsNothing());

toImplement()

$this
  ->allClasses()
  ->fromRaw('<?php class Foo implements \ArrayAccess, \JsonSerializable {}')
  ->should(fn(Expr $expr) => $expr->toImplement(ArrayAccess::class));

toImplementNothing()

$this
  ->allClasses()
  ->fromRaw('<?php class Foo {}')
  ->should(fn(Expr $expr) => $expr->toImplementNothing());

toOnlyImplement()

$this
  ->allClasses()
  ->fromRaw('<?php class Foo implements \ArrayAccess {}')
  ->should(fn(Expr $expr) => $expr->toOnlyImplement(ArrayAccess::class));

toUseTrait()

$this
  ->allClasses()
  ->fromRaw('<?php class Foo { use Bar, Baz; }')
  ->should(fn(Expr $expr) => $expr->toUseTrait(Bar::class));

toNotUseTrait()

$this
  ->allClasses()
  ->fromRaw('<?php class Foo {}')
  ->should(fn(Expr $expr) => $expr->toNotUseTrait());

toOnlyUseTrait()

$this
  ->allClasses()
  ->fromRaw('<?php class Foo { use Bar; }')
  ->should(fn(Expr $expr) => $expr->toOnlyUseTrait(Bar::class));

toHaveAttribute()

$this
  ->allClasses()
  ->fromRaw('<?php #[\Deprecated] class Foo {}')
  ->should(fn(Expr $expr) => $expr->toHaveAttribute(Deprecated::class));

toHaveNoAttribute()

$this
  ->allClasses()
  ->fromRaw('<?php class Foo {}')
  ->should(fn(Expr $expr) => $expr->toHaveNoAttribute());

toHaveOnlyAttribute()

$this
  ->allClasses()
  ->fromRaw('<?php #[\Deprecated] class Foo {}')
  ->should(fn(Expr $expr) => $expr->toHaveOnlyAttribute(Deprecated::class));

toHaveMethod()

$this
  ->allClasses()
  ->fromRaw('<?php class Foo { public function bar() {} }')
  ->should(fn(Expr $expr) => $expr->toHaveMethod('bar'));

toHaveConstructor()

$this
  ->allClasses()
  ->should(fn(Expr $expr) => $expr->toHaveConstructor());

toHaveDestructor()

$this
  ->allClasses()
  ->should(fn(Expr $expr) => $expr->toHaveDestructor());

toHavePrefix()

$this
  ->allClasses()
  ->fromRaw('<?php class ExempleFoo {}')
  ->should(fn(Expr $expr) => $expr->toHavePrefix('Exemple'));

toHaveSuffix()

$this
  ->allClasses()
  ->fromRaw('<?php class FooExemple {}')
  ->should(fn(Expr $expr) => $expr->toHaveSuffix('Exemple'));

toHaveCorresponding()

Check the correspondence between a class/enum/interface/trait and a mask. To build the mask, you have access to the description of the current class.

Correspondence rules can be used in many scenarios, such as:

  • If a model has a repository interface,
  • If a model has a policy with the same name,
  • If your controllers have associated queries or resources,
  • ...

For example, you can check whether each unit test class has a corresponding class in your project :

$this
    ->allClasses()
    ->fromDir('tests/Unit')
    ->should(
        static fn(Expr $assert): Expr => $assert
            ->toHaveCorrespondingClass(
                static fn (ClassDescription $classDescription): string => preg_replace(
                    '/^(.+?)\\\Tests\\\Unit\\\(.+?)(Test)$/',
                    '$1\\\$2',
                    $classDescription->namespace,
                )
            ),
    );

toHaveCorrespondingClass()

Similar to toHaveCorresponding, but for matching with a class.

toHaveCorrespondingEnum()

Similar to toHaveCorresponding, but for matching with an enum.

toHaveCorrespondingInterface()

Similar to toHaveCorresponding, but for matching with an interface.

toHaveCorrespondingTrait()

Similar to toHaveCorresponding, but for matching with a trait.

toUseStrictTypes()

$this
  ->allClasses()
  ->fromRaw('<?php declare(strict_types=1); class Foo {}')
  ->should(fn(Expr $expr) => $expr->toUseStrictTypes());

toUseDeclare()

$this
  ->allClasses()
  ->fromRaw('<?php declare(encoding='ISO-8859-1'); class Foo {}')
  ->should(fn(Expr $expr) => $expr->toUseDeclare('encoding', 'ISO-8859-1'));

toBeInOneOfTheNamespaces()

Allows you to specifically target classes contained in a namespace.

Information !

Anonymous classes cannot have namespaces

$this
  ->allClasses()
  ->fromDir('tests')
  ->that(
    fn(Expr $expr) => $expr
      ->toBeInOneOfTheNamespaces('Tests\Unit.+')
  )
  ->should(fn(Expr $expr) => $expr /* our rules */);

You can use regexes to select namespaces.

notToBeInOneOfTheNamespaces()

Allows you to specifically target classes not contained in a namespace.

Information !

Anonymous classes cannot have namespaces

$this
  ->allClasses()
  ->fromDir('tests')
  ->that(
    fn(Expr $expr) => $expr
      ->notToBeInOneOfTheNamespaces('Tests\Unit.+')
  )
  ->should(fn(Expr $expr) => $expr /* our rules */);

You can use regexes to select namespaces.

and()

To be valid, all the rules contained in the and() method must meet the requirements.

// if Foo interface extends ArrayAccess and (JsonSerializable and Countable)
$this
  ->allClasses()
  ->fromRaw('<?php interface Foo extends ArrayAccess, JsonSerializable, Countable {}')
  ->should(fn(Expr $expr) => $expr
    ->toBeInterfaces()
    ->toExtend(ArrayAccess::class)
    ->and(fn(Expr $expr) => $expr
      ->toExtend(JsonSerializable::class)
      ->toExtend(Countable::class)
    )
  );

or()

To be valid at least one of the rules contained in the or() method must meet the requirements.

// if Foo class implements ArrayAccess and (JsonSerializable or Countable)
$this
  ->allClasses()
  ->fromRaw('<?php class Foo implements ArrayAccess, JsonSerializable {}')
  ->should(fn(Expr $expr) => $expr
    ->toBeClasses()
    ->toImplement(ArrayAccess::class)
    ->or(fn(Expr $expr) => $expr
      ->toImplement(JsonSerializable::class)
      ->toImplement(Countable::class)
    )
  );

Custom assert

To create a custom rule :

  • for class analysis, implement the StructuraPhp\Structura\Contracts\ExprInterface interface
  • for script analysis, implement the StructuraPhp\Structura\Contracts\ExprScriptInterface interface.
<?php

final readonly class CustomRule implements ExprInterface
{
    public function __construct(
        private string $message = '',
    ) {}

    public function __toString(): string
    {
        return 'exemple'; // Name of the rule
    }

    public function assert(ClassDescription $class): bool
    {
        return true; // Must return false if the test fails
    }

    public function getViolation(ClassDescription $class): ViolationValueObject
    {
        return new ViolationValueObject(
            'error message', // Console output
            $this::class,
            $class->lines,
            $class->getFileBasename(),
            $this->message,
        );
    }
}

To use a custom rule, add it using the addExpr() method.

$this
  ->allClasses()
  ->fromRaw('<?php class Foo {}')
  ->should(fn(Expr $expr) => $expr
    ->addExpr(new CustomRule('foo'))
  );

Use existing rules as an example.

With PHPUnit

Structura can integrate architecture testing with PHPUnit with this project:

https://github.com/structuraphp/structura-phpunit