Skip to content

Extending Deptrac

Deptrac defines its extension points by providing a set of contract classes that you can depend on for your implementation. The classes can be found in the src/Contract directory and are covered by the backwards compatibility policy promise, meaning they will stay stable within major releases.

Note In non-code excerpt examples where FQCN is not specified, the base namespace Deptrac\Deptrac\Contract\ is omitted for readability.

Before you decide to extend Deptrac, it is useful to understand how Deptrac works to see where is a good place to insert your extension.

First, files collected based on paths parameter are parsed by a PHP code parser and an Abstract Syntax Tree (AST) for each file is created. Then we use reference extractors to find references in each file. These are the first 2 places where you can extend Deptrac. You can write a custom reference extractor implementing the Ast\ReferenceExtractorInterface if you need to create additional references. If parser as a whole is not to your liking, you can completely replace it by implementing the Ast\ParserInterface. In either case the result of this step is an Ast\AstMap\AstMapInterface that contains all the references found in each file.

Not every reference automatically consist a dependency. To decide this, references are passed to dependency emitters implementing Dependency\DependencyEmitterInterface. If you find a reference in your AstMap that is not transformed into a dependency, you can create a custom emitter to do so. The result of this transformation is a dependency list Dependency\DependencyListInterface.

Every dependency (Dependency\DependencyInterface) in the list has a depender and a dependee. Both of them can belong to one or more layers that you specified in your configuration. But what if the current layer collector cannot satisfy your rules for what should belong to a layer? That's where Layer\CollectorInterface comes into play. You can create a custom implementation of this interface that can decide whether a token does or does not belong to your layer.

Once the layers for both the depender and the dependee tokens are known, we dispatch a Analyser\ProcessEvent. Now the task is to decide whether this particular dependency is allowed or not. To help you with that, you can implement the \Symfony\Component\EventDispatcher\EventSubscriberInterface and subscribe to the Analyser\ProcessEvent. The result of this processing for all dependencies is an Analysis\AnalysisResult.

Now, that you have the result of the analysis, the last step is to format the result. For this you have the option of implementing a custom output formatter using the OutputFormatter\OutputFormatterInterface.

To recap, the main extension points are: - Ast\ReferenceExtractorInterface to extract references from the AST - Ast\ParserInterface to replace the whole PHP parser - Dependency\DependencyEmitterInterface to transform references to dependencies - Layer\CollectorInterface to better define tokens that belong to a layer - Event subscribers to Analyser\ProcessEvent to decide whether a dependency is allowed or not - OutputFormatter\OutputFormatterInterface to customize how the results are displayed

As you can see, there are many ways you can customize Deptrac behavior.

Reference extractors

Reference extractors implement the Ast\ReferenceExtractorInterface and serve to create references from tokens parsed by the AST parser. Let's look at one of the default extractors that ship with Deptrac for an example:

use PhpParser\Node\Stmt\Catch_;

/**
 * @implements ReferenceExtractorInterface<Catch_>
 */
final class CatchExtractor implements ReferenceExtractorInterface
{
    public function __construct(private readonly TypeResolverInterface $typeResolver) {}

    public function processNode(Node $node, ReferenceBuilderInterface $referenceBuilder, TypeScope $typeScope): void
    {
        foreach ($this->typeResolver->resolvePHPParserTypes($typeScope, ...$node->types) as $classLikeName) {
            $referenceBuilder->dependency(ClassLikeToken::fromFQCN($classLikeName), $node->getLine(), DependencyType::CATCH);
        }
    }

    public function getNodeType(): string
    {
        return Catch_::class;
    }
}

Function getNodeType() specified for which Nikic PHP Parser types encountered in the code should this particular extractor be called for. Therefore, for every catch expression (from the try-catch) the processNode() function of this extractor will be called. The first parameter Node $node specifies the Nikic PHP parser node. In this case it is guaranteed to be \PhpParser\Node\Stmt\Catch_ because that is what we specified in getNodeType(). The second parameter, ReferenceBuilderInterface $referenceBuilder is the builder class where we define that a reference exists.

Dependencies come in 2 flavors: dependency() and astInherits(). dependency() is a direct dependency that exists in the code. astInherits() is a dependency that is created because the current source code "inherits" some source code from somewhere else. That happens in cases of classes implementing an interface, extending a class or using a trait (they also "inherit" the dependencies of the interface, parent class or the trait).

The third parameter of processNode(), TypeScope $typeScope provides you with the current type scope. It is aware of what use statements are in effect at the current point in code and can therefore resolve a class name to the Fully Qualified Class Name (FQCN).

Last piece of the puzzle is the TypeResolverInterface $typeResolver passed in the constructor. Type resolver is useful to resolve complex PHP types (like unions) to the individual class names that are part of the type.

If you want to write your own reference extractor, the best place to start is to take a look at the default reference extractors that Deptrac ships with in the \Deptrac\Deptrac\DefaultBehavior\Ast\Extractors namespace. You can find some advanced behaviour there as well, like how to deal with template types.

