REST API design is one of those topics where everyone has an opinion but few have a consistent set of rules. Resources vs actions, the right HTTP status codes, response structure, versioning – decisions made here stay with the API for years. I show the principles I follow when designing REST APIs in PHP projects and Magento 2 modules.
Resources vs actions – the fundamental distinction
# BAD - RPC style (actions as verbs in URL)
POST /api/cancelOrder/42
POST /api/getProductsByCategory?id=5
GET /api/activateUser?userId=12
# GOOD - resource-oriented REST
DELETE /api/orders/42 (or PATCH with status change)
GET /api/products?category=5
PATCH /api/users/12 {"status": "active"}
HTTP status codes – use them correctly
| Code | Meaning | When to use |
|---|---|---|
| 200 OK | Success | GET, PUT, PATCH with response body |
| 201 Created | Resource created | POST that creates a new resource |
| 204 No Content | Success, no body | DELETE, PATCH without response body |
| 400 Bad Request | Invalid input | Validation error, malformed JSON |
| 401 Unauthorized | Not authenticated | Missing or invalid token |
| 403 Forbidden | Not authorised | Authenticated but insufficient permissions |
| 404 Not Found | Resource not found | GET/DELETE on non-existent ID |
| 409 Conflict | State conflict | Duplicate, optimistic locking violation |
| 422 Unprocessable | Semantic validation error | Invalid field values (vs 400 for syntax) |
| 500 Internal Error | Server error | Unhandled exception – never leak details |
Consistent response structure
<?php
declare(strict_types=1);
class ApiResponse
{
// Success response
public static function success(mixed $data, int $status = 200): array
{
return ['data' => $data, 'status' => 'success'];
}
// Collection with pagination
public static function collection(array $items, int $total, int $page, int $perPage): array
{
return [
'data' => $items,
'meta' => [
'total' => $total,
'per_page' => $perPage,
'current_page' => $page,
'last_page' => (int) ceil($total / $perPage),
],
];
}
// Error response
public static function error(string $message, array $errors = [], int $status = 400): array
{
$response = ['status' => 'error', 'message' => $message];
if (!empty($errors)) {
$response['errors'] = $errors;
}
return $response;
}
}
// Example controller responses
class ProductController
{
public function show(int $id): JsonResponse
{
try {
$product = $this->productRepository->getById($id);
return new JsonResponse(ApiResponse::success($product->toArray()), 200);
} catch (NoSuchEntityException $e) {
return new JsonResponse(ApiResponse::error('Product not found'), 404);
}
}
public function store(Request $request): JsonResponse
{
$errors = $this->validator->validate($request->all());
if (!empty($errors)) {
return new JsonResponse(ApiResponse::error('Validation failed', $errors), 422);
}
$product = $this->productService->create($request->all());
return new JsonResponse(ApiResponse::success($product->toArray()), 201);
}
}
Versioning strategies
# Strategy 1: URL versioning (most common, clearest) GET /api/v1/products GET /api/v2/products # Strategy 2: Header versioning (cleaner URLs, harder to test in browser) GET /api/products Accept: application/vnd.api+json; version=2 # Strategy 3: Query parameter (simplest, least REST-like) GET /api/products?v=2
<?php
// Router configuration for versioned API
$router->prefix('api')->group(function () {
// v1 - original contracts, never change
$router->prefix('v1')->group(function () {
$router->get('products', [V1\ProductController::class, 'index']);
$router->get('products/{id}', [V1\ProductController::class, 'show']);
});
// v2 - new response structure
$router->prefix('v2')->group(function () {
$router->get('products', [V2\ProductController::class, 'index']);
$router->get('products/{id}', [V2\ProductController::class, 'show']);
});
});
Magento 2 REST API – built-in versioning
<!-- etc/webapi.xml - Magento follows v1/v2 URL convention -->
<route url="/V1/vendor-module/subscriptions" method="GET">
<service class="Vendor\Module\Api\SubscriptionRepositoryInterface" method="getList"/>
<resources>
<resource ref="Magento_Customer::manage"/>
</resources>
</route>
<route url="/V1/vendor-module/subscriptions/:id" method="GET">
<service class="Vendor\Module\Api\SubscriptionRepositoryInterface" method="getById"/>
<resources>
<resource ref="self"/>
</resources>
</route>
<route url="/V1/vendor-module/subscriptions" method="POST">
<service class="Vendor\Module\Api\SubscriptionRepositoryInterface" method="save"/>
<resources>
<resource ref="anonymous"/>
</resources>
</route>
Summary
Consistent REST API design is an investment in the future – every developer who consumes your API will be grateful for predictable URL structure, correct status codes, and uniform error responses. In Magento 2 the webapi.xml system covers versioning and access control automatically, but the response structure quality still depends on how well you design your Service Contracts.
