SoFloPHPIndependent reference

How to Build a REST API in PHP With Validation and Error Handling

Design a REST API in PHP with PSR-7 responses, correct status codes, input validation, and error handling that works across frameworks.

Why Resource Design Comes Before Any Framework

REST API design starts with the nouns, not the router. List every resource your API exposes: users, orders, invoices, sensor-readings. Assign each a stable URI path. A collection lives at /orders. A single entity lives at /orders/{id}. Sub-resources nest one level deep: /orders/{id}/items. Every resource gets exactly one canonical URI. Two paths returning the same representation cause inconsistent client caches and a drifting API.

Once the resource map is drawn, the HTTP methods lock in. GET retrieves a representation. It must be safe and idempotent. PUT replaces the entire resource and is idempotent. POST creates a new resource and is not idempotent. DELETE removes the resource and is idempotent. PATCH applies a partial update and is not idempotent. Assigning these methods to the wrong resource is the most common defect in PHP REST APIs. You cannot fix it later by swapping the framework.

Build REST API PHP Validation Status Codes With PSR-7 First

Validation errors must produce machine-readable responses with precise HTTP semantics, not a generic error page. The tool that makes this possible is PSR-7, the HTTP message interface standard from the PHP-FIG. A PSR-7 response factory lets you set the status code, the Content-Type header, and the JSON body as a single immutable object before it leaves your application. Framework convenience layers like Laravel's response() helper or Symfony's JsonResponse are built on this contract.

Start with a bare PSR-7 implementation. Install nyholm/psr7 or laminas-diactoros through Composer. Write a factory that accepts an int status code and an array payload and returns a ResponseInterface with a Content-Type of application/json. This factory becomes the single point where your API decides what the client sees. When validation fails, call it with 422 Unprocessable Entity. When a resource is created, call it with 201 Created and a Location header pointing to the new URI.

Status Codes Are Not Decoration

A 400 Bad Request means the client sent malformed JSON that could not be parsed at all. A 422 Unprocessable Entity means the JSON was valid but the data failed business rules: a missing required field, an email that does not match the format, an age below the minimum. Distinguishing these two codes lets an API client decide whether to fix the payload or fix the syntax. A 404 Not Found means the URI does not identify an existing resource. A 405 Method Not Allowed means the resource exists but the client used POST on a URI that only accepts GET and PUT. A 500 Internal Server Error means the fault is on the server side; the client should retry later or report the incident.

PSR-7 JSON Response Handling in Practice

A PSR-7 JSON response is not just json_encode() wrapped in echo. The response object carries a status code, a reason phrase, headers, and a stream body. Set the Content-Type header to application/json. The body stream must contain valid JSON. Omitting the header or writing JSON to the output buffer directly breaks HTTP content negotiation. The client parses the response as plain text or HTML.

Serialization is the step between a domain object and the JSON payload. A data transfer object, a plain PHP class with public typed properties, sits between your entity and the response factory. The DTO enforces that only the fields you intend to expose ever reach the client. Never pass a database row or an ORM entity directly to json_encode. That path leaks internal identifiers, password hashes, and timestamp formats that change with the database driver.

Accept Header Negotiation

An API that ignores the Accept header sends JSON to a client requesting XML and then logs a support ticket about a broken integration. Read the Accept header from the PSR-7 ServerRequestInterface. If the client requests application/xml and your API does not support it, return 406 Not Acceptable, not 200 with JSON. This one rule prevents silent data corruption across API versions.

PHP Resource Design Patterns for Collection and Entity Endpoints

The collection resource is the first pattern. A GET to /invoices returns a JSON array of invoice summaries. Pagination is mandatory here. Without it, a collection that grows to 50,000 rows times out the PHP process. Use query parameters: ?page=2&per_page=50. Return a Link header with rel="next" and rel="prev" so clients traverse pages without constructing URIs themselves.

The entity resource is the second pattern. A GET to /invoices/42 returns the full invoice representation, including line items. This endpoint must return 404 if the entity does not exist. A common PHP mistake is to return 200 with an empty body or a null JSON value. Both break client deserialization.

Idempotency is the constraint most PHP APIs get wrong on PUT. A PUT to /invoices/42 must replace the entire resource. If the client sends a partial payload missing a required field, reject it with 422. Do not merge it with existing data. Merging on PUT is PATCH behavior. When two clients issue overlapping PUT requests, the second write must fully overwrite the first. That makes the operation safe to retry after a network failure.

Sub-Resource Nesting

Nest sub-resources only one level deep. /orders/17/items is clear. /orders/17/items/5/discounts is a maintenance burden and a routing nightmare. The deeper path creates a URI that changes meaning when the parent resource is reorganised. A flat /discounts?order_item=5 is easier to version and cache.

API Error Handling PHP With Structured Problem Responses

API error handling code must produce a response body a machine can parse. The de facto standard is RFC 7807 Problem Details. A JSON object with a type URI, a title string, a status integer, a detail string, and an instance URI. When validation fails, add an errors array where each entry names the field and the constraint violated.

