REST API на Symfony писать удобно, но есть одна скучная особенность: контроллеры быстро начинают обрастать повторяющимся кодом. Парсинг запроса, валидация, однотипная JSON‑обёртка, try/catch с преобразованием исключений в HTTP‑ответы. Всё это несложно, но со временем размазывается по десяткам эндпоинтов и отвлекает от основной задачи.
Ниже - способ привести все в порядок с помощью небольшого бандла, который я использую в своих проектах.
Как может выглядеть типичный контроллер:
public function create(Request $request): JsonResponse { $data = json_decode($request->getContent(), true); $dto = new CreateUserDto( $data['email'], $data['name'] ); $errors = $this->validator->validate($dto); if (count($errors) > 0) { // у каждого разработчика свой формат ошибок return new JsonResponse(['errors' => (string) $errors], 422); } try { $user = $this->userService->create($dto); return new JsonResponse(['data' => $user->toArray()], 201); } catch (DuplicateEmailException $e) { return new JsonResponse(['error' => $e->getMessage()], 409); } catch (\Throwable $e) { // и вот это везде return new JsonResponse(['error' => 'Internal server error'], 500); } }
Хорошо, скажете вы, — введём DTO и будет порядок. Но DTO решает только проблему структурирования входных данных и валидации. Оборачивать ответы в единый формат, ловить исключения из сервисного слоя и превращать их в нужный JSON всё равно приходится вручную — в каждом контроллере, снова и снова. И формат у каждого разработчика всё равно будет немного своим. Умножьте это на десятки эндпоинтов. Добавьте трёх разработчиков с разными привычками. Получите неконсистентный API, который сложно поддерживать и невозможно документировать автоматически.
ApiKit — это легковесный Symfony‑бандл, который решает именно эту задачу: стандартизирует ответы, перехватывает исключения и даёт контроллеру делать только то, для чего он предназначен.
Что даёт проекту
Зависимости. Бандл опирается на symfony/validator и symfony/serializer — оба пакета входят в стандартный Symfony‑проект и скорее всего уже установлены. Doctrine — опциональна, нужна только для EntityExists constraint.
DTO не обязательны. Бандл не требует использования DTO. Контроллер может принимать Request напрямую и всё равно использовать respondSuccess/respondCreated. DTO — рекомендуемый паттерн, потому что он хорошо сочетается с #[MapRequestPayload] и делает валидацию декларативной, но это выбор разработчика, не требование бандла.
Единый формат ответов. Все успешные ответы имеют одну структуру, все ошибки — другую. Фронтенд, мобильное приложение, другой микросервис — все знают чего ожидать:
// Успешный ответ { "success": true, "data": { "id": 1, "email": "user@example.com", "name": "Mr. Author" }, "meta": { "timestamp": "2025-06-01T12:00:00+00:00" } } // Ошибка валидации { "success": false, "error": { "code": "VALIDATION_ERROR", "message": "Validation error", "details": { "violations": [ { "field": "email", "message": "This value is not a valid email address." } ] } } }
Тонкие контроллеры. Контроллер занимается только маршрутизацией и передачей данных — без ручной валидации, без try/catch, без json_decode. Валидация происходит автоматически через #[MapRequestPayload] и Symfony Validator, все необработанные исключения перехватывает ExceptionListener.
Перехват исключений на уровне ядра. ExceptionListener обрабатывает HttpExceptionInterface, ValidationFailedException и любой непойманный \Throwable. В dev‑окружении к ошибкам добавляется стек вызовов, в prod — только сообщение. 5xx‑ошибки логируются через PSR-3 логгер (по умолчанию включено, управляется через log_errors в конфиге), 4xx — нет, потому что это ошибки клиента.
ApiException для доменных ошибок. Вместо того чтобы создавать JsonResponse прямо в сервисах, бросаете типизированное исключение с нужным статусом и деталями:
throw new ApiException(409, 'Email already taken', [ 'field' => 'email', 'reason' => 'already_taken', ]); // Или блокировка аккаунта с контекстом throw new ApiException(423, 'Account locked', [ 'locked_until' => $lockedUntil->format(\DateTimeInterface::ATOM), 'attempts_left' => 0, ]);
EntityExists constraint. Опциональный валидатор для Doctrine, который прямо в DTO проверяет существование записи в БД — подключается автоматически, если Doctrine установлен:
#[EntityExists(Category::class, field: 'slug')] public readonly string $categorySlug;
Кастомный формат ответов. Дефолтная структура с success, data и error подходит большинству проектов, но если в команде принят другой формат — бандл это поддерживает. Достаточно реализовать ResponseFactoryInterface и зарегистрировать свою реализацию в контейнере:
final readonly class MyResponseFactory implements ResponseFactoryInterface { public function success(mixed $data = null, int $statusCode = 200, array $meta = []): JsonResponse { return new JsonResponse(['result' => $data, 'ok' => true], $statusCode); } // ... }
# config/services.yaml ApiKit\Response\ResponseFactoryInterface: alias: App\Api\MyResponseFactory
Весь бандл — контроллеры и ExceptionListener — начнёт использовать новый формат автоматически.
Тонкие контроллеры на практике
Вот пример CRUD для пользователей. Обратите внимание: каждый экшен — это 1–3 строки логики. Атрибуты #[OA\*] — опциональны и нужны только если используете nelmio/api-doc-bundle для генерации документации. Без него контроллер работает точно так же, update и delete ниже показывают как это выглядит без OpenAPI‑аннотаций.
#[Route('/api/users', name: 'api_users_')] #[OA\Tag(name: 'Users')] final class UserController extends AbstractApiController { public function __construct( private readonly UserService $userService, ) {} #[Route('', name: 'list', methods: ['GET'])] #[OA\Get(path: '/api/users', summary: 'List all users', responses: [ new OA\Response(response: 200, description: 'List of users', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: new Model(type: UserResponseDto::class)))) ])] public function list(): JsonResponse { return $this->respondSuccess( array_map(UserResponseDto::fromEntity(...), $this->userService->findAll()) ); } #[Route('', name: 'create', methods: ['POST'])] #[OA\Post(path: '/api/users', summary: 'Create a new user', requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: new Model(type: CreateUserDto::class))), responses: [ new OA\Response(response: 201, description: 'User created', content: new OA\JsonContent(ref: new Model(type: UserResponseDto::class))), new OA\Response(response: 422, description: 'Validation error'), new OA\Response(response: 409, description: 'Email already taken'), ] )] public function create(#[MapRequestPayload] CreateUserDto $dto): JsonResponse { return $this->respondCreated( UserResponseDto::fromEntity($this->userService->create($dto)) ); } #[Route('/{id}', name: 'update', requirements: ['id' => '\d+'], methods: ['PUT'])] public function update(int $id, #[MapRequestPayload] UpdateUserDto $dto): JsonResponse { return $this->respondSuccess( UserResponseDto::fromEntity($this->userService->update($id, $dto)) ); } #[Route('/{id}', name: 'delete', requirements: ['id' => '\d+'], methods: ['DELETE'])] public function delete(int $id): JsonResponse { $this->userService->delete($id); return $this->respondNoContent(); } }
DTO несёт в себе и правила валидации, и OpenAPI‑схему — одновременно:
#[OA\Schema(description: 'Create user payload', required: ['email', 'name'])] final readonly class CreateUserDto { public function __construct( #[Assert\NotBlank] #[Assert\Email] #[Assert\Length(max: 180)] #[OA\Property(example: 'user@example.com')] public string $email, #[Assert\NotBlank] #[Assert\Length(min: 1, max: 100)] #[OA\Property(example: 'Mr. Author')] public string $name, ) {} }
Если email невалиден — #[MapRequestPayload] сам бросает ValidationFailedException, ExceptionListener перехватывает и возвращает 422 со списком нарушений. Контроллер об этом ничего не знает.
Альтер��ативы
Прямых аналогов на уровне бандла немного, обычно разработчики решают те же задачи вручную или через отдельные пакеты.
API Platform — самый очевидный сосед. Мощный фреймворк для Resource‑based API с автогенерацией эндпоинтов, JSONAPI/Hydra/OpenAPI из коробки. Но это совсем другой масштаб: он диктует архитектуру, требует адаптеров и подходит не всем проектам. ApiKit — это инструмент для тех, кто хочет писать обычные контроллеры, но чище.
FOSRestBundle — исторически популярное решение для REST в Symfony, но активная разработка замедлилась, и для Symfony 7+ его использование уже не очевидно.
Самописные базовые контроллеры и ExceptionListener — самый распространённый путь. Большинство команд в итоге пишут то же самое, что делает ApiKit, только каждый раз заново и немного по‑разному.
league/fractal или spatie/laravel‑\* — экосистема Laravel, не применимо для Symfony.
Загрузка файлов и медиа
#[MapRequestPayload] работает с JSON и form‑data, но не умеет резолвить UploadedFile — это задача другого атрибута. Начиная с Symfony 7.1, для файлов есть #[MapUploadedFile], и он отлично вписывается в тот же паттерн тонкого контроллера. Валидация файла описывается прямо в атрибуте через стандартные Symfony‑constraints — Assert\Image или Assert\Video (появился в 7.4, требует ffprobe на сервере — устанавливается вместе с apt install ffmpeg).
#[Route('/{id}/avatar', name: 'upload_avatar', requirements: ['id' => '\d+'], methods: ['POST'])] public function uploadAvatar( int $id, #[MapUploadedFile([ new Assert\NotNull(message: 'Avatar file is required'), new Assert\Image( maxSize: '5M', mimeTypes: ['image/jpeg', 'image/png', 'image/webp'], mimeTypesMessage: 'Only JPEG, PNG and WebP images are allowed', maxWidth: 2048, maxHeight: 2048, ), ])] UploadedFile $avatar, ): JsonResponse { return $this->respondSuccess( UserResponseDto::fromEntity($this->userService->updateAvatar($id, $avatar)) ); }
При нарушении constraint'а #[MapUploadedFile] бросает HttpException, который ExceptionListener уже перехватывает и превращает в стандартный 422-ответ — никакого дополнительного кода в контроллере не нужно.
Для видео паттерн идентичен - выносим в отдельный контроллер:
#[Route('/api/media', name: 'api_media_')] final class MediaController extends AbstractApiController { public function __construct( private readonly FileUploader $fileUploader, ) {} #[Route('/video', name: 'upload_video', methods: ['POST'])] public function uploadVideo( #[MapUploadedFile([ new Assert\NotNull(message: 'Video file is required'), new Assert\Video( maxSize: '100M', mimeTypes: ['video/mp4', 'video/webm'], maxWidth: 1920, maxHeight: 1080, mimeTypesMessage: 'Only MP4 and WebM videos are allowed', ), ])] UploadedFile $video, ): JsonResponse { $path = $this->fileUploader->upload($video, 'videos'); return $this->respondSuccess([ 'path' => $path, 'originalName' => $video->getClientOriginalName(), 'size' => $video->getSize(), 'mimeType' => $video->getMimeType(), ]); } }
Если нужно принять JSON‑поля и файл в одном запросе (multipart/form-data), оба атрибута комбинируются в сигнатуре метода — валидируются независимо, любое нарушение даёт 422:
#[Route('', name: 'create', methods: ['POST'])] public function create( #[MapRequestPayload] CreatePostDto $dto, #[MapUploadedFile([ new Assert\Image( maxSize: '2M', mimeTypes: ['image/jpeg', 'image/png', 'image/webp'], ), ])] ?UploadedFile $thumbnail = null, ): JsonResponse { return $this->respondCreated( PostResponseDto::fromEntity($this->postService->create($dto, $thumbnail)) ); }
Assert\Image и Assert\Video не имеют никакого отношения к самому бандлу — они стандартные Symfony‑constraints, а ExceptionListener просто перехватывает нарушения независимо от того, чем они вызваны.
На каких архитектурах работает
Классический MVC / сервисный слой — идеальный fit. Контроллер -> Сервис -> Репозиторий. ApiKit написан именно для этого сценария.
Hexagonal Architecture (Ports & Adapters) — отлично сочетается. Контроллер остаётся тонким адаптером на входе, DTO — портом. ApiException можно бросать из любого слоя Application‑сервисов.
CQRS — работает без ограничений. Контроллер маппит DTO в Command/Query и передаёт в шину. Ответ — результат Handler'а, обёрнутый через respondSuccess.
DDD — работает корректно, включая строгую изоляцию слоёв. Подробнее — в следующем разделе.
Микросервисы — отличный выбор для небольших сервисов, где нужен консистентный API без избыточности API Platform.
Vertical Slice Architecture (VSA) — хорошо сочетается. Каждый слайс — это вертикальный срез под конкретную фичу (CreateUser, UploadAvatar, PublishPost) со своим контроллером, DTO и хендлером. Бандл не знает как организован код и не накладывает ограничений на структуру директорий. Контроллер‑слайс с __invoke выглядит естественно:
// src/Features/CreateUser/CreateUserController.php final class CreateUserController extends AbstractApiController { public function __construct( private readonly CreateUserHandler $handler, ) {} #[Route('/api/users', methods: ['POST'])] public function __invoke(#[MapRequestPayload] CreateUserInput $input): JsonResponse { return $this->respondCreated($this->handler->handle($input)); } }
ApiException бросается из хендлера, ExceptionListener перехватывает глобально — каждый слайс пишется независимо, но внешний API‑контракт остаётся единым.
Модульный монолит — каждый модуль может использовать ApiControllerTrait независимо, формат ответов будет единым по всему приложению.
EntityExists в DDD-проектах
Классический вопрос при использовании бандла в проектах с DDD: не нарушает ли EntityExists изоляцию доменного слоя? Нет — архитектура построена через порт.
Constraint #[EntityExists] и EntityExistsValidator зависят только от EntityExistenceCheckerInterface. Вся работа с Doctrine изолирована в DoctrineEntityExistenceChecker на инфраструктурном уровне.
Как бандл регистрирует Doctrine‑реализацию. Здесь важна точность формулировки — недостаточно просто проверить «установлен ли пакет». doctrine/orm может лежать в vendor (например, как require-dev), но DoctrineBundle при этом может быть не сконфигурирован, и сервис EntityManagerInterface в контейнере не появится. Автоварирование тогда упадёт с ошибкой на cache:clear.
Бандл решает это двухуровневой проверкой:
ApiKitExtensionрегистрирует определения только еслиinterface_exists('Doctrine\ORM\EntityManagerInterface')— строковая проверка, чтобы не создавать статическую ссылку на класс и не получать «Undefined class» от PHPStan в проектах без Doctrine.RegisterDoctrineCheckerPassзапускается позже, когда контейнер уже собран, и проверяет$container->has('doctrine.orm.entity_manager'). Если серв��са нет — убирает определенияDoctrineEntityExistenceChecker,EntityExistsValidatorи алиас интерфейса. Это защищает от кейса «пакет установлен, но DoctrineBundle не настроен».
Итог: бандл корректно работает в любой конфигурации — с Doctrine, без Doctrine, и в промежуточном состоянии.
Custom persistence backend. В проектах с другим ORM или нестандартным persistence‑слоем можно зарегистрировать собственную реализацию:
// Infrastructure/Persistence/CustomEntityExistenceChecker.php final class CustomEntityExistenceChecker implements EntityExistenceCheckerInterface { public function __construct( private readonly MyRepositoryRegistry $registry, ) {} public function exists(string $entityClass, mixed $value, string $field = 'id'): bool { return $this->registry->for($entityClass)->existsBy($field, $value); } }
# config/services.yaml services: ApiKit\Validator\Constraint\EntityExistenceCheckerInterface: alias: App\Infrastructure\Persistence\CustomEntityExistenceChecker
Constraint #[EntityExists] остаётся в слое Application или Domain — он не знает ничего о Doctrine или конкретном хранилище. Вся работа с БД изолирована в инфраструктурном слое, как и должно быть.
Отдельный момент — производительность(!): каждый EntityExists на поле DTO это отдельный SELECT. При валидации нескольких таких полей запросы идут последовательно. Для большинства случаев это незначительно, но при высоконагруженных эндпоинтах стоит учитывать.
Установка и конфигурация
composer require bulatronic/api-kit
Бандл доступен на Packagist. Требует PHP 8.2+ и Symfony ^7.4|^8.0. При установке через Composer бандл регистрируется автоматически. Рецепт для Symfony Flex добавлен. Конфигурационный файл config/packages/api_kit.yaml будет создаваться автоматически. Все параметры имеют разумные значения по умолчанию, но вы можете переопределить их при необходимости:
# config/packages/api_kit.yaml api_kit: response: include_timestamp: true pretty_print: '%kernel.debug%' exception_handling: log_errors: true show_trace: '%kernel.debug%' validation: translate_messages: true
Если контроллер не наследует никакой базовый класс — extend AbstractApiController:
final class UserController extends AbstractApiController { #[Route('/api/users', methods: ['GET'])] public function list(): JsonResponse { return $this->respondSuccess($this->userService->findAll()); } }
Если уже есть своя иерархия наследования — подключить трейт напрямую:
// Уже наследуете другой базовый класс - просто добавьте трейт class MyController extends MyBaseController { use ApiControllerTrait; }
Оба варианта дают одинаковый набор методов: respondSuccess, respondCreated, respondNoContent, respondError, respondNotFound, respondForbidden, respondUnauthorized.
Открытый исходный код
Бандл — open source. Можно использовать, менять и адаптировать под свои проекты. Если не хватает какой‑то фичи, поведение отличается от ожидаемого, или есть идея по улучшению — можно открыть issue или отправить PR. Если инструмент оказался полезным, звезда на GitHub — это лучшая валюта и мотивация.
Итого
ApiKit решает скучную, но важную задачу: убирает шаблонный код из контроллеров, гарантирует консистентность API и даёт команде единый язык для ошибок и ответов. Он не навязывает архитектуру, не тянет лишних зависимостей и интегрируется в любой Symfony‑проект за несколько минут.
Основные сценарии, где он даёт наибольший эффект — проекты с несколькими разработчиками, где консистентность важна, и API для мобильных приложений или SPA, где фронтенд рассчитывает на предсказуемую структуру ответов.