Lastly, don't forget to register it in the deptrac.config.php file:

return static function (DeptracConfig $config, ContainerConfigurator $containerConfigurator): void {
    $services = $containerConfigurator->services();
    $services->set(CustomExtractor::class)
        ->tag('reference_extractors');
}

AST Parser

It may be the case that the current AST parser implementation using Nikic PHP parser is insufficient to your needs. It might not capture all the nodes that you are interested in or does not provide all the context information for you to decide on a reference. Or the resolution capabilities are not as good as you might expect from for example PHPStan. In such case you have the option to replace the AST parser completely.

All you need to do is to implement the Ast\ParserInterface and its 2 methods: - parseFile(string $file): Ast\AstMap\FileReference - getMethodNamesForClassLikeReference(Ast\AstMap\ClassLikeReference $classReference): array

parseFile takes the path to a file a returns a Ast\AstMap\FileReference that consists of all the references found in that file. getMethodNamesForClassLikeReference should return all the method names found in a particular class-like.

Implementing a custom AST parser is quite a difficult thing to do, and we do not expect that it is something that most of the users would need to do. If you do, take a look at the default implementation that ships with Deptrac in the \Deptrac\Deptrac\DefaultBehavior\Ast\Parser namespace. It can serve as a template for your implementation.

Lastly, don't forget to register it in the deptrac.config.php file:

return static function (DeptracConfig $config, ContainerConfigurator $containerConfigurator): void {
    $services = $containerConfigurator->services();
    $services->alias(ParserInterface::class, CustomParser::class);
    $services->set(CustomParser::class);
}

There can be only one active parser at the time, so you are replacing it.

Dependency emitters

Dependency emitters transform the references found between tokens by the AST parser into a fully fledged dependencies that are recognized by Deptrac and that you are familiar from the output. Not all references automatically mean a dependency and also any reference can cause multiple dependencies (like a reference to a parent class via the extends keyword would cause a dependency for every dependency in the parent class).

If you want to create a custom dependency emitter, you need to implement the Dependency\DependencyEmitterInterface. It has only 2 methods - getName() to give it a name that you can reference in your configuration using the AnalyserConfig->types() to decide whether the emitter should be used and applyDependencies(Ast\AstMap\AstMapInterface $astMap, Dependency\DependencyListInterface $dependencyList): void.

The usage is relatively simple. Iterate over all the references in the Ast\AstMap\AstMapInterface and when you decide that the reference should cause a dependency, add it to Dependency\DependencyListInterface. For inspiration, you can take a look at the default Deptrac emitters in the \Deptrac\DefaultBehavior\Dependency namespace. Once you have your emitter, don't forget to register it in your deptrac.config.php file:

return static function (DeptracConfig $config, ContainerConfigurator $containerConfigurator): void {
    $services = $containerConfigurator->services();
    $services
        ->set(CustomDependencyEmitter::class)
        ->tag('dependency_emitter', ['key' => 'emitter_name_for_config'])
    ;
}

Layer collectors

Layer collectors help you decide whether a particular token should be part of your configured layer. Deptrac already ships with an extensive collection of collectors for you to use. But if you need something more custom, you have the option of adding your own by implementing the Layer\CollectorInterface. It has only one function to implement satisfy(array $config, Ast\AstMap\TokenReferenceInterface $reference): bool.

The $config parameters passes whatever configuration you have given in the deptrac.config.php file to you and the $reference is the token for whom you should decide whether it should be a part of the layer or not. Also don't forget that you can throw one of the specified exceptions defined in the interface if you are for some reason not able to make the decision.

Once you have your collector ready, don't forget to register it in your deptrac.config.php file:

return static function (DeptracConfig $config, ContainerConfigurator $containerConfigurator): void {
    $services = $containerConfigurator->services();
    $services
        ->set(CustomCollector::class)
        ->tag('collector', ['type' => 'collector_name_to_be_used_in_config'])
    ;
}

As always, you can look at the default collectors in the \Deptrac\Deptrac\DefaultBehavior\Layer namespace for inspiration.

Analyser event subscribers

Analyser events are published for every dependency and its subscribers decide whether the dependencies are allowed or not. There are two events that you can subscribe to: - Analyser\ProcessEvent that is published for every dependency - Analyser\PostProcessEvent that is published once all dependencies are processed

All of your subscribers have to implement the \Symfony\Component\EventDispatcher\EventSubscriberInterface.

Let's look at one of the default Deptrac event subscribers to see how it works:

use Deptrac\Deptrac\Contract\Analyser\EventHelper;
use Deptrac\Deptrac\Contract\Analyser\ProcessEvent;
use Deptrac\Deptrac\Contract\Analyser\ViolationCreatingInterface;

final class DependsOnPrivateLayer implements ViolationCreatingInterface
{
    public function __construct(private readonly EventHelper $eventHelper) {}

