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 ловит для формата