Skip to content

12: REST APIs - Express.js vs PHP Native

If you’ve built APIs with Express.js, you’ll find PHP’s approach familiar. This chapter shows you how to build REST APIs in native PHP, then compares it to using Slim Framework (PHP’s Express equivalent). We’ll cover routing, middleware, validation, and best practices.

By the end of this chapter, you’ll be able to:

  • ✅ Build REST APIs without frameworks
  • ✅ Implement routing (like Express routes)
  • ✅ Create middleware pipelines
  • ✅ Handle JSON requests/responses
  • ✅ Validate input data
  • ✅ Use Slim Framework (PHP’s Express)
  • ✅ Apply RESTful best practices
  • ✅ Handle errors properly

📁 View Code Examples on GitHub

This chapter includes complete REST API examples:

  • 01-native-api.php - Basic REST API without frameworks
  • 02-router-class.php - Custom router implementation
  • 03-request-response.php - Clean request/response objects
  • 04-middleware.php - Middleware pipeline pattern
  • 05-slim-api/ - Full CRUD API with Slim Framework
  • 06-validation.php - Input validation examples

Run the examples:

Terminal window
cd code/php-typescript-developers/chapter-12
php -S localhost:8000 01-native-api.php
curl http://localhost:8000/api/users
FeatureExpress.jsPHP NativeSlim Framework
Routingapp.get('/users')Manual routing$app->get('/users')
Middlewareapp.use()Manual->add()
JSON Parsingexpress.json()json_decode()Automatic
Responseres.json()echo json_encode()$response->withJson()
Parametersreq.params.id$_GET, $_POST$request->getAttribute()
server.js
const express = require('express');
const app = express();
app.use(express.json());
// GET /api/users
app.get('/api/users', (req, res) => {
const users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
];
res.json(users);
});
// GET /api/users/:id
app.get('/api/users/:id', (req, res) => {
const id = parseInt(req.params.id);
const user = { id, name: 'Alice' };
res.json(user);
});
// POST /api/users
app.post('/api/users', (req, res) => {
const { name, email } = req.body;
const user = { id: 3, name, email };
res.status(201).json(user);
});
app.listen(3000, () => {
console.log('Server running on port 3000');
});
index.php
<?php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
// Simple router
if ($method === 'GET' && $path === '/api/users') {
$users = [
['id' => 1, 'name' => 'Alice'],
['id' => 2, 'name' => 'Bob']
];
echo json_encode($users);
exit;
}
if ($method === 'GET' && preg_match('#^/api/users/(\d+)$#', $path, $matches)) {
$id = (int) $matches[1];
$user = ['id' => $id, 'name' => 'Alice'];
echo json_encode($user);
exit;
}
if ($method === 'POST' && $path === '/api/users') {
$data = json_decode(file_get_contents('php://input'), true);
$user = [
'id' => 3,
'name' => $data['name'] ?? '',
'email' => $data['email'] ?? ''
];
http_response_code(201);
echo json_encode($user);
exit;
}
// 404 Not Found
http_response_code(404);
echo json_encode(['error' => 'Not Found']);

Run:

Terminal window
php -S localhost:8000
const express = require('express');
const router = express.Router();
router.get('/', (req, res) => {
res.json({ message: 'User list' });
});
router.post('/', (req, res) => {
res.json({ message: 'User created' });
});
app.use('/api/users', router);
<?php
declare(strict_types=1);
class Router {
private array $routes = [];
public function get(string $path, callable $handler): void {
$this->addRoute('GET', $path, $handler);
}
public function post(string $path, callable $handler): void {
$this->addRoute('POST', $path, $handler);
}
public function put(string $path, callable $handler): void {
$this->addRoute('PUT', $path, $handler);
}
public function delete(string $path, callable $handler): void {
$this->addRoute('DELETE', $path, $handler);
}
private function addRoute(string $method, string $path, callable $handler): void {
$this->routes[] = [
'method' => $method,
'path' => $path,
'handler' => $handler
];
}
public function dispatch(): void {
$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
foreach ($this->routes as $route) {
if ($route['method'] !== $method) {
continue;
}
$pattern = $this->convertPathToRegex($route['path']);
if (preg_match($pattern, $path, $matches)) {
array_shift($matches); // Remove full match
call_user_func_array($route['handler'], $matches);
return;
}
}
http_response_code(404);
echo json_encode(['error' => 'Not Found']);
}
private function convertPathToRegex(string $path): string {
// Convert /users/:id to /users/(\d+)
$pattern = preg_replace('#:([a-zA-Z]+)#', '([^/]+)', $path);
return '#^' . $pattern . '$#';
}
}

