HardТеория10 min

API-контроллеры

JsonResponse, сериализация с Serializer, content negotiation, #[MapRequestPayload], #[MapQueryString], обработка ошибок API, нормализация ответов

Symfony предоставляет мощный инструментарий для создания API: встроенная сериализация, маппинг запросов на DTO, структурированная обработка ошибок. На экзамене проверяют JsonResponse, Serializer, атрибуты маппинга и правильную обработку ошибок.

JsonResponse

Базовое использование

<?php

declare(strict_types=1);

namespace App\Controller\Api;

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

#[Route('/api', name: 'api_')]
final class ProductController extends AbstractController
{
    #[Route('/products', name: 'product_list', methods: ['GET'])]
    public function list(): JsonResponse
    {
        $products = [
            ['id' => 1, 'name' => 'Widget', 'price' => 29.99],
            ['id' => 2, 'name' => 'Gadget', 'price' => 49.99],
        ];

        // json() from AbstractController -- uses Serializer if available
        return $this->json($products);

        // Equivalent manual JsonResponse
        return new JsonResponse($products);

        // With status code and headers
        return $this->json($products, Response::HTTP_OK, [
            'X-Total-Count' => '2',
        ]);

        // With serialization context
        return $this->json($products, Response::HTTP_OK, [], [
            'groups' => ['product:list'],
        ]);
    }
}

JsonResponse vs $this->json()

<?php

declare(strict_types=1);

use Symfony\Component\HttpFoundation\JsonResponse;

// JsonResponse: uses json_encode() directly
// Does NOT use Serializer component
// Only works with scalar/array data
$response = new JsonResponse(['key' => 'value']);

// $this->json(): uses Serializer component if available
// Handles objects, DateTime, Enums, custom normalization
// Supports serialization groups
$response = $this->json($product, 200, [], ['groups' => ['product:read']]);

Подвох экзамена: new JsonResponse($data) использует json_encode() напрямую и НЕ использует Serializer. $this->json($data) (из AbstractController) использует Serializer component, что позволяет обрабатывать объекты, группы сериализации и кастомные нормализаторы.

Создание JsonResponse вручную

<?php

declare(strict_types=1);

use Symfony\Component\HttpFoundation\JsonResponse;

// From array
$response = new JsonResponse(['status' => 'ok']);

// From pre-encoded JSON string
$response = JsonResponse::fromJsonString('{"status":"ok"}');

// Set data after creation
$response = new JsonResponse();
$response->setData(['key' => 'value']);

// Set callback for JSONP
$response->setCallback('handleResponse');

// Custom encoding options
$response->setEncodingOptions(
    JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);

// Response headers
$response->headers->set('Cache-Control', 'no-cache');
$response->headers->set('X-Request-Id', 'abc-123');

Сериализация с Serializer Component

Настройка групп сериализации

<?php

declare(strict_types=1);

namespace App\Entity;

use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Serializer\Attribute\Ignore;

final class Product
{
    #[Groups(['product:read', 'product:list'])]
    private int $id;

    #[Groups(['product:read', 'product:list', 'product:write'])]
    private string $name;

    #[Groups(['product:read', 'product:write'])]
    private string $description;

    #[Groups(['product:read', 'product:list'])]
    #[SerializedName('unit_price')]  // Custom JSON key name
    private float $price;

    #[Groups(['product:read'])]
    private \DateTimeImmutable $createdAt;

    #[Ignore]  // Never serialized
    private string $internalCode;

    // Computed property
    #[Groups(['product:read'])]
    public function getFormattedPrice(): string
    {
        return number_format($this->price, 2, '.', ' ') . ' RUB';
    }
}
<?php

declare(strict_types=1);

namespace App\Controller\Api;

use App\Repository\ProductRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/api')]
final class ProductController extends AbstractController
{
    #[Route('/products', methods: ['GET'])]
    public function list(ProductRepository $products): JsonResponse
    {
        // Only fields in 'product:list' group are serialized
        return $this->json(
            $products->findAll(),
            Response::HTTP_OK,
            [],
            ['groups' => ['product:list']],
        );
        // Output: [{"id": 1, "name": "Widget", "unit_price": 29.99}, ...]
    }

    #[Route('/products/{id}', methods: ['GET'])]
    public function show(Product $product): JsonResponse
    {
        // 'product:read' group includes more fields
        return $this->json(
            $product,
            Response::HTTP_OK,
            [],
            ['groups' => ['product:read']],
        );
        // Output: {"id": 1, "name": "Widget", "description": "...",
        //          "unit_price": 29.99, "createdAt": "2026-...",
        //          "formattedPrice": "29.99 RUB"}
    }
}

Serializer: Normalizers и Encoders

<?php

declare(strict_types=1);

use Symfony\Component\Serializer\SerializerInterface;

