MidТеория6 min

Атрибуты контроллеров

#[Route], #[IsGranted], #[MapRequestPayload], #[MapQueryString], #[ValueResolver] — все атрибуты

PHP-атрибуты — основной способ конфигурации контроллеров в Symfony 7+/8.0. На экзамене проверяют знание всех атрибутов: маршрутизация, безопасность, маппинг данных запроса и argument resolving.

#[Route] — маршрутизация

<?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 prefix — applies to all methods
#[Route('/api/products', name: 'api_product_')]
class ProductApiController extends AbstractController
{
    // GET /api/products → route: api_product_list
    #[Route('', name: 'list', methods: ['GET'])]
    public function list(): Response
    {
        return $this->json([]);
    }

    // POST /api/products → route: api_product_create
    #[Route('', name: 'create', methods: ['POST'])]
    public function create(): Response
    {
        return $this->json([], Response::HTTP_CREATED);
    }

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

    // Multiple routes on one method
    #[Route('/{id<\d+>}', name: 'update', methods: ['PUT'])]
    #[Route('/{id<\d+>}', name: 'patch', methods: ['PATCH'])]
    public function update(int $id): Response
    {
        return $this->json(['id' => $id]);
    }
}

Все параметры #[Route]

Параметр Тип Описание Пример
path string URL-паттерн '/products/{id}'
name string Уникальное имя маршрута 'product_show'
methods array Допустимые HTTP-методы ['GET', 'POST']
requirements array Regex для параметров ['id' => '\d+']
defaults array Значения по умолчанию ['page' => 1]
host string Паттерн хоста 'api.example.com'
schemes array Допустимые схемы ['https']
condition string Expression Language "request.isSecure()"
priority int Приоритет (выше = раньше) 10
stateless bool Без сессии true
locale string Фиксированная локаль 'ru'
format string Формат ответа 'json'
utf8 bool UTF-8 параметры true

Symfony 8.0: Namespace атрибута: Symfony\Component\Routing\Attribute\Route. Старый Annotation\Route удалён. Если на экзамене показан use Symfony\Component\Routing\Annotation\Route — это НЕ будет работать в Symfony 8.0.

#[IsGranted] — проверка доступа

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\Security\Http\Attribute\IsGranted;

// Class-level — applies to ALL methods
#[IsGranted('ROLE_ADMIN')]
class AdminController extends AbstractController
{
    #[Route('/admin/dashboard', name: 'admin_dashboard')]
    public function dashboard(): Response
    {
        return $this->render('admin/dashboard.html.twig');
    }
}

class ProductController extends AbstractController
{
    // Method-level — specific actions only
    #[Route('/products/{id}/edit', name: 'product_edit')]
    #[IsGranted('ROLE_EDITOR')]
    public function edit(int $id): Response
    {
        return $this->render('product/edit.html.twig');
    }

    // Check against a specific object (voter)
    #[Route('/products/{id}/delete', name: 'product_delete', methods: ['DELETE'])]
    #[IsGranted('PRODUCT_DELETE', subject: 'product')]
    public function delete(Product $product): Response
    {
        // Voter checks if current user can delete THIS product
        return new Response(null, Response::HTTP_NO_CONTENT);
    }

    // Custom status code and message
    #[IsGranted('ROLE_ADMIN', statusCode: 404, message: 'Page not found')]
    #[Route('/secret', name: 'secret_page')]
    public function secret(): Response
    {
        return $this->render('secret.html.twig');
    }
}

Подвох экзамена: #[IsGranted] проверяется в событии kernel.controller, ДО выполнения контроллера. Если пользователь не имеет роли — бросается AccessDeniedException (403). Параметр subject позволяет передать объект в Voter для проверки owner-based доступа.

#[MapRequestPayload] — маппинг тела запроса

Автоматическая десериализация и валидация JSON/XML/form-данных из тела запроса.

<?php

declare(strict_types=1);

namespace App\Controller;

use App\DTO\CreateProductRequest;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\Constraints as Assert;

// DTO class for request body
final readonly class CreateProductRequest
{
    public function __construct(
        #[Assert\NotBlank]
        #[Assert\Length(min: 3, max: 255)]
        public string $name,

        #[Assert\Positive]
        public float $price,

        #[Assert\NotBlank]
        public string $category,

        public ?string $description = null,
    ) {
    }
}

