Search by

cesurapp / api-bundle

cesurapp

Symfony Api Bundle

Package info

github.com/cesurapp/api-bundle

Type:symfony-bundle

pkg:composer/cesurapp/api-bundle

Statistics

Installs: 659

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

2.2.15 2026-09-23 21:19 UTC

README

App Tester Software License

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 (or application/*+json) is decoded. text/plain or 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_origin or its host is exactly localhost (http, https, capacitor, ionic) or it is file://. Preflights mirror any origin. Every response carries Vary: Origin. Access-Control-Allow-Headers: * is answered with the headers the preflight asks for, since * never covers Authorization. Methods other than GET/HEAD/POST/PUT/PATCH/DELETE/OPTIONS get 405.
  • Errors: an unexpected 5xx (not an HttpException/ApiException) returns Internal Server Error instead of the exception message unless kernel.debug is 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 numbers
  • UniqueEntity - 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 entity
  • Username - 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