structuraphp/structura

Architectural testing for PHP

0.2.0 2025-05-26 20:21 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);
    }
}

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:

->that(static fn(Expr $expr): Expr => $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:

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

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()

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

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(),
  );

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

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));

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'));

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'));

toHaveAttribute()

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

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, implement the StructuraPhp\StructuraContracts\ExprInterface 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