RESTful API Design in PHP: Resources, Verbs and Status Codes
AI generated
<?php
8.4
PHP · API Design · REST · Backend
RESTful API Design in PHP
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.

18 min read Resources · Verbs · Status Codes · Idempotency PHP 8.4 · framework agnostic

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.

11. FAQ: RESTful API Design in PHP

1RESTful API design vs. a plain HTTP API?
RESTful API design uses HTTP semantics itself as the contract: resources in the URL, meaning in the verb, outcome in the status code, rather than using HTTP as a plain transport path.
2Do I need a framework for this?
No, plain PHP with a simple router and the type system is enough. Frameworks make routing easier, but are not a prerequisite.
3Why 422 instead of 400?
400 belongs to syntactically malformed requests, 422 to syntactically valid but semantically invalid ones, which is the applicable case for validation.
4PUT or PATCH?
PUT replaces entirely, PATCH changes only the given fields. For partial updates, PATCH is the correct and safer choice.
5Preventing duplicate orders on timeouts?
Have the client generate an Idempotency-Key header, store the result server side, and return it on repeat instead of executing the operation again.
6Must every response be wrapped in an envelope?
Not necessarily, but the choice must be consistent across the entire API. Collections usually benefit from an envelope with metadata.
7Content negotiation without a framework?
Parse the Accept header, match it against supported media types, and respond with 406 on no match.
8Is GET really never allowed to change state?
Correct, because proxies and browsers are allowed to repeat and cache GET requests. State changes belong exclusively to POST, PUT, PATCH and DELETE.
9How to model search?
As a query parameter on the existing collection rather than a separate action, keeping the URL structure consistent.
10Unsupported content type in the request?
Use status code 415 Unsupported Media Type, instead of guessing the body or silently ignoring it.