bulatronic/api-kit

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

Installs: 13

Dependents: 0

Suggesters: 0

Security: 0

Stars: 0

Watchers: 0

Forks: 0

Open Issues: 0

Type:symfony-bundle

pkg:composer/bulatronic/api-kit

1.3.1 2026-02-28 16:00 UTC

This package is auto-updated.

Last update: 2026-02-28 16:01:26 UTC


README

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,
    ) {}
}

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 EntityExists validator

Documentation

License

MIT — see LICENSE.

Author

Bulat Timerbaevbulat.coder@gmail.com