Santaji GadePHP, DevelopmentYesterday10 Views

A practical guide to a rest api in php without a framework, covering routing, PDO backed models, bearer token authentication, and JSON error handling.
Table of Contents
ToggleInstalling a full framework to serve a handful of JSON endpoints is a lot of weight for a small job. A REST API in PHP without a framework needs only a router, a database connection, and a clear idea of what each HTTP verb should do.
A framework earns its weight on a large application with dozens of routes, middleware, and moving parts. Roy Fielding's original REST dissertation never mentions a framework at all, since the architecture itself is just a set of constraints over plain HTTP.
A small internal tool, a mobile app backend with six endpoints, or a proof of concept rarely needs any of that structure.
Plain PHP with a thin routing layer starts in minutes and stays easy to read months later, since nothing hides behind a framework's own conventions.
Everything below is production usable code, not a toy example, built around the same HTTP rules a framework would enforce anyway.
Every request routes through a single PHP file, which keeps the routing logic in one place instead of scattered across separate files per endpoint.
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$ index.php [QSA,L]
Every request that does not point at a real file gets forwarded to index.php, which becomes the single entry point the router below reads from. mod_rewrite handles this on Apache, and Nginx uses an equivalent try_files directive in its own server block.
A router just needs to match a method and a path pattern against the current request, then call the matching handler.
final class Router {
private array $routes = [];
public function add(string $method, string $pattern, callable $handler): void {
$this->routes[] = ['method' => $method, 'pattern' => $pattern, 'handler' => $handler];
}
public function dispatch(string $method, string $path): void {
foreach ($this->routes as $route) {
if ($route['method'] !== $method) {
continue;
}
$regex = '#^' . preg_replace('/\{(\w+)\}/', '(?P<$1>[^/]+)', $route['pattern']) . '$#';
if (preg_match($regex, $path, $matches)) {
$route['handler'](array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY));
return;
}
}
http_response_code(404);
echo json_encode(['error' => 'Route not found']);
}
}
Turning {id} style placeholders into a named regex group lets a single pattern like /posts/{id} match /posts/42 and hand back id already extracted.
A REST API's status code carries real meaning, and a client integrating against it depends on that meaning being consistent. The full list of codes and what each one is meant to signal comes from MDN's HTTP status reference, which is worth keeping open while building any API.
function json_response(array $data, int $status = 200): void {
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($data, JSON_UNESCAPED_SLASHES);
exit;
}
| HTTP Method | Action | Typical Status Code |
|---|---|---|
| GET | Read one or many resources | 200 on success, 404 if missing |
| POST | Create a new resource | 201 on success |
| PUT | Replace an existing resource | 200 on success |
| DELETE | Remove a resource | 204 on success |
Registering routes for a single resource follows the same shape regardless of how many resources the API eventually grows to cover, and every response still follows the same JSON structure a client already expects.
$router = new Router();
$router->add('GET', '/posts', fn() => json_response(get_all_posts($pdo)));
$router->add('GET', '/posts/{id}', fn($p) => json_response(get_post($pdo, (int) $p['id'])));
$router->add('POST', '/posts', fn() => json_response(create_post($pdo, get_json_body()), 201));
$router->add('PUT', '/posts/{id}', fn($p) => json_response(update_post($pdo, (int) $p['id'], get_json_body())));
$router->add('DELETE', '/posts/{id}', fn($p) => json_response(delete_post($pdo, (int) $p['id']), 204));
$router->dispatch($_SERVER['REQUEST_METHOD'], parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));
Five lines wire up a complete resource, and adding a second resource means five more lines rather than a new controller class and configuration file.
The handler functions referenced above stay small, since each one wraps a single PDO prepared statement.
function get_post(PDO $pdo, int $id): array {
$stmt = $pdo->prepare('SELECT id, title, body FROM posts WHERE id = ?');
$stmt->execute([$id]);
$post = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$post) {
json_response(['error' => 'Post not found'], 404);
}
return $post;
}
Calling json_response directly from inside the model and letting it exit keeps the missing resource case short, without an extra layer of exceptions just to bubble a 404 upward.
A write endpoint left open to anyone is a real risk the moment the API is reachable from outside a local network. The Bearer scheme is a standard HTTP authentication pattern, not something specific to this guide.
function require_bearer_token(): void {
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/^Bearer\s+(.+)$/', $header, $matches)) {
json_response(['error' => 'Missing or malformed token'], 401);
}
if (!hash_equals(getenv('API_TOKEN'), $matches[1])) {
json_response(['error' => 'Invalid token'], 403);
}
}
hash_equals() compares strings in constant time, which matters here since a plain === comparison can leak timing information about how much of the token matched. RFC 6750 defines the Bearer token scheme this pattern follows.
A client integrating against the API should be able to parse every error the same way, regardless of which endpoint produced it.
set_exception_handler(function (Throwable $e) {
json_response([
'error' => 'Server error',
'request_id' => bin2hex(random_bytes(8))
], 500);
});
Returning a random request_id instead of the raw exception message keeps internal details out of the response while still giving support a value to search server logs for.
A quick command line request using curl confirms the whole chain works before any client application gets involved.
# create a post
curl -X POST https://api.example.com/posts \
-H "Authorization: Bearer your_token_here" \
-H "Content-Type: application/json" \
-d '{"title":"Hello","body":"First post"}'
A successful call returns status 201 along with the newly created row, confirming the router, the model, and the authentication check all worked together correctly.
Dave Hollingworth builds a working REST API using plain, object oriented PHP and MySQL, which follows a very similar shape to the router and model pattern in this guide.
Video credit: Dave Hollingworth.
Swapping the video's approach for the named route parameters and bearer token check in this guide is a natural next step once the base pattern feels familiar.
For a small, focused API it often is. Once the number of resources and business rules grows large, the structure a framework provides usually becomes worth the added weight.
Faster setup, fewer dependencies to keep updated, and a codebase where every line of routing and request handling is visible instead of hidden behind configuration.
It is a reasonable baseline for a server to server integration, though a public facing API serving many users usually needs a fuller OAuth style flow eventually.
Yes. Always setting an explicit status code, even 200, avoids relying on PHP's default, which can vary depending on what already ran earlier in the request.
Add a new model file with its own functions and register a matching set of routes, following the exact same five line pattern used for posts in this guide.
Every request passes through one file instead of many scattered endpoints.
Clients depend on the code, not just the response body, to know what happened.
Five lines wire up a full resource without any generated boilerplate.
hash_equals prevents a timing attack a plain comparison would allow.
A request ID beats a raw exception message in every response.
A single command line request tests routing, auth, and the model together.
Pair a rest api in php without a framework with a wider development workflow to keep every endpoint fast, secure, and easy to extend.









