cesurapp / api-bundle
Symfony Api Bundle
Requires
- php: >=8.4
- doctrine/doctrine-bundle: ^3.3
- doctrine/orm: ^3.7 || ^4.0
- giggsey/libphonenumber-for-php-lite: ^8.13.55 || ^9.0
- sonata-project/exporter: ^3.4
- symfony/dependency-injection: ^8.1
- symfony/filesystem: ^8.1
- symfony/framework-bundle: ^8.1
- symfony/http-kernel: ^8.1
- symfony/intl: ^8.1
- symfony/mime: ^8.1
- symfony/polyfill-php86: ^1.37
- symfony/security-bundle: ^8.1
- symfony/translation: ^8.1
- symfony/validator: ^8.1
Requires (Dev)
- php-cs-fixer/shim: ^3.95
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^12.5 || ^13.0
- symfony/browser-kit: ^8.1
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- 2.2.15
- 2.2.14
- 2.2.13
- 2.2.12
- 2.2.11
- 2.2.10
- 2.2.9
- 2.2.8
- 2.2.7
- 2.2.6
- 2.2.5
- 2.2.4
- 2.2.3
- 2.2.2
- 2.2.1
- 2.2.0
- 2.1.02
- 2.1.01
- 2.1.0
- 2.0.12
- 2.0.11
- 2.0.10
- 2.0.09
- 2.0.08
- 2.0.07
- 2.0.06
- 2.0.05
- 2.0.04
- 2.0.03
- 2.0.02
- 2.0.01
- 2.0.0
- 1.0.12
- 1.0.11
- 1.0.10
- 1.0.09
- 1.0.08
- 1.0.07
- 1.0.06
- 1.0.05
- 1.0.04
- 1.0.03
- 1.0.02
- 1.0.01
- 1.0.0
This package is auto-updated.
Last update: 2026-09-23 21:20:01 UTC
README
This package allows you to expose fast API endpoints with Symfony.
Features:
- JSON request body transformer
- Error messages collected under a single format
- Language translation applied to all error messages
- Custom CORS header support
- Automatic documentation generator (Thor)
- TypeScript client generator
- API DTO resolver with auto-validation
- Doctrine filter & sorter resource
- PhoneNumber, UniqueEntity, Username validators
- Excel, CSV exporter (Sonata Export Bundle)
Documentation:
- GUIDELINES.md - Comprehensive usage guide for developers and AI agents
Installation
Requirements: Symfony 8+, PHP 8.4+, Doctrine ORM 3.7+ (ready for ORM 4.0)
composer require cesurapp/api-bundle
Configuration
Create config/packages/api.yaml (every key is optional, values below are the defaults unless noted):
api: exception_converter: true # JSON error responses cors: true # CORS listener json_body: true # decode application/json bodies into $request->request sticky_locale: true # keep the route _locale in the session export_max_rows: 100000 # row limit of ?export=csv|xls, 0 = unlimited cors_header: - { name: 'Access-Control-Allow-Methods', value: 'GET,POST,PUT,PATCH,DELETE' } - { name: 'Access-Control-Allow-Headers', value: '*' } - { name: 'Access-Control-Expose-Headers', value: 'Content-Disposition' } cors_allowed_origin: # exact origins; default: none - 'https://panel.example.com' thor: base_url: "%env(APP_DEFAULT_URI)%" global_config: authHeader: Content-Type: application/json Authorization: 'Bearer Token' query: [] request: [] header: Content-Type: application/json Accept: application/json response: [] isAuth: true isPaginate: false isHidden: false
Security Behaviour
- JSON body: only a body sent as
application/json(orapplication/*+json) is decoded.text/plainor a body without Content-Type is ignored: browsers send those cross-origin without a preflight (CSRF). - CORS: the real response names an origin only when it is listed in
cors_allowed_originor its host is exactlylocalhost(http,https,capacitor,ionic) or it isfile://. Preflights mirror any origin. Every response carriesVary: Origin.Access-Control-Allow-Headers: *is answered with the headers the preflight asks for, since*never coversAuthorization. Methods other than GET/HEAD/POST/PUT/PATCH/DELETE/OPTIONS get 405. - Errors: an unexpected 5xx (not an
HttpException/ApiException) returnsInternal Server Errorinstead of the exception message unlesskernel.debugis on. The status is the exception code only when it is a 4xx/5xx. - DTO: only public properties of the DTO (and of its parent DTO classes) are filled from the request.
ApiDto's own state (auto,constraints, …) can never be set by a client.
TypeScript Client Generation
View Documentation: http://127.0.0.1:8000/thor — public; outside debug the page and the Api.tar.gz download
are built once per deploy and served from var/cache/<env>/thor. Controller files and lines are only shown in dev.
bin/console thor:extract ./output-directory
The output directory is replaced on every run. It is only deleted when it is empty or was generated by Thor (a
.thor marker, or index.ts + flatten.ts of older clients); pass --force to replace anything else. The project
directory and its parents are always refused.
Usage Examples
Basic Controller with POST Endpoint
use Cesurapp\ApiBundle\AbstractClass\ApiController; use Cesurapp\ApiBundle\Response\ApiResponse; use Cesurapp\ApiBundle\Thor\Attribute\Thor; use Symfony\Component\Routing\Annotation\Route; class UserController extends ApiController { #[Thor( stack: 'User|1', title: 'Create User', info: 'Creates a new user account', request: [ 'email' => 'string', 'password' => 'string', 'name' => 'string', ], response: [ 200 => ['data' => UserResource::class], ], dto: CreateUserDto::class, isAuth: false, isPaginate: false )] #[Route('/users', methods: ['POST'])] public function create(CreateUserDto $dto): ApiResponse { $user = new User(); $user->setEmail($dto->email); $user->setPassword($dto->password); $user->setName($dto->name); $this->entityManager->persist($user); $this->entityManager->flush(); return ApiResponse::create() ->setData($user) ->setResource(UserResource::class); } #[Thor( stack: 'User|2', title: 'List Users', query: [ 'filter' => [ 'name' => '?string', 'email' => '?string', ], ], response: [200 => ['data' => UserResource::class]], isAuth: true, isPaginate: true )] #[Route('/users', methods: ['GET'])] public function list(UserRepository $repo): ApiResponse { return ApiResponse::create() ->setQuery($repo->createQueryBuilder('u')) ->setPaginate() ->setResource(UserResource::class); } }
API Resource
Purpose: Transform entities to API responses and define filtering/sorting behavior.
Note: filter[] and sort_by apply to every response with a QueryBuilder and a resource; export
(?export=csv|xls) is offered by paginated responses only. A filter value of the wrong shape (an array for a
string $data filter) and an \InvalidArgumentException thrown by a filter are answered with 400. Sorting adds the
identifier as a tie-breaker, so offset pages stay stable. A sortable_field callable receives the direction as the
string 'ASC' / 'DESC'; pass Doctrine a \SortDirection (string directions are deprecated in ORM 3.7 and removed in
4.0): $builder->orderBy('u.firstName', 'ASC' === $direction ? \SortDirection::Ascending : \SortDirection::Descending).
use Cesurapp\ApiBundle\Response\ApiResourceInterface; use Doctrine\ORM\QueryBuilder; class UserResource implements ApiResourceInterface { public function toArray(mixed $item, mixed $optional = null): array { return [ 'id' => $item->getId(), 'email' => $item->getEmail(), 'name' => $item->getName(), 'createdAt' => $item->getCreatedAt()->format(\DateTime::ATOM), ]; } public function toResource(): array { return [ 'id' => [ 'type' => 'string', 'filter' => static function (QueryBuilder $builder, string $alias, mixed $data) { $builder->andWhere("$alias.id = :id")->setParameter('id', $data); }, 'table' => [ 'label' => 'ID', 'sortable' => true, 'sortable_default' => true, 'sortable_desc' => true, 'filter_input' => 'input', ], ], 'email' => [ 'type' => 'string', 'filter' => static function (QueryBuilder $builder, string $alias, string $data) { // A prefix match can use an index; "%$data%" scans the whole table $builder->andWhere("$alias.email LIKE :email") ->setParameter('email', addcslashes($data, '%_').'%'); }, 'table' => [ 'label' => 'Email', 'sortable' => true, 'filter_input' => 'input', ], ], 'createdAt' => [ 'type' => 'string', 'filter' => [ 'from' => static function (QueryBuilder $builder, string $alias, mixed $data) { $builder->andWhere("$alias.createdAt >= :dateFrom") ->setParameter('dateFrom', $data); }, 'to' => static function (QueryBuilder $builder, string $alias, mixed $data) { $builder->andWhere("$alias.createdAt <= :dateTo") ->setParameter('dateTo', $data); }, ], 'table' => [ 'label' => 'Created At', 'sortable' => true, 'filter_input' => 'daterange', ], ], ]; } }
Using Filters:
GET /users?filter[email]=john&filter[createdAt][from]=2024-01-01&filter[createdAt][to]=2024-12-31
Data Transfer Object (DTO)
Purpose: Validate and type-cast incoming request data automatically.
Date Format: Backend uses UTC ATOM format. Send/receive dates in ATOM format.
use Cesurapp\ApiBundle\AbstractClass\ApiDto; use Cesurapp\ApiBundle\Thor\Attribute\ThorResource; use Symfony\Component\Validator\Constraints as Assert; class CreateUserDto extends ApiDto { #[Assert\NotNull] #[Assert\Email] public string $email; #[Assert\NotNull] #[Assert\Length(min: 8, max: 100)] public string $password; #[Assert\NotNull] #[Assert\Length(min: 2, max: 100)] public string $name; public ?int $age = null; #[Assert\NotNull] #[Assert\GreaterThan('now')] public ?\DateTimeImmutable $activatedAt = null; }
Complex Array Validation:
class UpdateSettingsDto extends ApiDto { #[Assert\Optional([ new Assert\Type('array'), new Assert\Count(['min' => 1]), new Assert\All([ new Assert\Collection([ 'key' => [ new Assert\NotBlank(), new Assert\Type('string'), ], 'value' => [ new Assert\NotBlank(), ], ]), ]), ])] #[ThorResource(data: [[ 'key' => 'string', 'value' => 'string|int|boolean', ]])] public ?array $settings = null; }
Validation Response (HTTP 422):
{
"message": "Validation failed",
"errors": {
"email": ["This value is not a valid email address."],
"password": ["This value is too short. It should have 8 characters or more."]
}
}
ApiResponse Methods
| Method | Description |
|---|---|
setData(mixed $data) |
Set response data |
setQuery(QueryBuilder|Query $query) |
Set Doctrine query for pagination/filtering (filter, sort, cursor need a QueryBuilder) |
setPaginate(?int $max = 20, bool $total = false, ?bool $fetchJoin = null, bool $cursor = false, int $maxQuery = 100) |
Enable pagination; fetchJoin: null detects to-many joins |
setExportLimit(?int $limit) |
Row limit of ?export= for this response (0 = unlimited) |
setResource(string $class) |
Apply resource transformation |
setCode(int $code) |
Set HTTP status code (default: 200) |
setHeaders(array $headers) |
Set custom headers |
setHTTPCache(int $lifetime) |
Enable HTTP caching with lifetime in seconds |
addMessage(string $message, MessageType $type) |
Add translatable message |
addData(string $key, mixed $value) |
Add additional data to response |
Advanced Features
Custom Validation Hooks
class CustomDto extends ApiDto { protected function beforeValidated(): void { // Normalize data before validation if ($this->email) { $this->email = strtolower(trim($this->email)); } } protected function endValidated(): void { // Additional logic after successful validation } }
Manual Validation Control
class ManualDto extends ApiDto { protected bool $auto = false; // Disable auto-validation } // In controller $dto = new ManualDto($request, $validator); if (!$dto->validate(throw: false)) { // Handle validation failure }
HTTP Caching
return ApiResponse::create() ->setData($data) ->setHTTPCache(60, tags: ['user', 'profile']) // Cache for 60 seconds ->setResource(UserResource::class);
Pagination with Custom Max
return ApiResponse::create() ->setQuery($queryBuilder) ->setPaginate(max: 50, total: true) // 50 items per page, include total count ->setResource(UserResource::class);
?page below 1 and ?max below 1 fall back to page 1 / the default size; ?max is capped at maxQuery.
Cursor Pagination
return ApiResponse::create() ->setQuery($repo->createQueryBuilder('m')) ->setPaginate(50, cursor: true) // ?cursor=<pager.next>&sort=ASC|DESC ->setResource(MessageResource::class);
Pages on the root entity's identifier (integer, UUID, ULID…); sort_by is ignored. A malformed cursor is a 400.
Avoiding N+1 in Resources
Implement ApiResourcePreloadInterface next to ApiResourceInterface: preload(array $items, mixed $optional) gets
every item of the response once, before toArray() runs for each, so relations can be fetched in one query.
Pagination Response:
{
"data": [...],
"pager": {
"max": 50,
"prev": 1,
"next": 3,
"current": 2,
"total": 150
}
}
Custom Validators
This bundle includes custom validators:
PhoneNumber- Validates phone numbersUniqueEntity- Validates entity uniqueness in database (keep a unique index too: two concurrent requests can both pass)EntityExists- The value must match an entity column; the property then holds the entityUsername- Validates username format
use Cesurapp\ApiBundle\Validator\PhoneNumber; use Cesurapp\ApiBundle\Validator\UniqueEntity; class RegisterDto extends ApiDto { #[Assert\NotNull] #[PhoneNumber] public string $phone; #[Assert\NotNull] #[UniqueEntity(entityClass: User::class, fields: ['email'])] public string $email; }
License
MIT License - see LICENSE