Resources, verbs and status codes instead of RPC in a REST costume
Many PHP APIs call themselves REST but are, on closer inspection, RPC calls wrapped in HTTP: one POST endpoint per action, always status code 200 regardless of outcome. Genuine RESTful API design in PHP requires resources instead of actions, correctly applied HTTP verbs, deliberate status codes and idempotency where it is expected, and none of that needs a framework to implement cleanly.
Table of Contents
- 1. What RESTful API design actually means in PHP
- 2. Resources instead of actions: structuring URLs correctly
- 3. Using HTTP verbs correctly: GET, POST, PUT, PATCH, DELETE
- 4. Choosing status codes deliberately instead of always 200
- 5. Understanding and enforcing idempotency in PHP
- 6. Content negotiation: Accept headers and response format
- 7. Request validation and DTOs without a framework
- 8. Keeping response structures consistent
- 9. RESTful API design side by side
- 10. Summary
- 11. FAQ
1. What RESTful API design actually means in PHP
RESTful API design is not a question of framework, it is a question of modeling: is the API built around resources that get queried, created, changed and deleted, or around actions that get invoked. Many PHP projects that carry "REST API" in their name turn out, on closer look, to be a collection of POST endpoints like /getUser, /updateOrder and /deleteInvoice. That works technically, but it is not RESTful API design, it is RPC with HTTP as the transport layer.
The difference is more than cosmetic. A clean RESTful API design uses the semantics of HTTP itself as the contract language: the URL describes the resource, the verb describes the operation, the status code describes the outcome. This reduces documentation burden because clients can already infer what happens from the URL structure and the verb, and it makes caching, proxies and generic HTTP tooling usable again instead of pushing all semantics into the response body.
2. Resources instead of actions: structuring URLs correctly
The first step in any RESTful API design is writing nouns instead of verbs into the URL. Instead of /createOrder and /cancelOrder, there is exactly one resource /orders, and the operation follows from the HTTP verb plus, for status changes, from the request body or a sub resource like /orders/{id}/cancellation. This mindset forces a clearer domain model, because it demands an upfront decision on what actually counts as a resource and what is merely a property of one.
Nested resources such as /orders/{id}/items make sense when the child resource has no standalone meaning without the parent. But once items also need to be queried independently, for example through a global search, a flat endpoint /items?order_id={id} is worth adding as well. A common mistake in PHP projects is modeling filter logic as its own action, such as /orders/search. A query parameter on the existing collection is better, because it keeps the RESTful API design consistent and avoids introducing special cases for search.
<?php
declare(strict_types=1);
// Minimal router that maps HTTP verb + resource path
// to a handler, without any framework dependency.
final class ResourceRouter
{
/** @var array<string, array<string, callable>> */
private array $routes = [];
public function map(string $method, string $pattern, callable $handler): void
{
$this->routes[$method][$pattern] = $handler;
}
public function dispatch(string $method, string $path): mixed
{
foreach ($this->routes[$method] ?? [] as $pattern => $handler) {
if (preg_match($this->toRegex($pattern), $path, $matches)) {
return $handler(...array_filter($matches, is_string(...), ARRAY_FILTER_USE_KEY));
}
}
http_response_code(404);
return ['error' => 'Resource not found'];
}
private function toRegex(string $pattern): string
{
$escaped = preg_replace('#\{(\w+)\}#', '(?P<$1>[^/]+)', $pattern);
return '#^' . $escaped . '$#';
}
}
$router = new ResourceRouter();
// Resources, not actions — nouns in the URL, verbs via HTTP method
$router->map('GET', '/orders', fn () => OrderRepository::all());
$router->map('GET', '/orders/{id}', fn (string $id) => OrderRepository::find($id));
$router->map('POST', '/orders', fn () => OrderRepository::create());
$router->map('PATCH', '/orders/{id}', fn (string $id) => OrderRepository::update($id));
$router->map('DELETE', '/orders/{id}', fn (string $id) => OrderRepository::delete($id));
3. Using HTTP verbs correctly: GET, POST, PUT, PATCH, DELETE
Every HTTP verb carries a fixed meaning in RESTful API design that plenty of PHP endpoints ignore. GET must never change state, not even as a side effect of a logging call with write access to a database, because proxies and browsers are allowed to repeat, cache and prefetch GET requests. POST creates a new resource within a collection and is deliberately not idempotent. PUT replaces a resource entirely under a known URL, while PATCH changes only the fields given in the request, which in practice is the more frequent and often the only sensible use case.
DELETE removes a resource and, like PUT, should converge idempotently on the same end state: a second DELETE call on the same id may technically respond with 404 instead of 204, but it must never leave the system in an error state. A common mistake in PHP codebases is abusing PUT for partial updates because PATCH was historically implemented less often. For a consistent RESTful API design it pays off to consistently support PATCH for partial updates, even if the initial effort looks higher than a blanket PUT.
4. Choosing status codes deliberately instead of always 200
An API that responds with status code 200 on every request, regardless of success, validation, or rejection, and hides the actual state inside the JSON body, throws away one of the most important parts of HTTP. Status codes are part of the contract that a RESTful API design enters with its consumers: 201 Created after a successful POST with a Location header pointing to the new resource, 204 No Content after a successful DELETE or PUT without a response body, 404 Not Found when the resource does not exist.
For validation errors, 422 Unprocessable Entity is semantically more accurate than the often used 400 Bad Request, because 400 is really reserved for syntactically malformed requests, while 422 describes a syntactically valid but semantically invalid request. 409 Conflict fits resource conflicts, for example in optimistic locking. Anyone who applies these distinctions consistently in their RESTful API design enables client libraries to react generically at the HTTP level instead of having to parse every response body individually.
<?php
declare(strict_types=1);
// Status codes as part of the contract, not an afterthought
final class OrderController
{
public function create(array $payload): void
{
$errors = OrderValidator::validate($payload);
if ($errors !== []) {
http_response_code(422);
header('Content-Type: application/json');
echo json_encode(['errors' => $errors]);
return;
}
$order = OrderRepository::create($payload);
http_response_code(201);
header('Content-Type: application/json');
header("Location: /orders/{$order->id}");
echo json_encode($order);
}
public function delete(string $id): void
{
$existed = OrderRepository::delete($id);
// Idempotent: repeated DELETE on the same id never errors the system state
http_response_code($existed ? 204 : 404);
}
public function update(string $id, array $payload): void
{
$order = OrderRepository::find($id);
if ($order === null) {
http_response_code(404);
return;
}
$updated = OrderRepository::patch($id, $payload);
http_response_code(200);
header('Content-Type: application/json');
echo json_encode($updated);
}
}
5. Understanding and enforcing idempotency in PHP
Idempotency means that an identical request, no matter how often it is repeated, always leads to the same end state. For a robust RESTful API design this is essential, because networks are unreliable: a client waiting for a response that hits a timeout has no way of knowing whether the request ever reached the server. For idempotent verbs like PUT, DELETE and GET, a retry is harmless. For POST, which by definition is not idempotent, the risk of duplicate orders or duplicate payments arises without additional safeguards.
The common solution is an Idempotency-Key header generated by the client and sent unchanged on retry attempts. The server stores the result of the first request under that key, usually with a short time to live, and on a matching key returns the stored response instead of executing the operation a second time. This technique now belongs to the standard toolkit of a resilient RESTful API design, especially for payment related or inventory changing endpoints, where duplicate execution can cause real financial damage.
<?php
declare(strict_types=1);
// Idempotency-Key pattern for otherwise non-idempotent POST requests
final class IdempotentPostHandler
{
public function __construct(private readonly IdempotencyStore $store) {}
public function handle(string $idempotencyKey, callable $operation): array
{
$cached = $this->store->get($idempotencyKey);
if ($cached !== null) {
// Same key seen before — return the stored result, do not repeat the write
return $cached;
}
$result = $operation();
$this->store->put($idempotencyKey, $result, ttlSeconds: 86400);
return $result;
}
}
// Usage in a payment endpoint
$key = $_SERVER['HTTP_IDEMPOTENCY_KEY'] ?? throw new InvalidArgumentException(
'Idempotency-Key header required for payment operations'
);
$handler = new IdempotentPostHandler(new RedisIdempotencyStore());
$result = $handler->handle($key, fn () => PaymentService::charge($payload));
6. Content negotiation: Accept headers and response format
Content negotiation lets client and server agree, via the Accept header, on which format a response is delivered in. In many PHP APIs, JSON is hardwired exclusively, which is rarely a problem in internal contexts but costs flexibility for public APIs, for example when a consumer needs CSV exports or XML for legacy systems. A clean RESTful API design treats the format as a matter of negotiation between the parties, not as a fixed property of the endpoint.
Implementing this in PHP is not costly: the Accept header is parsed, matched against a list of supported media types, and on no match the server returns 406 Not Acceptable instead of silently forcing a format the client cannot process. The Content-Type given on the request itself is also part of content negotiation: a server that only accepts application/json should respond with 415 Unsupported Media Type on other values, rather than trying to guess the body.
<?php
declare(strict_types=1);
// Simple content negotiation without any framework
final class ContentNegotiator
{
/** @param string[] $supported */
public function __construct(private readonly array $supported) {}
public function resolve(string $acceptHeader): string
{
$accepted = array_map(
fn (string $part) => trim(explode(';', $part)[0]),
explode(',', $acceptHeader)
);
foreach ($accepted as $type) {
if ($type === '*/*') {
return $this->supported[0];
}
if (in_array($type, $this->supported, true)) {
return $type;
}
}
http_response_code(406);
header('Content-Type: application/json');
echo json_encode(['error' => 'None of the requested media types are supported']);
exit;
}
}
$negotiator = new ContentNegotiator(['application/json', 'application/xml', 'text/csv']);
$format = $negotiator->resolve($_SERVER['HTTP_ACCEPT'] ?? 'application/json');
7. Request validation and DTOs without a framework
A solid RESTful API design strictly separates validation from business logic. Passing raw $_POST arrays or associative arrays obtained via json_decode directly into the domain layer leads to controllers that scatter validation rules across several methods. A data transfer object representing the validated request makes the expected shape explicit and uses PHP's type system to rule out entire classes of bugs already at construction time.
In PHP 8.4 this can be implemented without any framework using readonly classes and a lean validation layer. The controller accepts the raw array, hands it to a validator, and only after successful validation does the DTO come into existence, which is then passed to the domain layer in a type safe way. This approach makes RESTful API design more resilient against incomplete or incorrectly typed requests, without needing to pull in a full validation framework.
<?php
declare(strict_types=1);
// Readonly DTO plus a minimal validator, no framework dependency
final readonly class CreateOrderRequest
{
public function __construct(
public string $customerId,
public array $items,
public string $currency,
) {}
public static function fromArray(array $data): self
{
$errors = [];
if (!isset($data['customer_id']) || !is_string($data['customer_id'])) {
$errors['customer_id'] = 'must be a string';
}
if (!isset($data['items']) || !is_array($data['items']) || $data['items'] === []) {
$errors['items'] = 'must be a non-empty array';
}
if (!isset($data['currency']) || !in_array($data['currency'], ['EUR', 'USD'], true)) {
$errors['currency'] = 'must be EUR or USD';
}
if ($errors !== []) {
throw new ValidationException($errors);
}
return new self($data['customer_id'], $data['items'], $data['currency']);
}
}
// Controller stays thin: parse, validate, delegate
$dto = CreateOrderRequest::fromArray(json_decode(file_get_contents('php://input'), true));
8. Keeping response structures consistent
Whether a successful response gets wrapped in an envelope object with a data key or returns the resource directly as the root object is a fundamental decision in RESTful API design that must be made consistently across the entire API. An envelope like {"data": {...}, "meta": {...}} makes it easy to attach pagination information and metadata without polluting the actual resource. The direct variant is more minimal and matches what many HTTP clients expect without extra logic.
What should be avoided in any case is mixing both styles within the same API. If one endpoint returns the resource directly and another wraps it in an envelope, every client developer has to look up the response shape per endpoint. For collections, an envelope is almost always advisable, because pagination metadata like total, page and next needs to be delivered alongside anyway. For single resources, the direct variant can be chosen, as long as the RESTful API design documents this rule and applies it consistently.
9. RESTful API design side by side
The following table contrasts typical antipatterns with the recommended solutions in RESTful API design. It summarizes what was covered in detail in the previous sections and serves as a quick reference during code reviews.
| Aspect | Antipattern | RESTful API Design | Benefit |
|---|---|---|---|
| URL structure | /getOrder?id=5 |
GET /orders/5 |
Cacheable, self documenting, usable HTTP tooling |
| Status code | 200 + {"success": false} |
422 + error list |
Client can react at the HTTP level |
| Partial update | PUT with partial data |
PATCH with partial data |
Semantically correct, prevents data loss |
| Duplicate POST | No safeguard | Idempotency-Key header |
No duplicate orders on retries |
| Deletion | POST /orders/5/delete |
DELETE /orders/5 |
Uses HTTP semantics instead of an action URL |
A recurring pattern in the table: almost every antipattern arises because an action, rather than a resource, was put at the center of the design. Anyone who consistently thinks of their RESTful API design from the resource outward, instead of from the function to be executed, ends up almost automatically at the recommended solutions in the right column.
Mironsoft
PHP backend development and API architecture
An API that is genuinely RESTful, not just called that?
We review existing PHP APIs for resource modeling, status codes and idempotency, and design new endpoints following real REST principles instead of RPC habit.
API review
Checking existing endpoints for RESTful API design and status code usage
Fresh design
Resource modeling, idempotency and content negotiation from the ground up
Migration
Step by step transition from RPC-style endpoints to REST
10. Summary
RESTful API design in PHP is not a framework feature, it is a modeling decision made before the first line of code: resources instead of actions, HTTP verbs with their real meaning, status codes as part of the contract, and idempotency wherever networks are unreliable. Each of the nine sections above shows a building block that is simple to implement on its own, but together they make the difference between an API that feels like REST and one that is just RPC wearing HTTP wallpaper.
The most pragmatic starting point for a better RESTful API design is usually the URL structure, followed by status codes, because both can be changed without a major overhaul of business logic. Idempotency keys and content negotiation can be added incrementally once the foundation is in place. What matters is applying these principles consistently across all endpoints, instead of only for new features while old endpoints remain stuck in RPC style.
RESTful API Design in PHP — The Essentials at a Glance
Resources & verbs
Nouns in the URL, operation via GET/POST/PUT/PATCH/DELETE. No action URLs like /deleteOrder.
Status codes
201, 204, 404, 409, 422 instead of a blanket 200. Status code is part of the contract, not an afterthought.
Idempotency
Idempotency-Key header for POST prevents duplicate orders and payments on retries.
Consistency
Envelope or direct response, decided once and kept consistent across the whole API.