HardТеория7 min

Продвинутая маршрутизация

Prefix, host, condition, priority, localized routes, subdomain routing, groups

Продвинутые возможности Routing — сложная тема экзамена. Condition expression language, subdomain routing, localized routes и route groups проверяются на hard-уровне.

Route Prefix на уровне класса

<?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;

// Class-level Route provides prefix for all methods
#[Route('/api/v1/products', name: 'api_v1_product_')]
class ProductApiController extends AbstractController
{
    // Final path: /api/v1/products
    // Route name: api_v1_product_list
    #[Route('', name: 'list', methods: ['GET'])]
    public function list(): Response { return $this->json([]); }

    // Final path: /api/v1/products/{id}
    // Route name: api_v1_product_show
    #[Route('/{id<\d+>}', name: 'show', methods: ['GET'])]
    public function show(int $id): Response { return $this->json(['id' => $id]); }

    // Final path: /api/v1/products
    // Route name: api_v1_product_create
    #[Route('', name: 'create', methods: ['POST'])]
    public function create(): Response { return $this->json([], 201); }
}

Route Groups (Symfony 6.2+)

<?php

declare(strict_types=1);

// Class-level Route with full configuration — acts as a "group"
#[Route(
    path: '/api/v2',
    name: 'api_v2_',
    host: 'api.example.com',
    schemes: ['https'],
    defaults: ['_format' => 'json'],
    stateless: true,
)]
class ApiV2Controller extends AbstractController
{
    // Inherits: host, scheme, stateless, format, name prefix
    #[Route('/users', name: 'users', methods: ['GET'])]
    // Full: https://api.example.com/api/v2/users
    // Name: api_v2_users, stateless: true, format: json
    public function users(): Response
    {
        return $this->json([]);
    }

    #[Route('/orders', name: 'orders', methods: ['GET'])]
    // Full: https://api.example.com/api/v2/orders
    // Name: api_v2_orders
    public function orders(): Response
    {
        return $this->json([]);
    }
}

YAML Prefix

# config/routes.yaml
admin_controllers:
    resource:
        path: ../src/Controller/Admin/
        namespace: App\Controller\Admin
    type: attribute
    prefix: /admin
    name_prefix: admin_
    host: admin.example.com
    schemes: [https]

Подвох экзамена: Все параметры class-level #[Route] наследуются method-level маршрутами. path конкатенируется, name конкатенируется, host/schemes/stateless наследуются (если не переопределены на уровне метода).

Host Matching (subdomain routing)

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\Routing\Attribute\Route;

class SubdomainController extends AbstractController
{
    // Match specific subdomain
    #[Route('/dashboard', name: 'admin_dashboard', host: 'admin.example.com')]
    public function adminDashboard(): Response
    {
        return $this->render('admin/dashboard.html.twig');
    }

    // Host with parameters
    #[Route('/dashboard', name: 'tenant_dashboard',
        host: '{tenant}.example.com',
        requirements: ['tenant' => '[a-z]+']
    )]
    public function tenantDashboard(string $tenant): Response
    {
        // acme.example.com/dashboard → tenant='acme'
        // beta.example.com/dashboard → tenant='beta'
        return $this->render('tenant/dashboard.html.twig', ['tenant' => $tenant]);
    }

    // Host with default
    #[Route('/', name: 'subdomain_home',
        host: '{subdomain}.{domain}',
        defaults: ['subdomain' => 'www', 'domain' => 'example.com'],
        requirements: [
            'subdomain' => 'www|blog|shop',
            'domain' => 'example\.com|example\.org',
        ]
    )]
    public function home(string $subdomain, string $domain): Response
    {
        return $this->render('home.html.twig');
    }
}

YAML host matching

# config/routes.yaml
api_products:
    path: /products
    controller: App\Controller\Api\ProductController::list
    host: 'api.{domain}'
    defaults:
        domain: example.com
    requirements:
        domain: 'example\.(com|org)'
    schemes: [https]

Подвох экзамена: Host parameters доступны в контроллере как обычные аргументы. При генерации URL с host-параметрами нужно передавать их: $this->generateUrl('tenant_dashboard', ['tenant' => 'acme']). Без host-параметра генерация URL выбросит исключение.

Condition Expression Language

Conditions используют Symfony ExpressionLanguage для сложного matching, выходящего за рамки URL-паттерна.

<?php

declare(strict_types=1);

use Symfony\Component\Routing\Attribute\Route;