class ProductController extends AbstractController
{
    #[Route('/api/products', name: 'api_product_create', methods: ['POST'])]
    public function create(
        #[MapRequestPayload] CreateProductRequest $dto,
    ): Response {
        // Automatically:
        // 1. Deserializes JSON body → CreateProductRequest object
        // 2. Validates using #[Assert\*] constraints
        // 3. Returns 422 if validation fails

        return $this->json(['name' => $dto->name, 'price' => $dto->price], 201);
    }

    // With specific format and validation groups
    #[Route('/api/products/{id}', methods: ['PUT'])]
    public function update(
        int $id,
        #[MapRequestPayload(
            acceptFormat: 'json',
            validationGroups: ['update'],
        )] UpdateProductRequest $dto,
    ): Response {
        return $this->json(['id' => $id]);
    }
}

Подвох экзамена: #[MapRequestPayload] автоматически десериализует и валидирует данные. При ошибке валидации возвращается HTTP 422 (Unprocessable Entity) с деталями ошибок. Десериализация использует Symfony Serializer, валидация — Symfony Validator. Оба компонента должны быть установлены.

#[MapQueryString] — маппинг query string

Маппинг всех query-параметров в DTO-объект.

<?php

declare(strict_types=1);

namespace App\DTO;

use Symfony\Component\Validator\Constraints as Assert;

final readonly class ProductFilter
{
    public function __construct(
        #[Assert\Length(max: 100)]
        public ?string $search = null,

        #[Assert\Choice(choices: ['name', 'price', 'created_at'])]
        public string $sortBy = 'name',

        #[Assert\Choice(choices: ['asc', 'desc'])]
        public string $direction = 'asc',

        #[Assert\Positive]
        #[Assert\LessThanOrEqual(100)]
        public int $limit = 20,

        #[Assert\PositiveOrZero]
        public int $offset = 0,
    ) {
    }
}
<?php

declare(strict_types=1);

namespace App\Controller;

use App\DTO\ProductFilter;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;

class ProductController extends AbstractController
{
    // GET /products?search=widget&sortBy=price&direction=desc&limit=10
    #[Route('/products', name: 'product_list', methods: ['GET'])]
    public function list(
        #[MapQueryString] ProductFilter $filter = new ProductFilter(),
    ): Response {
        // $filter->search = 'widget'
        // $filter->sortBy = 'price'
        // $filter->direction = 'desc'
        // $filter->limit = 10
        // $filter->offset = 0 (default)

        return $this->json(['filter' => $filter]);
    }
}

Подвох экзамена: #[MapQueryString] маппит ВСЕ query-параметры разом в один DTO. #[MapQueryParameter] (см. ниже) маппит ОТДЕЛЬНЫЕ параметры. Если query string невалиден — возвращается 404 (не 422, как у #[MapRequestPayload]).

#[MapQueryParameter] — отдельные query-параметры

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;

class SearchController extends AbstractController
{
    // GET /search?q=symfony&page=2&limit=50&tags[]=php&tags[]=web
    #[Route('/search', name: 'search')]
    public function search(
        #[MapQueryParameter] string $q = '',
        #[MapQueryParameter] int $page = 1,
        #[MapQueryParameter] int $limit = 20,
        #[MapQueryParameter] array $tags = [],
        #[MapQueryParameter] ?string $category = null,
    ): Response {
        return $this->json([
            'query' => $q,
            'page' => $page,
            'limit' => $limit,
            'tags' => $tags,
            'category' => $category,
        ]);
    }

    // With validation
    #[Route('/products', name: 'product_list')]
    public function list(
        #[MapQueryParameter(
            filter: \FILTER_VALIDATE_INT,
            options: ['min_range' => 1, 'max_range' => 100],
        )] int $page = 1,
    ): Response {
        return $this->json(['page' => $page]);
    }
}

Сравнение #[MapQueryString] vs #[MapQueryParameter]

Характеристика #[MapQueryString] #[MapQueryParameter]
Маппинг Все параметры → DTO Один параметр → аргумент
Валидация Через #[Assert*] на DTO Через filter/options
Для сложных фильтров Да Нет
Ошибка при невалидности 404 404
Значение по умолчанию Через конструктор DTO Через default аргумента

#[MapEntity] — загрузка сущностей

<?php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\Product;
use App\Entity\Category;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;

