Writing REST APIs in Symfony is quite comfortable, but there's one recurring annoyance: controllers quickly become cluttered with repetitive code. Parsing the request, validating input, wrapping responses in the same JSON envelope, try/catch blocks converting exceptions into HTTP responses. None of this is hard, but over time it ends up duplicated across dozens of endpoints and distracts from the actual work.
Below is a way to bring order to all of this with a small bundle I use in my own projects.
What a Typical Controller Looks Like
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) {
// every developer has their own error format
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) {
// and this is everywhere
return new JsonResponse(['error' => 'Internal server error'], 500);
}
}
Sure, you might say — introduce DTOs and things will be fine. But DTOs only solve the problem of structuring and validating input. Wrapping responses in a unified format, catching exceptions from the service layer and turning them into proper JSON — that still has to be done manually, in every controller, over and over again. And each developer tends to introduce a slightly different format. Scale that across dozens of endpoints. Add three developers with different habits. The result is an inconsistent API that's hard to maintain and impossible to document automatically.
ApiKit is a lightweight Symfony bundle that solves exactly this problem: it standardizes responses, intercepts exceptions, and lets the controller do only what it's meant to do.
What It Gives Your Project
Dependencies. The bundle relies on symfony/validator and symfony/serializer — both are part of a standard Symfony project and are most likely already installed. Doctrine is optional and only needed for the EntityExists constraint.
DTOs are not required. The bundle does not require you to use DTOs. A controller can accept Request directly and still use respondSuccess/respondCreated. DTOs are a recommended pattern because they pair well with #[MapRequestPayload] and make validation declarative — but it's the developer's choice, not the bundle's requirement.
Unified response format. All successful responses share one structure, all errors share another. A frontend, a mobile app, another microservice — everyone knows what to expect:
// Successful response
{
"success": true,
"data": { "id": 1, "email": "user@example.com", "name": "Mr. Author" },
"meta": { "timestamp": "2025-06-01T12:00:00+00:00" }
}
// Validation error
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation error",
"details": {
"violations": [
{ "field": "email", "message": "This value is not a valid email address." }
]
}
}
}
Slim controllers. A controller handles only routing and data passing — no manual validation, no try/catch, no json_decode. Validation happens automatically via #[MapRequestPayload] and Symfony Validator; all unhandled exceptions are caught by ExceptionListener.
Kernel-level exception handling. ExceptionListener handles HttpExceptionInterface, ValidationFailedException, and any uncaught \Throwable. In the dev environment, a stack trace is appended to errors; in prod — only the message. 5xx errors are logged via a PSR-3 logger (enabled by default, controlled via log_errors in config), 4xx are not — because those are client errors.
ApiException for domain errors. Instead of building a JsonResponse directly inside services, you throw a typed exception with the desired status and details:
throw new ApiException(409, 'Email already taken', [
'field' => 'email',
'reason' => 'already_taken',
]);
// Or an account lock with context
throw new ApiException(423, 'Account locked', [
'locked_until' => $lockedUntil->format(\DateTimeInterface::ATOM),
'attempts_left' => 0,
]);
EntityExists constraint. An optional Doctrine validator that checks for the existence of a database record directly inside a DTO — it is wired automatically if Doctrine is installed:
#[EntityExists(Category::class, field: 'slug')]
public readonly string $categorySlug;
Custom response format. The default structure with success, data, and error works for most projects, but if your team uses a different format — the bundle supports that too. Just implement ResponseFactoryInterface and register your implementation in the container:
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
The entire bundle — controllers and ExceptionListener — will start using the new format automatically.
Slim Controllers in Practice
Here's a CRUD example for users. Notice: each action is 1–3 lines of logic. The #[OA\*] attributes are optional and only needed if you use nelmio/api-doc-bundle for documentation generation. Without it the controller works exactly the same — the update and delete actions below show what it looks like without OpenAPI annotations.
#[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();
}
}
A DTO carries both validation rules and the OpenAPI schema at the same time:
#[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,
) {}
}
If the email is invalid, #[MapRequestPayload] throws a ValidationFailedException on its own, ExceptionListener catches it and returns a 422 with the list of violations. The controller knows nothing about this.
Alternatives
There are not many direct bundle-level equivalents; developers usually solve the same problems manually or through separate packages.
API Platform is the most obvious neighbor. A powerful framework for resource-based APIs with auto-generated endpoints, JSONAPI/Hydra/OpenAPI out of the box. But it's a completely different scale: it imposes a specific architectural approach, requires adapters, and is not a fit for every project. ApiKit is a tool for those who want to write regular controllers — just cleaner.
FOSRestBundle was historically popular for REST in Symfony, but active development has slowed down, and its use with Symfony 7+ is no longer straightforward.
Handwritten base controllers and ExceptionListener — the most common path. Most teams end up writing exactly what ApiKit does, just every time from scratch and slightly differently.
league/fractal or spatie/laravel-* — the Laravel ecosystem, not applicable to Symfony.
File and Media Uploads
#[MapRequestPayload] works with JSON and form-data, but it cannot resolve UploadedFile — that's the job of a different attribute. As of Symfony 7.1, there is #[MapUploadedFile] for files, and it fits naturally into the same slim-controller pattern. File validation is described directly in the attribute using standard Symfony constraints — Assert\Image or Assert\Video (added in 7.4, requires ffprobe on the server — installed via 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))
);
}
If a constraint is violated, #[MapUploadedFile] throws an HttpException, which ExceptionListener already intercepts and converts into a standard 422 response — no extra code needed in the controller.
For video the pattern is identical — extract it into a separate controller:
#[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(),
]);
}
}
If you need to accept both JSON fields and a file in a single request (multipart/form-data), both attributes can be combined in the method signature — they are validated independently, and any violation produces a 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 and Assert\Video have nothing to do with the bundle itself — they are standard Symfony constraints, and ExceptionListener simply intercepts violations regardless of what triggered them.
Supported Architectures
Classic MVC / service layer — an ideal fit. Controller → Service → Repository. ApiKit was written exactly for this scenario.
Hexagonal Architecture (Ports & Adapters) — pairs well. The controller stays a thin input adapter, the DTO serves as a port. ApiException can be thrown from any Application service layer.
CQRS — works without restrictions. The controller maps a DTO into a Command/Query and passes it to the bus. The response is the result of a Handler, wrapped with respondSuccess.
DDD — works correctly, including strict layer isolation. More details in the next section.
Microservices — a great choice for small services that need a consistent API without the overhead of API Platform.
Vertical Slice Architecture (VSA) — pairs nicely. Each slice is a vertical cut for a specific feature (CreateUser, UploadAvatar, PublishPost) with its own controller, DTO, and handler. The bundle does not know how the code is organized and places no constraints on directory structure. An invokable controller slice looks natural:
// 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 is thrown from the handler, ExceptionListener catches it globally — each slice is written independently, but the external API contract stays unified.
Modular monolith — each module can use ApiControllerTrait independently, and the response format will be consistent across the entire application.
EntityExists in DDD Projects
A classic question when using the bundle in DDD projects: does EntityExists violate the isolation of the domain layer? No — the architecture is built around a port.
The #[EntityExists] constraint and EntityExistsValidator depend only on EntityExistenceCheckerInterface. All Doctrine interaction is isolated in DoctrineEntityExistenceChecker at the infrastructure level.
How the bundle registers the Doctrine implementation. Precision matters here — simply checking whether the package is installed is not enough. doctrine/orm may exist in vendor (e.g. as require-dev) while DoctrineBundle is not configured and EntityManagerInterface is therefore absent from the container. Autowiring would then fail with an error on cache:clear.
The bundle handles this with a two-level check:
-
ApiKitExtensionregisters definitions only ifinterface_exists('Doctrine\ORM\EntityManagerInterface')— a string check, to avoid creating a static class reference and triggering "Undefined class" from PHPStan in projects without Doctrine. -
RegisterDoctrineCheckerPassruns later, after the container is compiled, and checks$container->has('doctrine.orm.entity_manager'). If the service is absent — it removes the definitions forDoctrineEntityExistenceChecker,EntityExistsValidator, and the interface alias. This guards against the "package installed but DoctrineBundle not configured" scenario.
The result: the bundle works correctly in any configuration — with Doctrine, without Doctrine, and in the in-between state.
Custom persistence backend. In projects using a different ORM or a non-standard persistence layer, you can register your own implementation:
// 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
The #[EntityExists] constraint stays in the Application or Domain layer — it knows nothing about Doctrine or any specific storage. All database work is isolated in the infrastructure layer, as it should be.
One separate note — performance: every EntityExists on a DTO field means a separate SELECT. When validating multiple such fields, queries run sequentially. For most use cases this is negligible, but it's worth keeping in mind for high-load endpoints.
Installation and Configuration
composer require bulatronic/api-kit
The bundle is available on Packagist. Requires PHP 8.2+ and Symfony ^7.4|^8.0. When installed via Composer, the bundle registers itself automatically. A recipe for Symfony Flex has been submitted to symfony/recipes-contrib. Once approved, the config/packages/api_kit.yaml configuration file will be created automatically. Until then, create it manually if needed — all values have sensible defaults and the file is not required:
# 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
If your controller does not extend any base class — extend AbstractApiController:
final class UserController extends AbstractApiController
{
#[Route('/api/users', methods: ['GET'])]
public function list(): JsonResponse
{
return $this->respondSuccess($this->userService->findAll());
}
}
If you already have your own inheritance hierarchy — add the trait directly:
// Already extending another base class — just add the trait
class MyController extends MyBaseController
{
use ApiControllerTrait;
}
Both options give you the same set of methods: respondSuccess, respondCreated, respondNoContent, respondError, respondNotFound, respondForbidden, respondUnauthorized.
Open Source
The bundle is open source. You are free to use it, modify it, and adapt it to your own projects. If a feature is missing, behavior differs from what you expected, or you have an improvement idea — feel free to open an issue or send a PR. If the tool proved useful, a GitHub star is the best currency and motivation.
Summary
ApiKit solves a boring but important problem: it removes boilerplate from controllers, guarantees API consistency, and gives the team a shared language for errors and responses. It does not impose an architecture, does not pull in unnecessary dependencies, and integrates into any Symfony project in a few minutes.
The scenarios where it delivers the most value are projects with multiple developers where consistency matters, and APIs for mobile apps or SPAs where the frontend relies on a predictable response structure.
GitHub: bulatronic/api-kit
Packagist: bulatronic/api-kit
Top comments (0)