bulatronic / api-kit
Lightweight Symfony bundle for building REST APIs with DTO-first validation, standardized responses, and thin controllers
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)
Provides
None
Conflicts
Replaces
None
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
Security Considerations
ApiKit standardizes the HTTP layer only — it does not authenticate, authorize, rate-limit, or sanitize anything for you. The table below is an honest account of what the bundle's code actually protects against, and what remains entirely the consuming project's responsibility.
| Risk | Protection in ApiKit's code | Residual risk (on the consumer) |
|---|---|---|
| Stack traces leaking to clients in production | show_trace defaults to '%kernel.debug%' — off unless APP_DEBUG=1 |
Misconfiguring show_trace: true in a prod environment, or leaving APP_DEBUG=1 on in prod, re-opens the leak |
| Entity internals (Doctrine proxies, hidden fields) serialized into a response | None — ApiKit has no serialization-group enforcement by design (see docs/ARCHITECTURE.md, "What's Not Included") | Passing a Doctrine entity straight to respondSuccess()/respondCreated() instead of mapping it to a response DTO; see docs/CONTROLLER-CONVENTIONS.md |
Sensitive data leaking via ApiException details |
None — getDetails() is serialized verbatim, exactly as the developer constructed it |
Throwing ApiException with secrets, internal IDs, or raw DB values in details sends them straight to the client |
| Mass assignment / over-posting via request DTOs | #[MapRequestPayload] only ever populates the properties declared on your DTO class, never an Entity directly |
A DTO that mirrors privileged Entity fields too closely (e.g. role, isAdmin) still lets a client set them — ApiKit has no field allow-listing |
Existence oracle / enumeration via EntityExists |
None — it's a convenience validator, not an authorization check | A 422 vs. no error on EntityExists(User::class) confirms whether a given ID/UUID exists; add rate limiting or avoid exposing it on sensitive fields |
| Vulnerable transitive dependencies (Doctrine, Nelmio, swagger-php) | Kept as require-dev/suggest, never require — nothing loads unless your own code references ApiKit\OpenApi\* or EntityExists |
Whatever CVEs exist in the exact versions you pick are on you — ApiKit pins broad ranges, not exact versions; run composer audit in your own project |
None of this is enforced by a scanner or a runtime guard — it's a map of where the line between "ApiKit's job" and "your job" actually sits, so you know what to check for in review instead of assuming the bundle does it for you.
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