and Status Codes
Many REST APIs use HTTP as a transport protocol without ever tapping into its semantics. Resources modeled incorrectly, verbs misused, idempotency not guaranteed and status codes chosen arbitrarily, these are not questions of style but design errors with direct consequences for caching, retry logic and client implementation.
Table of Contents
- 1. Resource Modeling: Nouns Instead of Verbs in URLs
- 2. HTTP Verbs: Semantics Instead of Convention
- 3. Idempotency: GET, PUT, DELETE vs. POST and PATCH
- 4. Status Codes: Precise Instead of Approximate
- 5. Modeling Sub-Resources and Relationships
- 6. Actions on Resources: When Verbs Make Sense
- 7. Content Negotiation and the Accept Header
- 8. Comparison: Anti-Patterns vs. REST-Compliant Design
- 9. Summary
- 10. FAQ
1. Resource Modeling: Nouns Instead of Verbs in URLs
The basic rule of REST resource modeling states: URLs identify resources (things), not actions (verbs). /getUser, /createOrder or /deleteProduct are RPC patterns, not REST URLs. The REST alternative: /users/{id}, /orders, /products/{id}. The HTTP verb takes on the semantics of the action. This separation is not semantic purism, it has practical consequences: REST-compliant URLs enable generic proxies, caches and gateways to decide how to handle a request based purely on the HTTP verb and the URL.
Pluralization is a convention, not a requirement, but it creates consistency: /users for the collection, /users/{id} for a single element. Hierarchical resources are expressed through the URL structure: /orders/{id}/items for the items of an order. Flat hierarchies are better than deep ones: /order-items/{id} is often more sensible than /orders/{id}/items/{itemId}/details/{detailId}. The rule of thumb: more than two levels often signal a modeling problem.
# REST-compliant URL structure, examples from a shop system
# Collections (Plural)
GET /products # List all products (with pagination)
POST /products # Create a new product
# Single resource
GET /products/42 # Get product 42
PUT /products/42 # Replace product 42 completely
PATCH /products/42 # Partially update product 42
DELETE /products/42 # Delete product 42
# Sub-resources (max. 2 levels deep)
GET /orders/99/items # List items of order 99
POST /orders/99/items # Add item to order 99
DELETE /orders/99/items/5 # Remove item 5 from order 99
# WRONG (RPC-style URLs, never do this)
# POST /createProduct
# GET /getProductById?id=42
# POST /deleteProduct/42
# GET /product/getActiveOnes
2. HTTP Verbs: Semantics Instead of Convention
HTTP verbs carry semantic meaning that goes beyond mere convention and is evaluated by proxies, caches and browsers. GET is safe and idempotent: no side effects, repeatable without consequence. Proxies and caches are allowed to store and replay GET requests. POST is neither safe nor idempotent: it creates side effects and can create duplicate resources if repeated. PUT is idempotent but not safe: it replaces a resource completely, and repeated execution has the same effect. PATCH is neither safe nor idempotent per specification, but it can be implemented idempotently. DELETE is idempotent: after the first successful delete, there is nothing left to delete.
A common mistake is implementing all write operations as POST. POST /products/42/update instead of PUT /products/42 or PATCH /products/42. This prevents clients and proxies from making use of the correct semantics. Another mistake is attaching a request body to DELETE requests. HTTP technically allows it, but many proxies ignore or strip DELETE body data. If a DELETE request needs parameters, they belong in the URL or as query parameters.
3. Idempotency: GET, PUT, DELETE vs. POST and PATCH
Idempotency means: executing the same operation multiple times has the same effect as executing it once. This property is crucial for retry logic: if a client is not sure whether its request reached the server (for example after a network timeout), it can safely retry an idempotent request. A non-idempotent request, by contrast, cannot be blindly retried, it could lead to duplicate orders, duplicate payments or other unwanted side effects.
For POST, there is a mechanism to add idempotency: the Idempotency-Key header (widely used in payment APIs such as Stripe). The client generates a UUID for each operation and sends it as a header. The server stores the result under this UUID and returns the cached result on a retry instead of executing the operation again. This mechanism is essential for critical operations such as orders and payments. In Symfony, it can be implemented as middleware that checks the key before the controller is reached.
['onRequest', 20],
KernelEvents::RESPONSE => ['onResponse', 0],
];
}
public function onRequest(RequestEvent $event): void
{
$request = $event->getRequest();
if ($request->getMethod() !== 'POST') {
return;
}
$key = $request->headers->get('Idempotency-Key');
if ($key === null || strlen($key) < 8 || strlen($key) > 255) {
return; // Key optional, only enforce on /payments/* routes
}
$cacheKey = 'idempotency_' . hash('sha256', $key . $request->getPathInfo());
$cached = $this->idempotencyCache->get($cacheKey, function (ItemInterface $item) {
$item->expiresAfter(self::TTL);
return null; // Not yet cached
});
if ($cached !== null) {
// Replay cached response
$response = new JsonResponse(
json_decode($cached['body'], true),
$cached['status'],
array_merge($cached['headers'], ['X-Idempotent-Replayed' => 'true'])
);
$event->setResponse($response);
}
$request->attributes->set('_idempotency_key', $cacheKey);
}
public function onResponse(ResponseEvent $event): void
{
$request = $event->getRequest();
$cacheKey = $request->attributes->get('_idempotency_key');
if ($cacheKey === null) {
return;
}
$response = $event->getResponse();
if ($response->getStatusCode() >= 500) {
return; // Don't cache server errors
}
$this->idempotencyCache->delete($cacheKey);
$this->idempotencyCache->get($cacheKey, function (ItemInterface $item) use ($response) {
$item->expiresAfter(self::TTL);
return [
'status' => $response->getStatusCode(),
'body' => $response->getContent(),
'headers' => ['Content-Type' => $response->headers->get('Content-Type')],
];
});
}
}
4. Status Codes: Precise Instead of Approximate
HTTP status codes are semantic signals, not convention. Returning 200 when a resource was not found, or using 500 for validation errors, breaks the expectations of every client and proxy. The most common status code mistakes in REST APIs: 200 for everything, 400 for all client errors without distinction, and 500 for all server errors. Correct mapping requires an understanding of the respective semantics. 200 OK: resource found and returned. 201 Created: new resource created, the Location header contains the URL. 204 No Content: successful, no response body (typical for DELETE and successful PATCH operations that do not return a body).
For client errors: 400 Bad Request for syntactically malformed requests (invalid JSON). 401 Unauthorized: no valid token present. 403 Forbidden: token valid, but no permission for this resource. 404 Not Found: resource does not exist. 409 Conflict: conflict with the current state (for example optimistic locking, duplicate email address). 422 Unprocessable Entity: JSON syntactically correct, but validation fails (missing required fields, invalid values). 429 Too Many Requests: rate limit exceeded. 503 Service Unavailable: server temporarily unreachable (maintenance, overload).
5. Modeling Sub-Resources and Relationships
Relationships between resources are modeled in REST in two ways: as embedded data (inline in the representation) or as a separate sub-resource with its own URL. The decision depends on how the data is typically queried. Order items are almost always fetched together with the order, so embedding makes sense. A user profile picture is optional and needed less often, so a separate resource is better. URL hierarchies that go too deep (/users/{id}/addresses/{aId}/phones/{pId}) are a signal that the modeling should be simplified.
For many-to-many relationships, standalone resources for the relationship itself are often a good fit: /product-categories/{productId}/{categoryId} instead of modeling the relationship only through nested URLs. This enables clean CRUD operations on the relationship. For representing relationships in the response representation, two approaches are common: IDs (compact, requires follow-up requests) or embedded objects with a ?embed=category parameter (flexible, but more complex to implement).
6. Actions on Resources: When Verbs Make Sense
Not every API operation naturally maps to CRUD on a resource. Canceling an order, publishing an article or sending an email are actions that are hard to model as a plain resource update. The REST-compliant solution: model actions as a sub-resource. POST /orders/{id}/cancellations instead of POST /orders/{id}/cancel. Creating a "cancellation" is a POST operation on a new resource that implies the order is being cancelled.
Alternatively, the state of the resource can be updated explicitly via PATCH: PATCH /orders/{id} with {"status": "cancelled"}. This is simpler, but less expressive for complex state transitions with additional parameters (cancellation reason, partial amounts, etc.). The rule of thumb: if the action results in a new entity or has multiple parameters, use a sub-resource. If it is a simple state transition, a PATCH on the main resource is sufficient.
# OpenAPI: actions as sub-resources, REST-compliant modeling
paths:
# Cancel an order, the cancellation is its own resource
/orders/{orderId}/cancellations:
post:
operationId: cancelOrder
summary: "Cancel an order"
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [reason]
properties:
reason:
type: string
enum: [customer_request, fraud, out_of_stock]
refundAmount:
type: number
format: float
responses:
"201":
description: "Cancellation created, order is now cancelled"
headers:
Location:
schema:
type: string
description: "URL of the cancellation resource"
"409":
description: "Order already cancelled or shipped"
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetails"
# Publish a product, status transition via PATCH
/products/{id}:
patch:
operationId: updateProduct
requestBody:
content:
application/json:
schema:
type: object
properties:
status:
type: string
enum: [draft, published, archived]
7. Content Negotiation and the Accept Header
Content negotiation allows an API to serve different representations of the same resource, depending on what the client is asking for. The Accept header signals which MIME types the client understands. Accept: application/json is the default for REST APIs. Accept: text/csv can make sense for export endpoints. Accept: application/vnd.mironsoft.v2+json is a vendor-specific media type that enables versioning through the header instead of the URL.
In Symfony, content negotiation is available via the Request::getPreferredFormat() mechanism. A common source of errors: if the client sends an Accept header the server does not support, the server must respond with 406 Not Acceptable, not with the default format and no warning. 406 is often forgotten or not implemented in practice, which leads to silent format errors the client only notices when parsing.
8. Comparison: Anti-Patterns vs. REST-Compliant Design
The most common REST design mistakes in practice are not a lack of knowledge of the HTTP specification, but habits carried over from RPC development or quick ad-hoc design without systematic decisions. The following table shows the most widespread anti-patterns and their REST-compliant alternatives.
| Anti-Pattern | Problem | REST-Compliant Alternative | Consequence |
|---|---|---|---|
POST /getProduct |
Verb in URL, wrong method | GET /products/42 |
Caching and proxies work correctly |
| 200 for 404 | Clients cannot tell the difference | 404 Not Found | Monitoring and retry logic work correctly |
| POST for update | Idempotency lost | PUT (full) / PATCH (partial) | Safe retries possible |
| 400 for everything | No distinction between auth/validation | 401 / 403 / 422 precisely | Clients can react correctly |
| No Location header on 201 | Client has to guess the URL | Location: /products/43 header | No follow-up request needed |
9. Summary
REST-compliant API design is not dogma, but a toolbox of proven conventions that fundamentally simplifies caching, retry logic and client implementation. Model resources using nouns in URLs. Use HTTP verbs according to their semantic meaning, not as convention. Guarantee idempotency for GET, PUT and DELETE, and retrofit it for POST via idempotency keys. Assign status codes precisely: 201 with a Location header, 422 for validation errors, 409 for conflicts. Use sub-resources for complex actions.
The practical benefit of these principles shows up above all in collaboration between teams: an API design that correctly uses HTTP semantics is immediately understandable to experienced HTTP clients, without extra documentation for every method. Proxies, load balancers and API gateways can automatically make the correct decisions based on the verb and the URL. This reduces misconfiguration and debugging effort across the entire infrastructure.
REST API Design: The Essentials at a Glance
Resources
URLs identify things (nouns), not actions. Plural for collections. Max. 2 levels in the hierarchy. Sub-resources for relationships.
HTTP Verbs
GET: safe + idempotent. PUT: idempotent (full replace). PATCH: partial. DELETE: idempotent. POST: creates new resources, not idempotent.
Status Codes
201 + Location on create. 204 on delete. Separate 401 vs. 403. 422 for validation. 409 for conflicts. 429 for rate limits.
Idempotency
POST with an Idempotency-Key header for critical operations. Server caches the result for 24h and replays it on retry.