🌐 REST API Design Guide¶
Build robust, well-documented RESTful APIs for XOOPS 2026 modules.
REST APIs enable external applications to interact with your XOOPS modules. This guide covers best practices for designing, implementing, and documenting APIs.
API Architecture Overview¶
flowchart TB
subgraph Client["Client Applications"]
WEB[Web App]
MOB[Mobile App]
CLI[CLI Tool]
EXT[Third Party]
end
subgraph API["XOOPS API Layer"]
GW[API Gateway]
AUTH[Authentication]
RATE[Rate Limiting]
ROUTE[Router]
end
subgraph Handlers["Request Handlers"]
CTRL[API Controllers]
VAL[Validators]
TRANS[Transformers]
end
subgraph Core["Business Logic"]
SVC[Services]
REPO[Repositories]
ENT[Entities]
end
Client --> GW
GW --> AUTH --> RATE --> ROUTE
ROUTE --> Handlers
Handlers --> Core Configuration¶
Enable API in module.json¶
{
"api": {
"enabled": true,
"version": "1.0",
"prefix": "/api/v1/articles",
"authentication": ["bearer", "api_key"],
"rate_limit": {
"requests": 100,
"window": 60
},
"cors": {
"origins": ["*"],
"methods": ["GET", "POST", "PUT", "DELETE"],
"headers": ["Authorization", "Content-Type"]
}
}
}
Route Definition¶
<?php
// config/api-routes.php
declare(strict_types=1);
use Xoops\Routing\Router;
return static function (Router $router): void {
$router->group([
'prefix' => '/api/v1/articles',
'middleware' => ['api', 'throttle:100,60'],
], function (Router $router) {
// Collection routes
$router->get('/', [ArticleApiController::class, 'index']);
$router->post('/', [ArticleApiController::class, 'store']);
// Resource routes
$router->get('/{id}', [ArticleApiController::class, 'show']);
$router->put('/{id}', [ArticleApiController::class, 'update']);
$router->delete('/{id}', [ArticleApiController::class, 'destroy']);
// Nested resources
$router->get('/{id}/comments', [CommentApiController::class, 'index']);
$router->post('/{id}/comments', [CommentApiController::class, 'store']);
// Actions
$router->post('/{id}/publish', [ArticleApiController::class, 'publish']);
$router->post('/{id}/archive', [ArticleApiController::class, 'archive']);
});
};
RESTful URL Design¶
Resource Naming¶
flowchart LR
subgraph Good["✅ Good URLs"]
G1["/api/v1/articles"]
G2["/api/v1/articles/123"]
G3["/api/v1/articles/123/comments"]
G4["/api/v1/users/456/articles"]
end
subgraph Bad["❌ Bad URLs"]
B1["/api/v1/getArticles"]
B2["/api/v1/article/get/123"]
B3["/api/v1/articleComments/123"]
B4["/api/v1/get-user-articles"]
end HTTP Methods¶
| Method | URL | Action | Description |
|---|---|---|---|
| GET | /articles | index | List all articles |
| POST | /articles | store | Create new article |
| GET | /articles/{id} | show | Get single article |
| PUT | /articles/{id} | update | Update entire article |
| PATCH | /articles/{id} | update | Partial update |
| DELETE | /articles/{id} | destroy | Delete article |
URL Patterns¶
# Collection
GET /api/v1/articles # List articles
POST /api/v1/articles # Create article
# Single resource
GET /api/v1/articles/123 # Get article 123
PUT /api/v1/articles/123 # Update article 123
DELETE /api/v1/articles/123 # Delete article 123
# Nested resources
GET /api/v1/articles/123/comments # List comments for article 123
POST /api/v1/articles/123/comments # Add comment to article 123
# Filtering & searching
GET /api/v1/articles?status=published
GET /api/v1/articles?category=5&author=10
GET /api/v1/articles?search=xoops
# Pagination
GET /api/v1/articles?page=2&per_page=20
# Sorting
GET /api/v1/articles?sort=created_at&order=desc
# Including relations
GET /api/v1/articles?include=author,comments,tags
API Controllers¶
Basic Controller¶
<?php
declare(strict_types=1);
namespace MyModule\Controller\Api;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Xoops\Http\Controller\ApiController;
final class ArticleApiController extends ApiController
{
public function __construct(
private readonly ArticleService $service,
private readonly ArticleTransformer $transformer,
) {}
public function index(ServerRequestInterface $request): ResponseInterface
{
$params = $request->getQueryParams();
$articles = $this->service->paginate(
page: (int) ($params['page'] ?? 1),
perPage: (int) ($params['per_page'] ?? 15),
filters: $this->extractFilters($params),
);
return $this->json([
'data' => $this->transformer->collection($articles->items()),
'meta' => [
'current_page' => $articles->currentPage(),
'per_page' => $articles->perPage(),
'total' => $articles->total(),
'last_page' => $articles->lastPage(),
],
'links' => [
'first' => $this->buildPageUrl(1),
'last' => $this->buildPageUrl($articles->lastPage()),
'prev' => $articles->currentPage() > 1
? $this->buildPageUrl($articles->currentPage() - 1)
: null,
'next' => $articles->hasMorePages()
? $this->buildPageUrl($articles->currentPage() + 1)
: null,
],
]);
}
public function show(ServerRequestInterface $request, int $id): ResponseInterface
{
$article = $this->service->find($id);
if ($article === null) {
return $this->notFound('Article not found');
}
return $this->json([
'data' => $this->transformer->transform($article),
]);
}
public function store(ServerRequestInterface $request): ResponseInterface
{
$data = $request->getParsedBody();
$validator = $this->validate($data, [
'title' => 'required|string|min:3|max:255',
'content' => 'required|string',
'category_id' => 'required|integer|exists:categories,id',
]);
if ($validator->fails()) {
return $this->validationError($validator->errors());
}
$article = $this->service->create(
new CreateArticleCommand(
title: $data['title'],
content: $data['content'],
categoryId: $data['category_id'],
authorId: $request->getAttribute('user')->id,
)
);
return $this->created([
'data' => $this->transformer->transform($article),
]);
}
public function update(ServerRequestInterface $request, int $id): ResponseInterface
{
$article = $this->service->find($id);
if ($article === null) {
return $this->notFound('Article not found');
}
$data = $request->getParsedBody();
$validator = $this->validate($data, [
'title' => 'string|min:3|max:255',
'content' => 'string',
'category_id' => 'integer|exists:categories,id',
]);
if ($validator->fails()) {
return $this->validationError($validator->errors());
}
$article = $this->service->update($id, $data);
return $this->json([
'data' => $this->transformer->transform($article),
]);
}
public function destroy(ServerRequestInterface $request, int $id): ResponseInterface
{
$article = $this->service->find($id);
if ($article === null) {
return $this->notFound('Article not found');
}
$this->service->delete($id);
return $this->noContent();
}
}
Response Formats¶
Success Response¶
{
"data": {
"id": 123,
"type": "article",
"attributes": {
"title": "Getting Started with XOOPS 2026",
"slug": "getting-started-xoops-2026",
"content": "Article content here...",
"status": "published",
"created_at": "2026-01-29T10:30:00Z",
"updated_at": "2026-01-29T14:45:00Z",
"published_at": "2026-01-29T12:00:00Z"
},
"relationships": {
"author": {
"data": {"type": "user", "id": 456}
},
"category": {
"data": {"type": "category", "id": 5}
}
}
},
"included": [
{
"id": 456,
"type": "user",
"attributes": {
"name": "John Doe",
"email": "john@example.com"
}
}
]
}
Collection Response¶
{
"data": [
{"id": 1, "type": "article", "attributes": {...}},
{"id": 2, "type": "article", "attributes": {...}},
{"id": 3, "type": "article", "attributes": {...}}
],
"meta": {
"current_page": 1,
"per_page": 15,
"total": 47,
"last_page": 4
},
"links": {
"first": "/api/v1/articles?page=1",
"last": "/api/v1/articles?page=4",
"prev": null,
"next": "/api/v1/articles?page=2"
}
}
Error Response¶
{
"error": {
"code": "VALIDATION_ERROR",
"message": "The given data was invalid.",
"details": [
{
"field": "title",
"message": "The title field is required."
},
{
"field": "category_id",
"message": "The selected category is invalid."
}
]
}
}
HTTP Status Codes¶
flowchart TB
subgraph Success["2xx Success"]
S200[200 OK]
S201[201 Created]
S204[204 No Content]
end
subgraph ClientError["4xx Client Error"]
E400[400 Bad Request]
E401[401 Unauthorized]
E403[403 Forbidden]
E404[404 Not Found]
E422[422 Validation Error]
E429[429 Too Many Requests]
end
subgraph ServerError["5xx Server Error"]
E500[500 Internal Error]
E503[503 Service Unavailable]
end | Code | Meaning | Use Case |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH |
| 201 | Created | Successful POST (resource created) |
| 204 | No Content | Successful DELETE |
| 400 | Bad Request | Malformed request |
| 401 | Unauthorized | Missing/invalid authentication |
| 403 | Forbidden | Authenticated but not authorized |
| 404 | Not Found | Resource doesn't exist |
| 422 | Unprocessable Entity | Validation errors |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Server-side error |
Authentication¶
Bearer Token¶
<?php
// Middleware checks Authorization header
// Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
final class BearerAuthMiddleware implements MiddlewareInterface
{
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface {
$header = $request->getHeaderLine('Authorization');
if (!str_starts_with($header, 'Bearer ')) {
return $this->unauthorized('Missing bearer token');
}
$token = substr($header, 7);
$user = $this->tokenService->validateAndGetUser($token);
if ($user === null) {
return $this->unauthorized('Invalid or expired token');
}
return $handler->handle(
$request->withAttribute('user', $user)
);
}
}
API Key¶
<?php
// Header: X-API-Key: your-api-key-here
final class ApiKeyMiddleware implements MiddlewareInterface
{
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface {
$apiKey = $request->getHeaderLine('X-API-Key');
if (empty($apiKey)) {
return $this->unauthorized('Missing API key');
}
$client = $this->apiKeyService->validate($apiKey);
if ($client === null) {
return $this->unauthorized('Invalid API key');
}
return $handler->handle(
$request
->withAttribute('api_client', $client)
->withAttribute('user', $client->getUser())
);
}
}
Authentication Flow¶
sequenceDiagram
participant C as Client
participant API as API Gateway
participant AUTH as Auth Service
participant H as Handler
C->>API: POST /api/auth/login
API->>AUTH: Validate credentials
AUTH-->>API: JWT Token
API-->>C: 200 {token: "eyJ..."}
C->>API: GET /api/articles (Bearer token)
API->>AUTH: Validate token
AUTH-->>API: User context
API->>H: Forward request
H-->>API: Response
API-->>C: 200 {data: [...]} Data Transformers¶
Transformer Class¶
<?php
declare(strict_types=1);
namespace MyModule\Transformer;
use Xoops\Api\Transformer;
final class ArticleTransformer extends Transformer
{
protected array $availableIncludes = [
'author',
'category',
'comments',
'tags',
];
protected array $defaultIncludes = [
'author',
];
public function transform(Article $article): array
{
return [
'id' => $article->getId(),
'type' => 'article',
'attributes' => [
'title' => $article->getTitle(),
'slug' => $article->getSlug(),
'excerpt' => $article->getExcerpt(),
'content' => $article->getContent(),
'status' => $article->getStatus()->value,
'views' => $article->getViews(),
'created_at' => $article->getCreatedAt()->format('c'),
'updated_at' => $article->getUpdatedAt()?->format('c'),
'published_at' => $article->getPublishedAt()?->format('c'),
],
'links' => [
'self' => "/api/v1/articles/{$article->getId()}",
'web' => "/articles/{$article->getSlug()}",
],
];
}
public function includeAuthor(Article $article): array
{
$author = $article->getAuthor();
return [
'data' => [
'id' => $author->getId(),
'type' => 'user',
'attributes' => [
'name' => $author->getName(),
'avatar' => $author->getAvatarUrl(),
],
],
];
}
public function includeComments(Article $article): array
{
$comments = $article->getComments();
$transformer = new CommentTransformer();
return [
'data' => array_map(
fn($c) => $transformer->transform($c),
$comments->toArray()
),
'meta' => [
'count' => $comments->count(),
],
];
}
}
Validation¶
Request Validation¶
<?php
declare(strict_types=1);
namespace MyModule\Request;
use Xoops\Http\Request\FormRequest;
final class CreateArticleRequest extends FormRequest
{
public function rules(): array
{
return [
'title' => [
'required',
'string',
'min:3',
'max:255',
],
'content' => [
'required',
'string',
'min:50',
],
'category_id' => [
'required',
'integer',
'exists:categories,id',
],
'tags' => [
'array',
'max:10',
],
'tags.*' => [
'string',
'max:50',
],
'publish_at' => [
'nullable',
'date',
'after:now',
],
];
}
public function messages(): array
{
return [
'title.required' => 'Please provide an article title.',
'content.min' => 'Article content must be at least 50 characters.',
'category_id.exists' => 'The selected category does not exist.',
];
}
}
Rate Limiting¶
Configuration¶
<?php
// Apply to routes
$router->group([
'middleware' => ['throttle:100,60'], // 100 requests per 60 seconds
], function ($router) {
// ...
});
// Different limits for different endpoints
$router->get('/articles', 'index')->middleware('throttle:200,60');
$router->post('/articles', 'store')->middleware('throttle:10,60');
Rate Limit Headers¶
flowchart LR
subgraph Limiting["Rate Limit Flow"]
REQ[Request] --> CHECK{Under Limit?}
CHECK -->|Yes| PROCESS[Process Request]
CHECK -->|No| REJECT[429 Too Many Requests]
PROCESS --> DEC[Decrement Counter]
REJECT --> RETRY[Retry-After Header]
end API Versioning¶
URL Versioning¶
Header Versioning¶
Version Handling¶
<?php
$router->group(['prefix' => '/api/v1'], function ($router) {
$router->get('/articles', [ArticleV1Controller::class, 'index']);
});
$router->group(['prefix' => '/api/v2'], function ($router) {
$router->get('/articles', [ArticleV2Controller::class, 'index']);
});
API Documentation¶
OpenAPI Specification¶
openapi: 3.0.3
info:
title: Articles API
version: 1.0.0
description: API for managing articles
paths:
/api/v1/articles:
get:
summary: List articles
parameters:
- name: page
in: query
schema:
type: integer
default: 1
- name: per_page
in: query
schema:
type: integer
default: 15
maximum: 100
responses:
'200':
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/ArticleCollection'
post:
summary: Create article
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateArticle'
responses:
'201':
description: Article created
'422':
description: Validation error
components:
schemas:
Article:
type: object
properties:
id:
type: integer
title:
type: string
content:
type: string
status:
type: string
enum: [draft, published, archived]
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT