A minimalist Symfony Bundle for building REST APIs with standardized responses, automatic exception handling, and DTO validation.
- Standardized JSON responses — unified format for all endpoints
- Automatic exception handling — exceptions become JSON without
try/catchin controllers ApiException— throw structured errors with details from anywhere in the codebase- DTO validation — uses native Symfony
#[MapRequestPayload]/#[MapQueryString] EntityExistsvalidator — check entity existence directly in DTO- File uploads —
#[MapUploadedFile]with automatic validation error handling (images, videos, mixed multipart) AbstractApiController+ApiControllerTrait— convenient response helpers- PHP 8.2 + Symfony 7.4 — modern features, minimal dependencies
ApiKit only standardizes the HTTP layer — responses and exception handling. It has no opinion on how the rest of your application is organized.
| Architecture | How ApiKit fits |
|---|---|
| Layered / Traditional | Controller → Service → Repository. Controllers use AbstractApiController, services throw exceptions. |
| DDD | ApiKit lives in the infrastructure/presentation layer. The domain knows nothing about it — domain services throw standard PHP exceptions, ExceptionListener catches them outside. |
| Hexagonal (Ports & Adapters) | AbstractApiController is a driving adapter. The application core (ports + domain) has zero dependency on ApiKit. |
| Vertical Slice Architecture | ApiControllerTrait is the natural fit — each slice is an independent class with no shared inheritance. The trait adds respond* methods without forcing a class hierarchy. |
Without ApiKit — boilerplate in every controller:
public function create(Request $request): JsonResponse
{
try {
$dto = $this->serializer->deserialize($request->getContent(), CreatePostDto::class, 'json');
$errors = $this->validator->validate($dto);
if (count($errors) > 0) {
return $this->json(['error' => (string) $errors], 422);
}
$result = $this->service->create($dto);
return $this->json(['success' => true, 'data' => $result], 201);
} catch (ConflictException $e) {
return $this->json(['error' => $e->getMessage()], 409);
} catch (\Throwable $e) {
$this->logger->error($e->getMessage());
return $this->json(['error' => 'Internal error'], 500);
}
}With ApiKit — one line, same result:
public function create(#[MapRequestPayload] CreatePostDto $dto): JsonResponse
{
return $this->respondCreated($this->service->create($dto));
}Validation, exception handling, and logging are handled automatically and uniformly across all endpoints.
composer require bulatronic/api-kitThe bundle is automatically registered via Symfony Flex.
final readonly class CreatePostDto
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Length(min: 3, max: 255)]
public string $title,
#[Assert\NotBlank]
public string $content,
) {}
}Two options — pick one:
Option A: extend AbstractApiController (simplest, when you don't extend another class):
use ApiKit\Controller\AbstractApiController;
#[Route('/api/posts')]
final class PostController extends AbstractApiController
{
public function __construct(
private readonly PostService $postService,
) {}
#[Route('', methods: ['GET'])]
public function list(): JsonResponse
{
return $this->respondSuccess($this->postService->findAll());
}
#[Route('', methods: ['POST'])]
public function create(#[MapRequestPayload] CreatePostDto $dto): JsonResponse
{
return $this->respondCreated($this->postService->create($dto));
}
#[Route('/{id}', methods: ['DELETE'])]
public function delete(int $id): JsonResponse
{
$this->postService->delete($id);
return $this->respondNoContent();
}
}Option B: use ApiControllerTrait (when you already extend another class):
use ApiKit\Controller\ApiControllerTrait;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
#[Route('/api/posts')]
final class PostController extends AbstractController
{
use ApiControllerTrait;
// ... same methods
}use ApiKit\Exception\ApiException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class PostService
{
public function findOrFail(int $id): Post
{
$post = $this->repository->find($id);
if (null === $post) {
throw new NotFoundHttpException('Post not found');
}
return $post;
}
public function create(CreatePostDto $dto): Post
{
if ($this->repository->existsByTitle($dto->title)) {
throw new ApiException(409, 'Post with this title already exists', [
'field' => 'title',
'value' => $dto->title,
]);
}
// ...
}
}ExceptionListener catches everything automatically. Your controller stays clean:
public function create(#[MapRequestPayload] CreatePostDto $dto): JsonResponse
{
return $this->respondCreated($this->postService->create($dto));
}Success (200):
{
"success": true,
"data": [{"id": 1, "title": "Post 1"}],
"meta": {"timestamp": "2026-02-23T12:00:00+00:00"}
}Validation error (422) — from #[MapRequestPayload]:
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation error",
"details": {
"violations": [
{"field": "title", "message": "This value should not be blank."}
]
}
}
}ApiException error (409):
{
"success": false,
"error": {
"code": "CONFLICT",
"message": "Post with this title already exists",
"details": {
"field": "title",
"value": "My Post"
}
}
}// Success responses
$this->respondSuccess($data, $status = 200, $meta = []);
$this->respondCreated($data, $meta = []);
$this->respondNoContent();
// Error responses
$this->respondError($message, $status = 400, $code = 'ERROR', $details = []);
$this->respondNotFound($message = 'Resource not found');
$this->respondForbidden($message = 'Access forbidden');
$this->respondUnauthorized($message = 'Unauthorized');public function __construct(
private readonly ResponseFactory $responseFactory,
) {}
$this->responseFactory->success($data, $statusCode = 200, $meta = []);
$this->responseFactory->created($data, $meta = []);
$this->responseFactory->noContent();
$this->responseFactory->error($message, $code = 'ERROR', $statusCode = 400, $details = []);use ApiKit\Exception\ApiException;
// Without details
throw new ApiException(409, 'Email already taken');
// With structured details
throw new ApiException(423, 'Account locked', [
'locked_until' => $until->format(\DateTimeInterface::ATOM),
'reason' => 'too_many_attempts',
]);Requires Doctrine ORM:
composer require doctrine/orm doctrine/doctrine-bundleuse ApiKit\Validator\Constraint\EntityExists;
final readonly class CreateCommentDto
{
public function __construct(
#[Assert\NotBlank]
public string $content,
#[Assert\Uuid]
#[EntityExists(User::class)]
public string $authorId,
// Search by field other than id
#[EntityExists(entityClass: Category::class, field: 'slug')]
public ?string $categorySlug = null,
) {}
}ExceptionListener handles automatically (no try/catch in controllers needed):
| Exception | HTTP Status | Notes |
|---|---|---|
ValidationFailedException |
422 | From #[MapRequestPayload] or manual |
HttpException(*, prev: ValidationFailed) |
Same as exception | Violations extracted |
ApiException |
Configured code | getDetails() included in response |
Any HttpExceptionInterface |
Status from exception | Standard Symfony exceptions |
Any other \Throwable |
500 | Logged; trace shown in debug mode |
Create config/packages/api_kit.yaml (optional — sensible defaults work out of the box):
api_kit:
response:
include_timestamp: true # Include timestamp in responses
pretty_print: '%kernel.debug%' # Pretty-print JSON in debug mode
exception_handling:
log_errors: true # Log server errors (5xx only)
show_trace: '%kernel.debug%' # Include stack trace in 500 responses# Run tests
composer test
# PHPStan static analysis
composer phpstan
# Check code style
composer cs-check
# Fix code style
composer cs-fix- PHP 8.2+
- Symfony 7.4+
Optional:
- Doctrine ORM — required for
EntityExistsvalidator
MIT — see LICENSE.
Bulat Timerbaev — bulat.coder@gmail.com