Skip to content

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.

Vendor/ModuleName/etc/frontend/api.php
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

new SerializationContext(groups: ['product_read', 'product_item_read'])

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

new Resource(
    id: Order::class,
    accessScopes: ['orders_read'],
    identityTypes: ['customer'],
)

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 any DataModel resource that has an identifier set on the operation. Loads the item by ID from the repository pool.
  • DataModeCollectionProvider — supports collection operations backed by a DataModel. 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:

Vendor/ModuleName/Configuration/Di.php
$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;
    }
}
Vendor/ModuleName/Configuration/Di.php
$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:

frontend_api/openapi/expose = true

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).