Usage:

<?php
require 'Router.php';
header('Content-Type: application/json');
$router = new Router();
$router->get('/api/users', function() {
echo json_encode([
['id' => 1, 'name' => 'Alice'],
['id' => 2, 'name' => 'Bob']
]);
});
$router->get('/api/users/:id', function($id) {
echo json_encode(['id' => $id, 'name' => 'Alice']);
});
$router->post('/api/users', function() {
$data = json_decode(file_get_contents('php://input'), true);
http_response_code(201);
echo json_encode(['id' => 3, 'name' => $data['name']]);
});
$router->dispatch();
// Logging middleware
app.use((req, res, next) => {
console.log(`${req.method} ${req.path}`);
next();
});
// Auth middleware
const requireAuth = (req, res, next) => {
const token = req.headers.authorization;
if (!token) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
};
app.get('/api/protected', requireAuth, (req, res) => {
res.json({ message: 'Protected data' });
});
<?php
class Router {
private array $middleware = [];
public function use(callable $middleware): void {
$this->middleware[] = $middleware;
}
public function dispatch(): void {
// Run middleware chain
$this->runMiddleware(0, function() {
// Then dispatch route
$this->matchRoute();
});
}
private function runMiddleware(int $index, callable $next): void {
if ($index >= count($this->middleware)) {
$next();
return;
}
$middleware = $this->middleware[$index];
$middleware(function() use ($index, $next) {
$this->runMiddleware($index + 1, $next);
});
}
}
// Usage
$router = new Router();
// Logging middleware
$router->use(function($next) {
$method = $_SERVER['REQUEST_METHOD'];
$path = $_SERVER['REQUEST_URI'];
error_log("{$method} {$path}");
$next();
});
// Auth middleware
$requireAuth = function($next) {
$token = $_SERVER['HTTP_AUTHORIZATION'] ?? null;
if (!$token) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
$next();
};
$router->use($requireAuth);
$router->get('/api/protected', function() {
echo json_encode(['message' => 'Protected data']);
});
app.post('/api/users', (req, res) => {
// Request
const body = req.body; // Parsed JSON
const query = req.query; // Query params
const params = req.params; // Route params
const headers = req.headers; // Headers
// Response
res.status(201)
.header('X-Custom', 'value')
.json({ success: true });
});
<?php
class Request {
public function body(): array {
return json_decode(file_get_contents('php://input'), true) ?? [];
}
public function query(string $key = null): mixed {
return $key ? ($_GET[$key] ?? null) : $_GET;
}
public function header(string $name): ?string {
$name = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
return $_SERVER[$name] ?? null;
}
public function method(): string {
return $_SERVER['REQUEST_METHOD'];
}
public function path(): string {
return parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
}
}
class Response {
private int $statusCode = 200;
private array $headers = [];
public function status(int $code): self {
$this->statusCode = $code;
return $this;
}
public function header(string $name, string $value): self {
$this->headers[$name] = $value;
return $this;
}
public function json(array $data): void {
http_response_code($this->statusCode);
foreach ($this->headers as $name => $value) {
header("{$name}: {$value}");
}
header('Content-Type: application/json');
echo json_encode($data);
}
}
// Usage
$req = new Request();
$res = new Response();
$res->status(201)
->header('X-Custom', 'value')
->json(['success' => true]);

Slim is a microframework inspired by Sinatra/Express:

