Symfony Routing: Custom Loaders and Custom Requirements
AI generated
SF
{ }
Symfony · Routing · Architecture
Routing: Custom Loaders and
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.

17 min read Custom Loader Route Requirements

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.

11. FAQ: Routing Loaders: The Essentials at a Glance

1When do I need a custom routing loader instead of attribute routing?
Whenever routes need to come into existence at runtime from business data, for example CMS pages with freely chosen URL paths or a redirect management module where new redirects should take effect without a deployment.
2Do I have to register my custom loader as a service manually?
In most projects, no, because classes implementing LoaderInterface that live in the App\ namespace are automatically tagged with routing.loader through the standard services.yaml configuration.
3What exactly does the type string in routes.yaml mean?
The type string is a freely chosen discriminator that the respective loader evaluates in its supports() method. It has no fixed meaning to Symfony itself, and only serves to identify the right loader for an entry in routes.yaml.
4What happens when a parameter does not satisfy the defined requirement?
The route is considered not to match for that request, so the router continues with the next, possibly matching route, or returns a 404 response if none matches, instead of calling the controller with an invalid value.
5Can I set requirements on dynamically generated routes from a custom loader too?
Yes, via Route::setRequirements() or directly as a constructor parameter when creating the route instance inside the loader, just like with the #[Route] attribute, only programmatically instead of declaratively.
6How do I resolve ambiguity between two routes with a similar path pattern?
Most reliably through a restrictive requirement on the more specific route, for example a numeric pattern for an ID-based route, combined with a deliberate registration order that loads the more precise route first.
7What does the priority option on the #[Route] attribute do?
It allows an explicit, numeric priority independent of declaration or loading order, with higher values evaluated before lower ones. This is especially useful for routes coming from multiple bundles or directory scans, where the natural order would otherwise not be deterministic.
8Do thousands of dynamic routes from a custom loader slow the application down?
The actual routing match stays fast thanks to the compiled matcher class. What is more critical is the cache-warmup process, if the loader builds the routes inefficiently, for example through N+1 database queries.
9How do new entries from the database become visible in the router?
Only after a router cache clear, because Symfony caches the compiled route table in production mode. Management interfaces for dynamic routes should therefore automatically invalidate the relevant cache after saving.
10How do I test a custom loader without a real database?
The simplest way is injecting a test double for the underlying repository with fixed test data, calling load(), and then checking the returned RouteCollection for expected route names, paths and requirements.