Custom Requirements
Attribute routing covers the vast majority of applications, but it hits its limits once routes need to come from a database at runtime, for example for CMS pages or redirects. This article shows how a custom loader covers those cases, how custom requirements validate parameters with regex right inside routing, and how to reliably control ordering when routes overlap.
Table of Contents
- 1. When standard attribute routing is no longer enough
- 2. The Loader base class: supports() and load()
- 3. Registering the loader as a service and wiring it into routes.yaml
- 4. Practical example: redirects and dynamic pages from the database
- 5. Custom requirements with regex for parameter validation
- 6. Disambiguating overlapping routes with requirements
- 7. Priority for overlapping routes
- 8. Route caching and performance with many dynamic routes
- 9. Testing custom loaders and summary
- 10. Summary
- 11. FAQ
1. When standard attribute routing is no longer enough
The #[Route] attribute on controller methods works great as long as the set of routes is known at compile time and does not change once the application is deployed. That covers the vast majority of classic application routes, but it fails once routes genuinely need to come into existence at runtime from business data, for example when editors in a CMS can create arbitrary URL paths for landing pages, or a redirect management module needs to route old URLs to new ones without a deployment for every single URL.
The routing component offers a dedicated extension point for exactly these cases: a custom loader, which is called alongside the usual sources such as attributes or YAML files when the route table is built, and which generates a RouteCollection from any data source. The loader mechanism is deliberately generic and is already used internally for the built-in loaders such as AttributeRouteLoader or YamlFileLoader, so a custom loader integrates seamlessly into the same infrastructure without needing special handling in the kernel or router.
2. The Loader base class: supports() and load()
A custom loader extends Symfony\Component\Config\Loader\Loader and implements two methods: supports() decides whether this loader is responsible for a given resource and an optional type string, while load() actually generates and returns the RouteCollection. The type parameter is entirely free to choose and only serves as a discriminator that routes.yaml uses to address this one loader specifically, with Symfony itself not attaching any fixed meaning to the string.
Inside load() you can use any dependency such as a repository or an HTTP client, because the loader is registered as a normal service in the container and is therefore supplied through regular constructor injection. It matters that load() returns a complete, internally consistent RouteCollection on every call, because Symfony typically caches the result for production use, so the loader does not get executed again on every single request.
<?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. Registering the loader as a service and wiring it into routes.yaml
Since the loader implements Symfony\Component\Config\Loader\LoaderInterface, it automatically gets tagged with routing.loader through the _instanceof configuration in services.yaml, as long as autoconfiguration is enabled, which it is by default in the standard Symfony skeleton. Manual tagging is therefore unnecessary in most projects, as long as the loader lives in the usual App\ namespace and gets registered automatically through services.yaml.
In the central routes.yaml, the loader is addressed through the type string previously defined in supports(), while resource is just an arbitrary placeholder meaningless to this loader, since load() does not even evaluate it in this example. This separation between the abstract resource declaration and the concrete type discriminator lets you run several different custom loaders side by side, without their supports() implementations getting in each other's way.
# config/routes.yaml
cms_pages:
resource: .
type: cms_page
controllers:
resource:
path: ../src/Controller/
namespace: App\Controller
type: attribute
4. Practical example: redirects and dynamic pages from the database
A typical use case besides CMS pages is a redirect table, where editors can point old URLs at new destinations without requiring developers to trigger a deployment for every new redirect. A RedirectRouteLoader reads all active entries from a redirects table for this and builds a route for each one with RedirectController as the target controller, passing the target URL as a default parameter, so the generic RedirectController already shipped with the Symfony framework bundle can be reused directly instead of writing custom controller logic.
One important practical aspect is that any change to the data source, such as a new redirect entry, only becomes visible in production after a router cache clear, because Symfony caches the compiled route table by default. For redirect or CMS management interfaces this means, in practice, automating a bin/console cache:clear, or specifically invalidating only the router cache, after saving a new entry, so new entries take effect immediately without a full deployment.
5. Custom requirements with regex for parameter validation
Requirements are regular expressions that determine, per route parameter, which values even count as valid before the route is considered a match at all. Without a requirement, a placeholder such as {id} by default matches almost any character sequence except the path separator, meaning that /product/abc would reach the same controller as /product/42, even though abc is obviously not a valid numeric ID. A requirement like id: '\d+' already rules out such cases at the routing level, so invalid values never even reach the controller to be incorrectly parsed as an integer there.
Requirements can be set both as the fourth named parameter of the #[Route] attribute and programmatically via Route::setRequirements() in a custom loader, which matters especially for dynamically generated routes, since attributes are not available there. Beyond simple type checks such as \d+ for numeric IDs, value ranges can also be expressed through alternation, for example locale: 'de|en|fr' for a fixed set of allowed language codes, which moves validation logic out of the controller and directly into the routing configuration.
<?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. Disambiguating overlapping routes with requirements
Requirements also solve a classic ambiguity problem when two routes structurally occupy the same path placeholder but carry different meaning, for example /blog/{slug} for human-readable URLs and /blog/{id} for a numeric legacy URL from an older system generation. Without requirements, Symfony would simply use whichever route was registered first and never reach the second, because both match the same path placeholder unspecifically. With id: '\d+' on the numeric route and a correspondingly restricted requirement on the slug route, for example slug: '[a-z0-9-]+(?
In practice a simple numeric requirement on the more specific route is usually enough, combined with a deliberate registration order that loads the more precise route before the more general one. It matters not to rely on requirements alone for ambiguous path patterns, but to also document which route is meant for which use case, since requirement regex patterns quickly become hard to read for more complex cases.
7. Priority for overlapping routes
As a basic rule, the routing component works like this: the first route in the final RouteCollection that matches a request wins, regardless of whether further, equally matching routes follow afterward. With attribute routing within a single controller, the order is usually the declaration order of the methods, but it can change unexpectedly across multiple controllers or bundles whose routes get loaded through directory scans, for example when the alphabetical file order on the filesystem shifts.
For exactly this case, the #[Route] attribute has offered a priority option since Symfony 5.4, letting you assign an explicit, numeric priority independent of declaration or loading order, with higher values evaluated before lower ones. Custom loaders have no equivalent automatic priority option, so there the order in which routes are added to the RouteCollection via add(), as well as the order of resource imports in routes.yaml, are the only levers available to control which route wins in case of doubt.
8. Route caching and performance with many dynamic routes
In production mode, Symfony compiles the entire RouteCollection into an optimized PHP matcher class stored in the var/cache directory, which means even several thousand routes, including those coming from a custom loader, cause no noticeable performance penalty compared to a handful of static routes. The actual cost does not lie in the routing match itself but in building the RouteCollection during compilation, which is why a custom loader that fetches tens of thousands of entries with a separate database query per route can noticeably slow down the cache-warmup process.
For very large sets of dynamic routes, for example tens of thousands of CMS pages, a deliberate architectural decision is worth making: either load every entry in a single, efficient database query instead of looping with N+1 queries, or alternatively switch to a generic catch-all controller with a single route placeholder that resolves the actual path inside the controller through a database lookup, instead of modeling every single page as its own route. The latter shifts the cost from cache warmup into the individual request, which is often the better balance for very large numbers of entries.
9. Testing custom loaders and summary
A custom loader can be tested in isolation by injecting a test double of the underlying repository with fixed test data into the loader and then calling load(), after which the returned RouteCollection can be checked for expected route names, paths and requirements, entirely without a kernel boot or a real database connection. In addition, a functional test is worthwhile that fetches the real router service from the container and calls match() for concrete test URLs, to make sure requirements and priorities actually work as expected together with every other registered route.
In summary, custom loaders extend the routing system exactly where static attribute routing hits its limits, namely for routes that arise from business data instead of code, while custom requirements move validation directly into the routing layer and reliably resolve ambiguity between similar path patterns. Combined with deliberate prioritization and an efficient data query inside the loader, the result is a routing system that stays maintainable and fast even with thousands of dynamic routes.
| Mechanism | Purpose | Defined where | Typical use |
|---|---|---|---|
| Custom loader (Loader base class) | Build a RouteCollection from any data source | Custom PHP class as a service | CMS pages, redirects from the database |
| Route requirement | Restrict valid values for a parameter | #[Route] attribute or Route::setRequirements() | Numeric IDs, fixed locale values |
| priority option | Control order among multiple matching attribute routes | #[Route] attribute | Deterministic choice among overlapping paths |
| Order within a custom loader | Control order among multiple matching dynamic routes | Order of add() calls | Specific routes before general dynamic routes |
Mironsoft
Symfony architecture, clean domain logic, and legacy modernization
Symfony applications that stay maintainable two years down the line?
We review existing Symfony projects for bloated controllers, missing service abstractions, and untested core logic, then build an architecture that absorbs new features without getting more fragile with every release.
Architecture Review
Checking bundle structure, dependency injection, and service abstractions for maintainability.
Legacy Modernization
Incrementally migrating outdated Symfony versions without a full rewrite.
Testing and Quality Assurance
Setting up PHPUnit, PHPStan, and CI pipelines for lasting code quality.
10. Summary
Routing Loaders: The Essentials at a Glance
Core problem
Attribute routing only works for routes known at compile time, not for database-driven URLs.
Solution
A custom loader extends Loader, implements supports() and load(), and is wired in via type in routes.yaml.
Parameter validation
Requirements check parameter values with regex directly in routing, before the controller is even reached.
Priority
The first matching route wins, controllable via priority on attributes or ordering inside the loader.