Real-world examples based on a working Users + Posts blog API.
Note. This is an intentionally simple blog demo. Architectural patterns such as CQRS, domain events, hexagonal layers, or dedicated write/read models are deliberately omitted to keep the focus on ApiKit itself — not on project structure decisions.
A complete Users resource: Entity → Repository → DTOs → Service → Controller.
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: 'users')]
class User
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 180, unique: true)]
private string $email = '';
#[ORM\Column(length: 100)]
private string $name = '';
public function getId(): ?int { return $this->id; }
public function getEmail(): string { return $this->email; }
public function setEmail(string $email): static { $this->email = $email; return $this; }
public function getName(): string { return $this->name; }
public function setName(string $name): static { $this->name = $name; return $this; }
}
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/** [@extends](https://github.com/extends) ServiceEntityRepository<User> */
class UserRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
public function existsByEmail(string $email, ?int $excludeId = null): bool
{
$qb = $this->createQueryBuilder('u')
->select('1')
->where('u.email = :email')
->setParameter('email', $email);
if ($excludeId !== null) {
$qb->andWhere('u.id != :id')->setParameter('id', $excludeId);
}
return $qb->getQuery()->getOneOrNullResult() !== null;
}
}
<?php
declare(strict_types=1);
namespace App\Dto\User;
use OpenApi\Attributes as OA;
use Symfony\Component\Validator\Constraints as Assert;
#[OA\Schema(description: 'Create user payload', required: ['email', 'name'])]
final readonly class CreateUserDto
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Email]
#[Assert\Length(max: 180)]
public string $email,
#[Assert\NotBlank]
#[Assert\Length(min: 1, max: 100)]
public string $name,
) {
}
}
<?php
declare(strict_types=1);
namespace App\Dto\User;
use OpenApi\Attributes as OA;
use Symfony\Component\Validator\Constraints as Assert;
#[OA\Schema(description: 'Update user payload (all fields optional)')]
final readonly class UpdateUserDto
{
public function __construct(
#[Assert\Email]
#[Assert\Length(max: 180)]
public ?string $email = null,
#[Assert\Length(min: 1, max: 100)]
public ?string $name = null,
) {
}
}
<?php
declare(strict_types=1);
namespace App\Dto\User;
use App\Entity\User;
use OpenApi\Attributes as OA;
#[OA\Schema(description: 'User response')]
final readonly class UserResponseDto
{
public function __construct(
public int $id,
public string $email,
public string $name,
) {
}
public static function fromEntity(User $user): self
{
return new self(
id: $user->getId(),
email: $user->getEmail(),
name: $user->getName(),
);
}
}
<?php
declare(strict_types=1);
namespace App\Service;
use ApiKit\Exception\ApiException;
use App\Dto\User\CreateUserDto;
use App\Dto\User\UpdateUserDto;
use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final readonly class UserService
{
public function __construct(
private UserRepository $userRepository,
) {
}
/** [@return](https://github.com/return) list<User> */
public function findAll(): array
{
return $this->userRepository->findBy([], ['id' => 'ASC']);
}
public function findOrFail(int $id): User
{
$user = $this->userRepository->find($id);
if ($user === null) {
throw new NotFoundHttpException('User not found');
}
return $user;
}
public function create(CreateUserDto $dto): User
{
if ($this->userRepository->existsByEmail($dto->email)) {
throw new ApiException(409, 'User with this email already exists', [
'field' => 'email',
'value' => $dto->email,
]);
}
$user = new User();
$user->setEmail($dto->email);
$user->setName($dto->name);
$this->userRepository->getEntityManager()->persist($user);
$this->userRepository->getEntityManager()->flush();
return $user;
}
public function update(int $id, UpdateUserDto $dto): User
{
$user = $this->findOrFail($id);
if ($dto->email !== null) {
if ($this->userRepository->existsByEmail($dto->email, $id)) {
throw new ApiException(409, 'User with this email already exists', [
'field' => 'email',
'value' => $dto->email,
]);
}
$user->setEmail($dto->email);
}
if ($dto->name !== null) {
$user->setName($dto->name);
}
$this->userRepository->getEntityManager()->flush();
return $user;
}
public function delete(int $id): void
{
$user = $this->findOrFail($id);
$this->userRepository->getEntityManager()->remove($user);
$this->userRepository->getEntityManager()->flush();
}
}
<?php
declare(strict_types=1);
namespace App\Controller\Api;
use ApiKit\Controller\AbstractApiController;
use App\Dto\User\CreateUserDto;
use App\Dto\User\UpdateUserDto;
use App\Dto\User\UserResponseDto;
use App\Service\UserService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/users', name: 'api_users_')]
final class UserController extends AbstractApiController
{
public function __construct(
private readonly UserService $userService,
) {
}
#[Route('', name: 'list', methods: ['GET'])]
public function list(): JsonResponse
{
$users = $this->userService->findAll();
return $this->respondSuccess(array_map(UserResponseDto::fromEntity(...), $users));
}
#[Route('/{id}', name: 'get', requirements: ['id' => '\d+'], methods: ['GET'])]
public function get(int $id): JsonResponse
{
return $this->respondSuccess(UserResponseDto::fromEntity($this->userService->findOrFail($id)));
}
#[Route('', name: 'create', methods: ['POST'])]
public function create(#[MapRequestPayload] CreateUserDto $dto): JsonResponse
{
return $this->respondCreated(UserResponseDto::fromEntity($this->userService->create($dto)));
}
#[Route('/{id}', name: 'update', requirements: ['id' => '\d+'], methods: ['PUT'])]
public function update(int $id, #[MapRequestPayload] UpdateUserDto $dto): JsonResponse
{
return $this->respondSuccess(UserResponseDto::fromEntity($this->userService->update($id, $dto)));
}
#[Route('/{id}', name: 'delete', requirements: ['id' => '\d+'], methods: ['DELETE'])]
public function delete(int $id): JsonResponse
{
$this->userService->delete($id);
return $this->respondNoContent();
}
}
POST /api/users — create a user
POST /api/users
Content-Type: application/json
{
"email": "author@example.com",
"name": "Mr. Author"
}
HTTP/1.1 201 Created
{
"success": true,
"data": {
"id": 1,
"email": "author@example.com",
"name": "Mr. Author"
},
"meta": {
"timestamp": "2026-02-26T11:21:42+00:00"
}
}
GET /api/users/1 — found
HTTP/1.1 200 OK
{
"success": true,
"data": {
"id": 1,
"email": "author@example.com",
"name": "Mr. Author"
},
"meta": {
"timestamp": "2026-02-26T11:24:51+00:00"
}
}
GET /api/users/99 — not found (NotFoundHttpException → ExceptionListener)
HTTP/1.1 404 Not Found
{
"success": false,
"error": {
"code": "NOT_FOUND",
"message": "User not found"
}
}
Posts are related to Users. The authorId field is validated with EntityExists directly in the DTO — the service receives a guaranteed-valid object.
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\PostRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: PostRepository::class)]
#[ORM\Table(name: 'posts')]
class Post
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $title = '';
#[ORM\Column(type: Types::TEXT)]
private string $content = '';
#[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'posts')]
#[ORM\JoinColumn(nullable: false)]
private ?User $author = null;
#[ORM\Column]
public \DateTimeImmutable $createdAt {
get { return $this->createdAt; }
}
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int { return $this->id; }
public function getTitle(): string { return $this->title; }
public function setTitle(string $title): static { $this->title = $title; return $this; }
public function getContent(): string { return $this->content; }
public function setContent(string $content): static { $this->content = $content; return $this; }
public function getAuthor(): ?User { return $this->author; }
public function setAuthor(?User $author): static { $this->author = $author; return $this; }
}
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Post;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/** [@extends](https://github.com/extends) ServiceEntityRepository<Post> */
class PostRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Post::class);
}
}
#[EntityExists] runs a DB query inside the Symfony validator — the field is checked before the controller action executes:
<?php
declare(strict_types=1);
namespace App\Dto\Post;
use ApiKit\Validator\Constraint\EntityExists;
use App\Entity\User;
use OpenApi\Attributes as OA;
use Symfony\Component\Validator\Constraints as Assert;
#[OA\Schema(description: 'Create post payload', required: ['title', 'content', 'authorId'])]
final readonly class CreatePostDto
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Length(min: 1, max: 255)]
public string $title,
#[Assert\NotBlank]
public string $content,
#[Assert\NotNull]
#[EntityExists(User::class)]
public int $authorId,
) {
}
}
<?php
declare(strict_types=1);
namespace App\Dto\Post;
use ApiKit\Validator\Constraint\EntityExists;
use App\Entity\User;
use OpenApi\Attributes as OA;
use Symfony\Component\Validator\Constraints as Assert;
#[OA\Schema(description: 'Update post payload (all fields optional)')]
final readonly class UpdatePostDto
{
public function __construct(
#[Assert\Length(min: 1, max: 255)]
public ?string $title = null,
public ?string $content = null,
#[EntityExists(User::class)]
public ?int $authorId = null,
) {
}
}
<?php
declare(strict_types=1);
namespace App\Dto\Post;
use App\Entity\Post;
use OpenApi\Attributes as OA;
#[OA\Schema(description: 'Post in API response')]
final readonly class PostResponseDto
{
public function __construct(
public int $id,
public string $title,
public string $content,
public int $authorId,
public string $authorName,
public string $createdAt,
) {
}
public static function fromEntity(Post $post): self
{
$author = $post->getAuthor();
return new self(
id: (int) $post->getId(),
title: $post->getTitle(),
content: $post->getContent(),
authorId: (int) $author?->getId(),
authorName: $author !== null ? $author->getName() : '',
createdAt: $post->createdAt->format(\DateTimeInterface::ATOM),
);
}
}
Because EntityExists already guaranteed authorId exists, the service can load the author without a defensive check:
<?php
declare(strict_types=1);
namespace App\Service;
use App\Dto\Post\CreatePostDto;
use App\Dto\Post\UpdatePostDto;
use App\Entity\Post;
use App\Repository\PostRepository;
use App\Repository\UserRepository;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final readonly class PostService
{
public function __construct(
private PostRepository $postRepository,
private UserRepository $userRepository,
) {
}
/** [@return](https://github.com/return) list<Post> */
public function findAll(): array
{
return $this->postRepository->findBy([], ['createdAt' => 'DESC', 'id' => 'DESC']);
}
public function findOrFail(int $id): Post
{
$post = $this->postRepository->find($id);
if ($post === null) {
throw new NotFoundHttpException('Post not found');
}
return $post;
}
public function create(CreatePostDto $dto): Post
{
$author = $this->userRepository->find($dto->authorId);
if ($author === null) {
throw new NotFoundHttpException('Author not found');
}
$post = new Post();
$post->setTitle($dto->title);
$post->setContent($dto->content);
$post->setAuthor($author);
$this->postRepository->getEntityManager()->persist($post);
$this->postRepository->getEntityManager()->flush();
return $post;
}
public function update(int $id, UpdatePostDto $dto): Post
{
$post = $this->findOrFail($id);
if ($dto->title !== null) {
$post->setTitle($dto->title);
}
if ($dto->content !== null) {
$post->setContent($dto->content);
}
if ($dto->authorId !== null) {
$author = $this->userRepository->find($dto->authorId);
if ($author === null) {
throw new NotFoundHttpException('Author not found');
}
$post->setAuthor($author);
}
$this->postRepository->getEntityManager()->flush();
return $post;
}
public function delete(int $id): void
{
$post = $this->findOrFail($id);
$this->postRepository->getEntityManager()->remove($post);
$this->postRepository->getEntityManager()->flush();
}
}
<?php
declare(strict_types=1);
namespace App\Controller\Api;
use ApiKit\Controller\AbstractApiController;
use App\Dto\Post\CreatePostDto;
use App\Dto\Post\PostResponseDto;
use App\Dto\Post\UpdatePostDto;
use App\Service\PostService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/posts', name: 'api_posts_')]
final class PostController extends AbstractApiController
{
public function __construct(
private readonly PostService $postService,
) {
}
#[Route('', name: 'list', methods: ['GET'])]
public function list(): JsonResponse
{
$posts = $this->postService->findAll();
return $this->respondSuccess(array_map(PostResponseDto::fromEntity(...), $posts));
}
#[Route('/{id}', name: 'get', requirements: ['id' => '\d+'], methods: ['GET'])]
public function get(int $id): JsonResponse
{
return $this->respondSuccess(PostResponseDto::fromEntity($this->postService->findOrFail($id)));
}
#[Route('', name: 'create', methods: ['POST'])]
public function create(#[MapRequestPayload] CreatePostDto $dto): JsonResponse
{
return $this->respondCreated(PostResponseDto::fromEntity($this->postService->create($dto)));
}
#[Route('/{id}', name: 'update', requirements: ['id' => '\d+'], methods: ['PUT'])]
public function update(int $id, #[MapRequestPayload] UpdatePostDto $dto): JsonResponse
{
return $this->respondSuccess(PostResponseDto::fromEntity($this->postService->update($id, $dto)));
}
#[Route('/{id}', name: 'delete', requirements: ['id' => '\d+'], methods: ['DELETE'])]
public function delete(int $id): JsonResponse
{
$this->postService->delete($id);
return $this->respondNoContent();
}
}
POST /api/posts — create with valid author
POST /api/posts
Content-Type: application/json
{
"title": "Getting Started with Web Development",
"content": "Web development is an exciting field...",
"authorId": 1
}
HTTP/1.1 201 Created
{
"success": true,
"data": {
"id": 1,
"title": "Getting Started with Web Development",
"content": "Web development is an exciting field...",
"authorId": 1,
"authorName": "Mr. Author",
"createdAt": "2026-02-26T11:23:14+00:00"
},
"meta": {
"timestamp": "2026-02-26T11:23:14+00:00"
}
}
POST /api/posts — non-existent authorId (EntityExists fails → 422)
POST /api/posts
Content-Type: application/json
{
"title": "10 Tips for Better Time Management",
"content": "Do you often feel like there aren't enough hours...",
"authorId": 3
}
HTTP/1.1 422 Unprocessable Entity
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation error",
"details": {
"violations": [
{
"field": "authorId",
"message": "Entity \"User\" with id = \"3\" not found."
}
]
}
}
}
GET /api/posts — list (sorted by createdAt DESC)
HTTP/1.1 200 OK
{
"success": true,
"data": [
{
"id": 2,
"title": "10 Tips for Better Time Management",
"content": "Do you often feel like there aren't enough hours...",
"authorId": 1,
"authorName": "Mr. Author",
"createdAt": "2026-02-26T11:28:05+00:00"
},
{
"id": 1,
"title": "Getting Started with Web Development",
"content": "Web development is an exciting field...",
"authorId": 1,
"authorName": "Mr. Author",
"createdAt": "2026-02-26T11:23:14+00:00"
}
],
"meta": {
"timestamp": "2026-02-26T11:28:08+00:00"
}
}
Use ApiException when you need to return a structured error with custom details — for example, a unique-constraint violation with the conflicting field name:
use ApiKit\Exception\ApiException;
public function create(CreateUserDto $dto): User
{
if ($this->userRepository->existsByEmail($dto->email)) {
throw new ApiException(409, 'User with this email already exists', [
'field' => 'email',
'value' => $dto->email,
]);
}
// ...
}
HTTP/1.1 409 Conflict
{
"success": false,
"error": {
"code": "CONFLICT",
"message": "User with this email already exists",
"details": {
"field": "email",
"value": "author@example.com"
}
}
}
The controller needs no try/catch — ExceptionListener handles it automatically:
#[Route('', name: 'create', methods: ['POST'])]
public function create(#[MapRequestPayload] CreateUserDto $dto): JsonResponse
{
return $this->respondCreated(UserResponseDto::fromEntity($this->userService->create($dto)));
}
#[Route('', methods: ['GET'])]
public function list(#[MapQueryString] PostsQueryDto $query): JsonResponse
{
$result = $this->postService->paginate($query->page, $query->perPage);
return $this->respondSuccess(
data: $result->items,
meta: [
'pagination' => [
'total' => $result->total,
'page' => $result->page,
'per_page' => $result->perPage,
'total_pages' => $result->totalPages,
],
],
);
}
Response:
{
"success": true,
"data": [...],
"meta": {
"timestamp": "2026-02-26T12:00:00+00:00",
"pagination": {
"total": 100,
"page": 1,....
How can I help you explore Laravel packages today?