Api
The API layer is a declarative, resource-based system. Every API endpoint is defined in a PHP
configuration file (etc/api.php) using Resource and Operation objects. The framework wires
routes, data providers, filters, security, serialization, and rate limiting automatically from
that configuration. It also generates and serves a live OpenAPI 3.0 spec and Swagger UI with no
extra work.
Configuration file placement
API configuration files implement ResourceConfiguratorInterface and are loaded from every module's
etc/ directory. The area the file is placed in determines which application areas will expose its
resources:
| File path | Active in |
|---|---|
Vendor/ModuleName/etc/api.php |
Frontend and backend |
Vendor/ModuleName/etc/frontend/api.php |
Frontend only |
Vendor/ModuleName/etc/backend/api.php |
Backend only |
Files are loaded in module dependency order, so later modules can shadow or extend resources from earlier ones.
use Amarant\Framework\Contract\Application\ContextInterface;
use Amarant\Framework\Contract\Routing\Api\ResourceCollectionInterface;
use Amarant\Framework\Contract\Routing\Api\ResourceConfiguratorInterface;
use Amarant\Framework\Data\Api\Configuration;
use Amarant\Framework\Data\Api\OpenApi;
use Amarant\Framework\Data\Api\Resource;
use Symfony\Component\HttpFoundation\Request;
return new class () implements ResourceConfiguratorInterface {
public function configure(
ContextInterface $context,
ResourceCollectionInterface $resourceCollection
): void {
$resourceCollection->addResource(
new Resource(
id: Product::class,
configuration: new Configuration(output: ProductModel::class),
)->addItem(
id: 'get-product-collection',
method: Request::METHOD_GET,
path: '/products/v1',
collection: true,
openApi: new OpenApi(tags: ['Products'], description: 'Get product collection.')
)->addItem(
id: 'get-product-item',
method: Request::METHOD_GET,
path: '/products/v1/{id:\d+}',
identifier: 'id',
openApi: new OpenApi(
path: '/products/v1/{id}',
tags: ['Products'],
description: 'Get product item.'
)
)
);
}
};
Resource
A Resource groups one or more operations under a common subject (the DataModel or output model class).
Resource-level configuration, filters, extensions, guards, and access scopes are inherited by all
operations added via addItem(). Operation-level values override or extend resource-level ones.
Resource constructor
| Parameter | Type | Description |
|---|---|---|
id |
class-string |
Primary identifier — typically the DataModel class the resource is backed by. Used for item loading and routing. |
alias |
?string |
When set, overrides id as the resource ID used in route metadata (rarely needed). |
configuration |
?Configuration |
Default configuration inherited by all operations. |
controller |
string |
Default controller. Defaults to DefaultController (handles standard GET/OPTIONS flows). |
filters |
Filter[] |
Filters applied to every operation. |
extensions |
class-string[] |
Extension classes applied to every operation. |
guards |
Guard[] |
Security guards checked after the item is loaded. |
cache |
?Cache |
Default HTTP response cache config. |
accessScopes |
string[] |
Required access scopes for every operation. |
identityTypes |
string[] |
Allowed identity types for every operation. |
addItem() parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
id |
string |
— | Unique operation ID. Becomes the route name. |
method |
string |
— | HTTP method (Request::METHOD_GET, POST, PUT, PATCH, DELETE). |
path |
string |
— | URL path relative to the API prefix. Inline regex constraints use {name:pattern} syntax (e.g. {id:\d+}). |
controller |
?string |
Resource default | Controller class. Omit for standard data-provider-backed endpoints. |
identifier |
?string |
null |
Name of the route parameter used as the item ID (e.g. 'id'). When set, the item is loaded before the controller runs. |
identifierPathName |
?string |
null |
Overrides identifier as the path variable name when they differ. |
collection |
bool |
false |
Whether this operation returns a collection. |
status |
int |
200 |
HTTP status code returned on success. |
configuration |
?Configuration |
null |
Operation-specific configuration (merged with resource-level). |
openApi |
?OpenApi |
null |
OpenAPI documentation hints. |
filters |
Filter[] |
[] |
Additional filters (merged with resource-level). |
extensions |
class-string[] |
[] |
Additional query extensions (merged with resource-level). |
guards |
Guard[] |
[] |
Operation-level guards (run before resource-level guards). |
cache |
?Cache |
Resource default | HTTP response cache config. |
accessScopes |
?string[] |
Resource default | Required access scopes. Overrides resource-level when not null. |
identityTypes |
?string[] |
Resource default | Allowed identity types. Overrides resource-level when not null. |
paramRequirements |
array<string, string> |
[] |
Additional route parameter regex constraints as ['name' => 'pattern']. |
links |
Link[] |
[] |
Link definitions for hypermedia (rarely used directly). |
Configuration
Controls how input is read, how the output is serialized, and optional features per operation.
use Amarant\Framework\Data\Api\Configuration;
use Amarant\Framework\Data\Api\Pagination;
use Amarant\Framework\Data\Api\RateLimiter;
use Amarant\Framework\Data\Api\SerializationContext;
new Configuration(
input: ProductInput::class, // DTO class for request body deserialization
output: ProductModel::class, // class serialized as the response body
normalization: new SerializationContext(groups: ['product_read']),
denormalization: new SerializationContext(groups: ['product_write']),
pagination: new Pagination(defaultItems: 12, maxItems: 48),
rateLimiter: new RateLimiter(
limit: 60,
policy: RateLimiter::POLICY_SLIDING_WINDOW,
rate: ['interval' => '1 minute']
),
)
| Parameter | Type | Default | Description |
|---|---|---|---|
input |
?string |
null |
DTO class used to deserialize the request body. When set, the deserialized and validated object is injected into the controller as a typed parameter. See Controller argument providers for how the framework resolves controller parameters from the request. |
inputMediaType |
?string |
null |
Expected Content-Type of the request (e.g. Configuration::MEDIA_TYPE_MULTIPART_FORM_DATA). |
output |
?string |
null |
Class serialized as the JSON response. Applies at the resource level to all operations unless overridden. |
outputMediaType |
?string |
application/json |
Content-Type of the response. |
collectionClass |
?string |
null |
Custom collection wrapper class. Defaults to the framework's PaginatedCollection / Collection. |
normalization |
?SerializationContext |
null |
Serialization groups applied when converting the output object to JSON. |
denormalization |
?SerializationContext |
null |
Serialization groups applied when deserializing the input body. |
pagination |
?Pagination |
null |
Enables pagination for collection operations. |
skipNullValues |
?bool |
null |
Omit null properties from the serialized output. |
read |
?bool |
true |
Whether the data provider's read flow runs. |
write |
?bool |
true |
Whether the data provider's write flow runs. |
documented |
?bool |
true |
Whether this operation appears in the OpenAPI spec. |
rateLimiter |
?RateLimiter |
null |
Rate limiting configuration. |
allowIdentifiers |
?bool |
null |
Whether identifier path parameters are resolved to items. |
SerializationContext
Groups are matched against #[Context] attributes on the output model's properties.
See Serialization — groups for details.
Pagination
| Parameter | Default | Description |
|---|---|---|
defaultItems |
12 |
Items returned when no itemsPerPage query parameter is given. |
maxItems |
24 |
Upper bound for itemsPerPage. |
pageParamName |
'page' |
Query parameter name for the page number. |
sizeParamName |
'itemsPerPage' |
Query parameter name for the page size. |
Collection responses with pagination are wrapped in PaginatedCollection:
{
"data": [...],
"meta": { "totalItems": 100, "currentPage": 1, "lastPage": 9 },
"links": { "first": "...", "last": "...", "prev": null, "next": "..." },
"aggregations": null,
"errors": null
}
Non-paginated collections use Collection (same shape without links/aggregations).
Filters
Filters receive the active QueryBuilder and modify it before the data provider runs. They are
identified by a name string and declared in the operation's filters array. The framework ships
three built-in filters.
use Amarant\Framework\Data\Api\Filter;
use Amarant\Framework\Api\Filter\DataModel\SearchCriteriaFilter;
use Amarant\Framework\Data\Api\Filter\SearchCriteria\Path;
use Amarant\Framework\Search\Builder\Filter as FilterOperator;
->addItem(
...
filters: [
new Filter(
filterClass: SearchCriteriaFilter::class,
settings: [
'paths' => [
'name' => new Path(
allowedConditions: [FilterOperator::OPERATOR_EQ, FilterOperator::OPERATOR_LIKE],
filterable: true,
sortable: true,
),
'price' => new Path(
allowedConditions: [
FilterOperator::OPERATOR_EQ,
FilterOperator::OPERATOR_GTE,
FilterOperator::OPERATOR_LTE,
FilterOperator::OPERATOR_RANGE,
],
allowedValueTypes: ['double', 'integer'],
filterable: true,
sortable: true,
),
],
]
),
]
)
Built-in filters
| Class | Name tag | Purpose |
|---|---|---|
SearchCriteriaFilter |
search_criteria_filter |
Applies client-provided filter groups and sort from the q query parameter. |
PaginationFilter |
pagination |
Adds page and itemsPerPage parameters. Automatically active when Configuration::pagination is set. |
FieldInclusionExclusionFilter |
field_inclusion_exclusion |
Adds include and exclude comma-separated field selectors on collection endpoints. |
SearchCriteriaFilter — query format
The client sends filtering and sorting via the q query parameter. Condition values are the
string values of the constants on Amarant\Framework\Search\Builder\Filter.
The q parameter accepts two formats — both work, JSON string is preferred:
JSON string (preferred):
GET /api/products/v1?q={"groups":[{"name":{"condition":"like","value":"shirt"}}],"sort":[{"path":"price","direction":"ASC"}]}
PHP array syntax (also supported):
GET /api/products/v1?q[groups][0][name][condition]=like&q[groups][0][name][value]=shirt&q[sort][0][path]=price&q[sort][0][direction]=ASC
The structure is the same in both cases:
{
"groups": [
{
"name": { "condition": "like", "value": "shirt" }
}
],
"sort": [
{ "path": "price", "direction": "ASC" }
]
}
groups is an array of filter groups. Each group is an object whose keys are field paths and whose
values are { "condition": "<operator>", "value": <value> }. Fields within the same group are
combined with AND; multiple groups are combined with AND between them. sort is an ordered
array of { "path", "direction" } objects.
SearchCriteria\Path — per-field configuration
| Parameter | Type | Description |
|---|---|---|
allowedConditions |
string[] |
Which filter operators are permitted. Use constants from Filter (e.g. Filter::OPERATOR_EQ, Filter::OPERATOR_LIKE, Filter::OPERATOR_IN, Filter::OPERATOR_GTE, Filter::OPERATOR_LTE, Filter::OPERATOR_RANGE). Empty = all operators allowed. |
allowedValueTypes |
string[] |
PHP types accepted as filter values (e.g. ['string'], ['integer', 'double']). |
allowedCollectionValueType |
?string |
Element type when the value is an array ('integer', 'string', etc.). |
filterable |
bool |
Whether clients may filter on this field. |
sortable |
bool |
Whether clients may sort by this field. |
path |
string\|string[]\|null |
Overrides the actual DB column/expression. Multiple paths are combined with pathOperator. |
pathOperator |
string |
How multiple paths are combined: Path::PATH_OPERATOR_OR (default) or Path::PATH_OPERATOR_AND. |
sortPath |
string\|string[]\|null |
DB expression used for sorting (falls back to path). |
joins |
array<string, Join> |
Additional table joins required for this field, keyed by alias. |
groupBy |
?string[] |
GROUP BY expressions required when this field is active. |
filter |
?string |
Name of a nested filter to delegate to (for complex join scenarios). |
appendQuery |
?string |
Raw SQL appended after the generated condition with AND. |
meta |
array |
Extra options. Path::META_CAST — cast type ('integer', 'decimal', 'timestamp'). Path::META_APPLICATION_TIMESTAMP — convert date strings to application timezone. |
Per-field filter examples
The patterns below cover the common field configurations.
Sort-only field — clients can sort but not filter:
use Amarant\Framework\Data\Api\Filter\SearchCriteria\Path;
'id' => new Path(sortable: true),
'created_at' => new Path(sortable: true),
'updated_at' => new Path(sortable: true),
String search with LIKE — partial match, filterable and sortable:
use Amarant\Framework\Data\Api\Filter\SearchCriteria\Path;
use Amarant\Framework\Search\Builder\Filter;
'identifier' => new Path(
allowedConditions: [Filter::OPERATOR_LIKE],
allowedValueTypes: ['string'],
filterable: true,
sortable: true,
),
Boolean flag with flexible value types — clients may send true/false, 1/0, or "1"/"0":
use Amarant\Framework\Data\Api\Filter\SearchCriteria\Path;
use Amarant\Framework\Search\Builder\Filter;
'active' => new Path(
allowedConditions: [Filter::OPERATOR_EQ],
allowedValueTypes: ['bool', 'int', 'string'],
filterable: true,
sortable: true,
),
Array containment — filter by a set of IDs (e.g. a relation column):
use Amarant\Framework\Data\Api\Filter\SearchCriteria\Path;
use Amarant\Framework\Search\Builder\Filter;
'tag_ids' => new Path(
allowedConditions: [Filter::OPERATOR_INA],
allowedValueTypes: ['array'],
filterable: true,
),
OPERATOR_INA checks whether any element of the client-supplied array matches any element of the
column value (PostgreSQL: &&).
Virtual path alias — expose a different key in the API than the underlying DB column. Useful for backwards-compatible renames or when a single concept needs multiple operators under different names:
use Amarant\Framework\Data\Api\Filter\SearchCriteria\Path;
use Amarant\Framework\Search\Builder\Filter;
// API key 'identifiers' maps the IN query to the 'identifier' column
'identifiers' => new Path(
allowedConditions: [Filter::OPERATOR_IN],
allowedValueTypes: ['array'],
filterable: true,
path: 'identifier',
sortPath: 'identifier',
),
Range / numeric bounds:
use Amarant\Framework\Data\Api\Filter\SearchCriteria\Path;
use Amarant\Framework\Search\Builder\Filter;
'price' => new Path(
allowedConditions: [
Filter::OPERATOR_GTE,
Filter::OPERATOR_LTE,
Filter::OPERATOR_RANGE,
],
allowedValueTypes: ['double', 'integer'],
filterable: true,
sortable: true,
),
Custom filter
Implement ApplicableFilterInterface and tag the class with DiEnum::API_FILTER. The #[Filter]
attribute on the class sets the name used in filterClass:
use Amarant\Framework\Attribute\Api\Filter;
use Amarant\Framework\Contract\Api\Filter\ApplicableFilterInterface;
use Amarant\Framework\Contract\Api\Filter\FilterContextInterface;
#[Filter('my_custom_filter')]
final class MyCustomFilter implements ApplicableFilterInterface
{
public function apply(FilterContextInterface $filterContext): void
{
$queryBuilder = $filterContext->getFilter()->settings['queryBuilder'] ?? null;
// modify $queryBuilder
}
}
Rate limiting
Apply per-operation rate limiting via RateLimiter on the Configuration or directly on the resource.
The rate limiter runs at route dispatch time (priority 900, before security and data loading).
use Amarant\Framework\Data\Api\RateLimiter;
new Configuration(
rateLimiter: new RateLimiter(
limit: 60,
policy: RateLimiter::POLICY_SLIDING_WINDOW,
rate: ['interval' => '1 minute']
)
)
| Parameter | Description |
|---|---|
limit |
Maximum number of requests within the rate window. |
policy |
RateLimiter::POLICY_TOKEN_BUCKET, POLICY_FIXED_WINDOW, or POLICY_SLIDING_WINDOW. |
rate |
Array with at least interval (e.g. '1 minute', '1 hour'). Token bucket also accepts amount. |
When the limit is exceeded the API returns HTTP 429 Too Many Requests.
Rate limit state is persisted in the cache pool via RequestLimiter.
Security
Access scopes and identity types
accessScopes — the current identity must hold all listed scopes. If not met:
- Anonymous identity → HTTP 401 redirect to login.
- Authenticated identity with wrong scopes → HTTP 403.
identityTypes — the identity type must match any of the listed types, and the identity must be
fully authenticated. Use this to restrict an operation to e.g. 'customer' or 'admin'.
Operation-level accessScopes/identityTypes override the resource-level defaults when not null.
Guards
Guards run after the item is loaded (for item operations) or after an empty instance is created
(for POST operations). They call SecurityManagerInterface::isGranted().
use Amarant\Framework\Data\Api\Guard;
use Symfony\Component\HttpFoundation\Response;
new Guard(
resourceId: null, // null = primary resource; set to override
attributes: null, // null = inferred from HTTP method (read/write/delete)
message: 'You cannot modify this order.',
status: Response::HTTP_FORBIDDEN
)
Default attribute inference from HTTP method:
| Method | Attributes checked |
|---|---|
GET, HEAD |
SecurityGuardInterface::ATTRIBUTE_READ |
DELETE |
SecurityGuardInterface::ATTRIBUTE_DELETE |
| All others | ATTRIBUTE_READ + ATTRIBUTE_WRITE |
Data providers
Data providers supply items and collections to the GetRequestHandler. The framework ships two
default providers for DataModel-backed resources; custom providers can be added for any resource.
Built-in providers
DataModelItemProvider— supports anyDataModelresource that has anidentifierset on the operation. Loads the item by ID from the repository pool.DataModeCollectionProvider— supports collection operations backed by aDataModel. Applies all configured filters and extensions to the query builder, then paginates.
Custom item provider
use Amarant\Framework\Contract\Api\ContextInterface;
use Amarant\Framework\Contract\Api\DataProvider\ItemDataProviderInterface;
use Amarant\Framework\Enum\DiEnum;
final class ProductItemProvider implements ItemDataProviderInterface
{
public function supports(ContextInterface $context): bool
{
return $context->getResource()->id === Product::class
&& !$context->getOperation()->collection;
}
public function getItem(ContextInterface $context): mixed
{
$id = $context->getRequestContext()->getRoute()?->getParam('id');
return $this->productRepository->getById((int) $id);
}
}
Tag with DiEnum::API_ITEM_DATA_PROVIDER:
$container->tag([ProductItemProvider::class], [DiEnum::API_ITEM_DATA_PROVIDER]);
Custom collection provider
Implement CollectionDataProviderInterface and tag with DiEnum::API_COLLECTION_DATA_PROVIDER.
getCollection() may return a raw Traversable, a CollectionInterface, or a PaginatorInterface
(which the serializer wraps in a PaginatedCollection automatically).
Extensions
Extensions modify the QueryBuilder for DataModel-backed providers after filters are applied.
Use them to add mandatory WHERE conditions without modifying the filter configuration.
Collection extension
use Amarant\Framework\Contract\Api\ContextInterface;
use Amarant\Framework\Contract\Api\Extension\DataModel\CollectionExtensionInterface;
use Amarant\Framework\Database\Connection\QueryBuilder;
use Amarant\Framework\Enum\DiEnum;
final class ActiveProductCollectionExtension implements CollectionExtensionInterface
{
public function extend(ContextInterface $context, QueryBuilder $queryBuilder): void
{
if ($context->getResource()->id !== Product::class) {
return;
}
$queryBuilder->andWhere('is_active', true);
}
}
Tag with DiEnum::API_COLLECTION_EXTENSION. For item queries, implement
ItemExtensionInterface instead and tag with DiEnum::API_ITEM_EXTENSION.
Extensions can also be declared per-operation in the extensions array on addItem() — these
are class names resolved from the DI container and applied in addition to globally tagged extensions.
Request handlers
The DefaultController delegates the actual response generation to RequestHandlerInterface
implementations. The first handler whose supports() returns true handles the request.
Built-in handlers (registered in priority order):
| Handler | Handles | Description |
|---|---|---|
GetRequestHandler |
GET, HEAD |
Delegates to the data provider pool. |
OptionsRequestHandler |
OPTIONS |
Returns CORS preflight response. |
The ChainedRequestHandler aggregates all tagged handlers and tries them in order. To add a custom
handler for write operations (POST, PUT, PATCH, DELETE), implement RequestHandlerInterface and tag
with DiEnum::API_REQUEST_HANDLER:
use Amarant\Framework\Contract\Api\ContextInterface;
use Amarant\Framework\Contract\Api\RequestHandlerInterface;
use Amarant\Framework\Enum\DiEnum;
use Symfony\Component\HttpFoundation\Request;
final class CreateProductRequestHandler implements RequestHandlerInterface
{
public function supports(ContextInterface $context): bool
{
return $context->getResource()->id === Product::class
&& $context->getRequestContext()->getRequest()->isMethod(Request::METHOD_POST);
}
public function handle(ContextInterface $context): mixed
{
/** @var ProductInput $input */
$input = $context->getRequestContext()->getRoute()?->getInput();
$product = $this->productFacade->create($input);
return $product;
}
}
$container->tag([CreateProductRequestHandler::class], [DiEnum::API_REQUEST_HANDLER]);
OpenApi documentation hints
Every operation can carry an OpenApi value object that provides metadata used when generating the
OpenAPI spec.
use Amarant\Framework\Data\Api\OpenApi;
new OpenApi(
path: '/products/v1/{id}', // OpenAPI path (may differ from routing path)
outputSchemaName: 'ProductModel', // override the auto-derived schema name
tags: ['Products'], // group operations in Swagger UI
description: 'Get a product.', // operation description
summary: 'Get product by ID.', // short summary line
deprecated: true, // marks the operation deprecated; adds Deprecation response header
parameters: [ // explicit path/query parameters (merged with auto-generated)
[
'name' => 'id',
'in' => 'path',
'schema' => ['type' => 'integer'],
'description' => 'Product ID.',
'required' => true,
]
],
responses: [], // explicit response objects (advanced)
inheritSchemas: [], // schema names to compose with oneOf
)
Schema generation
The OpenAPI generator reflects over the output class (set in Configuration) and produces an
OpenAPI schema from its PHP type declarations, #[Json] attribute metadata, and any OpenAPI
attributes (#[OA\Property]) on the class. Nested objects and enums are resolved recursively.
Serialization groups (#[Context]) can produce multiple schema variants.
The input class is similarly reflected when set, producing the request body schema.
OpenAPI spec and Swagger UI
The spec and UI are served at fixed routes registered in the framework, directly on the application host:
| Path | Controller | Purpose |
|---|---|---|
/openapi |
OpenApiController |
Returns the raw OpenAPI 3.0 JSON spec. |
/openapi/swagger |
SwaggerController |
Renders the Swagger UI HTML page. |
Both endpoints are disabled by default. Enable them via configuration:
Config key constant: ConfigEnum::FRONTEND_API_OPENAPI_EXPOSE.
When disabled, both URLs return a 404 response. In production environments, restrict access to these URLs at the web server or firewall level if you do not want to expose your API surface publicly.
The spec is generated from the live resource collection — every operation declared in any api.php
file loaded for the current area is included automatically. No manual spec writing is needed.
Request lifecycle for API endpoints
All of this is wired together by four event subscribers that fire in sequence on every API request:
| Subscriber | Event | Priority | What it does |
|---|---|---|---|
ResourceRateLimiterEventSubscriber |
APP_REQUEST_ROUTE |
900 | Checks the rate limiter. Returns HTTP 429 if exceeded. |
ResourceInitializerEventSubscriber |
APP_REQUEST_ROUTE |
500 | Resolves the Resource and Operation, validates access scopes and identity types, loads the item (if identifier is set), and evaluates security guards. |
ResponseSerializerEventSubscriber |
APP_REQUEST_RESPONSE |
450 | Serializes the controller's return value to JSON using ResourceResponseSerializer, applying normalization groups and extracting HTTP cache tags. |
ResponseHeadersEventSubscriber |
APP_REQUEST_RESPONSE_FINALIZED_BEFORE |
−1900 | Adds Deprecation: true header (when OpenApi::deprecated is set) and overrides Content-Type (when Configuration::outputMediaType is set). |