// Serializer = Normalizer + Encoder

// Normalizer: PHP object -> array (and back)
// Encoder: array -> JSON/XML/CSV (and back)

// Process: object -> [normalize] -> array -> [encode] -> JSON
// Reverse: JSON -> [decode] -> array -> [denormalize] -> object

Встроенные Normalizers (порядок приоритета)

Normalizer Что обрабатывает
UnwrappingDenormalizer Unwrap nested data
ProblemNormalizer RFC 7807 ошибки
UidNormalizer UUID, ULID
DateTimeNormalizer DateTime объекты
DateTimeZoneNormalizer DateTimeZone
DateIntervalNormalizer DateInterval
FormErrorNormalizer Form ошибки
BackedEnumNormalizer PHP Enums
JsonSerializableNormalizer JsonSerializable
ArrayDenormalizer Массивы объектов
ObjectNormalizer Любые объекты (reflection)

Подвох экзамена: ObjectNormalizer работает через getters/setters/property access и является fallback. Для лучшей производительности используйте #[Groups] для ограничения сериализуемых полей. Без групп ObjectNormalizer сериализует ВСЕ публичные свойства/getters, что может вызвать circular reference.

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

<?php

declare(strict_types=1);

namespace App\DTO;

use Symfony\Component\Validator\Constraints as Assert;

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

        #[Assert\NotBlank]
        public string $description,

        #[Assert\Positive]
        public float $price,

        #[Assert\NotBlank]
        #[Assert\Choice(choices: ['active', 'draft', 'archived'])]
        public string $status = 'draft',

        /** @var string[] */
        #[Assert\All([
            new Assert\NotBlank(),
            new Assert\Length(max: 50),
        ])]
        public array $tags = [],
    ) {}
}
<?php

declare(strict_types=1);

namespace App\Controller\Api;

use App\DTO\CreateProductRequest;
use App\Service\ProductService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/api')]
final class ProductController extends AbstractController
{
    #[Route('/products', methods: ['POST'])]
    public function create(
        #[MapRequestPayload] CreateProductRequest $request,
        ProductService $productService,
    ): JsonResponse {
        // $request is already validated!
        // If validation fails → 422 Unprocessable Entity automatically

        $product = $productService->create(
            name: $request->name,
            description: $request->description,
            price: $request->price,
            status: $request->status,
            tags: $request->tags,
        );

        return $this->json(
            $product,
            Response::HTTP_CREATED,
            [],
            ['groups' => ['product:read']],
        );
    }
}

Параметры MapRequestPayload

<?php

declare(strict_types=1);

use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Validator\Constraints as Assert;

#[Route('/products', methods: ['POST'])]
public function create(
    #[MapRequestPayload(
        acceptFormat: 'json',              // Accept only JSON (default: json)
        serializationContext: [             // Serializer deserialization context
            'groups' => ['product:write'],
        ],
        validationGroups: ['Default', 'create'], // Validation groups
        resolver: 'custom_resolver',       // Custom resolver (optional)
    )]
    CreateProductRequest $request,
): JsonResponse {
    // ...
}

// Accept multiple formats
#[Route('/products', methods: ['POST'])]
public function createMultiFormat(
    #[MapRequestPayload(acceptFormat: ['json', 'xml'])]
    CreateProductRequest $request,
): JsonResponse {
    // Works with both JSON and XML request bodies
}

Подвох экзамена: #[MapRequestPayload] автоматически: 1) десериализует тело запроса в DTO, 2) валидирует через Validator, 3) возвращает 422 при ошибках валидации, 4) возвращает 400 при невалидном JSON. Контроллер получает уже валидный объект.

#[MapQueryString] -- маппинг query-параметров

<?php

declare(strict_types=1);

namespace App\DTO;

use Symfony\Component\Validator\Constraints as Assert;

// DTO for query string parameters
final readonly class ProductFilterQuery
{
    public function __construct(
        #[Assert\Length(max: 100)]
        public ?string $search = null,

        #[Assert\Choice(choices: ['active', 'draft', 'archived'])]
        public ?string $status = null,

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

        #[Assert\PositiveOrZero]
        public int $offset = 0,

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

        #[Assert\Choice(choices: ['asc', 'desc'])]
        public string $sortOrder = 'desc',
    ) {}
}
<?php

declare(strict_types=1);

namespace App\Controller\Api;

use App\DTO\ProductFilterQuery;
use App\Repository\ProductRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/api')]
final class ProductController extends AbstractController
{
    #[Route('/products', methods: ['GET'])]
    public function list(
        #[MapQueryString] ProductFilterQuery $filter,
        ProductRepository $products,
    ): JsonResponse {
        // GET /api/products?search=widget&status=active&limit=10&sortBy=price&sortOrder=asc

        $result = $products->findByFilter($filter);

        return $this->json($result, 200, [], [
            'groups' => ['product:list'],
        ]);
    }