class ConditionalController extends AbstractController
{
    // Match only if specific header is present
    #[Route('/api/internal',
        name: 'api_internal',
        condition: "request.headers.has('X-Internal-Token')"
    )]
    public function internal(): Response
    {
        return $this->json(['source' => 'internal']);
    }

    // Match based on Accept header
    #[Route('/data',
        name: 'data_json',
        condition: "request.headers.get('Accept') matches '/json/'"
    )]
    public function dataJson(): Response
    {
        return $this->json([]);
    }

    // Match AJAX requests only
    #[Route('/api/quick',
        name: 'api_quick',
        condition: "request.isXmlHttpRequest()"
    )]
    public function quickApi(): Response
    {
        return $this->json([]);
    }

    // Match based on query parameter
    #[Route('/search',
        name: 'search_advanced',
        condition: "request.query.has('advanced') and request.query.get('advanced') == '1'"
    )]
    public function advancedSearch(): Response
    {
        return $this->json([]);
    }

    // Combined conditions
    #[Route('/secure',
        name: 'secure_endpoint',
        condition: "request.isSecure() and request.getClientIp() starts with '10.'"
    )]
    public function secureInternal(): Response
    {
        return $this->json([]);
    }
}

Доступные переменные в condition

Переменная Тип Описание
context RequestContext Контекст маршрутизации
request Request Текущий HTTP-запрос (если есть)
params array Параметры маршрута
<?php

declare(strict_types=1);

// Useful methods on request:
// request.getClientIp()
// request.getMethod()
// request.getHost()
// request.getScheme()
// request.getPathInfo()
// request.isSecure()
// request.isXmlHttpRequest()
// request.headers.get('name')
// request.headers.has('name')
// request.query.get('name')
// request.query.has('name')
// request.cookies.get('name')
// request.getPreferredLanguage(['en', 'ru'])

// Useful methods on context:
// context.getMethod()
// context.getHost()
// context.getScheme()
// context.getPathInfo()

Подвох экзамена: Conditions выполняются ПОСЛЕ базового URL matching, но ДО вызова контроллера. Если condition false — маршрут считается несовпавшим, Router пробует следующий. Conditions НЕ могут использовать сервисы из контейнера — только context, request и params.

Localized Routes

Массив путей по локалям

<?php

declare(strict_types=1);

class PageController extends AbstractController
{
    // Different paths per locale
    #[Route(
        path: [
            'en' => '/about-us',
            'ru' => '/o-nas',
            'de' => '/uber-uns',
        ],
        name: 'about'
    )]
    public function about(): Response
    {
        // Current locale determines which path matches
        return $this->render('page/about.html.twig');
    }

    #[Route(
        path: [
            'en' => '/contact',
            'ru' => '/kontakty',
            'de' => '/kontakt',
        ],
        name: 'contact'
    )]
    public function contact(): Response
    {
        return $this->render('page/contact.html.twig');
    }
}

Конфигурация локалей

# config/packages/framework.yaml
framework:
    default_locale: en
    enabled_locales: ['en', 'ru', 'de']
    translator:
        default_path: '%kernel.project_dir%/translations'

Как генерируются маршруты

# Localized routes generate SEPARATE routes per locale:
# about.en → /about-us
# about.ru → /o-nas
# about.de → /uber-uns

# URL generation uses current locale:
# path('about') → /o-nas (if current locale is 'ru')
# path('about', {_locale: 'en'}) → /about-us (force English)

Подвох экзамена: Localized routes с массивом path генерируют ОТДЕЛЬНЫЕ маршруты: about.en, about.ru, about.de. При генерации URL Symfony использует текущую локаль. path('about') вернёт /o-nas если локаль ru. Для явной локали: path('about', {_locale: 'en'}).

Locale prefix стратегия

<?php

declare(strict_types=1);

// Strategy: locale in URL prefix
#[Route('/{_locale}', requirements: ['_locale' => 'en|ru|de'])]
class LocalizedController extends AbstractController
{
    // /en/products, /ru/products, /de/products
    #[Route('/products', name: 'product_list')]
    public function list(): Response
    {
        // _locale automatically sets $request->setLocale()
        return $this->render('product/list.html.twig');
    }

    // /en/products/42, /ru/products/42
    #[Route('/products/{id<\d+>}', name: 'product_show')]
    public function show(int $id): Response
    {
        return $this->render('product/show.html.twig');
    }
}

Priority и порядок маршрутов

Числовой приоритет

<?php

declare(strict_types=1);

class CatchAllController extends AbstractController
{
    // Priority 10 — checked first
    #[Route('/products/featured', name: 'product_featured', priority: 10)]
    public function featured(): Response
    {
        return $this->render('product/featured.html.twig');
    }