Terminal window
composer require slim/slim slim/psr7
<?php
require 'vendor/autoload.php';
use Slim\Factory\AppFactory;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
$app = AppFactory::create();
// GET /api/users
$app->get('/api/users', function (Request $request, Response $response) {
$users = [
['id' => 1, 'name' => 'Alice'],
['id' => 2, 'name' => 'Bob']
];
$response->getBody()->write(json_encode($users));
return $response->withHeader('Content-Type', 'application/json');
});
// GET /api/users/{id}
$app->get('/api/users/{id}', function (Request $request, Response $response, $args) {
$id = (int) $args['id'];
$user = ['id' => $id, 'name' => 'Alice'];
$response->getBody()->write(json_encode($user));
return $response->withHeader('Content-Type', 'application/json');
});
// POST /api/users
$app->post('/api/users', function (Request $request, Response $response) {
$data = json_decode($request->getBody()->getContents(), true);
$user = [
'id' => 3,
'name' => $data['name'] ?? '',
'email' => $data['email'] ?? ''
];
$response->getBody()->write(json_encode($user));
return $response
->withHeader('Content-Type', 'application/json')
->withStatus(201);
});
$app->run();

Run:

Terminal window
php -S localhost:8000 -t public/
const Joi = require('joi');
const userSchema = Joi.object({
name: Joi.string().required(),
email: Joi.string().email().required(),
age: Joi.number().min(18)
});
app.post('/api/users', (req, res) => {
const { error } = userSchema.validate(req.body);
if (error) {
return res.status(400).json({ errors: error.details });
}
// Create user...
res.json({ success: true });
});
Terminal window
composer require respect/validation
<?php
use Respect\Validation\Validator as v;
$app->post('/api/users', function (Request $request, Response $response) {
$data = json_decode($request->getBody()->getContents(), true);
$validator = v::key('name', v::stringType()->notEmpty())
->key('email', v::email())
->key('age', v::intType()->min(18));
try {
$validator->assert($data);
} catch (\Respect\Validation\Exceptions\ValidationException $e) {
$response->getBody()->write(json_encode([
'errors' => $e->getMessages()
]));
return $response
->withHeader('Content-Type', 'application/json')
->withStatus(400);
}
// Create user...
$response->getBody()->write(json_encode(['success' => true]));
return $response->withHeader('Content-Type', 'application/json');
});
// Error handler (must be last)
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
error: 'Internal Server Error',
message: err.message
});
});
<?php
use Slim\Exception\HttpNotFoundException;
use Slim\Handlers\ErrorHandler;
$customErrorHandler = function (
ServerRequestInterface $request,
Throwable $exception,
bool $displayErrorDetails
) use ($app) {
$response = $app->getResponseFactory()->createResponse();
$payload = [
'error' => get_class($exception),
'message' => $exception->getMessage()
];
$response->getBody()->write(json_encode($payload));
return $response
->withHeader('Content-Type', 'application/json')
->withStatus(500);
};
$errorMiddleware = $app->addErrorMiddleware(true, true, true);
$errorMiddleware->setDefaultErrorHandler($customErrorHandler);
// ✅ Good
GET /api/users // List users
GET /api/users/1 // Get user
POST /api/users // Create user
PUT /api/users/1 // Update user
DELETE /api/users/1 // Delete user
// ❌ Bad
GET /api/getUsers
POST /api/createUser
GET /api/user_detail/1
CodeMeaningWhen to Use
200OKSuccessful GET, PUT, PATCH, DELETE
201CreatedSuccessful POST
204No ContentSuccessful DELETE (no body)
400Bad RequestValidation error
401UnauthorizedMissing/invalid auth
403ForbiddenAuth valid but insufficient permissions
404Not FoundResource doesn’t exist
422Unprocessable EntityValidation error (alternative to 400)
500Internal Server ErrorServer error

Successful Response:

{
"data": {
"id": 1,
"name": "Alice",
"email": "alice@example.com"
}
}

Error Response:

{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid input data",
"details": [
{
"field": "email",
"message": "Invalid email format"
}
]
}
}

Express.js:

const cors = require('cors');
app.use(cors());

PHP:

