Architecture and design decisions of the ApiKit Bundle.
ApiKit is a minimalist Symfony Bundle that provides essential components for building REST APIs. The philosophy is to standardize API responses and automate common patterns without being opinionated about business logic.
ResponseFactoryInterface and ResponseFactoryPurpose: Create standardized JSON responses.
Response format:
// Success
{
"success": true,
"data": mixed,
"meta": {"timestamp": "ISO8601", ...}
}
// Error
{
"success": false,
"error": {
"code": "SNAKE_CASE_CODE",
"message": "Human-readable message",
"details": {}
}
}
Why this format?
success flag makes client-side parsing trivialcode is machine-readable (for i18n, frontend logic)details is optional structured payload (violations, business context)meta carries cross-cutting concerns like pagination or timestampExtension points:
ResponseFactoryInterface is the declared contract. ResponseFactory is the default implementation. ExceptionListener and ApiControllerTrait both depend on the interface, so replacing the factory replaces the format everywhere — including exception handling.
Two extension strategies:
| Goal | Approach |
|---|---|
Add methods (paginated(), accepted(), …) |
Extend ResponseFactory |
| Replace the format entirely | Implement ResponseFactoryInterface |
AbstractApiController and ApiControllerTraitPurpose: Provide convenience respond* methods in controllers.
Two options for the same functionality:
AbstractApiController |
ApiControllerTrait |
|
|---|---|---|
| Usage | extends AbstractApiController |
use ApiControllerTrait |
| When | No other base class | Already extends something |
| How it works | Extends AbstractController + includes trait |
Includes trait directly |
DI injection: ApiControllerTrait exposes a #[Required] setter setResponseFactory().
Symfony autowiring calls it automatically — no constructor boilerplate.
ApiExceptionPurpose: Throw structured HTTP errors with domain-specific details from anywhere in the codebase.
throw new ApiException(409, 'Slug already taken', [
'field' => 'slug',
'value' => $dto->slug,
]);
Why? Symfony's HttpException only carries a string message. ApiException adds
getDetails(): array which ExceptionListener automatically includes in the response.
This lets services communicate rich error context without depending on ResponseFactory.
ExceptionListenerPurpose: Catch all exceptions and convert them to standardized JSON responses. Controllers never need try/catch.
Exception routing:
\Throwable
├── HttpExceptionInterface
│ ├── previous: ValidationFailedException → 422 with violations
│ ├── ApiException → status + getDetails()
│ └── other HttpException → status + default message
├── ValidationFailedException (direct) → 422 with violations
└── Any other \Throwable → 500 Internal Server Error
Logging: Only 5xx responses are logged at error level. 4xx are client errors — logging them would pollute production logs.
EntityExists ValidatorPurpose: Validate entity existence in database directly in DTOs.
Optional dependency: Only registered when doctrine/orm is installed.
#[EntityExists(User::class)] // search by id (default)
#[EntityExists(Category::class, field: 'slug')] // search by any field
Why in DTO? Keeps validation logic in one place. The service receives a DTO that is already guaranteed valid — no defensive checks needed.
Architecture: The validator is split into three parts to keep the bundle decoupled from Doctrine:
| Class | Role |
|---|---|
EntityExistenceCheckerInterface |
Port — defines the exists() contract, no Doctrine dependency |
DoctrineEntityExistenceChecker |
Adapter — wraps EntityManagerInterface, auto-registered when doctrine/orm is installed |
EntityExistsValidator |
Constraint validator — depends only on the interface, Doctrine-agnostic |
ApiKitExtension registers DoctrineEntityExistenceChecker and binds it to EntityExistenceCheckerInterface only when interface_exists('Doctrine\ORM\EntityManagerInterface') returns true. The check uses a string literal to avoid a static class reference that would trigger PHPStan "Undefined class" errors in projects without Doctrine.
Custom persistence backend: If your project uses a different ORM, an HTTP API, or any other data source, register your own implementation:
// In your bundle's Extension or services.yaml
$container->setAlias(EntityExistenceCheckerInterface::class, YourCustomChecker::class);
#[MapRequestPayload] does not handle file uploads — it deserializes JSON/form-encoded bodies only. For files, use Symfony's #[MapUploadedFile] attribute (available since Symfony 7.1).
The ExceptionListener already handles HttpException thrown by #[MapUploadedFile] when validation fails (wrong MIME type, size exceeded, dimension constraints), so no bundle changes are needed — it works out of the box.
#[MapRequestPayload] and #[MapUploadedFile] can be combined in the same action for mixed multipart requests (JSON fields + file):
public function create(
#[MapRequestPayload] CreatePostDto $dto,
#[MapUploadedFile([new Assert\Image(...)])]
?UploadedFile $thumbnail = null,
): JsonResponse { ... }
Note:
Assert\Videorequires Symfony 7.4+ and FFmpeg/ffprobe installed on the server. Using#[MapUploadedFile]withPATCHhad a known bug in earlier Symfony 7.x versions — preferPOSTfor file upload endpoints; the issue was resolved in Symfony 7.4.
See examples.md — File Uploads for complete working examples (avatar upload, video upload, FileUploader service).
api_kit:
response:
include_timestamp: true # Add timestamp to all success responses
pretty_print: '%kernel.debug%' # JSON_PRETTY_PRINT in dev
exception_handling:
log_errors: true # Log 5xx errors
show_trace: '%kernel.debug%' # Include stack trace in 5xx error responses (true in dev, false in prod)
All options have sensible defaults — the bundle works without any config file.
Extend ResponseFactory to add project-specific methods while keeping the default format:
// src/Api/AppResponseFactory.php
readonly class AppResponseFactory extends ResponseFactory
{
public function paginated(array $items, int $total, int $page, int $perPage): JsonResponse
{
return $this->success($items, meta: [
'pagination' => ['total' => $total, 'page' => $page, 'per_page' => $perPage],
]);
}
}
# config/services.yaml
ApiKit\Response\ResponseFactory:
class: App\Api\AppResponseFactory
Implement ResponseFactoryInterface to use a completely different response structure.
ExceptionListener will automatically use your format for all error responses too:
// src/Api/MyResponseFactory.php
final readonly class MyResponseFactory implements ResponseFactoryInterface
{
public function success(mixed $data = null, int $statusCode = 200, array $meta = []): JsonResponse
{
return new JsonResponse(['ok' => true, 'result' => $data] + ($meta ? ['meta' => $meta] : []), $statusCode);
}
public function error(string $message, string $code = 'ERROR', int $statusCode = 400, array $details = []): JsonResponse
{
return new JsonResponse(['ok' => false, 'error' => $code, 'message' => $message], $statusCode);
}
public function created(mixed $data, array $meta = []): JsonResponse
{
return $this->success($data, 201, $meta);
}
public function noContent(): JsonResponse
{
return new JsonResponse(null, 204);
}
}
# config/services.yaml
ApiKit\Response\ResponseFactoryInterface:
class: App\Api\MyResponseFactory
#[AsEventListener(event: KernelEvents::EXCEPTION, priority: 10)]
final readonly class DomainExceptionListener
{
public function onKernelException(ExceptionEvent $event): void
{
if (!$event->getThrowable() instanceof MyDomainException) {
return; // let ApiKit's listener handle the rest
}
// ...
}
}
| Pattern | Where used |
|---|---|
| Factory | ResponseFactory — creates JsonResponse objects |
| Interface / Port | ResponseFactoryInterface, EntityExistenceCheckerInterface — declared extension points |
| Trait / Mixin | ApiControllerTrait — adds behavior without inheritance |
| Template Method | AbstractApiController — base class using the trait |
| Event Listener | ExceptionListener — reacts to Symfony's kernel exception event |
| Value Object | ApiException — carries error code + details as a unit |
| Constraint Pattern | EntityExists — follows Symfony validator convention |
symfony/security-bundleAccept header negotiationnelmio/api-doc-bundlewilldurand/hateoas-bundle| ApiKit | API Platform | FOSRestBundle | |
|---|---|---|---|
| Response format | Fixed, simple | Hydra/JSON-LD | Configurable |
| Validation | Symfony native | Symfony native | Symfony native |
| Exception handling | Automatic | Automatic | Manual |
| Serialization | Manual (pass array/object) | Automatic | Automatic |
| Scope | Minimal foundation | Full framework | Comprehensive |
| Opinionated | Low | High | Medium |
How can I help you explore Laravel packages today?