What Makes an API "RESTful"
REST (Representational State Transfer) is a set of conventions, not a strict protocol — there is no single "REST compliance" checker. In practice, a RESTful PHP API generally follows a few consistent rules: resources are represented by URLs (/products, /products/42), HTTP methods express the action (GET to read, POST to create, PUT/PATCH to update, DELETE to remove), and responses use standard HTTP status codes to indicate success or failure rather than burying that information only in the response body.
Designing the Endpoints
| Method | URL | Action |
|---|---|---|
| GET | /api/products | List all products |
| GET | /api/products/42 | Get one product |
| POST | /api/products | Create a new product |
| PUT | /api/products/42 | Replace a product entirely |
| PATCH | /api/products/42 | Update specific fields |
| DELETE | /api/products/42 | Remove a product |

A Minimal Working Endpoint in Plain PHP
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$id = $_GET['id'] ?? null;
if ($method === 'GET' && $id) {
$stmt = $pdo->prepare('SELECT * FROM products WHERE id = :id');
$stmt->execute(['id' => $id]);
$product = $stmt->fetch();
if (!$product) {
http_response_code(404);
echo json_encode(['error' => 'Product not found']);
exit;
}
echo json_encode($product);
} elseif ($method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
// validate $input here before inserting
$stmt = $pdo->prepare('INSERT INTO products (name, price) VALUES (:name, :price)');
$stmt->execute(['name' => $input['name'], 'price' => $input['price']]);
http_response_code(201);
echo json_encode(['id' => $pdo->lastInsertId()]);
}Status Codes That Actually Matter
- 200 OK — successful GET, PUT, or PATCH
- 201 Created — successful POST that created a new resource
- 204 No Content — successful DELETE with nothing to return
- 400 Bad Request — malformed request the server cannot process
- 401 Unauthorized — missing or invalid authentication
- 403 Forbidden — authenticated, but not allowed to perform this action
- 404 Not Found — resource does not exist
- 422 Unprocessable Entity — well-formed request that fails validation
- 500 Internal Server Error — something broke on the server side
Returning 200 for everything, including failures, with an error flag buried in the JSON body, is a common shortcut that makes APIs harder to consume — HTTP clients, monitoring tools, and API gateways all rely on status codes to make automatic decisions (retry, alert, cache) without parsing every response body.
Authentication for APIs
Session-based authentication (cookies) works for browser-based clients but is awkward for mobile apps and server-to-server integrations. Token-based authentication is the standard alternative: the client sends a token (often a JWT or a simple API key) in an Authorization header, validated on every request:
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/^Bearer\s+(.+)$/', $authHeader, $matches)) {
http_response_code(401);
echo json_encode(['error' => 'Missing or invalid authorization header']);
exit;
}
$token = $matches[1];
// validate $token against your token store or decode/verify a JWTValidation and Consistent Error Responses
Every endpoint accepting input needs the same server-side validation discipline covered in our dedicated guide on PHP form validation — the only difference for an API is that errors come back as structured JSON rather than rendered HTML:
{
"error": "validation_failed",
"fields": {
"email": "Must be a valid email address.",
"price": "Must be a positive number."
}
}Versioning Your API
APIs change over time, and breaking an existing integration when you change a field name or response shape damages trust with anyone consuming the API. Common versioning approaches include a version segment in the URL (/api/v1/products) or a version header. Whichever approach is chosen, the important discipline is committing to it before the first breaking change is needed, not retrofitting it afterward once consumers already depend on an unversioned endpoint.
Rate Limiting
Without limits, a single client (intentionally or through a bug in their own code) can overwhelm an API with requests. A simple sliding-window counter per API key or IP, checked before processing each request, protects against both abuse and accidental overload:
$key = "rate_limit:{$apiKey}:" . floor(time() / 60);
$count = $redis->incr($key);
$redis->expire($key, 60);
if ($count > 100) {
http_response_code(429);
echo json_encode(['error' => 'Rate limit exceeded']);
exit;
}Documenting the API
An API without documentation is effectively unusable to anyone outside the team that built it. Tools like OpenAPI/Swagger let you describe endpoints, parameters, and response shapes in a format that also generates interactive documentation and, in many toolchains, client SDKs automatically — worth setting up before an API has external consumers, since retrofitting documentation onto an already-shipped API is consistently deprioritized once there is no longer pressure forcing it.
Frequently Asked Questions
Should I build a REST API in plain PHP or use a framework?
For anything beyond a handful of endpoints, a framework like Laravel (with its routing, validation, and resource/transformer classes for shaping JSON output) saves significant repeated effort compared to hand-rolling the same plumbing in plain PHP.
How is GraphQL different from REST, and when would I choose it?
GraphQL lets clients request exactly the fields they need in a single query, which can reduce over-fetching compared to fixed REST endpoints, at the cost of more complex server-side setup and caching. For most standard CRUD-style APIs, REST remains simpler to build, secure, and reason about.
Do I need API keys if my API is only used internally by my own frontend?
Yes, generally — "internal-only" APIs are routinely discovered and called directly by anyone who opens browser dev tools, so authentication should not be skipped just because the intended consumer is your own first-party frontend.
What is the right way to handle pagination in an API response?
Include pagination metadata (current page, total pages, total count) alongside the data, either in the response body or via headers, so clients can build pagination controls without making an extra request just to discover how many results exist.
Conclusion
A REST API is, underneath the conventions, the same validation, authentication, and error-handling discipline covered throughout this series, applied to JSON responses instead of rendered HTML. If you need an API built for a mobile app or third-party integration, our team can build it properly from the start.
Content Negotiation and Response Shaping
A well-built API should be explicit about the format it serves rather than assuming. Checking the Accept header and always setting Content-Type: application/json on the response avoids ambiguity for clients:
header('Content-Type: application/json');
$accept = $_SERVER['HTTP_ACCEPT'] ?? '';
if (str_contains($accept, 'application/xml')) {
// optionally support XML clients, though most modern APIs standardize on JSON only
}Transforming Models Into API Responses
Returning a raw database row directly as JSON often leaks internal fields (password hashes, internal flags) that should never reach a client. A transformer or resource class makes the exposed shape explicit and intentional:
function transformProduct(array $row): array {
return [
'id' => $row['id'],
'name' => $row['name'],
'price' => (float) $row['price'],
// deliberately omit internal_cost, supplier_id, etc.
];
}Case Study: An Internal API That Leaked Sensitive Data
A recurring issue found in API audits: an endpoint built quickly by returning json_encode($row) directly from a database query, which silently exposes every column in that table to any client — including ones added later by someone who never considered that the table was also feeding a public-facing API. The fix, an explicit transformer function naming exactly which fields are exposed, also has a side benefit beyond security: it documents the actual public contract of the API in code, rather than leaving it as an accidental byproduct of whatever the table happens to contain.
Glossary
- Idempotency — a property where repeating the same request produces the same result without unintended side effects; GET, PUT, and DELETE are expected to be idempotent, POST is not.
- Resource/transformer — a class or function responsible for shaping internal data into the specific structure exposed by an API, decoupling the public response shape from the database schema.
- Bearer token — an authentication token sent in the
Authorizationheader, prefixed with the word "Bearer."
Frequently Asked Questions
Should every field in a database table be exposed through the API?
No — expose only what consumers actually need, using an explicit transformer. Internal fields (cost prices, supplier references, soft-delete flags) should stay out of public responses by default.
How do I handle API errors consistently across many endpoints?
A shared error-formatting function or global exception handler that converts any caught exception into the same JSON error shape keeps responses consistent without repeating the same formatting logic in every endpoint.
What is the most common mistake in PHP REST APIs built without a framework?
Inconsistent response shapes between endpoints — one returns a bare array, another wraps it in a "data" key, a third uses different field naming conventions. This makes the API harder to consume and document. Standardizing the response envelope early avoids this.
Step-by-Step: Designing a New API Endpoint From Scratch
Walking through adding an "archive order" endpoint to an existing API shows the full set of decisions a well-built endpoint requires:
- Choose the URL and method —
PATCH /api/orders/{id}/archivereads clearly and follows the convention that PATCH modifies part of a resource rather than replacing it entirely. - Define authentication and authorization — confirm the request carries a valid token, and separately confirm the authenticated user actually owns or has permission to archive this specific order, not just that they are logged in as someone.
- Validate the request — even an endpoint with no body still needs to validate that the order ID in the URL is a valid integer and that the order exists before attempting any action.
- Apply the business rule — perhaps orders can only be archived if already marked "completed"; this check belongs in the Model or service layer, returning a clear failure reason if it does not pass.
- Choose the response and status code — 200 with the updated order representation, or 204 with no body, depending on whether clients need the updated state returned immediately.
- Document it — add the endpoint to the OpenAPI spec or equivalent documentation before considering the feature complete, not as an afterthought once consumers start asking how to use it.
Comparing API Architectural Styles
| Style | Strengths | Tradeoffs |
|---|---|---|
| REST (this guide) | Simple, widely understood, maps cleanly to CRUD operations | Can require multiple round trips for complex, related data |
| GraphQL | Clients request exactly the fields needed in one query | More complex server setup, harder to cache at the HTTP level |
| RPC-style (action-based endpoints) | Natural fit for actions that do not map neatly to a resource | Less standardized conventions than REST |
Caching API Responses
Not every API request needs to hit the database fresh every time. For endpoints serving data that changes infrequently (a product catalog, a list of categories), caching the response for a short period — in memory, in Redis, or via HTTP caching headers like Cache-Control and ETag — significantly reduces database load without meaningfully affecting data freshness for most use cases. Endpoints returning highly dynamic or sensitive per-user data (an account balance, real-time order status) generally should not be cached the same way, or need much shorter cache lifetimes and careful invalidation when the underlying data changes.
Webhooks: APIs in Reverse
Most of this guide covers APIs your application exposes for others to call. Webhooks invert this: your application registers a URL with a third-party service (a payment processor, for example), and that service calls your URL when an event happens (a payment succeeded, a subscription was cancelled). Building a webhook receiver requires the same validation discipline as any other endpoint, plus one additional concern: verifying the request actually came from the expected third party, typically via a signature included in the request headers, computed using a shared secret — without this check, anyone who discovers your webhook URL could send fake event notifications.
Final Checklist Before Shipping a New API Endpoint
- Authentication and authorization are both checked, not just "is this request logged in at all"
- Every input is validated server-side, with consistent, structured error responses on failure
- The response uses an explicit transformer, not a raw database row, to avoid leaking internal fields
- Status codes are used meaningfully (not everything returns 200 regardless of outcome)
- Rate limiting is in place for any endpoint that could be abused through repeated calls
- The endpoint is documented before being considered done, not left for "later"
Handling File Uploads Through an API
API endpoints that accept file uploads (a profile picture, a document attachment) need the same validation discipline covered in our dedicated guide on secure PHP file uploads, with one added consideration: APIs are often consumed by non-browser clients (mobile apps, other services) sending the file as multipart form data or as a base64-encoded field within a JSON body, and the endpoint needs to clearly document which format it expects, since the two require different parsing entirely on the server side.
Idempotency Keys for Safe Retries
Network failures mean clients sometimes cannot tell whether a request they sent actually succeeded on the server before the connection dropped — leading to a natural instinct to simply retry. For a GET request this is harmless, but retrying a POST that creates a new order risks creating a duplicate. Idempotency keys solve this: the client generates a unique key for a given logical operation and sends it with the request; the server checks whether it has already processed that exact key and, if so, returns the original result instead of performing the action again. Payment APIs in particular rely heavily on this pattern, since a duplicate payment is a far more serious problem than a duplicate read.
API Gateway and Reverse Proxy Considerations
In production, a PHP API often sits behind a reverse proxy or API gateway (nginx, or a dedicated API gateway service) that can itself handle some concerns — SSL termination, basic rate limiting, request logging — before a request ever reaches PHP. Understanding which concerns are handled at that layer versus which need to be handled explicitly in application code avoids both gaps (assuming the gateway handles something it does not) and redundant duplicate effort (re-implementing rate limiting in PHP that the gateway already enforces more efficiently upstream).
A Worked Example: Designing and Evolving an Endpoint Over Time
Consider an orders endpoint that starts simple and evolves as the business grows, illustrating how the principles in this guide play out over an API's actual lifetime rather than just at initial launch. Version one exposes GET /api/v1/orders, returning every field from the orders table directly because the only consumer is the company's own admin dashboard and nobody anticipated needing to hide anything. Six months later, a mobile app needs the same data, and at that point the raw-row response becomes a liability — it includes an internal profit-margin field that should never reach a customer-facing app. Rather than breaking the existing admin dashboard integration, the team introduces an explicit transformer for the mobile-facing response and begins migrating the admin dashboard onto the same transformer over time, accepting some short-term duplication while removing the unintentional internal-data leak immediately, since waiting for a "clean" simultaneous migration would have left the leak live for longer. Later still, as the orders endpoint grows more complex (filtering, sorting, and pagination all needed together, exactly as covered earlier in this guide), the team introduces a v2 endpoint with an explicit version segment rather than silently changing v1's behavior, protecting the admin dashboard (which never updated to expect the new structure) from breaking unexpectedly. None of these individual decisions required dramatic foresight at launch; what mattered was treating each new requirement as a deliberate design decision rather than the path of least resistance applied to already-shipped code.
Testing an API Properly
An API's tests should exercise it the same way a real client would: real HTTP requests against real routes, asserting on status codes, response bodies, and headers — not internal method calls.
public function testCreatingProductRequiresAuthentication()
{
$response = $this->postJson('/api/v1/products', ['name' => 'Widget']);
$response->assertStatus(401);
}
public function testCreatingProductReturns201WithLocationHeader()
{
$response = $this->actingAsApiUser()->postJson('/api/v1/products', [
'name' => 'Widget', 'price' => 999,
]);
$response->assertStatus(201);
$response->assertHeader('Location');
}These tests double as living documentation — a new developer can read the test suite and understand exactly what the API guarantees, often faster than reading prose documentation that may have drifted out of date.
Documenting an API So People Actually Use It Correctly
An OpenAPI (formerly Swagger) specification describes every endpoint, every parameter, every possible response in a machine-readable format that tools can turn into interactive documentation, auto-generated client libraries, and automated contract tests. Writing one by hand for every endpoint is tedious; most frameworks offer annotations or attributes that generate the spec from the code itself, keeping documentation and implementation from drifting apart — a perpetual problem with hand-maintained API docs.
Idempotency: Why It Matters for POST and Why PUT Gets It for Free
If a mobile app's "place order" request times out on a flaky connection and the app retries automatically, did the order get created once or twice? With a plain POST endpoint, potentially twice. Idempotency keys solve this: the client generates a unique key per logical operation and sends it in a header; the server stores which keys it has already processed and returns the original response for a repeat, rather than performing the action again.
$key = $request->header('Idempotency-Key');
$cached = Cache::get("idempotency:$key");
if ($cached) {
return response($cached['body'], $cached['status']);
}
// ... perform the actual operation, then cache the response under $keyPUT requests are idempotent by definition — sending the same PUT twice should produce the same end state, since it replaces a resource rather than appending to a collection — which is one more reason the verb choice in REST is not just stylistic.
The Bigger Picture
A REST API is a contract between your server and every client that will ever consume it — today's mobile app, tomorrow's third-party integration, a future version of the same web frontend rewritten in something else. Every decision covered here — consistent status codes, predictable URLs, careful versioning, real authentication — exists to keep that contract stable enough that clients can be built against it with confidence, even as the underlying implementation keeps changing.
Caching Responses Without Serving Stale Data
Read-heavy endpoints — a product catalog, a list of public posts — are prime caching candidates, but caching a REST response wrong creates a worse bug than not caching at all: clients silently seeing outdated data with no error to alert anyone. The safest starting point is HTTP-level caching using ETag and Last-Modified headers, which lets the client (or a CDN in front of your API) ask "has this changed since I last fetched it?" rather than your server re-serving the full payload every time.
$etag = md5($product->updated_at);
if ($request->header('If-None-Match') === $etag) {
return response(null, 304);
}
return response()->json($product)->header('ETag', $etag);A 304 response tells the client its cached copy is still valid — no body needed, which saves bandwidth on every unchanged request. Application-level caching (Redis, Memcached) is the next layer, useful for expensive queries, but it needs an explicit invalidation strategy: when a product updates, its cache key must be cleared in the same transaction as the write, not on a timer that might lag behind.
Designing for the Client You Haven't Met Yet
The hardest part of API design is that the client consuming your endpoint today is rarely the only client that will ever consume it. A mobile app built against version 1 of your API may stay in production for years after version 2 ships, simply because not every user updates their app. This is the real argument for taking versioning, deprecation headers, and backward compatibility seriously from the first release — not because the rules are fun to follow, but because breaking an old client months after shipping a change is a support nightmare that proper versioning entirely avoids.