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