Components · v1.x

Api

The Api component gathers the tools of a JSON API: CORS, rate limiting, pagination, RFC 7807 problem details and OpenAPI 3.1 documentation. It plugs into the kernel events (preflight requests are answered before routing, error responses get the CORS headers) and reuses the Serializer, Validator, Cache and Routing components. No external library is used: the documentation page is a self-contained HTML file without CDN.

Quick start

use App\Dto\PostInput;
use App\Entity\Post;
use App\Repository\PostRepository;
use NeoPHP\Component\Api\Attribute\MapPagination;
use NeoPHP\Component\Api\Attribute\RateLimit;
use NeoPHP\Component\Api\OpenApi\Attribute as OA;
use NeoPHP\Component\Api\Pagination\PageRequest;
use NeoPHP\Component\Controller\Contract\AbstractController;
use NeoPHP\Component\Http\Response\JsonResponse;
use NeoPHP\Component\Routing\Attribute\Route;
use NeoPHP\Component\Serializer\Attribute\MapRequestPayload;

#[Route('/api/posts', name: 'api_post_')]
#[OA\Tag('Posts', description: 'Blog posts')]
#[RateLimit('api')]
class PostController extends AbstractController
{
    #[Route('', name: 'index', methods: ['GET'])]
    #[OA\Operation(summary: 'List the posts')]
    #[OA\Response(200, type: Post::class, groups: ['read'], paginated: true)]
    public function index(PostRepository $posts, #[MapPagination(maxLimit: 50)] PageRequest $pageRequest): JsonResponse
    {
        $page = $this->paginate($posts->createQueryBuilder('p')->orderBy('p.id', 'DESC'), $pageRequest);

        return $this->jsonPage($page, ['groups' => ['read']]);
    }

    #[Route('', name: 'create', methods: ['POST'])]
    #[OA\Response(201, type: Post::class, groups: ['read'])]
    public function create(#[MapRequestPayload] PostInput $input): JsonResponse
    {
        $post = (new Post())->setTitle($input->title);
        $this->getOrm()->persist($post);
        $this->getOrm()->flush();

        return $this->json($post, 201, [], ['groups' => ['read']]);
    }
}

GET /api/posts?page=2&limit=10 returns:

{
  "items": [{"id": 12, "title": "..."}],
  "pagination": {"total": 42, "page": 2, "limit": 10, "pages": 5},
  "links": {"self": "/api/posts?page=2&limit=10", "first": "/api/posts?page=1&limit=10", "prev": "/api/posts?page=1&limit=10", "next": "/api/posts?page=3&limit=10", "last": "/api/posts?page=5&limit=10"}
}

with the Link, X-Total-Count and X-RateLimit-* headers. An invalid body returns a 422 application/problem+json response with the violations, and php bin/neo openapi:dump prints the OpenAPI document of these routes.

Configuration

config/framework/api.yaml (key framework.api, generated by neo install, every option is optional):

cors:
  enabled: false
  defaults:
    allow_origin: []
    allow_methods: [GET, POST, PUT, PATCH, DELETE, OPTIONS]
    allow_headers: [Content-Type, Authorization, X-Requested-With]
    expose_headers: [Link, X-Total-Count, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After]
    allow_credentials: false
    max_age: 3600
  paths:
    '^/api': ~

rate_limiter:
  cache_pool: null
  headers: true
  limiters:
    api:
      policy: sliding_window
      limit: 100
      interval: '1 minute'
    login:
      policy: fixed_window
      limit: 5
      interval: '15 minutes'
    uploads:
      policy: token_bucket
      limit: 10
      rate: { amount: 1, interval: '30 seconds' }

pagination:
  page_parameter: page
  limit_parameter: limit
  default_limit: 20
  max_limit: 100
  absolute_links: false

problem_details:
  enabled: true
  paths: ['^/api']
  json_requests: true
  type_base_uri: null

openapi:
  title: '%env(APP_NAME)%'
  version: '1.0.0'
  description: null
  servers: []
  paths: ['^/api']
  route:
    enabled: false
    path: /api/doc

Without the file, every feature keeps the framework behaviour: CORS disabled, no limiter, problem details disabled (errors keep the {"error": ...} JSON format), OpenAPI routes disabled.

Paths (cors.paths, problem_details.paths, openapi.paths) are regular expressions when they start with ^ ('^/api/v[0-9]+'), prefixes otherwise (/api).

The services are CorsManager, RateLimiterFactory, PaginatorInterface (alias Paginator), ProblemDetailsFactory and OpenApiGenerator; the merged configuration is the api.config service (ApiProvider::CONFIG_ID).

CORS

With cors.enabled: true, the requests whose path matches a key of cors.paths get the CORS headers. Each path merges its options over defaults:

cors:
  enabled: true
  defaults:
    allow_origin: ['https://app.example.com', '#^https://[a-z0-9-]+\.example\.com$#']
  paths:
    '^/api/public': { allow_origin: ['*'], max_age: 86400 }
    '^/api': ~
Option Default Description
allow_origin [] allowed origins: exact values, '*' (any origin) or regular expressions delimited by #, / or ~
allow_methods [GET, POST, PUT, PATCH, DELETE, OPTIONS] Access-Control-Allow-Methods of preflight responses ('*' echoes the requested method)
allow_headers [Content-Type, Authorization, X-Requested-With] Access-Control-Allow-Headers ('*' echoes Access-Control-Request-Headers)
expose_headers [] Access-Control-Expose-Headers of actual responses
allow_credentials false sends Access-Control-Allow-Credentials: true; the origin is then echoed instead of *
max_age 0 Access-Control-Max-Age of preflight responses (seconds, 0 omits it)

How it runs:

  • a preflight request (OPTIONS with Origin and Access-Control-Request-Method) is answered with 204 No Content by a RequestEvent listener (priority 32), before the security firewall, the middlewares and the routing: no route has to accept OPTIONS and no 405 is returned. A refused origin gets a 204 without Access-Control-Allow-Origin, so the browser blocks the request;
  • the actual responses (successful or not: 404, 422, 429, 500...) get Access-Control-Allow-Origin, Access-Control-Allow-Credentials and Access-Control-Expose-Headers from a ResponseEvent listener; Vary: Origin is always added to the matching paths;
  • requests without Origin are not changed (except Vary).

#[Cors] attribute

#[Cors] on a controller class or method enables CORS for its routes, even when cors.enabled is false or the path does not match. Its options are merged over the matching path options (or defaults):

use NeoPHP\Component\Api\Attribute\Cors;

#[Route('/widgets/{id}', name: 'widget_show', methods: ['GET'])]
#[Cors(allowOrigin: ['*'], exposeHeaders: ['ETag'], maxAge: 600)]
public function show(int $id): JsonResponse

Parameters: allowOrigin, allowMethods, allowHeaders, exposeHeaders, allowCredentials, maxAge (null keeps the configured value). The preflight request of such a route is matched with the method of Access-Control-Request-Method.

CorsMiddleware

NeoPHP\Component\Api\Middleware\CorsMiddleware applies the same rules as a middleware (the matching path, else defaults). Registered as a global middleware (config/framework/middleware.yaml), it also answers the preflight requests of any path; as a route middleware it only sees the requests that match a route. The listeners are the recommended way: they also cover the error responses.

Rate limiting

A limiter is defined under rate_limiter.limiters and identified by its name; each client key has its own counter, stored in a cache pool (cache_pool, default: the default pool of config/framework/cache.yaml).

Policy Options Behaviour
fixed_window limit, interval at most limit hits per window of interval, the window starts at the first hit
sliding_window limit, interval the hits of the previous window are weighted by the part of it still in the sliding interval: smooth, no burst at window boundaries
token_bucket limit (bucket size), rate: { amount, interval } amount tokens are added every interval up to limit: bursts of limit, then a steady rate
no_limit - always accepts (useful to disable a limiter per environment)

interval accepts seconds (60), a relative time ('30 seconds', '1 minute', '15 minutes', '1 hour', '1 day') or an ISO 8601 duration (PT1H). rate.amount defaults to limit and rate.interval to interval.

#[RateLimit]

use NeoPHP\Component\Api\Attribute\RateLimit;

#[RateLimit('api')]
class PostController extends AbstractController
{
    #[Route('/api/posts', name: 'api_post_create', methods: ['POST'])]
    #[RateLimit('writes', key: 'user', cost: 2)]
    public function create(): JsonResponse

The attribute goes on the controller class and/or the method (repeatable: every limiter is consumed). It is enforced by a ControllerEvent listener, after the routing and the security firewall and before the controller arguments are resolved.

Parameter Default Description
limiter - name of the limiter
key ip ip (client IP), user (identifier of the logged user, else the IP), route (route name + IP), header:X-Api-Key, attribute:tenant (request attribute / route parameter), query:token, or a class implementing KeyResolverInterface
cost 1 tokens consumed per request
methods [] only for these HTTP methods (all when empty)

A refused request throws a TooManyRequestsHttpException (429) with Retry-After (seconds) and the X-RateLimit-* headers. The accepted responses get X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix timestamp) for the most restrictive limiter (rate_limiter.headers: false disables them).

A custom key:

use NeoPHP\Component\Api\RateLimiter\Contract\KeyResolverInterface;
use NeoPHP\Component\Http\Request\Request;

class TenantKeyResolver implements KeyResolverInterface
{
    public function resolve(Request $request): ?string
    {
        return $request->headers->get('X-Tenant');
    }
}

#[RateLimit('api', key: TenantKeyResolver::class)] (the class is taken from the container; null falls back to the IP).

Programmatic API

use NeoPHP\Component\Api\RateLimiter\RateLimiterFactory;

public function login(Request $request, RateLimiterFactory $limiters): Response
{
    $limiter = $limiters->create('login', $request->getClientIp() . '|' . $request->request->getString('email'));
    $limit = $limiter->consume(1);

    if (!$limit->isAccepted()) {
        return $this->problemJson(429, sprintf('Too many attempts, retry in %d seconds.', $limit->getRetryAfterSeconds()), [], null, null, ['Retry-After' => (string) $limit->getRetryAfterSeconds()]);
    }

    $limiter->reset();
}

$limiter->reset() clears the counter after a successful login. $limit->ensureAccepted() throws the 429 exception instead. In a controller, $this->rateLimit('login', $key) consumes and ensures in one call (see Controllers).

The counters are read then written in the cache without a lock: under heavy concurrency a few requests over the limit can be accepted. Use a shared pool (apcu, database, filesystem) in production; an array pool only lives for one request.

Pagination

PaginatorInterface::paginate($target, $request = null): Page paginates:

Target Adapter Count
array ArrayAdapter count()
ORM QueryBuilder QueryBuilderAdapter SELECT COUNT(*) FROM (<query without limit/offset>), then the page with setFirstResult() / setMaxResults()
any iterable (generator, collection) IterableAdapter iterates once (count() when Countable)
AdapterInterface itself your implementation (count(): int, slice(int $offset, int $length): iterable)

The page and the limit are read from the query string (?page=2&limit=10, names configurable): a missing value uses 1 / default_limit, a limit above max_limit is capped, a value that is not a positive integer throws a 400 BadRequestHttpException. A page after the last one returns no item.

$page = $paginator->paginate($repository->createQueryBuilder('p')->where('p.status = :status')->setParameter('status', 'published'));
$page->getItems();
$page->getTotal();
$page->getPages();
$page->hasNext();
$page->getLinks();
$page->getHeaders();
$page->map(fn (Post $post): array => ['id' => $post->getId()]);
Method Returns
getItems() the items of the page
getTotal() / getPages() total number of items / of pages
hasNext() / hasPrevious() whether a next / previous page exists
getLinks() self, first, prev, next, last URLs
getHeaders() Link and X-Total-Count headers
map(callable) a copy of the page with transformed items

Page implements JsonSerializable, IteratorAggregate and Countable. Normalized by the Serializer (json(), serialize()), its items go through the normalizers with the given context (groups...), and the output is {"items": [...], "pagination": {"total", "page", "limit", "pages"}, "links": {"self", "first", "prev", "next", "last"}} (prev / next are null at the ends). The links keep the other query parameters and are relative (absolute_links: true adds the scheme and host).

With an ORM query that fetch-joins a to-many association, the SQL limit applies to rows, not entities: paginate a query without such join, or count with new QueryBuilderAdapter($qb, fn (QueryBuilder $qb): int => ...).

Controller argument

PageRequest arguments are resolved from the query string; #[MapPagination] overrides the limits of one action:

use NeoPHP\Component\Api\Attribute\MapPagination;
use NeoPHP\Component\Api\Pagination\PageRequest;

public function index(#[MapPagination(defaultLimit: 10, maxLimit: 50)] PageRequest $pageRequest): JsonResponse
{
    return $this->jsonPage($this->paginate($this->loadItems(), $pageRequest));
}

PageRequest: getPage(), getLimit(), getOffset(), getPath(), getQuery(), withPage(int), url(int $page).

Problem details

With problem_details.enabled: true, the uncaught exceptions of API requests are rendered as RFC 7807 (RFC 9457) problem details, with the application/problem+json content type:

{
  "type": "about:blank",
  "title": "Unprocessable Content",
  "status": 422,
  "detail": "The data is not valid: 2 violation(s).",
  "instance": "/api/posts",
  "violations": [
    {"propertyPath": "title", "title": "This value should not be blank.", "template": "This value should not be blank."},
    {"propertyPath": "email", "title": "This value is not a valid email address.", "template": "This value is not a valid email address.", "parameters": {"value": "\"x\""}}
  ]
}

A request is an API request when its path matches problem_details.paths, when it sends Accept: application/problem+json, or (with json_requests: true) when it sends or accepts JSON. The other requests keep the HTML error page.

Member Value
type about:blank, or type_base_uri + status (https://example.com/problems/404)
title reason phrase of the status
status status of the exception (getStatusCode() of framework exceptions, else 500)
detail message of the exception; hidden for errors of 500 or more in production, unless it is an HttpException
instance path of the request
violations for ValidationFailedException (#[MapRequestPayload], #[MapQueryString]): propertyPath, title, template, parameters
exception in debug only: class, message, file, line and trace

The headers of the exception are kept (Retry-After of a 429, Allow of a 405...). The listener runs on ExceptionEvent with priority 4: after the security firewall (which may answer with a login redirect or its own 401), before the JSON error of the Validator component. Errors of 500 or more are still logged.

An exception can customize its problem by implementing ProblemDetailsProviderInterface:

use NeoPHP\Component\Api\ProblemDetails\ProblemDetails;
use NeoPHP\Component\Api\ProblemDetails\ProblemDetailsProviderInterface;
use NeoPHP\Component\Http\Exception\HttpException;

class OutOfStockException extends HttpException implements ProblemDetailsProviderInterface
{
    public function __construct(protected string $sku)
    {
        parent::__construct(409, 'The product {sku} is out of stock.', [], ['sku' => $sku]);
    }

    public function toProblemDetails(ProblemDetails $problem): ProblemDetails
    {
        return $problem->setType('https://example.com/problems/out-of-stock')->setExtension('sku', $this->sku);
    }
}

ProblemDetails can also be built directly: (new ProblemDetails(409, null, 'Already exists.'))->setExtension('id', 12)->toResponse(), or $this->problemJson(409, 'Already exists.', ['id' => 12]) in a controller.

OpenAPI

OpenApiGenerator::generate(): array builds an OpenAPI 3.1 document from the routes whose path matches openapi.paths (default ^/api):

Source Documentation
route path, methods, name paths, operationId (route name), tag from the controller name (PostController → Post)
route requirements and action arguments path parameters: integer for int arguments or \d+, enum for a|b, pattern otherwise, default
#[MapRequestPayload] argument requestBody (content types from acceptFormats / format, JSON by default), schema of the DTO with its groups, #[Type('App\Dto\Item[]')] for lists; 400, 415 and 422 responses
#[MapQueryString] argument one query parameter per property of the DTO; 400 and 422 responses
PageRequest / #[MapPagination] argument page and limit query parameters, paginated response, Link / X-Total-Count headers
#[RateLimit] 429 response with Retry-After, X-RateLimit-* headers on the success response
return type void → 204, a class → its schema, Response / JsonResponse → 200 without schema, Page → paginated schema
route parameters 404 response

The error responses use the ProblemDetails (and ValidationProblemDetails for 422) schemas with the application/problem+json content type.

Schemas (components/schemas, referenced with $ref) are read from the Serializer metadata: readable properties for responses, writable ones for request bodies, serialized names (#[SerializedName] and the name converter), #[Groups] (a schema per group set: Post-read), #[Ignore], #[Type]. Types: int → integer, float → number, bool → boolean, arrays, nullable → ['string', 'null'] or oneOf with null, backed enums → enum schema, dates → string / date-time, other classes → nested $ref (recursion supported).

The Validator constraints of the properties (default group, or the validationGroups of #[MapRequestPayload]) enrich the schema:

Constraint Schema
NotBlank, NotNull required (+ minLength: 1 / minItems: 1 for NotBlank)
Length, Count minLength / maxLength, minItems / maxItems
Range, GreaterThan(OrEqual), LessThan(OrEqual) minimum / maximum, exclusiveMinimum / exclusiveMaximum
Positive, PositiveOrZero, Negative, NegativeOrZero exclusiveMinimum: 0, minimum: 0, exclusiveMaximum: 0, maximum: 0
Email, Url, Uuid, Ip, Date, DateTime format: email, uri, uuid, ipv4 / ipv6 / ip, date, date-time
Choice enum (items.enum with multiple)
Regex pattern (delimiters removed)

Request body properties that are required constructor arguments are required; response properties with a non-nullable type are required.

Attributes

Namespace NeoPHP\Component\Api\OpenApi\Attribute (import it as OA: use NeoPHP\Component\Api\OpenApi\Attribute as OA;).

Attribute Target Parameters
#[OA\Operation] method (or class) summary, description, tags, deprecated, operationId, hidden (excluded from the document), security
#[OA\Response] method, repeatable status, description, type (class or type string: Post::class, 'int[]'), groups, isList, paginated, contentType, headers (name => description), schema (raw schema)
#[OA\Tag] class or method, repeatable name, description (added to the top-level tags)
#[OA\Parameter] method, repeatable name, in (query, header, path, cookie), description, required, type, format, enum, example, deprecated, schema
#[OA\Property] property description, example, format, deprecated, required, schema (merged into the property schema)
#[OA\Schema] class name, description, example

When #[OA\Response] is present, it replaces the response deduced from the return type; the automatic error responses are still added.

Documentation routes

With openapi.route.enabled: true, two routes are registered (at the first request, they are not listed by route:list):

Route Path Content
api_doc_json {path}.json (/api/doc.json) the OpenAPI document
api_doc {path} (/api/doc) a self-contained HTML page listing the endpoints by tag, with their parameters, bodies and responses (no external script or stylesheet)

They are public: protect them with an access_control rule of the security configuration or keep them disabled in production. The controller NeoPHP\Component\Api\Controller\OpenApiController (json(), html()) can also be routed manually in config/routes.yaml:

api_doc:
  path: /internal/api
  controller: NeoPHP\Component\Api\Controller\OpenApiController::html
  methods: [GET]

Controllers

AbstractController uses the ApiController trait (NeoPHP\Component\Api\Helper\Controller\ApiController):

Method Description
paginate(mixed $target, PageRequest|Request|null $request = null): Page paginates an array, an iterable, an ORM query builder or an adapter (current request by default)
jsonPage(Page $page, array $context = [], int $status = 200, array $headers = []): JsonResponse JSON response of a page with the Link and X-Total-Count headers
rateLimit(string $limiter, ?string $key = null, int $tokens = 1): RateLimit consumes and throws the 429 exception when refused; the key defaults to the client IP; the X-RateLimit-* headers are added to the response
createRateLimiter(string $limiter, ?string $key = null): LimiterInterface limiter for a manual use (consume(), peek(), reset())
problemJson(int $status, ?string $detail = null, array $extensions = [], ?string $title = null, ?string $type = null, array $headers = []): JsonResponse application/problem+json response

Console

Command Description
openapi:dump [--format=json|yaml] [--output=file] prints the OpenAPI document, or writes it to a file (directories are created)
php bin/neo openapi:dump
php bin/neo openapi:dump --format=yaml --output=public/openapi.yaml

Reference

Services

Service Methods
CorsManager isEnabled(), resolve(Request, mixed $controller = null): ?array, forPath(string): ?array, isPreflight(Request), isAllowedOrigin(string, array), preflight(Request, array): Response, apply(Request, Response, array): Response, getDefaults(), getPaths()
RateLimiterFactory create(string $name, ?string $key = null): LimiterInterface, has(string), getNames(), getConfig(string), parseInterval(int|string|DateInterval): int (static)
PaginatorInterface paginate(mixed $target, PageRequest|Request|null $request = null): Page, createPageRequest(?Request $request = null, ?int $defaultLimit = null, ?int $maxLimit = null): PageRequest
ProblemDetailsFactory isEnabled(), supports(Request), create(Throwable, ?Request = null): ProblemDetails, createResponse(Throwable, Request): JsonResponse, type(int $status)
OpenApiGenerator generate(): array, toJson(bool $pretty = true): string, getRoutes(): array, getConfig()

LimiterInterface and RateLimit

Method Description
consume(int $tokens = 1): RateLimit consumes tokens (refused without consuming when not enough remain)
peek(): RateLimit state without consuming
reset(): void deletes the counter of the key
getLimit(): int configured limit
RateLimit Description
isAccepted(): bool the tokens were consumed
getRemaining(): int tokens left
getLimit(): int limit
getRetryAfter(): DateTimeImmutable / getRetryAfterSeconds(): int when the request can be retried (now when accepted)
getResetAt(): DateTimeImmutable when the counter is back to full
getHeaders(): array X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset
ensureAccepted(): static throws TooManyRequestsHttpException when refused

ProblemDetails

__construct(int $status = 500, ?string $title = null, ?string $detail = null, string $type = 'about:blank', ?string $instance = null, array $extensions = [], array $headers = []), getters and fluent setters for every member, setExtension(string, mixed), getExtension(), removeExtension(), toArray(), toResponse(array $headers = []): JsonResponse (ProblemDetails::CONTENT_TYPE).

Kernel hooks

Listener Event Priority Role
OpenApiListener::onRequest RequestEvent 64 registers the documentation routes
CorsListener::onRequest RequestEvent 32 answers preflight requests
RateLimitListener::onController ControllerEvent 16 enforces #[RateLimit]
ProblemDetailsListener::onException ExceptionEvent 4 renders problem details
RateLimitListener::onResponse ResponseEvent -40 adds the X-RateLimit-* headers
CorsListener::onResponse ResponseEvent -50 adds the CORS headers (also to error responses)

Exceptions

NeoPHP\Component\Api\Exception\*, all extend ApiException, a FrameworkException:

Exception Status Thrown when
ApiException 500 a value cannot be paginated, an invalid PageRequest
InvalidConfigurationException 500 invalid api.yaml values: unknown policy, missing limit, invalid interval, CORS path or origin regex
RateLimiterException 500 unknown limiter or key type, tokens outside 0..limit, a key resolver not implementing KeyResolverInterface

NeoPHP\Component\Http\Exception\TooManyRequestsHttpException (429) is thrown when a limit is exceeded, and BadRequestHttpException (400) for invalid pagination parameters.

Changelog

  • v1.24.0 — Api component: CORS (cors configuration, preflight answered before routing, headers on error responses, #[Cors], CorsMiddleware), rate limiter (fixed window, sliding window, token bucket and no limit policies stored in a cache pool, RateLimiterFactory, #[RateLimit] with ip / user / route / header / attribute / query / custom keys, X-RateLimit-* and Retry-After headers), pagination (array, iterable and ORM query builder adapters, Page with links, Link and X-Total-Count headers, PageRequest / #[MapPagination] arguments), RFC 7807 problem details (application/problem+json with violations, ProblemDetailsProviderInterface), OpenAPI 3.1 generation (routes, DTO schemas from the Serializer metadata and the Validator constraints, #[OA\Operation], #[OA\Response], #[OA\Tag], #[OA\Parameter], #[OA\Property], #[OA\Schema]), openapi:dump, /api/doc and /api/doc.json routes, ApiController trait (paginate(), jsonPage(), rateLimit(), createRateLimiter(), problemJson()).