Skip to content
Viames Marino edited this page Apr 22, 2026 · 8 revisions

Pair framework: 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:

Core classes

  • Pair\Api\ApiController
  • Pair\Api\CrudController
  • Pair\Api\CrudResourceConfig
  • Pair\Api\Request
  • Pair\Api\RequestData
  • Pair\Api\ApiResponse
  • Pair\Api\ApiErrorResponse
  • Pair\Data\ReadModel
  • Pair\Data\MapsFromRecord
  • Pair\Data\RecordMapper
  • Pair\Data\Payload
  • Pair\Http\JsonResponse
  • Pair\Http\TextResponse
  • Pair\Http\EmptyResponse
  • Pair\Http\HttpCache
  • Pair\Http\ResponseInterface
  • Pair\Api\Middleware + Pair\Api\MiddlewarePipeline
  • Pair\Api\CorsMiddleware
  • Pair\Api\ThrottleMiddleware + Pair\Api\RateLimiter + Pair\Api\RateLimitResult
  • Pair\Api\Idempotency
  • Pair\Api\Resource
  • Pair\Api\QueryFilter
  • Pair\Core\Observability
  • Pair\Api\PasskeyController
  • Pair\Services\PasskeyAuth
  • Pair\Models\UserPasskey

Minimal API controller

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

Automatic CRUD with CrudController

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/faqs
  • GET /api/faqs/{id}
  • POST /api/faqs
  • PUT /api/faqs/{id} (also PATCH)
  • 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 and response helpers

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.

Rate limiting

Pair API throttling is enabled out of the box for ApiController through ThrottleMiddleware.

Current behavior:

  • Redis is the primary backend when REDIS_HOST is configured and ext-redis is 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, and Retry-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=1

Example 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'));
}

Middleware pipeline

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));
}

Authentication helpers

Inside ApiController:

  • requireAuthOrResponse() returns the loaded user or an explicit UNAUTHORIZED response.
  • requireAuth() requires an authenticated user.
  • requireBearerOrResponse() returns the bearer token or an explicit AUTH_TOKEN_MISSING response.
  • requireBearer() requires a bearer token.
  • getUser() returns current user or null.
  • requireJsonPostOrResponse() returns the parsed JSON body or an explicit method/media-type/body error response.
  • requireJsonPost() validates method + JSON content-type + body for POST requests only.

For PUT, PATCH and DELETE endpoints, read and validate the payload through Request directly instead of forcing requireJsonPost().

Idempotency for safe retries

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

Passkey/WebAuthn endpoints

Pair provides PasskeyController for ready-to-use endpoints:

  • POST /api/passkey/login/options
  • POST /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.

End-to-end recipes

Authenticated JSON endpoint with validation

/**
 * 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);
}

Idempotent create endpoint

/**
 * 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;
    }
}

Read headers after a throttled request

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

Common pitfalls

  • Overriding ApiController::_init() and forgetting parent::_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.

OpenAPI helpers

Pair provides generators under:

  • Pair\Api\OpenApi\SpecGenerator
  • Pair\Api\OpenApi\SchemaGenerator

Use them to build machine-readable API docs from your resource/controller metadata.

Secondary building blocks worth knowing

These classes are used frequently around the API layer even if they are not the first ones you touch:

  • Pair\Api\Middleware and Pair\Api\MiddlewarePipeline Define and execute reusable request guards.
  • Pair\Api\CorsMiddleware Adds CORS handling to API endpoints.
  • Pair\Api\ThrottleMiddleware, Pair\Api\RateLimiter, Pair\Api\RateLimitResult Implement the default API throttling system.
  • Pair\Api\Resource Shapes normalized API output for CRUD resources.
  • Pair\Api\QueryFilter Helps transform request filters into query constraints.

Related pages

Clone this wiki locally