Custom Requirements
Attribute-Routing deckt den weitaus größten Teil aller Anwendungen ab, stößt aber an seine Grenzen, sobald Routen zur Laufzeit aus einer Datenbank stammen, etwa für CMS-Seiten oder Redirects. Dieser Artikel zeigt, wie ein eigener Loader solche Fälle abdeckt, wie Custom-Requirements mit Regex Parameter direkt im Routing validieren und wie sich die Reihenfolge bei überlappenden Routen zuverlässig steuern lässt.
Inhaltsverzeichnis
- 1. Wann Standard-Attribute-Routing nicht mehr ausreicht
- 2. Die Loader-Basisklasse: supports() und load()
- 3. Loader als Service registrieren und in routes.yaml einbinden
- 4. Praxisbeispiel: Redirects und dynamische Seiten aus der Datenbank
- 5. Custom-Requirements mit Regex für Parameter-Validierung
- 6. Mehrdeutige Routen über Requirements auseinanderhalten
- 7. Priorisierung bei überlappenden Routen
- 8. Route-Caching und Performance bei vielen dynamischen Routen
- 9. Testing von Custom Loaders und Fazit
- 10. Zusammenfassung
- 11. FAQ
1. Wann Standard-Attribute-Routing nicht mehr ausreicht
Das #[Route]-Attribut auf Controller-Methoden funktioniert hervorragend, solange die Menge der Routen zur Compile-Zeit feststeht und sich beim Deployment der Anwendung nicht mehr ändert. Das trifft auf die große Mehrheit klassischer Anwendungsrouten zu, versagt aber sobald Routen tatsächlich zur Laufzeit aus fachlichen Daten entstehen sollen, etwa wenn Redakteure in einem CMS beliebige URL-Pfade für Landingpages anlegen können oder ein Redirect-Management-Modul alte URLs auf neue umleiten soll, ohne dass für jede einzelne URL ein Deployment nötig wäre.
Für genau diese Fälle bietet die Routing-Component einen Erweiterungspunkt: einen eigenen Loader, der beim Aufbau der Routentabelle zusätzlich zu den üblichen Quellen wie Attributen oder YAML-Dateien aufgerufen wird und eine RouteCollection aus einer beliebigen Datenquelle generiert. Der Loader-Mechanismus ist bewusst generisch gehalten und dient intern bereits für die eingebauten Loader wie den AttributeRouteLoader oder den YamlFileLoader, sodass sich ein eigener Loader nahtlos in dieselbe Infrastruktur einfügt, ohne Sonderbehandlung im Kernel oder Router zu benötigen.
2. Die Loader-Basisklasse: supports() und load()
Ein eigener Loader erweitert Symfony\Component\Config\Loader\Loader und implementiert zwei Methoden: supports() entscheidet, ob dieser Loader für eine gegebene Ressource und einen optionalen type-String zuständig ist, während load() die eigentliche RouteCollection erzeugt und zurückgibt. Der type-Parameter ist dabei frei wählbar und dient lediglich als Unterscheidungsmerkmal, über das routes.yaml gezielt diesen einen Loader anspricht, ohne dass Symfony selbst eine feste Bedeutung für diesen String vorgibt.
Innerhalb von load() lassen sich beliebige Abhängigkeiten wie ein Repository oder ein HTTP-Client nutzen, weil der Loader als normaler Service im Container registriert und damit ganz regulär per Constructor-Injection versorgt wird. Wichtig ist, dass load() bei jedem Aufruf eine vollständige, in sich konsistente RouteCollection zurückgibt, weil Symfony das Ergebnis für den produktiven Betrieb in der Regel cacht und der Loader dadurch nicht bei jedem einzelnen Request erneut ausgeführt wird.
<?php
// src/Routing/CmsPageRouteLoader.php
declare(strict_types=1);
namespace App\Routing;
use App\Repository\CmsPageRepository;
use Symfony\Component\Config\Loader\Loader;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
final class CmsPageRouteLoader extends Loader
{
public function __construct(
private readonly CmsPageRepository $pages,
) {
}
public function load(mixed $resource, ?string $type = null): RouteCollection
{
$collection = new RouteCollection();
foreach ($this->pages->findAllPublished() as $page) {
$route = new Route(
path: $page->getUrlPath(),
defaults: ['_controller' => 'App\Controller\CmsPageController::show', 'page' => $page->getId()],
);
$collection->add('cms_page_' . $page->getId(), $route);
}
return $collection;
}
public function supports(mixed $resource, ?string $type = null): bool
{
return $type === 'cms_page';
}
}
3. Loader als Service registrieren und in routes.yaml einbinden
Da der Loader Symfony\Component\Config\Loader\LoaderInterface implementiert, wird er dank der _instanceof-Konfiguration in services.yaml automatisch mit dem Tag routing.loader versehen, sofern der Autoconfigure-Mechanismus aktiv ist, was im Standard-Symfony-Skeleton bereits der Fall ist. Eine manuelle Tag-Vergabe ist also in den meisten Projekten nicht nötig, solange sich der Loader im üblichen App\-Namespace befindet und über services.yaml automatisch registriert wird.
In der zentralen routes.yaml wird der Loader über den zuvor in supports() definierten type-String angesprochen, wobei resource lediglich ein beliebiger, für diesen Loader bedeutungsloser Platzhalter ist, weil load() ihn im Beispiel gar nicht auswertet. Diese Trennung zwischen der abstrakten Ressourcenangabe und dem konkreten type-Diskriminator erlaubt es, mehrere unterschiedliche Custom-Loader nebeneinander zu betreiben, ohne dass sich ihre supports()-Implementierungen gegenseitig ins Gehege kommen.
# config/routes.yaml
cms_pages:
resource: .
type: cms_page
controllers:
resource:
path: ../src/Controller/
namespace: App\Controller
type: attribute
4. Praxisbeispiel: Redirects und dynamische Seiten aus der Datenbank
Ein typischer Anwendungsfall neben CMS-Seiten sind Redirect-Tabellen, in denen Redakteure alte URLs auf neue Ziele umleiten können, ohne dass Entwickler dafür bei jeder neuen Umleitung ein Deployment auslösen müssen. Ein RedirectRouteLoader liest dazu alle aktiven Einträge aus einer Redirects-Tabelle und erzeugt für jeden eine Route mit RedirectController als Ziel-Controller, wobei die Ziel-URL als Default-Parameter mitgegeben wird, sodass der generische RedirectController aus dem Symfony-Framework-Bundle direkt wiederverwendet werden kann, statt eine eigene Controller-Logik zu schreiben.
Ein wichtiger praktischer Aspekt ist, dass jede Änderung an der Datenquelle, etwa ein neuer Redirect-Eintrag, in Produktion erst nach einem Cache-Clear des Routers sichtbar wird, weil Symfony die kompilierte Routentabelle standardmäßig cacht. Für Redirect- oder CMS-Verwaltungsoberflächen bedeutet das in der Praxis, nach dem Speichern eines neuen Eintrags automatisiert bin/console cache:clear oder gezielt nur den Router-Cache zu invalidieren, damit neue Einträge ohne vollständiges Deployment sofort wirksam werden.
5. Custom-Requirements mit Regex für Parameter-Validierung
Requirements sind reguläre Ausdrücke, die pro Routenparameter festlegen, welche Werte überhaupt als gültig gelten, bevor die Route als passend betrachtet wird. Ohne Requirement matcht ein Platzhalter wie {id} standardmäßig fast beliebige Zeichenfolgen außer dem Pfadtrenner, was dazu führt, dass etwa /product/abc denselben Controller anspricht wie /product/42, obwohl abc offensichtlich keine gültige numerische ID ist. Ein Requirement wie id: '\d+' schließt solche Fälle bereits auf Routing-Ebene aus, sodass ungültige Werte gar nicht erst im Controller ankommen und dort fehlerhaft als Integer geparst werden müssen.
Requirements lassen sich sowohl im #[Route]-Attribut als vierter benannter Parameter als auch programmatisch über Route::setRequirements() im eigenen Loader setzen, was für dynamisch erzeugte Routen besonders relevant ist, weil dort keine Attribute zur Verfügung stehen. Neben einfachen Typprüfungen wie \d+ für numerische IDs lassen sich auch Wertebereiche über Alternativen abbilden, etwa locale: 'de|en|fr' für eine feste Menge erlaubter Sprachcodes, was Validierungslogik aus dem Controller direkt in die Routing-Konfiguration verlagert.
<?php
// src/Controller/ProductController.php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class ProductController
{
#[Route(
path: '/{_locale}/product/{id}',
name: 'product_show',
requirements: ['id' => '\d+', '_locale' => 'de|en'],
defaults: ['_locale' => 'de'],
)]
public function show(int $id, string $_locale): Response
{
// ...
return new Response();
}
}
6. Mehrdeutige Routen über Requirements auseinanderhalten
Requirements lösen zudem ein klassisches Mehrdeutigkeitsproblem, wenn zwei Routen strukturell denselben Pfad-Platzhalter belegen, inhaltlich aber unterschiedliche Bedeutung haben, etwa /blog/{slug} für sprechende URLs und /blog/{id} für eine numerische Legacy-URL aus einer alten Systemgeneration. Ohne Requirements würde Symfony schlicht die zuerst registrierte Route verwenden und die zweite niemals erreichen, weil beide denselben Pfad-Platzhalter unspezifisch matchen. Mit id: '\d+' auf der numerischen Route und einem entsprechend eingeschränkten Requirement auf der Slug-Route, etwa slug: '[a-z0-9-]+(?
In der Praxis reicht meist schon ein einfaches numerisches Requirement auf der spezifischeren Route, kombiniert mit einer bewusst gewählten Registrierungsreihenfolge, in der die präzisere Route vor der allgemeineren geladen wird. Wichtig ist, sich bei mehrdeutigen Pfad-Mustern nicht ausschließlich auf Requirements zu verlassen, sondern zusätzlich zu dokumentieren, welche Route für welchen Anwendungsfall gedacht ist, weil sich Requirement-Regex-Muster bei komplexeren Fällen schnell schwer lesbar werden.
7. Priorisierung bei überlappenden Routen
Grundsätzlich gilt für die Routing-Component: Die erste Route in der finalen RouteCollection, die auf eine Anfrage passt, gewinnt, unabhängig davon, ob weitere, ebenfalls passende Routen danach folgen. Bei Attribute-Routing innerhalb eines einzelnen Controllers ist die Reihenfolge meist die Deklarationsreihenfolge der Methoden, kann sich aber bei mehreren Controllern oder Bundles, deren Routen über Verzeichnis-Scans geladen werden, unerwartet ändern, etwa wenn sich die alphabetische Dateireihenfolge im Dateisystem ändert.
Für genau diesen Fall bietet das #[Route]-Attribut seit Symfony 5.4 die priority-Option, über die sich unabhängig von der Deklarations- oder Ladereihenfolge eine explizite, numerische Priorität vergeben lässt, wobei höhere Werte vor niedrigeren ausgewertet werden. Bei eigenen Loadern gibt es keine äquivalente automatische priority-Option, weshalb dort die Reihenfolge, in der Routen der RouteCollection per add() hinzugefügt werden, sowie die Reihenfolge der resource-Imports in routes.yaml die einzigen Stellschrauben sind, um zu steuern, welche Route im Zweifel gewinnt.
8. Route-Caching und Performance bei vielen dynamischen Routen
Symfony kompiliert die gesamte RouteCollection im Produktionsmodus zu einer optimierten PHP-Matcher-Klasse, die im var/cache-Verzeichnis abgelegt wird, wodurch selbst mehrere tausend Routen, inklusive der aus einem Custom Loader stammenden, keinen spürbaren Performance-Nachteil gegenüber wenigen statischen Routen verursachen. Der eigentliche Kostenpunkt liegt nicht im Routing-Matching selbst, sondern im Aufbau der RouteCollection beim Kompilieren, weshalb ein Custom Loader, der zehntausende Einträge per einzelner Datenbankabfrage pro Route lädt, den Cache-Warmup-Vorgang spürbar verlangsamen kann.
Bei sehr großen dynamischen Routenmengen, etwa zehntausenden CMS-Seiten, lohnt sich deshalb eine bewusste Architekturentscheidung: entweder alle Einträge in einer einzigen, effizienten Datenbankabfrage laden statt in einer Schleife mit N+1-Zugriffen, oder alternativ auf einen generischen Catch-all-Controller mit einem einzelnen Routen-Platzhalter umsteigen, der die eigentliche Auflösung des Pfads erst innerhalb des Controllers per Datenbankabfrage vornimmt, statt jede einzelne Seite als eigene Route abzubilden. Letzteres verschiebt die Kosten vom Cache-Warmup in den einzelnen Request, was bei sehr vielen Einträgen oft die bessere Balance ist.
9. Testing von Custom Loaders und Fazit
Ein Custom Loader lässt sich isoliert testen, indem eine Testdouble-Implementierung des zugrunde liegenden Repositorys mit fest definierten Testdaten in den Loader injiziert und anschließend load() aufgerufen wird, wonach sich die zurückgegebene RouteCollection auf erwartete Routennamen, Pfade und Requirements prüfen lässt, vollständig ohne Kernel-Boot oder echte Datenbankverbindung. Ergänzend empfiehlt sich ein funktionaler Test, der den echten Router-Service aus dem Container holt und für konkrete Test-URLs match() aufruft, um sicherzustellen, dass Requirements und Prioritäten im Zusammenspiel mit allen anderen registrierten Routen tatsächlich wie erwartet funktionieren.
Zusammengefasst erweitern eigene Loader das Routing-System genau dort, wo statisches Attribute-Routing an seine Grenzen stößt, nämlich bei Routen, die aus fachlichen Daten statt aus Code entstehen, während Custom-Requirements Validierung direkt an die Routing-Ebene verlagern und Mehrdeutigkeiten zwischen ähnlichen Pfad-Mustern zuverlässig auflösen. In Kombination mit einer bewussten Priorisierung und einer performanten Datenabfrage im Loader entsteht so ein Routing-System, das auch bei tausenden dynamischen Routen wartbar und schnell bleibt.
| Mechanismus | Zweck | Wo definiert | Typischer Einsatz |
|---|---|---|---|
| Custom Loader (Loader-Basisklasse) | RouteCollection aus beliebiger Datenquelle erzeugen | Eigene PHP-Klasse als Service | CMS-Seiten, Redirects aus der Datenbank |
| Route-Requirement | Gültige Werte für einen Parameter einschränken | #[Route]-Attribut oder Route::setRequirements() | Numerische IDs, feste Locale-Werte |
| priority-Option | Reihenfolge bei mehreren passenden Attribute-Routen steuern | #[Route]-Attribut | Deterministische Auswahl bei überlappenden Pfaden |
| Reihenfolge im Custom Loader | Reihenfolge bei mehreren passenden dynamischen Routen steuern | Reihenfolge der add()-Aufrufe | Spezifische vor allgemeinen dynamischen Routen |
Mironsoft
Symfony-Architektur, saubere Domänenlogik und Legacy-Modernisierung
Symfony-Anwendungen, die nach zwei Jahren noch wartbar sind?
Wir prüfen bestehende Symfony-Projekte auf aufgeblähte Controller, fehlende Serviceabstraktionen und ungetestete Kernlogik und bauen daraus eine Architektur, die neue Features aufnimmt, ohne bei jedem Release brüchiger zu werden.
Architektur-Review
Bundle-Struktur, Dependency Injection und Serviceabstraktionen auf Wartbarkeit prüfen.
Legacy-Modernisierung
Schrittweise Migration veralteter Symfony-Versionen ohne kompletten Neuschrieb.
Test- und Qualitätssicherung
PHPUnit, PHPStan und CI-Pipelines für nachhaltige Codequalität aufbauen.
10. Zusammenfassung
Routing-Loader: Das Wichtigste auf einen Blick
Kernproblem
Attribute-Routing funktioniert nur für zur Compile-Zeit bekannte Routen, nicht für datenbankgetriebene URLs.
Lösung
Ein eigener Loader erweitert Loader, implementiert supports() und load() und wird über type in routes.yaml eingebunden.
Parameter-Validierung
Requirements prüfen Parameterwerte per Regex direkt im Routing, bevor der Controller überhaupt erreicht wird.
Priorität
Erste passende Route gewinnt, steuerbar über priority bei Attributen oder Reihenfolge im Loader.