bulatronic / api-kit
Lightweight Symfony bundle for building REST APIs with DTO-first validation, standardized responses, and thin controllers
Package info
Type:symfony-bundle
pkg:composer/bulatronic/api-kit
Requires
- php: >=8.2
- symfony/event-dispatcher: ^7.4|^8.1.1
- symfony/framework-bundle: ^7.4|^8.1.1
- symfony/http-foundation: ^7.4|^8.1.1
- symfony/serializer: ^7.4|^8.1.1
- symfony/validator: ^7.4|~8.0.14
Requires (Dev)
- ext-ctype: *
- ext-iconv: *
- doctrine/orm: ^3.6.7
- friendsofphp/php-cs-fixer: ^3.95.15
- nelmio/api-doc-bundle: ^5.10.3
- phpstan/phpstan: ^2.2.5
- phpstan/phpstan-phpunit: ^2.0.18
- phpstan/phpstan-symfony: ^2.0.20
- phpunit/phpunit: ^13.2.4
- symfony/browser-kit: ^7.4|^8.1.1
- symfony/console: ^7.4|^8.1.1
- symfony/css-selector: ^7.4|^8.1
- symfony/dotenv: ^7.4|^8.1
- symfony/flex: ^2.11
- symfony/phpunit-bridge: ^7.4|^8.1.1
- symfony/runtime: ^7.4|^8.1
- symfony/yaml: ^7.4|^8.1.1
- zircote/swagger-php: ^6.4
Suggests
- doctrine/doctrine-bundle: Required if you want to use EntityExistsValidator (^2.14)
- doctrine/orm: Required if you want to use EntityExistsValidator (^3.4)
- nelmio/api-doc-bundle: Required at runtime if you use ApiKit\OpenApi\Attribute\* — they reference DTOs via Nelmio\ApiDocBundle\Attribute\Model (tested with ^5.0)
- zircote/swagger-php: Required at runtime if you use ApiKit\OpenApi\Attribute\* — every attribute there extends OpenApi\Attributes\Response (tested with ^6.0)
Conflicts
README
A minimalist Symfony Bundle for building REST APIs with standardized responses, automatic exception handling, and DTO validation.
Key Features
- 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
Architecture Compatibility
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. |
Before & After
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.
Installation
composer require bulatronic/api-kit
The bundle is automatically registered via Symfony Flex.
Quick Start
1. Create a DTO
final readonly class CreatePostDto { public function __construct( #[Assert\NotBlank] #[Assert\Length(min: 3, max: 255)] public string $title, #[Assert\NotBlank] public string $content, ) {} }
2. Create a Controller
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 }
3. Throw Errors from Services — No try/catch Needed
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)); }
4. Standardized Response Format
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"
}
}
}
Reference
AbstractApiController / ApiControllerTrait Methods
// 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');
ResponseFactory (inject as dependency)
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 = []);
ApiException
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', ]);
EntityExists Validator
Requires Doctrine ORM:
composer require doctrine/orm doctrine/doctrine-bundle
use 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, ) {} }
OpenAPI Documentation Attributes
Requires nelmio/api-doc-bundle + zircote/swagger-php:
composer require nelmio/api-doc-bundle
Attributes that document the {success, data, meta} / {success, error} envelope in one line,
instead of hand-writing it (and getting it wrong — see docs/OPENAPI.md for
the bug this fixes):
use ApiKit\OpenApi\Attribute\ApiCreatedResponse; use ApiKit\OpenApi\Attribute\ApiErrorResponse; #[OA\Post(path: '/api/tweets', summary: 'Create a tweet', tags: ['Tweets'])] #[ApiCreatedResponse(CreateTweetResponseDto::class)] #[ApiErrorResponse(422, 'Validation error', isValidation: true)] public function __invoke(#[MapRequestPayload] CreateTweetRequestDto $request): JsonResponse { return $this->respondCreated(['id' => $tweetId]); }
ApiSuccessResponse, ApiCreatedResponse (201 by default), ApiNoContentResponse (204), and
ApiErrorResponse (links to ErrorEnvelope/ValidationErrorEnvelope) — full reference,
before/after examples, and how to register the envelope schemas in nelmio_api_doc.yaml:
docs/OPENAPI.md.
Exception Handling
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 |
Configuration
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
Testing
# Run tests composer test # PHPStan static analysis composer phpstan # Check code style composer cs-check # Fix code style composer cs-fix
Requirements
- PHP 8.2+
- Symfony 7.4+
Optional:
- Doctrine ORM — required for
EntityExistsvalidator nelmio/api-doc-bundle+zircote/swagger-php— required for theApiKit\OpenApi\Attribute\*OpenAPI attributes
Documentation
- Usage Examples
- Architecture
- Controller Conventions — codified rules for using ApiKit correctly, written to be copied into your project's AI agent instructions (
AGENTS.md/CLAUDE.md/Copilot/etc.) - OpenAPI Documentation Attributes
- Development Guide
- Contributing
For agents working on ApiKit itself (not consuming it), see AGENTS.md.
License
MIT — see LICENSE.
Author
Bulat Timerbaev — bulat.coder@gmail.com