Bei der Entwicklung mit Symfony-basierten Lösungen kann eine abweichende Verzeichnisstruktur zu interessanten Problemen führen. Entitäten außerhalb der normalen Struktur werden weder von Doctrine noch von API Platform berücksichtigt.

In diesem konkreten Fall schauen wir uns eine abweichende Verzeichnisstruktur an. Sei es wegen Domain-driven Design, der Größe des Projekts oder der generellen Abneigung, Verzeichnisse zu ändern, die auf Typen und nicht auf Topics basieren.

Einleitung

Wer schon länger mit Symfony arbeitet, denkt wahrscheinlich sofort an die alte Bundle-Struktur, aber hier geht es nicht um Bundles und die damit verbundene Logik. Hier geht es nur um die Struktur in der die logischen Teile der gesamten Anwendung abgelegt werden, nicht in welchen Abhängigkeiten und Auskopplungen sie existieren können. Wir wollen unsere Verzeichnisse sortieren, nicht abstrakte Abhängigkeiten einführen und zusätzlichen Verwaltungsaufwand schaffen.

Hier sind einige Auszüge von Entitäten, die ich in thematischen Verzeichnissen sortiert haben möchte:

# Alte Struktur
src/Entity/ArchivedEmail.php
src/Entity/AuditEntry.php
src/Entity/BillingReport.php
src/Entity/GdprExpiryPointer.php
src/Entity/Invoice.php
src/Entity/IpBlock.php
src/Entity/Payment.php

# Neue Struktur
src/Auditing/AuditEntry.php
src/Billing/Entity/BillingReport.php
src/Billing/Entity/Invoice.php
src/Billing/Entity/Payment.php
src/Blocklisting/Entity/IpBlock.php
src/Mailing/Entity/ArchivedEmail.php
src/Gdpr/Entity/GdprExpiryPointer.php

Mir persönlich hilft es, alle Teile eines Themas zu gruppieren, Messages und Events sind nahe beim Handler, Entities und Repositories beschränken sich auf das Thema. Eine schnelle Navigation innerhalb des Themas ist möglich, das Sichtfeld beschränkt sich auf das Wesentliche und Tests können ebenfalls themenbasiert abgelegt werden.

Allerdings gibt es hier zwei Standardkonfigurationen die nicht ganz mitspielen:

  • Doctrine kennt nur src/Entity.
  • API Platform schaut nur in src/Entity nach ApiResource-Attributen.

Anhand der Konfiguration lernen wir schnell, dass alles manuell konfiguriert werden kann.

Für Doctrine können wir das Mapping in doctrine.orm.entity_managers pflegen.

Für die API-Platform können wir die Pfade in der Konfiguration unter api_platform.mapping.paths pflegen.

In einem Framework das über eine vollständige Dependency Injection und Compiler Passes verfügt, ein seltsam anmutender Mehraufwand der vollständig umgangen werden kann.

Anpassung von Doctrine

Der Einfachheit halber gehen wir davon aus, dass wir nur einen Entity Manager verwalten, der auch in der Standardkonfiguration ausgeliefert wird.

Wir können eine Liste aller erkannten Mappings aufrufen. Dies hilft uns die Funktionalität und unseren Fortschritt zu überprüfen:

bin/console doctrine:mapping:info

Gehen wir die Anpassungen durch:

In der config/packages/doctrine.yaml kommentieren wir zunächst den gesamten doctrine.orm.entity_managers.default.mappings-Block aus:

doctrine:
  orm:
    entity_managers:
      default:
        auto_mapping: true
        # mappings:
        # ...
```

Durch diese Änderung sollten wir alle bisherigen Mappings verlieren, auch solche die noch in src/Entity verblieben sind.

Als nächstes verwenden wir Doctrines eigenen DoctrineOrmMappingsPass für die Registrierung nach unseren eigenen Wünschen. Dazu passen wir unser src/Kernel.php an.

class Kernel extends BaseKernel {
    # ...
    
    protected function build(ContainerBuilder $container): void {
        # ...

        $this->addDoctrineOrmMappings($container);
    }

    private function addDoctrineOrmMappings(ContainerBuilder $container): void
    {
        $finder = Finder::create()
            ->in($container->getParameter('kernel.project_dir').'/src')
            ->name('Entity')
            ->directories();

        $namespaces = [];
        $directories = [];

        foreach ($finder as $dir) {
            $namespace = str_replace('/', '\\', 'App\\' . $dir->getRelativePathname());
            $namespaces[] = $namespace;
            $directories[] = $dir->getPathname();
        }

        $container->addCompilerPass(DoctrineOrmMappingsPass::createAttributeMappingDriver(
            $namespaces,
            $directories,

            // hotfix for https://github.com/doctrine/orm/pull/10455
            reportFieldsWhereDeclared: true
        ));
    }
}

Anpassung der API-Platform

Diese Anpassung ist einfacher, da hier ein einfacher Compiler-Pass erstellt werden kann.

Diesen legen wir in src/DependencyInjection/Compiler/RegisterDynamicResourceClassDirectoriesPass.php ab. Dabei wird die gleiche Idee wie bei Doctrine verwendet:

<?php

namespace App\DependencyInjection\Compiler;

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\Finder\Finder;

class RegisterDynamicResourceClassDirectoriesPass implements CompilerPassInterface
{
    private const string ParamName = 'api_platform.resource_class_directories';

    public function process(ContainerBuilder $container): void
    {
        $finder = Finder::create()
            ->in($container->getParameter('kernel.project_dir').'/src')
            ->name('Entity')
            ->directories();

        $directories = array_map(fn (\SplFileInfo $dir) => $dir->getPathname(), iterator_to_array($finder));

        if ($container->hasParameter(self::ParamName)) {
            $container->setParameter(self::ParamName,
                array_unique(array_merge(
                    $container->getParameter(self::ParamName),
                    $directories,
                ))
            );
        } else {
            $container->setParameter(self::ParamName, $directories);
        }
    }
}

Nun müssen wir nur noch dafür sorgen, dass unser Kernel diesen neuen Compiler-Pass auch verwendet. Dazu nehmen wir folgende Änderung in der src/Kernel.php vor:

# ...
use App\DependencyInjection\Compiler\RegisterDynamicResourceClassDirectoriesPass;

class Kernel extends BaseKernel {
    # ...
    
    protected function build(ContainerBuilder $container): void {
        # ...

        $container->addCompilerPass(new RegisterDynamicResourceClassDirectoriesPass());
    }
}

Ein Aufruf unserer API-Route /api/docs sollte nun alle API-Ressourcen wieder anzeigen.