    // Priority 5 — checked second
    #[Route('/products/new', name: 'product_new', priority: 5)]
    public function new(): Response
    {
        return $this->render('product/new.html.twig');
    }

    // Priority 0 (default) — checked last
    #[Route('/products/{slug}', name: 'product_show')]
    public function show(string $slug): Response
    {
        // /products/featured → product_featured (priority 10)
        // /products/new      → product_new (priority 5)
        // /products/widget   → product_show (priority 0)
        return $this->render('product/show.html.twig');
    }
}

Порядок объявления

<?php

declare(strict_types=1);

// WITHOUT priority: order of declaration matters
class OrderMattersController extends AbstractController
{
    // Declared FIRST — matched first
    #[Route('/products/new', name: 'product_new')]
    public function new(): Response { /* ... */ }

    // Declared SECOND — matched if /new doesn't match
    #[Route('/products/{slug}', name: 'product_show')]
    public function show(string $slug): Response { /* ... */ }
}

Подвох экзамена: Более высокий priority = раньше проверяется. При одинаковом priority — порядок объявления в файле. Для YAML — порядок в конфиг-файле. Для attributes — порядок методов в классе и порядок загрузки файлов.

Schemes (HTTP/HTTPS)

<?php

declare(strict_types=1);

// HTTPS only — HTTP requests get 301 redirect to HTTPS
#[Route('/secure/payment', schemes: ['https'])]
public function payment(): Response
{
    return $this->render('payment.html.twig');
}

// HTTP only (rare, but possible)
#[Route('/unsecure', schemes: ['http'])]
public function unsecure(): Response
{
    return new Response('OK');
}

Подвох экзамена: Если маршрут требует https и запрос приходит по http, Symfony вернёт 301 redirect на https-версию. Это НЕ 404 и НЕ 403. Аналогично и в обратную сторону.

Multiple Routes на одном методе

<?php

declare(strict_types=1);

class LegacyController extends AbstractController
{
    // Multiple routes pointing to same action
    #[Route('/products', name: 'product_list')]
    #[Route('/catalog', name: 'catalog_list')]
    #[Route('/shop', name: 'shop_list')]
    public function list(): Response
    {
        return $this->render('product/list.html.twig');
    }

    // Different methods, same action
    #[Route('/items/{id}', name: 'item_update_put', methods: ['PUT'])]
    #[Route('/items/{id}', name: 'item_update_patch', methods: ['PATCH'])]
    public function update(int $id, Request $request): Response
    {
        return $this->json(['id' => $id, 'method' => $request->getMethod()]);
    }
}

UTF-8 Routes

<?php

declare(strict_types=1);

// UTF-8 route parameters
#[Route('/kategorie/{name}', requirements: ['name' => '.+'], utf8: true)]
public function category(string $name): Response
{
    // /kategorie/электроника → name='электроника'
    return $this->json(['name' => $name]);
}

// UTF-8 in path
#[Route('/новости/{slug}', utf8: true)]
public function news(string $slug): Response
{
    return $this->render('news/show.html.twig');
}

Дебаг маршрутов

# List all routes
php bin/console debug:router

# Show specific route details
php bin/console debug:router product_show

# Match a URL
php bin/console router:match /products/42

# Match with method
php bin/console router:match /products --method=POST

# Match with host
php bin/console router:match /products --host=api.example.com

# Output as JSON
php bin/console debug:router --format=json

Частые проблемы маршрутизации

Проблема Симптом Решение
Порядок маршрутов Другой маршрут совпал раньше Используйте priority
Requirements 404 при валидном URL Проверьте regex
Methods 405 Method Not Allowed Добавьте метод в methods
Host 404 на правильном URL Проверьте host matching
Condition Route не совпадает router:match для дебага
Trailing slash 301 redirect loop Уберите или добавьте /
Cache Старые маршруты cache:clear

Подвох экзамена: router:match — ключевая команда для дебага. Она показывает какой маршрут совпал и почему остальные не подошли. На экзамене спрашивают "какой командой проверить matching URL" — ответ: php bin/console router:match /url.


Проверь себя

Можно ли использовать сервисы из DI-контейнера в condition expression?

Как Symfony обрабатывает localized routes с массивом path?

Какая команда показывает, какой маршрут совпадёт с URL `/products/42`?

Что произойдёт при HTTP-запросе на маршрут с `schemes: ['https']`?

Что произойдёт если condition маршрута вернёт false?