-
Notifications
You must be signed in to change notification settings - Fork 2
API
Pair includes a native API layer designed for JSON endpoints with authentication helpers, middleware pipeline, automatic CRUD resources, throttling, CORS, idempotency, and OpenAPI generation helpers.
This page is the overview. Use the dedicated pages when you need full method-level reference:
- ApiController for controller lifecycle, auth helpers and middleware execution
- Request for payload parsing, validation and transport metadata
- RequestData for explicit custom endpoint request objects
- ApiResponse for JSON success/error responses, registry-based error helpers, and explicit response builders
- JsonResponse for explicit JSON responses
- ApiErrorResponse for explicit API error responses
- TextResponse for explicit plain-text responses
- HttpCache for explicit ETag, Last-Modified, Cache-Control, and 304 responses
- Observability for API controller, middleware, response correlation, and timing spans
- CrudController for automatic REST resources
- CrudResourceConfig for typed CRUD config defaults and array compatibility
- ReadModel, RecordMapper, and Payload for explicit Pair v4 output contracts
Pair\Api\ApiControllerPair\Api\CrudControllerPair\Api\CrudResourceConfigPair\Api\RequestPair\Api\RequestDataPair\Api\ApiResponsePair\Api\ApiErrorResponsePair\Data\ReadModelPair\Data\MapsFromRecordPair\Data\RecordMapperPair\Data\PayloadPair\Http\JsonResponsePair\Http\TextResponsePair\Http\EmptyResponsePair\Http\HttpCachePair\Http\ResponseInterface-
Pair\Api\Middleware+Pair\Api\MiddlewarePipeline Pair\Api\CorsMiddleware-
Pair\Api\ThrottleMiddleware+Pair\Api\RateLimiter+Pair\Api\RateLimitResult Pair\Api\IdempotencyPair\Api\ResourcePair\Api\QueryFilterPair\Core\ObservabilityPair\Api\PasskeyControllerPair\Services\PasskeyAuthPair\Models\UserPasskey
<?php
namespace App\Modules\Api;
use Pair\Api\ApiController as BaseApiController;
class ApiController extends BaseApiController {
/**
* Initialize API request state and middleware.
*/
protected function _init(): void
{
// Always keep the base initialization: request + pipeline + default middleware.
parent::_init();
}
/**
* Return a minimal health-check response.
*/
public function healthAction(): \Pair\Http\ResponseInterface
{
// Returns an explicit JSON response.
return new \Pair\Http\JsonResponse(['ok' => true]);
}
}parent::_init() now initializes the request object, the middleware pipeline, and the default throttle middleware when PAIR_API_RATE_LIMIT_ENABLED=true.
When observability is enabled, Pair records api.controller and api.middleware spans around the API dispatch path. Explicit responses can also expose debug-only correlation and timing headers when APP_DEBUG=true.
CrudController can register ActiveRecord models and expose REST-style endpoints automatically.
<?php
namespace App\Modules\Api;
use Pair\Api\CrudController as BaseCrudController;
use App\Api\ReadModels\FaqReadModel;
use App\Models\Faq;
class ApiController extends BaseCrudController {
/**
* Register API resources for this module.
*/
protected function _init(): void
{
parent::_init();
// Registers one REST resource with an explicit Pair v4 response contract.
$this->crud('faqs', Faq::class, [
'readModel' => FaqReadModel::class,
]);
}
}Generated endpoints (for faqs):
GET /api/faqsGET /api/faqs/{id}POST /api/faqs-
PUT /api/faqs/{id}(alsoPATCH) DELETE /api/faqs/{id}
The readModel entry is the preferred Pair v4 response contract. It keeps public JSON output independent from the persistence model and is also used by OpenAPI generation.
Minimal read-model shape:
use Pair\Data\ArraySerializableData;
use Pair\Data\MapsFromRecord;
use Pair\Data\ReadModel;
use Pair\Orm\ActiveRecord;
/**
* Public FAQ payload used by CrudController responses.
*/
final readonly class FaqReadModel implements ReadModel, MapsFromRecord {
use ArraySerializableData;
/**
* Build the FAQ payload.
*/
public function __construct(
public int $id,
public string $question,
public bool $published
) {}
/**
* Build the FAQ payload from persistence.
*/
public static function fromRecord(ActiveRecord $record): static
{
return new self(
(int)$record->id,
(string)$record->question,
(bool)$record->published
);
}
/**
* Export the public FAQ payload.
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'id' => $this->id,
'question' => $this->question,
'published' => $this->published,
];
}
}Request provides method/content-type/body/query access:
// Reads transport metadata and parsed payloads.
$method = $this->request->method();
$isJson = $this->request->isJson();
$body = $this->request->json();
$status = $this->request->query('status');
$clientIp = $this->request->ip();For explicit v4 validation flows, Request::validateOrResponse() now returns either validated data or an ApiErrorResponse:
$result = $this->request->validateOrResponse([
'email' => 'required|email',
]);
if ($result instanceof \Pair\Api\ApiErrorResponse) {
return $result;
}
$data = $result;For custom endpoints that benefit from typed input, Request::validateObjectOrResponse() validates the payload and maps it into a class implementing RequestData:
$payload = $this->request->validateObjectOrResponse(\App\Api\Requests\CreateOrderRequest::class, [
'customerId' => 'required|int',
'amount' => 'required|numeric|min:0.01',
]);
if ($payload instanceof \Pair\Api\ApiErrorResponse) {
return $payload;
}ApiResponse provides both legacy send-now helpers and explicit v4 response builders:
// Sends a generic JSON payload immediately on the legacy path.
ApiResponse::respond(['saved' => true], 201);
// Sends a normalized error payload immediately on the legacy path.
ApiResponse::error('BAD_REQUEST', ['detail' => 'Invalid payload']);
// Sends an API payload with pagination metadata immediately on the legacy path.
ApiResponse::paginated($rows, $page, $perPage, $total);For new explicit v4 actions, return response objects instead of terminating immediately:
return ApiResponse::jsonResponse(['saved' => true], 201);
return ApiResponse::errorResponse('BAD_REQUEST', [
'detail' => 'Invalid payload',
]);
return ApiResponse::paginatedResponse($rows, $page, $perPage, $total);The same applies to success and paginated payloads:
return ApiResponse::jsonResponse(['saved' => true], 201);
return ApiResponse::paginatedResponse($rows, $page, $perPage, $total);The migrated CRUD v4 path already uses explicit response objects for GET /api/{slug}, GET /api/{slug}/{id}, POST /api/{slug}, PUT|PATCH /api/{slug}/{id}, and DELETE /api/{slug}/{id} success flows. CRUD media-type, body-shape, missing-id, not-found, conflict, method, and validation failures now also bubble as explicit ApiErrorResponse objects on the migrated v4 path.
Read endpoints can opt into HTTP cache validators explicitly:
use Pair\Http\HttpCache;
$payload = ['id' => 7, 'name' => 'Alice'];
return HttpCache::json(
$payload,
200,
HttpCache::etag($payload),
'2026-04-21 10:30:00 UTC',
HttpCache::cacheControl(300, 'public')
);If the request validators match, HttpCache::json() returns 304 Not Modified through EmptyResponse. Keep this explicit per endpoint or per resource policy; do not apply public caching to user-specific or tenant-specific data unless the representation is safe for every recipient.
If you are documenting or implementing a concrete endpoint, keep the method details in the dedicated pages above and use this page as the entry point.
Pair API throttling is enabled out of the box for ApiController through ThrottleMiddleware.
Current behavior:
- Redis is the primary backend when
REDIS_HOSTis configured andext-redisis available. - File storage in
TEMP_PATH/rate_limits/is used automatically as fallback. - Limits are evaluated with an atomic sliding window.
- Clients receive
X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset, andRetry-After(when blocked). - Keys are resolved by identity in this order:
sid, bearer token, authenticated user, then client IP.
Example .env:
PAIR_API_RATE_LIMIT_ENABLED=true
PAIR_API_RATE_LIMIT_MAX_ATTEMPTS=60
PAIR_API_RATE_LIMIT_DECAY_SECONDS=60
PAIR_API_RATE_LIMIT_REDIS_PREFIX="pair:rate_limit:"
PAIR_TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
REDIS_TIMEOUT=1Example 429 response handling on a client:
// Executes one API request that may be throttled.
const response = await fetch('/api/orders?sid=SESSION_ID');
if (response.status === 429) {
// Use Retry-After to decide when to retry.
console.log(response.headers.get('Retry-After'));
// X-RateLimit-Reset gives the reset timestamp.
console.log(response.headers.get('X-RateLimit-Reset'));
}ApiController includes a middleware pipeline.
/**
* Register middleware for this API controller.
*/
protected function _init(): void
{
parent::_init();
// default throttle is already registered by parent::_init()
$this->middleware(new \Pair\Api\CorsMiddleware());
}During normal API requests, Pair executes the controller middleware pipeline automatically before the action method runs.
/**
* Return the current authenticated user payload.
*/
public function meAction(): \Pair\Http\ResponseInterface
{
// Require an authenticated user after middleware checks.
$user = $this->requireAuth();
return new \Pair\Http\JsonResponse(['id' => $user->getId()]);
}If you invoke runMiddleware() manually in a focused test or advanced flow, it now returns the first explicit result produced by the middleware chain or destination callable, so explicit ResponseInterface objects can bubble back to the caller even when a middleware short-circuits the request.
Important: the default stack currently mounts the throttle first inside parent::_init(). Any middleware added later in _init() runs after it.
If you need a specific order, override registerDefaultMiddleware().
/**
* Register middleware in a custom order.
*/
protected function registerDefaultMiddleware(): void
{
$this->middleware(new \Pair\Api\CorsMiddleware());
$this->middleware(new \Pair\Api\ThrottleMiddleware(60, 60));
}If you need a second stricter limiter for a specific controller, add another ThrottleMiddleware after parent::_init().
/**
* Add a stricter limiter for this controller.
*/
protected function _init(): void
{
parent::_init();
$this->middleware(new \Pair\Api\ThrottleMiddleware(10, 60));
}Inside ApiController:
-
requireAuthOrResponse()returns the loaded user or an explicitUNAUTHORIZEDresponse. -
requireAuth()requires an authenticated user. -
requireBearerOrResponse()returns the bearer token or an explicitAUTH_TOKEN_MISSINGresponse. -
requireBearer()requires a bearer token. -
getUser()returns current user ornull. -
requireJsonPostOrResponse()returns the parsed JSON body or an explicit method/media-type/body error response. -
requireJsonPost()validates method + JSON content-type + body forPOSTrequests only.
For PUT, PATCH and DELETE endpoints, read and validate the payload through Request directly instead of forcing requireJsonPost().
Useful for replayed requests (mobile retries, offline queue replays, flaky networks):
use Pair\Api\Idempotency;
use Pair\Api\ApiResponse;
// Returns an explicit replay/conflict response if this request is a duplicate.
if ($response = Idempotency::duplicateResponse($this->request, 'orders:create')) {
return $response;
}
$result = ['orderId' => 123, 'saved' => true];
// Stores the successful response for future safe retries.
Idempotency::storeResponse($this->request, 'orders:create', $result, 201);
return ApiResponse::jsonResponse($result, 201);Pair provides PasskeyController for ready-to-use endpoints:
POST /api/passkey/login/optionsPOST /api/passkey/login/verify-
POST /api/passkey/register/options(requires authenticated session) -
POST /api/passkey/register/verify(requires authenticated session) -
GET /api/passkey/list(requires authenticated session) -
DELETE /api/passkey/revoke/{id}(requires authenticated session)
The migrated v4 response path is already active for unknown passkeyAction() routes and for all built-in passkey success endpoints: login/options, login/verify, register/options, register/verify, list, and revoke.
The passkey action path now also bubbles auth, method, media-type, body-shape, credential, and revoke validation failures as explicit ApiErrorResponse objects on the migrated v4 path.
Minimal setup:
// Inherit the ready-to-use passkey endpoints directly.
class ApiController extends \Pair\Api\PasskeyController {}Frontend integration is usually done with PairPasskey.js.
/**
* Create an authenticated order from a validated JSON payload.
*/
public function createOrderAction(): \Pair\Http\ResponseInterface
{
$user = $this->requireAuthOrResponse();
if ($user instanceof \Pair\Api\ApiErrorResponse) {
return $user;
}
$body = $this->requireJsonPostOrResponse();
if ($body instanceof \Pair\Api\ApiErrorResponse) {
return $body;
}
$payload = $this->request->validateObjectOrResponse(\App\Api\Requests\CreateOrderRequest::class, [
'customerId' => 'required|int',
'amount' => 'required|numeric|min:0.01',
'currency' => 'required|string|max:3',
]);
if ($payload instanceof \Pair\Api\ApiErrorResponse) {
return $payload;
}
$order = new \App\Orm\Order();
$order->customerId = $payload->customerId;
$order->amount = $payload->amount;
$order->currency = $payload->currency;
if (!$order->store()) {
// Returns a normalized API error with object validation errors.
return ApiResponse::errorResponse('INVALID_OBJECT_DATA', ['errors' => $order->getErrors()]);
}
// Returns the created id to the client.
return new \Pair\Http\JsonResponse(['id' => $order->getId()], 201);
}/**
* Create a payment with an idempotency guard.
*/
public function createPaymentAction(): \Pair\Http\ResponseInterface
{
$user = $this->requireAuthOrResponse();
if ($user instanceof \Pair\Api\ApiErrorResponse) {
return $user;
}
$body = $this->requireJsonPostOrResponse();
if ($body instanceof \Pair\Api\ApiErrorResponse) {
return $body;
}
// Returns the previous response immediately if this request is a replay.
if ($response = Idempotency::duplicateResponse($this->request, 'payments:create')) {
return $response;
}
try {
$payload = $this->request->validateOrResponse([
'orderId' => 'required|int',
'amount' => 'required|numeric|min:0.01',
]);
if ($payload instanceof \Pair\Api\ApiErrorResponse) {
Idempotency::clearProcessing($this->request, 'payments:create');
return $payload;
}
// Builds and stores the response for safe retries.
$result = ['paymentId' => 9911, 'orderId' => (int)$payload['orderId']];
Idempotency::storeResponse($this->request, 'payments:create', $result, 201);
return ApiResponse::jsonResponse($result, 201);
} catch (\Throwable $e) {
// Clears the "processing" lock so the client can retry cleanly.
Idempotency::clearProcessing($this->request, 'payments:create');
throw $e;
}
}curl -i "https://example.test/api/orders?sid=SESSION_ID"Expected headers when the limit is reached:
HTTP/1.1 429 Too Many Requests
Retry-After: 32
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1710842892
- Overriding
ApiController::_init()and forgettingparent::_init(). - Adding a second throttle middleware without realizing the default one is already active.
- Describing CORS-before-throttle as the default order when the real default stack mounts throttle first.
- Returning non-JSON output (
echo/print) in API actions. - Running
lockForUpdate()logic without transaction control. - Using idempotency key without
clearProcessing()in failure paths. - Trusting forwarded proxy headers without configuring
PAIR_TRUSTED_PROXIES.
Pair provides generators under:
Pair\Api\OpenApi\SpecGeneratorPair\Api\OpenApi\SchemaGenerator
Use them to build machine-readable API docs from your resource/controller metadata.
These classes are used frequently around the API layer even if they are not the first ones you touch:
-
Pair\Api\MiddlewareandPair\Api\MiddlewarePipelineDefine and execute reusable request guards. -
Pair\Api\CorsMiddlewareAdds CORS handling to API endpoints. -
Pair\Api\ThrottleMiddleware,Pair\Api\RateLimiter,Pair\Api\RateLimitResultImplement the default API throttling system. -
Pair\Api\ResourceShapes normalized API output for CRUD resources. -
Pair\Api\QueryFilterHelps transform request filters into query constraints.