Documentation menu
On this page
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 (
OPTIONSwithOriginandAccess-Control-Request-Method) is answered with204 No Contentby aRequestEventlistener (priority 32), before the security firewall, the middlewares and the routing: no route has to acceptOPTIONSand no405is returned. A refused origin gets a204withoutAccess-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-CredentialsandAccess-Control-Expose-Headersfrom aResponseEventlistener;Vary: Originis always added to the matching paths; - requests without
Originare not changed (exceptVary).
#[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 (
corsconfiguration, 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-*andRetry-Afterheaders), pagination (array, iterable and ORM query builder adapters,Pagewith links,LinkandX-Total-Countheaders,PageRequest/#[MapPagination]arguments), RFC 7807 problem details (application/problem+jsonwith 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/docand/api/doc.jsonroutes,ApiControllertrait (paginate(),jsonPage(),rateLimit(),createRateLimiter(),problemJson()).