HardТеория4 min

Подвохи HTTP, Routing, Controllers

Приоритет маршрутов, regex requirements, специальные параметры, sub-requests, StreamedResponse

Подвох 1: Приоритет маршрутов -- порядок имеет значение

В Symfony первый совпавший маршрут побеждает. Порядок определяется порядком загрузки файлов и приоритетом (priority).

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class ProductController
{
    // Этот маршрут перехватит ВСЕ URL вида /products/{что-угодно}
    #[Route('/products/{slug}', name: 'product_show')]
    public function show(string $slug): Response
    {
        return new Response("Product: $slug");
    }

    // Этот маршрут НИКОГДА не сработает!
    // /products/new совпадает с /products/{slug} где slug = "new"
    #[Route('/products/new', name: 'product_new')]
    public function new(): Response
    {
        return new Response('New product form');
    }
}

Решение -- requirements или порядок:

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class ProductController
{
    // Вариант 1: явный приоритет
    #[Route('/products/new', name: 'product_new', priority: 1)]
    public function new(): Response
    {
        return new Response('New product form');
    }

    // Вариант 2: ограничение через requirements
    #[Route('/products/{slug}', name: 'product_show', requirements: ['slug' => '[a-z0-9\-]+'])]
    public function show(string $slug): Response
    {
        return new Response("Product: $slug");
    }
}

На экзамене: Порядок маршрутов критичен. Статические маршруты (/products/new) должны быть определены ПЕРЕД динамическими (/products/{slug}), либо используйте priority или requirements.

Подвох 2: Специальные параметры маршрутов

Symfony имеет зарезервированные параметры маршрутов, которые имеют особое поведение.

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class PageController
{
    // _locale -- автоматически устанавливает locale запроса
    #[Route('/{_locale}/page/{slug}', name: 'page_show')]
    public function show(string $slug): Response
    {
        // Request::getLocale() автоматически вернёт значение из URL!
        return new Response("Page: $slug");
    }

    // _format -- автоматически устанавливает формат ответа
    #[Route('/api/data.{_format}', name: 'api_data', defaults: ['_format' => 'json'])]
    public function data(): Response
    {
        // Request::getRequestFormat() вернёт 'json' или 'xml'
        return new Response('data');
    }
}

Зарезервированные параметры:

Параметр Назначение
_controller Определяет контроллер (используется внутренне)
_locale Устанавливает locale запроса
_format Устанавливает формат запроса (json, xml, html)
_fragment Не передаётся на сервер (только для генерации URL)
_stateless Отмечает маршрут как stateless (без сессии)

На экзамене: _fragment НИКОГДА не доходит до сервера -- это часть URL после #, которая обрабатывается только браузером. Symfony использует его только при генерации URL.

Подвох 3: Requirements regex -- без разделителей!

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class UserController
{
    // ПРАВИЛЬНО: regex БЕЗ разделителей
    #[Route('/users/{id}', name: 'user_show', requirements: ['id' => '\d+'])]
    public function show(int $id): Response
    {
        return new Response("User: $id");
    }

    // НЕПРАВИЛЬНО: с разделителями -- вызовет ошибку!
    // requirements: ['id' => '/\d+/']  -- ОШИБКА!
}

На экзамене: В requirements пишется чистый regex без разделителей (/). Часто в ответах подсовывают варианты с разделителями -- это ошибка.

Подвох 4: Sub-requests и kernel.terminate

Sub-requests -- это внутренние запросы, которые обрабатываются тем же kernel.

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\Routing\Attribute\Route;

final class DashboardController
{
    public function __construct(
        private readonly HttpKernelInterface $httpKernel,
    ) {
    }

    #[Route('/dashboard', name: 'dashboard')]
    public function index(Request $request): Response
    {
        // Sub-request -- обрабатывается ТЕМ ЖЕ kernel
        $subRequest = Request::create('/api/stats');
        $subResponse = $this->httpKernel->handle(
            $subRequest,
            HttpKernelInterface::SUB_REQUEST, // <-- Важно!
        );

        // kernel.terminate НЕ вызывается для sub-request!
        // Только для MAIN request.
        return new Response(
            'Dashboard: ' . $subResponse->getContent(),
        );
    }
}