<?php
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
const express = require('express');
const app = express();
app.use(express.json());
let users = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' }
];
let nextId = 3;
// List
app.get('/api/users', (req, res) => {
res.json({ data: users });
});
// Get
app.get('/api/users/:id', (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json({ data: user });
});
// Create
app.post('/api/users', (req, res) => {
const user = { id: nextId++, ...req.body };
users.push(user);
res.status(201).json({ data: user });
});
// Update
app.put('/api/users/:id', (req, res) => {
const index = users.findIndex(u => u.id === parseInt(req.params.id));
if (index === -1) {
return res.status(404).json({ error: 'User not found' });
}
users[index] = { ...users[index], ...req.body };
res.json({ data: users[index] });
});
// Delete
app.delete('/api/users/:id', (req, res) => {
const index = users.findIndex(u => u.id === parseInt(req.params.id));
if (index === -1) {
return res.status(404).json({ error: 'User not found' });
}
users.splice(index, 1);
res.status(204).send();
});
app.listen(3000);
<?php
require 'vendor/autoload.php';
use Slim\Factory\AppFactory;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
$app = AppFactory::create();
$app->addBodyParsingMiddleware();
$app->addRoutingMiddleware();
$app->addErrorMiddleware(true, true, true);
$users = [
['id' => 1, 'name' => 'Alice', 'email' => 'alice@example.com'],
['id' => 2, 'name' => 'Bob', 'email' => 'bob@example.com']
];
$nextId = 3;
// List
$app->get('/api/users', function (Request $request, Response $response) use (&$users) {
$response->getBody()->write(json_encode(['data' => $users]));
return $response->withHeader('Content-Type', 'application/json');
});
// Get
$app->get('/api/users/{id}', function (Request $request, Response $response, $args) use (&$users) {
$id = (int) $args['id'];
$user = array_values(array_filter($users, fn($u) => $u['id'] === $id))[0] ?? null;
if (!$user) {
$response->getBody()->write(json_encode(['error' => 'User not found']));
return $response->withHeader('Content-Type', 'application/json')->withStatus(404);
}
$response->getBody()->write(json_encode(['data' => $user]));
return $response->withHeader('Content-Type', 'application/json');
});
// Create
$app->post('/api/users', function (Request $request, Response $response) use (&$users, &$nextId) {
$data = $request->getParsedBody();
$user = ['id' => $nextId++] + $data;
$users[] = $user;
$response->getBody()->write(json_encode(['data' => $user]));
return $response->withHeader('Content-Type', 'application/json')->withStatus(201);
});
// Update
$app->put('/api/users/{id}', function (Request $request, Response $response, $args) use (&$users) {
$id = (int) $args['id'];
$data = $request->getParsedBody();
$index = array_search($id, array_column($users, 'id'));
if ($index === false) {
$response->getBody()->write(json_encode(['error' => 'User not found']));
return $response->withHeader('Content-Type', 'application/json')->withStatus(404);
}
$users[$index] = array_merge($users[$index], $data);
$response->getBody()->write(json_encode(['data' => $users[$index]]));
return $response->withHeader('Content-Type', 'application/json');
});
// Delete
$app->delete('/api/users/{id}', function (Request $request, Response $response, $args) use (&$users) {
$id = (int) $args['id'];
$index = array_search($id, array_column($users, 'id'));
if ($index === false) {
$response->getBody()->write(json_encode(['error' => 'User not found']));
return $response->withHeader('Content-Type', 'application/json')->withStatus(404);
}
array_splice($users, $index, 1);
return $response->withStatus(204);
});
$app->run();
  1. PHP can build REST APIs just like Express.js with similar patterns
  2. Slim Framework is PHP’s Express equivalent - lightweight and minimal
  3. Routing patterns are nearly identical (verb + path + handler)
  4. Middleware works the same way (request → middleware chain → response)
  5. PSR-7 provides standard request/response objects across frameworks
  6. Native PHP is verbose but gives full control without framework overhead
  7. For production, use Laravel or Symfony for full-featured APIs
  8. Laravel API resources transform models to JSON (like Express serializers)
  9. Route model binding automatically injects models by ID (Laravel feature)
  10. API versioning typically done via URL prefix (/api/v1/) or headers
  11. CORS middleware needed for frontend consumption - built into most frameworks
  12. Use apiResource in Laravel for instant RESTful routes (index, store, show, update, destroy)
FeatureExpress.jsSlim Framework
Setupnpm install expresscomposer require slim/slim
Routingapp.get('/users')$app->get('/users')
Middlewareapp.use(middleware)$app->add(middleware)
Requestreq.body$request->getParsedBody()
Responseres.json()$response->withJson()
Error HandlingError middlewareError middleware
EcosystemLargeModerate

Now that you understand REST APIs, let’s dive into Laravel—PHP’s most popular framework.

Next Chapter: 13: Laravel Foundations: The PHP Framework


Questions or feedback? Open an issue on GitHub