    public static function getSubscribedEvents()
    {
        return [
            ProcessEvent::class => ['invoke', -3],
        ];
    }

    public function invoke(ProcessEvent $event): void
    {
        $ruleset = $event->getResult();

        foreach ($event->dependentLayers as $dependentLayer => $isPublic) {
            if ($event->dependerLayer !== $dependentLayer && !$isPublic) {
                $this->eventHelper->addSkippableViolation($event, $ruleset, $dependentLayer, $this);
                $event->stopPropagation();
            }
        }
    }

    public function ruleName(): string
    {
        return 'DependsOnPrivateLayer';
    }

    public function ruleDescription(): string
    {
        return 'You are depending on a part of a layer that was defined as private to that layer and you are not part of that layer.';
    }
}

Analyser\ViolationCreatingInterface extends the \Symfony\Component\EventDispatcher\EventSubscriberInterface and adds two new methods: ruleName(): string and ruleDescription(): string Any subscriber that can create rule violations SHALL implement this interface. The reason is to provide end user with a nice UX where they can clearly see why the violation occurred. This is especially important when there are multiple subscribers not no-trivial rules.

getSubscribedEvents() specifies that we are listening to the Analyser\ProcessEvent and when we encounter it to call the invoke method on this class. Lastly the number decides in what order should this subscriber be called with respect to other subscribers. For more details, look at Symfony documentation.

Post process event is dispatched only once when all the dependency processing is done. It allows you to make last minute changes to the result of rule applications.

For example, you can specify (as Deptrac already does) that all baseline entries that were not matched to a violation should be Errors:

    public function invoke(PostProcessEvent $event): void
    {
        $ruleset = $event->getResult();

        foreach ($this->eventHelper->unmatchedSkippedViolations() as $tokenA => $tokensB) {
            foreach ($tokensB as $tokenB) {
                $ruleset->addError(new Error(sprintf('Skipped violation "%s" for "%s" was not matched.', $tokenB, $tokenA)));
            }
        }
    }

As always, when creating event subscribers, the \Deptrac\Deptrac\DefaultBehavior namespace is your friend, in this case the \Deptrac\Deptrac\DefaultBehavior\Analyser. It contains the default implementations that ship with Deptrac and you can use them as template for your own and to know when the default ones are called to correctly schedule your own implementation between them.

And do not forget to register your subscriber in the deptrac.config.php file:

return static function (DeptracConfig $config, ContainerConfigurator $containerConfigurator): void {
    $services = $containerConfigurator->services();
    $services->set(CustomSubscriber::class)
        ->tag('kernel.event_subscriber');
}

Output formatter

Output formatter allows you to transform how the result of the rule application to the dependencies is displayed to you. If none of the default implementations in the \Deptrac\Deptrac\DefaultBehavior\OutputFormatter namespace is to your liking, you can create a custom one by implementing the OutputFormatter\OutputFormatterInterface.

This interface has only two methods to implement: getName(): string that specifies the name of the formatter that will be later used as the CLI parameter and finish(OutputResult $result, OutputInterface $output, OutputFormatterInput $outputFormatterInput): void.

The finish() method is where you will do all of your outputting. To help you, you are given the result of the rule application ready for outputting in OutputResult $result, CLI output helper in OutputInterface $output and all the optional formatter parameters in OutputFormatterInput $outputFormatterInput.

Once your implementation is complete, don't forget to register it in the deptrac.config.php file:

return static function (DeptracConfig $config, ContainerConfigurator $containerConfigurator): void {
    $services = $containerConfigurator->services();
    $services
        ->set(CustomFormatter::class)
        ->tag('output_formatter')
    ;
}

You can call your formatter by using the -f or --formatter CLI flag with the name you defined in the getName() method of your formatter.

Baseline mapper

Connected to output formatting is also creating a baseline. One of the default Deptrac formatters can create a baseline from the exiting Violations. If you want to change the way this formatter stores the baseline information, you can create a custom implementation of the OutputFormatter\BaselineMapperInterface.

interface BaselineMapperInterface
{
    /**
     * Maps a grouped list of violations to a format that will be stored to a
     * file by the `baseline` formatter.
     *
     * @param array<string,list<string>> $groupedViolations
     */
    public function fromPHPListToString(array $groupedViolations): string;

    /**
     * Load the existing violation to ignore by custom mapper logic.
     *
     * @return array<string,list<string>>
     */
    public function loadViolations(): array;
}

All you need to do is to be able to transform the existing list of violation to and from a PHP array to a string that can be stored to a file. Once done, don't forget to register you custom mapper in the deptrac.config.php file:

return static function (DeptracConfig $config, ContainerConfigurator $containerConfigurator): void {
    $services = $containerConfigurator->services();
    $services->set(CustomMapper::class)
        ->args([
            '$skippedViolations' => param('skip_violations'),
        ])
    ;
    $services->alias(BaselineMapperInterface::class, CustomMapper::class);
}

There can be only one active mapper at the time, so you are replacing it.