Жизненный цикл запросов:

MAIN Request:
  kernel.request -> kernel.controller -> kernel.response -> Response -> kernel.terminate

SUB Request:
  kernel.request -> kernel.controller -> kernel.response -> Response
  (БЕЗ kernel.terminate!)

На экзамене: kernel.terminate вызывается ТОЛЬКО для основного запроса (MAIN_REQUEST). Sub-requests не триггерят это событие. Это важно для задач, которые выполняются после отправки ответа клиенту.

Подвох 5: StreamedResponse vs BinaryFileResponse

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route;

final class DownloadController
{
    // StreamedResponse -- для ГЕНЕРАЦИИ данных на лету
    #[Route('/export/csv', name: 'export_csv')]
    public function exportCsv(): StreamedResponse
    {
        return new StreamedResponse(function (): void {
            $handle = fopen('php://output', 'w');
            // Данные отправляются клиенту по мере генерации
            // Не загружает весь файл в память!
            foreach ($this->getRows() as $row) {
                fputcsv($handle, $row);
                flush(); // Отправить данные немедленно
            }
            fclose($handle);
        }, Response::HTTP_OK, [
            'Content-Type' => 'text/csv',
        ]);
    }

    // BinaryFileResponse -- для СУЩЕСТВУЮЩИХ файлов
    #[Route('/download/{filename}', name: 'download_file')]
    public function downloadFile(string $filename): BinaryFileResponse
    {
        $path = '/var/uploads/' . $filename;

        // Поддерживает X-Sendfile, Range requests, кеширование
        $response = new BinaryFileResponse($path);
        $response->setContentDisposition('attachment', $filename);

        return $response;
    }

    /** @return iterable<array<string>> */
    private function getRows(): iterable
    {
        yield ['id', 'name'];
        yield ['1', 'Product A'];
    }
}

На экзамене: StreamedResponse -- для генерации данных на лету (CSV, большие данные). BinaryFileResponse -- для отдачи существующих файлов (поддерживает X-Sendfile, Range). Не путайте их!

Подвох 6: kernel.request -- Early Response

Listener на kernel.request может прервать обработку, установив Response.

<?php

declare(strict_types=1);

namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;

#[AsEventListener(event: RequestEvent::class, priority: 256)]
final readonly class MaintenanceListener
{
    public function __invoke(RequestEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return; // Не реагировать на sub-requests
        }

        // Установив Response, мы ПОЛНОСТЬЮ пропускаем контроллер!
        // kernel.controller, kernel.view -- НЕ будут вызваны
        $event->setResponse(new JsonResponse(
            ['error' => 'Maintenance mode'],
            Response::HTTP_SERVICE_UNAVAILABLE,
        ));
        // НО kernel.response БУДЕТ вызван!
    }
}

На экзамене: Если listener на kernel.request устанавливает Response через setResponse(), контроллер НЕ вызывается. Но kernel.response всё равно вызывается для этого Response.

Подвох 7: Методы HTTP и _method override

<?php

declare(strict_types=1);

// В HTML-формах нельзя отправить PUT, PATCH, DELETE.
// Symfony поддерживает _method override:

// config/packages/framework.yaml:
// framework:
//     http_method_override: true  # Включить поддержку _method

// Тогда в форме:
// <input type="hidden" name="_method" value="DELETE">

// НО! В Symfony 8.0 http_method_override: false по умолчанию!
// Нужно включать явно.

На экзамене: В Symfony 8.0 http_method_override выключен по умолчанию (false). Если в ответе подразумевается использование _method в формах -- нужно помнить об этом изменении.

Проверь себя

5 из 7

Как правильно указать requirement для параметра маршрута, чтобы он принимал только числа?

Какой специальный параметр маршрута НИКОГДА не доходит до сервера?

Чем StreamedResponse отличается от BinaryFileResponse?

Listener на kernel.request вызвал setResponse(). Что произойдёт дальше?

В контроллере определены два маршрута: /products/{slug} и /products/new (в таком порядке). Что произойдёт при запросе GET /products/new?