Requirements and Priority

Constrain a placeholder with a regular expression, and use priority when two routes could match the same URL.

Syntax#[Route('/articles/{id<\d+>}', name: 'app_article_show', priority: 0)]

A placeholder matches any segment by default. That is too loose. /articles/{id} will happily match /articles/banana and pass banana to a method expecting a number. A requirement is a regular expression that a placeholder must satisfy before the route matches at all. If it fails, the router moves on to the next route, and if nothing matches you get a 404.

Shorthand and longhand

The shorthand puts the pattern inside the braces, between angle brackets: {id<\d+>}. The longhand passes a requirements array keyed by placeholder name: requirements: ['id' => '\d+']. They compile to the same route. Combined with a default, the full inline form is {name<pattern>?default}.

Why /{id} and /new collide

With /articles/{id} and /articles/new both declared, /articles/new matches whichever was loaded first. Within one class, methods load top to bottom; across classes the order depends on how files are read, so it is not something to depend on.

There are two fixes. The better one is a requirement: {id<\d+>} cannot match new, so the ambiguity disappears. The other is priority, an integer on the #[Route] attribute, default 0. Higher priority is tried first, so priority: 1 on the /articles/new route wins regardless of load order.

What trips people up

A requirement is matched against the placeholder only, and the pattern is anchored. Writing .+ as a requirement lets that placeholder swallow slashes and the rest of the URL with them.

Example

Example · php
<?php
// src/Controller/ArticleController.php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class ArticleController extends AbstractController
{
    // priority defaults to 0; 1 means this route is tried first,
    // so it cannot be shadowed by the /{id} route below.
    #[Route('/articles/new', name: 'app_article_new', priority: 1, methods: ['GET'])]
    public function new(): Response
    {
        return new Response('<h1>New article</h1>');
    }

    // Shorthand requirement: {id<\d+>} matches digits only.
    // Longhand for the same route:
    // #[Route('/articles/{id}', name: 'app_article_show',
    //     requirements: ['id' => '\d+'])]
    #[Route('/articles/{id<\d+>}', name: 'app_article_show', methods: ['GET'])]
    public function show(int $id): Response
    {
        return new Response(sprintf('Article %d', $id));
    }
}

When to use it

  • Stop /articles/{id} from matching a non-numeric id and reaching a method typed for int.
  • Keep an /articles/new form reachable when /articles/{id} is declared in the same class.
  • Restrict a path segment to a fixed set of values such as daily|weekly|monthly.

More examples

An enumerated requirement

A requirement is an ordinary regular expression, so alternation works. `GET /reports/weekly` returns 200; `GET /reports/hourly` returns 404 without the controller ever running. Use this for a small closed set of values that belong in the path rather than the query string.

Example · php
<?php
// src/Controller/ReportController.php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class ReportController extends AbstractController
{
    // Only three values reach the method; anything else is a 404,
    // which means no validation code is needed inside it.
    #[Route(
        '/reports/{period<daily|weekly|monthly>}',
        name: 'app_report',
        methods: ['GET'],
    )]
    public function show(string $period): Response
    {
        return new Response('Report: ' . $period);
    }
}

A requirement that deliberately allows slashes

The one legitimate reason to allow slashes in a placeholder: a hierarchical document path. The negative priority is the point - a greedy route is tried after everything else, so it cannot steal `/docs/search`. `GET /docs/guide/routing` returns 200 with `Doc page: guide/routing`.

Example · php
<?php
// src/Controller/DocsController.php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class DocsController extends AbstractController
{
    // .+ lets the placeholder span slashes, so this matches
    // /docs/install and /docs/guide/routing/basics alike.
    // Because it is greedy, keep it last and give more specific
    // /docs/... routes a higher priority.
    #[Route('/docs/{path<.+>}', name: 'app_docs', priority: -10, methods: ['GET'])]
    public function show(string $path): Response
    {
        return new Response('Doc page: ' . $path);
    }
}

Requirements in YAML

In a YAML file, routes are also matched in the order they appear, so moving `app_article_new` above `app_article_show` would fix the collision by position alone. Pattern-constraining the id is still the safer fix, because it survives someone reordering the file.

Example · yaml
# config/routes.yaml
app_article_show:
    path: /articles/{id}
    controller: App\Controller\ArticleController::show
    methods: GET
    requirements:
        id: '\d+'

app_article_new:
    path: /articles/new
    controller: App\Controller\ArticleController::new
    methods: GET

Discussion

  • Be the first to comment on this lesson.