class ProductController extends AbstractController
{
    // Auto-resolve by {id} — works without #[MapEntity]
    #[Route('/products/{id}', name: 'product_show')]
    public function show(Product $product): Response
    {
        // Symfony auto-fetches Product WHERE id = {id}
        // If not found → 404
        return $this->json($product);
    }

    // Resolve by different field
    #[Route('/products/{slug}', name: 'product_by_slug')]
    public function showBySlug(
        #[MapEntity(mapping: ['slug' => 'slug'])]
        Product $product,
    ): Response {
        return $this->json($product);
    }

    // Multiple entities
    #[Route('/products/{product_id}/reviews/{review_id}')]
    public function review(
        #[MapEntity(id: 'product_id')] Product $product,
        #[MapEntity(id: 'review_id')] Review $review,
    ): Response {
        return $this->json(['product' => $product, 'review' => $review]);
    }

    // Entity with expression
    #[Route('/products/{slug}')]
    public function showWithExpr(
        #[MapEntity(expr: 'repository.findOneBySlug(slug)')]
        Product $product,
    ): Response {
        return $this->json($product);
    }

    // Disabled auto-resolution
    #[Route('/products/{id}')]
    public function rawId(
        #[MapEntity(disabled: true)]
        int $id,  // Just receives raw {id} value
    ): Response {
        return $this->json(['id' => $id]);
    }
}

Подвох экзамена: #[MapEntity] заменил @ParamConverter из SensioFrameworkExtraBundle. В Symfony 7+/8.0 EntityValueResolver встроен в DoctrineBundle. Если entity не найдена — автоматически бросается NotFoundHttpException (404). Параметр disabled: true отключает auto-resolution.

#[Cache] — HTTP кэширование

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\HttpKernel\Attribute\Cache;

class PageController extends AbstractController
{
    #[Cache(maxage: 3600, smaxage: 7200, public: true)]
    #[Route('/about', name: 'about')]
    public function about(): Response
    {
        // Response headers:
        // Cache-Control: max-age=3600, s-maxage=7200, public
        return $this->render('page/about.html.twig');
    }

    #[Cache(expires: '+1 hour', vary: ['Accept-Encoding', 'Accept-Language'])]
    #[Route('/faq', name: 'faq')]
    public function faq(): Response
    {
        return $this->render('page/faq.html.twig');
    }
}

#[Template] — автоматический рендеринг шаблона

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Bridge\Twig\Attribute\Template;

class BlogController extends AbstractController
{
    // Returns array — kernel.view event renders template
    #[Route('/blog', name: 'blog_list')]
    #[Template('blog/list.html.twig')]
    public function list(): array
    {
        return [
            'posts' => $this->getLatestPosts(),
        ];
    }

    // Can still return Response to override template
    #[Route('/blog/{slug}', name: 'blog_show')]
    #[Template('blog/show.html.twig')]
    public function show(string $slug): array|Response
    {
        $post = $this->findPost($slug);

        if (!$post) {
            return $this->redirectToRoute('blog_list');
        }

        return ['post' => $post];
    }
}

Подвох экзамена: #[Template] обрабатывается через kernel.view event. Контроллер возвращает массив (не Response), listener конвертирует его в Response через Twig. Если контроллер вернёт Response — #[Template] игнорируется.

Сводная таблица атрибутов контроллеров

Атрибут Пакет Назначение
#[Route] symfony/routing Маршрутизация
#[IsGranted] symfony/security-http Проверка доступа
#[MapRequestPayload] symfony/http-kernel Маппинг тела запроса
#[MapQueryString] symfony/http-kernel Маппинг всех query-параметров
#[MapQueryParameter] symfony/http-kernel Маппинг отдельного query-параметра
#[MapEntity] doctrine/doctrine-bundle Загрузка entity из БД
#[Cache] symfony/http-kernel HTTP-кэширование
#[Template] symfony/twig-bridge Авто-рендер шаблона
#[AsController] symfony/http-kernel Пометка класса как контроллера

Проверь себя

Что заменяет `#[MapEntity]` из предыдущих версий Symfony?

Чем `#[MapQueryString]` отличается от `#[MapQueryParameter]`?

Как `#[Template]` обрабатывает возвращаемое значение контроллера?

Когда проверяется `#[IsGranted]` — до или после выполнения контроллера?

Что произойдёт, если `#[MapRequestPayload]` получит невалидные данные?