    // Optional: allow no query params (nullable)
    #[Route('/products', methods: ['GET'])]
    public function listOptional(
        #[MapQueryString] ?ProductFilterQuery $filter = null,
    ): JsonResponse {
        // $filter is null if no query params provided
        $filter ??= new ProductFilterQuery();

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

Подвох экзамена: #[MapQueryString] маппит query-параметры (GET) на DTO. #[MapRequestPayload] маппит тело запроса (POST/PUT). Если DTO с #[MapQueryString] nullable (?DTO $filter = null), при отсутствии параметров $filter будет null, а не пустой объект.

Обработка ошибок API

Стандартная обработка ошибок

<?php

declare(strict_types=1);

namespace App\Controller\Api;

use App\Entity\Product;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/api')]
final class ProductController extends AbstractController
{
    #[Route('/products/{id}', methods: ['GET'])]
    public function show(int $id, ProductRepository $repo): JsonResponse
    {
        $product = $repo->find($id);

        if (null === $product) {
            // Throws 404 with JSON error (in API context)
            throw $this->createNotFoundException('Product not found.');
        }

        return $this->json($product, 200, [], ['groups' => ['product:read']]);
    }

    #[Route('/products', methods: ['POST'])]
    public function create(
        #[MapRequestPayload] CreateProductRequest $request,
    ): JsonResponse {
        // Business logic validation
        if ($this->productExists($request->name)) {
            throw new ConflictHttpException('Product with this name already exists.');
        }

        // Create product...
        return $this->json($product, Response::HTTP_CREATED);
    }

    #[Route('/products/{id}', methods: ['DELETE'])]
    public function delete(Product $product): JsonResponse
    {
        // Return 204 No Content
        $this->entityManager->remove($product);
        $this->entityManager->flush();

        return new JsonResponse(null, Response::HTTP_NO_CONTENT);
    }
}

Кастомный Exception Listener для API

<?php

declare(strict_types=1);

namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;

#[AsEventListener(event: ExceptionEvent::class, priority: -10)]
final class ApiExceptionListener
{
    public function __invoke(ExceptionEvent $event): void
    {
        $request = $event->getRequest();

        // Only handle API routes
        if (!str_starts_with($request->getPathInfo(), '/api')) {
            return;
        }

        $exception = $event->getThrowable();

        $statusCode = $exception instanceof HttpExceptionInterface
            ? $exception->getStatusCode()
            : 500;

        // RFC 7807 Problem Details format
        $data = [
            'type' => 'https://tools.ietf.org/html/rfc7807',
            'title' => Response::$statusTexts[$statusCode] ?? 'Error',
            'status' => $statusCode,
            'detail' => $exception->getMessage(),
        ];

        $response = new JsonResponse($data, $statusCode);
        $response->headers->set('Content-Type', 'application/problem+json');

        $event->setResponse($response);
    }
}

ErrorHandler и ProblemDetails (Symfony 6.3+)

# config/packages/framework.yaml
framework:
    exceptions:
        # Map exception classes to HTTP status codes
        App\Exception\ProductNotFoundException:
            status_code: 404
        App\Exception\InsufficientStockException:
            status_code: 409
<?php

declare(strict_types=1);

namespace App\Exception;

use Symfony\Component\HttpKernel\Attribute\WithHttpStatus;

// Automatically maps to HTTP 404
#[WithHttpStatus(404, headers: ['X-Error-Type' => 'not_found'])]
final class ProductNotFoundException extends \RuntimeException
{
    public function __construct(int $id)
    {
        parent::__construct(sprintf('Product #%d not found.', $id));
    }
}

// Automatically maps to HTTP 422
#[WithHttpStatus(422)]
final class ValidationException extends \RuntimeException
{
    public function __construct(
        private readonly array $violations,
    ) {
        parent::__construct('Validation failed.');
    }

    public function getViolations(): array
    {
        return $this->violations;
    }
}

Подвох экзамена: #[WithHttpStatus] (Symfony 6.3+) позволяет привязать HTTP-код к любому исключению без наследования от HttpException. Это чище, чем HttpExceptionInterface, и работает с ErrorHandler автоматически.

Content Negotiation

<?php

declare(strict_types=1);

namespace App\Controller\Api;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Serializer\SerializerInterface;

final class ExportController extends AbstractController
{
    #[Route('/api/products/export', methods: ['GET'])]
    public function export(
        Request $request,
        ProductRepository $products,
        SerializerInterface $serializer,
    ): Response {
        $data = $products->findAll();

        // Negotiate format from Accept header
        $format = $request->getPreferredFormat('json');

        // Or from _format parameter / extension
        // Route: /api/products/export.{_format}
        // $format = $request->getRequestFormat('json');

        $content = $serializer->serialize($data, $format, [
            'groups' => ['product:list'],
        ]);

        $contentTypes = [
            'json' => 'application/json',
            'xml' => 'application/xml',
            'csv' => 'text/csv',
        ];

        return new Response($content, 200, [
            'Content-Type' => $contentTypes[$format] ?? 'application/json',
        ]);
    }
}

Format Listener

<?php

declare(strict_types=1);

namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;

#[AsEventListener(event: RequestEvent::class, priority: 10)]
final class ApiFormatListener
{
    public function __invoke(RequestEvent $event): void
    {
        $request = $event->getRequest();

        if (!str_starts_with($request->getPathInfo(), '/api')) {
            return;
        }

        // Set JSON as default format for API routes
        if (null === $request->getContentTypeFormat()) {
            $request->setRequestFormat('json');
        }
    }
}

Нормализация ответов API

Паттерн: API Response Wrapper

<?php

declare(strict_types=1);

namespace App\DTO;

final readonly class ApiResponse
{
    public function __construct(
        public bool $success,
        public mixed $data = null,
        public ?string $message = null,
        public ?array $errors = null,
        public ?array $meta = null,
    ) {}

    public static function success(mixed $data, ?array $meta = null): self
    {
        return new self(success: true, data: $data, meta: $meta);
    }

    public static function error(string $message, ?array $errors = null): self
    {
        return new self(success: false, message: $message, errors: $errors);
    }

    public static function paginated(
        array $items,
        int $total,
        int $page,
        int $limit,
    ): self {
        return new self(
            success: true,
            data: $items,
            meta: [
                'total' => $total,
                'page' => $page,
                'limit' => $limit,
                'pages' => (int) ceil($total / $limit),
            ],
        );
    }
}
<?php

declare(strict_types=1);

// Usage in controller
#[Route('/api/products', methods: ['GET'])]
public function list(ProductRepository $repo): JsonResponse
{
    $products = $repo->findAll();

    return $this->json(
        ApiResponse::success($products, ['count' => count($products)]),
        200,
        [],
        ['groups' => ['product:list']],
    );
}

// Output:
// {
//     "success": true,
//     "data": [{"id": 1, "name": "Widget", ...}],
//     "meta": {"count": 2}
// }

Circular Reference Handler

<?php

declare(strict_types=1);

// config/packages/serializer.yaml
// framework:
//     serializer:
//         circular_reference_handler: App\Serializer\CircularReferenceHandler

namespace App\Serializer;

final class CircularReferenceHandler
{
    public function __invoke(object $object, string $format, array $context): mixed
    {
        // Return ID instead of the full object
        if (method_exists($object, 'getId')) {
            return $object->getId();
        }

        return (string) $object;
    }
}

MaxDepth для контроля глубины сериализации

<?php

declare(strict_types=1);

namespace App\Entity;

use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\MaxDepth;

final class Category
{
    #[Groups(['category:read'])]
    private int $id;

    #[Groups(['category:read'])]
    private string $name;

    #[Groups(['category:read'])]
    #[MaxDepth(1)]  // Only serialize 1 level deep
    private ?self $parent = null;

    /** @var Collection<int, self> */
    #[Groups(['category:read'])]
    #[MaxDepth(1)]
    private Collection $children;
}
<?php

declare(strict_types=1);

// Enable max depth in serialization context
return $this->json($category, 200, [], [
    'groups' => ['category:read'],
    'enable_max_depth' => true,  // Required to activate MaxDepth
]);

Итоги

  • new JsonResponse() -- простой JSON, json_encode() напрямую; $this->json() -- через Serializer
  • #[Groups] -- контроль сериализации: какие поля включать в ответ
  • #[SerializedName] -- кастомное имя поля в JSON; #[Ignore] -- исключить поле
  • #[MapRequestPayload] -- десериализация + валидация тела запроса в DTO (POST/PUT)
  • #[MapQueryString] -- маппинг query-параметров (GET) в DTO
  • #[WithHttpStatus] -- привязка HTTP-кода к исключению без HttpException
  • Circular reference: #[MaxDepth] + enable_max_depth: true
  • Content negotiation: $request->getPreferredFormat(), $request->getRequestFormat()
  • Ошибки API: HttpException бросается из контроллера, ExceptionListener ловит для формата

Проверь себя

Что делает опция `enable_max_depth: true` в контексте сериализации?

Что произойдёт, если валидация DTO с `#[MapRequestPayload]` не пройдёт?

Как маппить query-параметры GET-запроса на DTO в Symfony?

Как `#[WithHttpStatus(404)]` на классе исключения влияет на обработку ошибок?

В чём разница между `new JsonResponse($data)` и `$this->json($data)` в AbstractController?