Search by

bulatronic / api-kit

bulatronic

Lightweight Symfony bundle for building REST APIs with DTO-first validation, standardized responses, and thin controllers

Package info

github.com/bulatronic/api-kit

Type:symfony-bundle

pkg:composer/bulatronic/api-kit

Statistics

Installs: 66

Dependents: 0

Suggesters: 0

Stars: 3

Open Issues: 0

1.4.1 2026-10-13 12:22 UTC

This package is auto-updated.

Last update: 2026-10-13 12:30:09 UTC


README

CI PHP Version Symfony Version License

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/catch in controllers
  • ApiException — throw structured errors with details from anywhere in the codebase
  • DTO validation — uses native Symfony #[MapRequestPayload] / #[MapQueryString]
  • EntityExists validator — 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 EntityExists validator
  • nelmio/api-doc-bundle + zircote/swagger-php — required for the ApiKit\OpenApi\Attribute\* OpenAPI attributes

Documentation

For agents working on ApiKit itself (not consuming it), see AGENTS.md.

License

MIT — see LICENSE.

Author

Bulat Timerbaev — bulat.coder@gmail.com