Distinguish between a programmer error and an operational error. A programmer error is an uncaught exception, a type mismatch, a missing required config value. These produce a 500 status. An operational error is a validation failure, a duplicate record, a quota exceeded. These produce 4xx statuses. Catching a TypeError and returning 500 with the stack trace in the detail field is correct in development and a data leak in production. The production error handler must log the full trace and return a generic detail string: "An internal server error occurred."

Rate limiting sits at the boundary of error handling. When a client exceeds the limit, return 429 Too Many Requests with a Retry-After header stating the number of seconds to wait. Implement the limiter as PSR-15 middleware that runs before the controller. The middleware checks a token bucket stored in Redis or APCu, decrements the count, and either passes the request or returns the 429 response directly.

Input Validation and Data Transfer Objects

Input validation must run before any business logic. Accept the parsed JSON body into a data transfer object constructor. Validate the DTO's properties with type checks and constraint rules. If validation fails, collect every violation, not just the first one. A response that reports one missing field per request forces the client into a repair loop that could take five round trips. Return all violations in a single 422 response.

HTTP Status Code Usage PHP API Implementation Rules

201 Created means the POST succeeded and the Location header contains the new resource URI. 204 No Content is the correct response for a successful DELETE, not 200 with a confirmation message. 401 Unauthorized means the client did not send credentials. 403 Forbiden means the credentials were valid but insufficient. 409 Conflict means the request would create a duplicate or violate a uniqueness constraint. Tape this cheat sheet to the wall.

Version the API in the URI path: /v1/orders and /v2/orders. When v2 changes the meaning of a field from a string to an object, a v1 client that receives the new representation misinterprets the data and may corrupt its local state. The URI prefix isolates the change. A sunset header on v1 responses warns clients of the deprecation date.

The Content-Type header on error responses must match the request's Accept header. A client that sends Accept: application/json must receive JSON in the error body. Sending an HTML error page to a JSON client is the most common integration failure in PHP APIs. It takes the longest to diagnose because it looks like a network error in the client logs.

Status Code Decision Table for PHP REST Endpoints
SituationCorrect CodeWrong Code Often SeenResponse Body Requirement
Resource created via POST201 Created200 OKLocation header with new URI; body optional
Resource deleted successfully204 No Content200 OK with messageNo body
Validation failure on input422 Unprocessable Entity400 Bad RequestRFC 7807 Problem Details with errors array
Malformed JSON body400 Bad Request422 or 500Problem Details with syntax error detail
Resource not found by ID404 Not Found200 with null bodyProblem Details
Method not allowed on URI405 Method Not Allowed404 or 500Allow header listing valid methods; Problem Details
Unhandled server exception500 Internal Server Error200 with error stringProblem Details; stack trace only in dev

Real-World PHP API Performance Is Not Framework-Bound

Benchmarks that claim one PHP framework serves 5,000 requests per second and another serves 1,200 measure a loop returning a static string. Real-world API performance is I/O-bound. A single database query scanning an unindexed table costs more milliseconds than the entire framework bootstrap. A curl call to a third-party payment gateway in the critical path adds hundreds of milliseconds. Swapping the framework fixes neither bottleneck.

Serialization is the second real constraint. json_encode on a large object graph with circular references consumes CPU and memory. The fix is a DTO layer that flattens the data before encoding, not a faster JSON extension. The opcache hit rate matters more than the framework's dispatch speed. A 99 percent opcache hit rate on a codebase of 300 scripts keeps a PHP API responsive under load. A cache size too small for the number of scripts causes a recompile on every request. That cost dwarfs any framework overhead.

Track the database query count per request, not the framework's requests-per-second number. An N+1 query pattern where one entity fetch spawns a separate query for each related entity brings a production API to its knees whether it runs on Slim or Laravel. Fix the query pattern first, then measure.

Common Questions

What status code should a PHP REST API return when input validation fails?

Return 422 Unprocessable Entity. The request body was valid JSON but the data violated business rules. Include an RFC 7807 Problem Details response with an errors array that lists every field and every constraint that failed. Do not return 400 Bad Request for validation failures; 400 means the JSON itself was unparseable.

How do I handle a POST request that creates a resource successfully?

Return 201 Created with a Location header set to the URI of the new resource. The response body may contain a representation of the created resource. Do not return 200 OK. Without the Location header, the client has no programmatic way to find the resource it just created.

Should I use PUT or PATCH for partial updates in a PHP API?

Use PATCH for partial updates. PUT replaces the entire resource, and any field omitted from the request body is reset to its default or null. PATCH applies only the fields supplied. If you use PUT and the client sends only the changed fields, you will lose data on every update.

What is the difference between 401 and 403 in a REST API?

401 Unauthorized means the client did not send authentication credentials, or the credentials are invalid. 403 Forbiden means the credentials are valid but the authenticated user does not have permission to access the resource. A client receiving a 401 can prompt for login; a client receiving a 403 should not retry with the same credentials.

How can I version a PHP REST API without breaking existing clients?

Prefix the URI path with the version number: /v1/orders and /v2/orders. When a breaking change is introduced, it goes into a new version. The old version continues to serve existing clients. Add a Sunset header to the old version's responses with an RFC 1123 date after which the version will be decommissioned.