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.

Бандл решает это двухуровневой проверкой:

  1. ApiKitExtension регистрирует определения только если interface_exists('Doctrine\ORM\EntityManagerInterface') — строковая проверка, чтобы не создавать статическую ссылку на класс и не получать «Undefined class» от PHPStan в проектах без Doctrine.

  2. 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, где фронтенд рассчитывает на предсказуемую структуру ответов.