Every response returned from an /api/ route follows the same envelope shape. This makes it predictable for any client — mobile apps, game clients, third-party integrations — without them needing to guess the structure per endpoint.
{
"success": true,
"data": { ... },
"errors": null,
"meta": {
"timestamp": 1746316800,
"pagination": null
}
}
| Field | Type | Description |
|---|---|---|
success |
bool |
true for 2xx responses, false for errors |
data |
mixed\|null |
Response payload — entity, array, or null on error |
errors |
array\|null |
Error messages — null on success |
meta.timestamp |
int |
Unix timestamp of the response |
meta.pagination |
object\|null |
Cursor pagination metadata, or null if not paginated |
The HTTP status code lives only in the header — it is not duplicated in the body.
PHP_SF\System\Core\ApiResponse extends Symfony\Component\HttpFoundation\JsonResponse, so it is accepted everywhere a JsonResponse is expected. Instantiation is via static factory methods only — there is no public constructor.
// 200 — success with optional data and/or pagination
ApiResponse::success(mixed $data = null, ?CursorPaginationResult $pagination = null, int $status = 200): ApiResponse
// 201 — resource created
ApiResponse::created(mixed $data = null): ApiResponse
// 4xx — generic error (string wraps into a single-element array)
ApiResponse::error(string|array $errors, int $status = 400): ApiResponse
// 404
ApiResponse::notFound(?string $error = null): ApiResponse
// 403
ApiResponse::forbidden(?string $error = null): ApiResponse
// 401
ApiResponse::unauthorized(?string $error = null): ApiResponse
// 422 — validation errors (field => message map)
ApiResponse::unprocessableEntity(array $errors): ApiResponse
// 204 — empty body, no envelope (HTTP spec prohibits content on 204)
ApiResponse::noContent(): JsonResponse
use PHP_SF\System\Core\ApiResponse;
#[Route( url: 'api/player/{id}', httpMethod: 'GET', middleware: auth::class )]
public function get_player( int $id ): ApiResponse
{
$player = User::find( $id );
if ( $player === null )
return ApiResponse::notFound();
return ApiResponse::success( $player );
}
AbstractController includes JsonResponseHelperTrait, which adds api* wrapper methods so you don't need to import ApiResponse directly in every controller file:
// 200
return $this->apiSuccess( $entity );
return $this->apiSuccess( $items, pagination: $paginationResult );
// 201
return $this->apiCreated( $newEntity );
// Generic error
return $this->apiError( 'Something went wrong.' );
return $this->apiError( 'Rate limit exceeded.', status: 429 );
// 404
return $this->apiNotFound();
return $this->apiNotFound( 'Guild does not exist.' );
// 403
return $this->apiForbidden();
// 401
return $this->apiUnauthorized();
// 422
return $this->apiUnprocessableEntity( $entity->getValidationErrors() );
// 204 — empty body, no envelope
return $this->apiNoContent();
ApiResponse normalizes whatever you pass as $data automatically:
| Input type | Serialized as |
|---|---|
AbstractEntity |
jsonSerialize() — array of protected properties |
AbstractDataTransferObject |
toArray() |
Any JsonSerializable |
jsonSerialize() |
array |
each element normalized recursively |
null / scalar |
passed through |
// Single entity — serialized via jsonSerialize()
return $this->apiSuccess( $player );
// Array of entities — each serialized
return $this->apiSuccess( User::findAll() );
// Plain array
return $this->apiSuccess( [ 'level' => 42, 'xp' => 15000 ] );
// DTO
return $this->apiSuccess( PlayerStatsDTO::fromEntity( $player ) );
For a plain string message, errors is a single-element array:
{ "success": false, "errors": ["Player not found."] }
For validation errors from $entity->getValidationErrors(), errors is a field-keyed map:
{
"success": false,
"errors": {
"email": "Email: this value is not a valid email address",
"name": "Name: this value is too short"
}
}
Typical validation flow:
$player = Player::new();
$player->setName( $this->request->request->get( 'name' ) );
$player->setEmail( $this->request->request->get( 'email' ) );
if ( !$player->validate() )
return $this->apiUnprocessableEntity( $player->getValidationErrors() );
em( 'postgresql' )->persist( $player );
return $this->apiCreated( $player );
CursorPaginationHelper paginates a Doctrine QueryBuilder using a stable (sortField, id) cursor — efficient on large tables because it never uses OFFSET.
use PHP_SF\System\Classes\Helpers\CursorPaginationHelper;
use PHP_SF\System\Classes\Helpers\PaginationCursor;
#[Route( url: 'api/players', httpMethod: 'GET', middleware: auth::class )]
public function list_players(): ApiResponse
{
$qb = em( 'postgresql' )->createQueryBuilder()
->select( 'e' )
->from( Player::class, 'e' )
->andWhere( 'e.active = true' );
$result = CursorPaginationHelper::paginate(
qb: $qb,
sortField: 'createdAt',
cursor: PaginationCursor::tryFromString( $this->request->query->get( 'cursor' ) ),
perPage: (int) $this->request->query->get( 'per_page', 20 ),
);
return $this->apiSuccess( data: $result->items, pagination: $result );
}
CursorPaginationHelper::paginate(
QueryBuilder $qb,
string $sortField,
string $entityAlias = 'e',
?PaginationCursor $cursor = null,
int $perPage = 20, // capped at 100
): CursorPaginationResult
Rules for the QueryBuilder:
select('e'))paginate()ORDER BY or setMaxResults — the helper owns those clauses$sortField must be non-nullable for stable cursor behaviour{
"success": true,
"data": [ ... ],
"errors": null,
"meta": {
"timestamp": 1746316800,
"pagination": {
"cursor": "eyJmaWVsZCI6MTc0NjMxNjgwMCwiaWQiOjEsImRpciI6Im5leHQifQ==",
"next_cursor": "eyJmaWVsZCI6MTc0NjMxNjgwMCwiaWQiOjIxLCJkaXIiOiJuZXh0In0=",
"prev_cursor": null,
"per_page": 20,
"has_more": true
}
}
}
# First page — no cursor
GET /api/players
# Next page — pass next_cursor from previous response
GET /api/players?cursor=eyJmaWVsZCI6MTc0...
# Previous page — pass prev_cursor from current response
GET /api/players?cursor=eyJmaWVsZCI6MTc0...
The cursor is opaque — clients must treat it as a blob, not parse it. prev_cursor is null on the first page. next_cursor is null when has_more is false.
CursorPaginationHelper::DEFAULT_PER_PAGE // 20
CursorPaginationHelper::MAX_PER_PAGE // 100
Requests for more than 100 items per page are silently capped.
The value object returned by paginate():
final readonly class CursorPaginationResult
{
public array $items; // hydrated entities for this page
public ?PaginationCursor $cursor; // cursor used for this request (null = first page)
public ?PaginationCursor $nextCursor; // cursor for next page, null if no more
public ?PaginationCursor $prevCursor; // cursor for previous page, null if first page
public int $perPage; // items per page that was requested
public bool $hasMore; // whether a next page exists
}
Pass the full $result as pagination to apiSuccess() — it extracts the metadata into meta.pagination and the items go into data separately.
The framework itself uses ApiResponse for automatic error responses on /api/ routes:
| Situation | Factory method | Status |
|---|---|---|
| Middleware blocks request | ApiResponse::forbidden() |
403 |
auth middleware — not authenticated |
ApiResponse::unauthorized() |
401 |
| Route entity parameter not found | ApiResponse::notFound() |
404 |
These fire automatically — no controller code needed for these cases.