When a mobile app wants to fetch a user's orders from a site, or a plugin wants to talk to an external service, a contract is needed: what do I send to which address, and what do I get back? A REST API is the most common answer to that question. Although the name sounds complicated, the principles are simple, and most of the trouble comes from ignoring those same principles.
If you want the browser side, AJAX requests with fetch shows how to call such an API from JavaScript. Here the design and server side matter more.
What does REST mean?#
REST is a design style, not a protocol or a library. It has a few main ideas:
- Resource: every "thing" like an order or a user has its own address.
- Standard HTTP methods: what you want to do to the resource is expressed by the method, not by a function name in the URL.
- Stateless: each request carries all the information needed to understand it; the server doesn't keep memory of the earlier conversation.
- A defined representation: usually JSON, with the right
Content-Typeheader.
URLs: nouns, not verbs#
The URL should identify the resource and the method does the action. Compare:
| Weak | Better |
|---|---|
GET /getOrders | GET /orders |
POST /createOrder | POST /orders |
POST /deleteOrder?id=5 | DELETE /orders/5 |
GET /orders/5/getItems | GET /orders/5/items |
The rules: plural nouns, lowercase, no verbs, and nested paths only for a real relationship (the items of an order).
Methods and status codes#
| Operation | Method | Success code | Note |
|---|---|---|---|
| List | GET /orders | 200 | Should be paginated |
| One item | GET /orders/5 | 200 | If missing: 404 |
| Create | POST /orders | 201 | Return the URL of the new item |
| Update | PATCH /orders/5 | 200 | Only the fields sent |
| Delete | DELETE /orders/5 | 204 | No body |
The GET method must not change anything. Also send errors with the right code: 400 or 422 for bad input, 401 for unauthenticated, 403 for forbidden, 404 for not found, 429 for too many requests and 500 for a server error. Returning 200 with "error": true is confusing.
A consistent response and error format#
Choose one specific format for errors and send the same one everywhere:
{
"error": {
"code": "validation_failed",
"message": "The submitted data is invalid.",
"fields": {
"phone": ["The phone number is not valid."]
}
}
}Always paginate lists (?page=2&per_page=20) and set a cap on per_page on the server, otherwise a single request can ask for the whole table.
Filtering, sorting and searching#
Express filtering and sorting with URL parameters, not with new URLs:
GET /orders?status=paid&sort=-created_at&page=2&per_page=20Accept only the fields you have listed in advance for filtering and sorting (an allowlist). If you put a column name from input directly into the query, you open the way for an attack. Columns that are filtered should also have indexes; MySQL indexes explains why.
Versioning and compatibility#
Once a client has connected to an API, it is a contract that must not break suddenly. Removing a field or changing its meaning is a breaking change; adding a new field is usually safe. The most common versioning approach is to put the version in the path, like /api/v1/orders.
Authentication and security#
- HTTPS only; don't put a token or key in the URL, because it gets recorded in logs. Use the
Authorizationheader. - Authentication and authorization are two different things: the user is logged in, but are they allowed to see order number 5? Do this check for every resource, not just once.
- Validate and sanitize input and write queries as parameterized; PHP code security covers exactly this in detail.
- Set a rate limit.
- Error messages must not leak internal details (a file path or query text).
A safe example in WordPress#
WordPress has a built-in REST API. The most important point is permission_callback; without it the route is public:
<?php
add_action('rest_api_init', function () {
register_rest_route('shop/v1', '/orders/(?P<id>\d+)', [
'methods' => WP_REST_Server::READABLE,
'callback' => 'shop_get_order',
'permission_callback' => function (WP_REST_Request $request) {
return current_user_can('manage_woocommerce');
},
'args' => [
'id' => [
'validate_callback' => fn($v) => is_numeric($v),
'sanitize_callback' => 'absint',
],
],
]);
});
function shop_get_order(WP_REST_Request $request) {
$order = wc_get_order($request['id']);
if (!$order) {
return new WP_Error('not_found', 'Order not found.', ['status' => 404]);
}
return rest_ensure_response([
'id' => $order->get_id(),
'status' => $order->get_status(),
'total' => $order->get_total(),
]);
}When called from a browser for state-changing requests, WordPress also checks the nonce through the X-WP-Nonce header. To start writing plugins, see building a WordPress plugin from scratch.
A Laravel example#
In Laravel, routes in routes/api.php have the /api prefix by default. A controller with validation:
<?php
// routes/api.php
use App\Http\Controllers\OrderController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('orders', OrderController::class)->only(['index', 'show', 'store']);
});
// app/Http/Controllers/OrderController.php
public function store(Request $request)
{
$data = $request->validate([
'product_id' => ['required', 'integer', 'exists:products,id'],
'quantity' => ['required', 'integer', 'min:1', 'max:100'],
]);
$order = $request->user()->orders()->create($data);
return response()->json($order, 201);
}
public function show(Request $request, Order $order)
{
abort_unless($order->user_id === $request->user()->id, 403);
return response()->json($order);
}Look at show: without the abort_unless line, any user could see other people's orders by changing the number in the URL; this is the common "insecure direct object reference" vulnerability.
Summary#
- REST means resources with URLs, actions with HTTP methods and stateless responses.
- URLs should be plural nouns with no verbs in them.
- Use the right status codes and one consistent error format.
- Lists should be paginated and capped, and the API should be versioned.
- Authentication isn't enough; check authorization for each resource separately.
- Don't forget
permission_callbackin WordPress, and a Policy or ownership check in Laravel.