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.viewevent. Контроллер возвращает массив (не 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 |
Пометка класса как контроллера |