The API Request Pipeline: Routing, Middleware, Auth, and Layered Architecture
Trace a single API request through the full pipeline a production PHP API uses — routing, middleware, authentication, authorization, validation, controller, service, repository, and serialization — and see how each layer has one job and hands off to the next.
A request to POST /orders looks instantaneous from the client's side, but inside a well-structured API it passes through a chain of distinct stages, each responsible for exactly one concern. Routing decides which code should handle this URL and method. Middleware runs cross-cutting logic (logging, CORS, rate limiting) before the handler. Authentication figures out *who* is calling. Authorization decides *whether they're allowed* to do this specific thing. Validation checks the request body is well-formed. The controller coordinates the request/response. The service layer holds business logic. The repository talks to the database. Serialization turns the result back into JSON. Understanding this pipeline — and keeping each stage separate — is what turns a pile of if statements in one giant script into an API you can test, extend, and reason about a year later.
Why layer at all?
It's entirely possible to write an API endpoint as one long PHP script: read $_POST, run a validation if, write a SQL query, echo json_encode(...). This works for a toy example and becomes unmaintainable fast — business rules get duplicated across endpoints, you can't unit-test 'compute order total' without spinning up a full HTTP request and a real database, and a single typo in one giant function can leak unvalidated input straight into a SQL string.
Layering fixes this by giving each concern a home. The controller only orchestrates — it doesn't know how totals are computed or how rows are stored. The service only knows business rules — it doesn't know it's being called from HTTP (you could call the same service from a CLI script or a queue worker). The repository only knows how to persist and retrieve one type of entity — it doesn't know about HTTP or validation at all. This separation is what makes each piece independently testable: you can unit-test OrderService::place() with a fake repository and never touch a real database or an HTTP request.
Routing: matching a URL + method to a handler
The router is the entry point: it looks at the request method and path and decides which controller action should run, extracting any path parameters ({id} in /orders/{id}) along the way. In small PHP APIs this is often a simple array of pattern -> callback pairs checked in order; larger apps use a compiled routing table (as in Symfony's router or a library like FastRoute) for O(1)-ish lookup instead of testing every route in sequence.
A router's job ends the moment it has identified a handler and extracted parameters — it should never itself contain business logic, validation, or database calls. Keeping the router 'dumb' means adding a new endpoint is just adding one more line to a routing table, not hunting through a large conditional.
Middleware: cross-cutting concerns before the handler
Middleware wraps the handler with logic that applies to many or all endpoints: logging every request, adding CORS headers, rate-limiting by IP or API key, decompressing a gzip body, or generating a correlation ID for tracing. The key property of middleware is that it's composable and ordered — each layer can inspect or modify the request, decide to short-circuit (e.g. reject over the rate limit with 429 before ever reaching the controller), or call the next layer and then inspect/modify the response on the way back out.
Authentication and authorization are frequently implemented *as* middleware precisely because they're cross-cutting: nearly every endpoint needs 'is there a valid token' and many need 'is this user allowed to do X', so it's wasteful and error-prone to hand-roll that check inside every single controller action.
Authentication vs. authorization
These two words are easy to conflate but answer different questions. Authentication answers 'who is making this request?' — typically by validating a bearer token, API key, or session cookie and resolving it to a user/client identity. Authorization answers 'is this identity allowed to do this specific thing?' — e.g. can user 42 delete order 123, or is order 123 owned by a different customer entirely? Authentication failing returns 401 Unauthorized ('I don't know who you are, or your credentials are invalid'); authorization failing returns 403 Forbidden ('I know who you are, and you're not allowed to do this').
A common real bug is checking authentication but skipping per-resource authorization — verifying the token is valid, then fetching /orders/123 for *any* authenticated user without checking that order 123 actually belongs to them. This class of bug (Insecure Direct Object Reference) is one of the most common API vulnerabilities in production systems.
Controller, service, repository: who does what
The controller is the thin coordination layer for one HTTP action: it pulls parameters out of the request, calls into the service layer, and turns the result into an HTTP response (status code + serialized body). It should contain almost no logic of its own.
The service layer holds the actual business rules: 'placing an order requires the cart to be non-empty and the customer's payment method to be valid', 'cancelling an order after it has shipped is not allowed'. Services are framework-agnostic — the same OrderService could be called from an HTTP controller, a CLI import script, or a background queue worker, because it doesn't know or care where the call came from.
The repository is the only layer that knows SQL exists. It exposes methods like findById(int $id): ?Order and save(Order $order): void, hiding the actual query/ORM details behind a small interface. This means you can swap the storage engine, or substitute an in-memory fake repository in unit tests, without touching the service layer at all.
Serialization is the final step: converting a PHP object/array into the JSON representation the client receives, applying any field renaming, hiding internal-only fields (like a password hash), and setting the response Content-Type.
• Router -> which code handles this URL+method, and what are the path params? • Middleware -> cross-cutting: logging, CORS, rate limiting, correlation ID. • Auth(entication) -> who is calling? (401 if unknown/invalid credentials) • Auth(orization) -> are they allowed to do *this*? (403 if not) • Validation -> is the request body well-formed and semantically valid? (422 if not) • Controller -> orchestrate: parse request, call service, build response. • Service -> business rules and workflow, independent of HTTP. • Repository -> persistence: the only layer that talks to the database. • Serialization -> turn the result into the JSON the client actually receives.
The pipeline as a sequence of small functions
Each middleware receives the request and a $next callable — it decides whether to call $next or short-circuit with its own response. This 'onion' composition means order matters: authentication must run before authorization, which must run before validation of resource-specific business rules.
<?php
declare(strict_types=1);
function handle(Request $request): Response {
return pipeline(
[
correlationIdMiddleware(...),
loggingMiddleware(...),
authenticationMiddleware(...),
authorizationMiddleware(...),
validationMiddleware(...),
],
fn (Request $r) => (new OrderController($orderService))->handle($r)
)($request);
}
function pipeline(array $middleware, callable $handler): callable {
return array_reduce(
array_reverse($middleware),
fn (callable $next, callable $mw) => fn (Request $r) => $mw($r, $next),
$handler
);
}Code Examples
This single request walks through every named stage in order: the router already matched POST /orders to OrderController::create; three middleware functions run in sequence (correlation id, authentication, authorization), then a validation middleware checks the JSON body; only then does the thin controller call into OrderService, which enforces the business rule that an order needs at least one item and computes the total, delegating persistence to OrderRepository; the controller serializes the final Order into the JSON the client sees. Notice the service never touches $_SERVER, headers, or HTTP status codes — it raises a plain DomainException, and only the controller/middleware layer translates that into an HTTP status. This is what lets the same OrderService::place() be reused from a CLI import script unchanged.
$req->user = $user; // attach identity for downstream layers
<?php
declare(strict_types=1);
// --- 1. ROUTING: match method+path, extract params (none here) ---
// matched: POST /orders -> OrderController::create
// --- 2. MIDDLEWARE: correlation id + logging (cross-cutting) ---
function correlationIdMiddleware(Request $req, callable $next): Response {
$req->correlationId = $req->header('X-Correlation-Id') ?? bin2hex(random_bytes(8));
$res = $next($req);
$res->header('X-Correlation-Id', $req->correlationId);
return $res;
}
// --- 3. AUTHENTICATION: who is calling? ---
function authenticationMiddleware(Request $req, callable $next): Response {
$token = $req->bearerToken();
if ($token === null || !($user = AuthTokens::resolve($token))) {
return Response::json(['error' => ['code' => 'unauthenticated']], 401);
}
$req->user = $user; // attach identity for downstream layers
return $next($req);
}
// --- 4. AUTHORIZATION: are they allowed to create orders? ---
function authorizationMiddleware(Request $req, callable $next): Response {
if (!$req->user->can('orders.create')) {
return Response::json(['error' => ['code' => 'forbidden']], 403);
}
return $next($req);
}
// --- 5. VALIDATIO
…The full API request pipeline
Each stage has exactly one job and hands a narrower, more trusted request down to the next.
Client
│ POST /orders { items: [...] }
▼
┌─────────────┐
│ Routing │ match method+path -> OrderController::create, extract params
└──────┬──────┘
▼
┌─────────────┐
│ Middleware │ correlation id, logging, CORS, rate limiting
└──────┬──────┘
▼
┌─────────────┐
│ Auth │ authentication: resolve token -> user (401 if invalid)
│ (entication)│
└──────┬──────┘
▼
┌─────────────┐
│ Auth │ authorization: can this user do this? (403 if not)
│ (orization) │
└──────┬──────┘
▼
┌─────────────┐
│ Validation │ is the request body well-formed? (422 if not)
└──────┬──────┘
▼
┌─────────────┐
│ Controller │ thin: parse request, call service, build response
└──────┬──────┘
▼
┌─────────────┐
│ Service │ business rules, workflow (framework-agnostic)
└──────┬──────┘
▼
┌─────────────┐
│ Repository │ the only layer that speaks SQL
└──────┬──────┘
▼
┌─────────────┐
│ Serializer │ domain object -> JSON representation
└──────┬──────┘
▼
Response 201 Created { id, customer_id, total_cents }Important Rules
Order matters: authentication before authorization, authorization before business-rule validation.
Routers extract parameters and dispatch — they never contain business logic or database calls.
Controllers stay thin: parse request, call one service method, serialize the result. No SQL, no business rules.
Services never know they're being called over HTTP — no status codes, no $_SERVER, no header logic inside a service.
Repositories are the only layer allowed to write SQL; everything above them works with domain objects.
Never let raw request/database arrays leak straight into the JSON response — serialize deliberately so internal-only fields never leak.
Why this matters at scale
Frameworks like Laravel and Symfony formalize exactly this pipeline: route -> middleware stack -> form request validation -> controller -> (often) an application service -> Eloquent/Doctrine repository -> API Resource/Normalizer for serialization. Understanding the pipeline in plain PHP first means the framework's abstractions (middleware classes, form requests, resources) read as names for stages you already understand, rather than magic.
Checking authentication (valid token) but not authorization (does this token's owner actually own order 123?) before returning it if ($order->customerId !== $req->user->id) { return Response::json([...], 403); }
Skipping per-resource authorization after authentication is the classic Insecure Direct Object Reference bug — any authenticated user can read or modify any other user's resource just by guessing/incrementing an id in the URL.
Putting SQL queries directly inside the controller action Controller calls OrderService::place(), which calls OrderRepository::save()
SQL inside a controller can't be unit-tested without a real database, duplicates itself across endpoints, and makes it impossible to reuse the same business logic from a non-HTTP entry point like a queue worker.
What is the key difference between authentication and authorization in the pipeline? A. They are two names for the same check B. Authentication identifies who is calling (401 on failure); authorization decides if that identity may do this specific thing (403 on failure) C. Authorization always runs before authentication D. Authentication is only needed for POST requests
B. Authentication identifies who is calling (401 on failure); authorization decides if that identity may do this specific thing (403 on failure) — Authentication answers 'who are you' and fails with 401; authorization answers 'are you allowed to do this' and fails with 403 — and authorization must run after authentication has established an identity.
Why should the service layer stay unaware of HTTP concepts like status codes? A. Because PHP doesn't allow classes to see $_SERVER B. So the same business logic can be reused from non-HTTP entry points (CLI, queue workers) and unit-tested without a real HTTP request C. Because services run in a different process than controllers D. It doesn't matter, coupling services to HTTP is fine
B. So the same business logic can be reused from non-HTTP entry points (CLI, queue workers) and unit-tested without a real HTTP request — Keeping services HTTP-agnostic (raising domain exceptions instead of setting status codes) lets the same business logic be called from a controller, a CLI script, or a queue worker, and lets it be unit-tested with plain objects.
In the layered pipeline, which layer is the only one that should contain SQL queries? A. Controller B. Service C. Repository D. Middleware
C. Repository — The repository is the designated persistence layer — hiding SQL/ORM details behind it means the service and controller layers work only with domain objects, and the storage layer can be swapped or faked in tests.
Sketch the pipeline stages (as a list) for DELETE /orders/{id} for an API where only an order's own customer, or an admin, may cancel it. Name which stage returns 401, which returns 403, and which layer checks 'is this order already shipped, and therefore uncancellable'.
Routing -> Authentication (401 if no/invalid token) -> Authorization (403 if not the order's customer and not an admin) -> Controller calls OrderService::cancel($id) -> Service checks 'already shipped' (throws a domain exception mapped to 409 Conflict) -> Repository persists the cancellation -> Serializer returns the updated order.
401 belongs to authentication; 403 belongs to authorization (ownership/role check); business rules like 'already shipped' belong in the service layer, not the controller.