Imagine a restaurant. You're the customer, your app is the waiter, and the kitchen is the server. A REST API is just the menu and the rules for how you ask the kitchen for things — 'GET me a burger', 'POST a new order', 'DELETE that side of fries'. PHP is the kitchen staff that reads your order, prepares it, and sends back a plate (JSON data). No fancy equipment needed — just the basics.
Every app you use daily — Instagram, Spotify, your bank — is powered by APIs running quietly in the background. When your phone loads your feed, it's making a REST API call. Understanding how to build one from scratch, without hiding behind Laravel or Symfony, is what separates developers who use tools from developers who understand them.
Frameworks are great, but they can mask what's really happening. When something breaks in production at 2am, you need to know what's underneath. Pure PHP API development forces you to confront the raw HTTP request lifecycle — how a URL becomes a route, how a method becomes an action, and how your data becomes a JSON response. That understanding makes you a better developer regardless of which framework you use later.
By the end of this article you'll have a fully working REST API in PHP — with routing, all four HTTP methods (GET, POST, PUT, DELETE), proper status codes, input validation, and API key authentication — built with nothing but PHP itself. No Composer packages. No magic. Just clean, readable code you fully understand.
When a mobile app returns an empty response and you have no framework to trace the issue, knowing how raw PHP handles requests becomes your only lifeline.
That's the real difference: you stop guessing and start fixing. Build it once from scratch, and every framework you touch after will feel like a tool you control, not a black box you trust.
What is REST API with Pure PHP?
REST API with Pure PHP is a core concept in PHP. Rather than starting with a dry definition, let's see it in action and understand why it exists.
The trade-off is clear: you write more code, but you understand every byte that leaves your server. When a mobile client reports an empty response, you know exactly where to look — your headers, your output buffer, your error handler. That knowledge is what separates senior engineers from those who panic when the framework magic fails.
index.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
namespace io\thecodeforge;
// Pure PHP REST API entry point
require_once __DIR__ . '/Router.php';
require_once __DIR__ . '/RequestHandler.php';
$router = newRouter();
$router->addRoute('GET', '/users', function () {
echojson_encode(['users' => []]);
});
$router->addRoute('POST', '/users', function () {
$data = RequestHandler::parseBody();
echojson_encode(['created' => true, 'data' => $data]);
});
$router->dispatch();
Mental Model: The Waiter in Your API
The router is the maître d' who directs each request to the right station.
The request body (php://input) is the order slip written in JSON — the kitchen reads every word.
The response (echo) is the plate sent back to the customer with a status code that says 'everything fine' or 'we messed up'.
Authentication middleware is the ID check at the door — without it, anyone can walk into the kitchen.
Production Insight
Without a framework, you control every byte sent to the client — but also every mistake.
If you forget to set Content-Type header, mobile clients silently drop the response.
Rule: always set response headers before any output, and validate Content-Type on input.
Key Takeaway
Pure PHP means you own the full request-response lifecycle.
There's no middleware magic — you write every conditional yourself.
Master the raw superglobals and you'll never fear a framework again.
When to Use Pure PHP vs a Framework
IfProject is a microservice with fewer than 10 endpoints
→
UsePure PHP — minimal overhead, full control, easier to reason about.
IfTeam is small and senior, no scaffolding needed
→
UsePure PHP — you'll move faster without learning a framework's conventions.
IfProject requires ORM, admin panels, or out-of-the-box auth
→
UseUse Laravel or Symfony — pure PHP would be reinventing the wheel.
Routing Requests Without a Framework
Every framework hides route matching behind a facade. In pure PHP, you parse the URL and method yourself using $_SERVER['REQUEST_METHOD'] and $_SERVER['REQUEST_URI']. The simplest router is a series of if/else if checks, but for scaling you'll want a dispatch table — an associative array mapping route patterns to handler functions.
One common production issue: trailing slashes. A route defined as /users won't match /users/ unless you normalize the URI. Always trim trailing slashes or add a redirect rule to avoid 404 surprises.
Use # as regex delimiter to avoid escaping forward slashes. It makes patterns like #^/users/(\d+)$# readable and avoids the 'leaning toothpick syndrome'.
Production Insight
A regex-based router can hit O(n) per request for n routes.
For high-traffic endpoints, compile patterns into a static array once and avoid preg_match overhead.
Rule: if you have more than 50 routes, switch to a trie-based matcher or use FastRoute.
Key Takeaway
Your router is just code — no magic, no middleware.
For < 50 routes, a regex dispatch table is clean and fast.
Performance matters: preg_match is ~1-2µs per route — 500 routes add 1ms.
Router Implementation Decision
IfFewer than 10 routes, simple patterns
→
UseUse simple if/else blocks — no regex overhead, easy to debug.
UseUse a nested trie or compile regex once at startup (e.g., FastRoute).
Handling HTTP Methods and JSON Data
PHP's $_POST only works for form-encoded data. For JSON, you must read raw input. GET params come from $_GET, but for PUT/DELETE you also read php://input. Always check Content-Type and return appropriate status codes: 200 for success, 201 for created, 204 for deleted, 400 for bad request.
Another edge case: PHP's php://input cannot be read twice. If you have middleware that reads the body for logging, you must capture it in a variable and pass it down. Otherwise, your route handler gets an empty string.
file_get_contents('php://input') can only be read once. If you read it and then try to read again, you'll get an empty string. Parse it once and store in a variable.
Production Insight
Mobile apps often omit Content-Type header due to framework bugs.
If you don't validate Content-Type, an empty body passes as valid JSON 'null' and decodes to null.
Rule: always reject or coerce missing Content-Type explicitly — return 415 or default to JSON parsing.
Key Takeaway
JSON doesn't fill $_POST — read php://input.
Validate Content-Type early to avoid silent null-body bugs.
Always set Content-Type: application/json on responses.
Input Validation and Error Handling
Validation in pure PHP is manual: check each expected field, return specific error messages. Use filter_var for sanitization and custom checks for required fields. Wrap your handler in try-catch to catch PHP errors and return a structured error response. Never expose internal errors to the client in production.
Beyond field validation, always validate the structure — e.g., ensure the request body is an object, not an array at the top level. A client sending [] instead of {} can cause unexpected errors downstream. Return 400 with a clear message.
For production without a framework, API key authentication is straightforward: expect a header like Authorization: Bearer <key> or X-API-Key. Compare against a stored value (env variable, database, or file). For read-only endpoints, consider a simpler key. Always use HTTPS to prevent key exposure. Never log the key.
Also consider rate limiting by API key. Without it, a compromised key or abusive client can overwhelm your server. A simple in-memory counter per key works for single-server deployments.
Always use hash_equals() for key comparison — it prevents timing attacks. Never use == or === which can leak the key length and content via timing differences.
Production Insight
We once saw a team store the API key in the same git repo as the code.
Cue a security audit finding the key in GitHub history — had to rotate all keys.
Rule: store keys in environment variables or a .env file outside version control.
Key Takeaway
Authenticate with a simple header check — no JWT needed for internal APIs.
Use hash_equals() to prevent timing attacks.
Keep keys out of code — use environment variables.
Authentication Approach Decision
IfInternal API, trusted clients only
→
UseStatic API key in env variable — simple and sufficient.
IfPublic API with many clients
→
UseUse JWT or OAuth2 — enables scoped access and revocation.
Database Integration with PDO
Pure PHP connects to databases via PDO — a consistent interface for MySQL, PostgreSQL, and others. Create a PDO instance once and pass it to handlers. Use prepared statements to prevent SQL injection. Group database operations inside transaction blocks for atomicity.
For read-heavy endpoints, consider caching query results with APCu or Redis. PDO doesn't cache by default, so repeated identical queries hit the database every time.
Use a singleton or dependency container to ensure only one PDO connection per request. Creating a new connection on every handler call will exhaust MySQL connections quickly.
Production Insight
A common failure: PDO exceptions not caught in API handlers.
If a database query fails, the API returns a 500 with a default error message, but sometimes PDO exposes the query in the error log (if display_errors is on).
Rule: always catch PDOException, log it, and return a generic 500 response.
Key Takeaway
PDO is your only ORM-less friend — use prepared statements.
Create connection once, reuse across handlers.
Catch database exceptions to avoid leaking internal error details.
Database Connection Management
IfSingle server, low concurrency
→
UseCreate a new PDO connection per request — simple and safe.
IfHigh concurrency or multiple servers
→
UseUse a persistent connection or connection pool via PDO::ATTR_PERSISTENT with caution — or move to a dedicated pool library.
Middleware: CORS, Request Logging, and Input Sanitization
In pure PHP, middleware is just code that runs before your route handler. Common middleware tasks include setting CORS headers, logging requests, and sanitizing input. You'll often need to handle a middleware chain where each step can exit early or modify the request data.
The biggest pitfall: PHP's php://input stream can only be consumed once. If your logging middleware reads the raw body, your route handler will get an empty string unless you pass the parsed data forward. Use a static class variable or a dependency container to share the decoded request body across the request lifecycle.
If you read php://input in middleware for logging, the route handler will get an empty body. Always cache the parsed body in a static property like above.
Production Insight
CORS headers must be set before any output. If you echo something by accident or include a file that outputs whitespace, the headers fail silently.
Rule: place header() calls at the very top of your entry point, before any includes or logic.
Also, logging the raw request body can leak PII — always mask sensitive fields before writing to logs.
Key Takeaway
Middleware in pure PHP is a series of function calls before the route.
Handle CORS early, parse body once, and never log raw credentials.
A middleware chain makes your API testable and maintainable.
Implementing Rate Limiting and Basic Security
Without a framework, you need to implement rate limiting yourself to prevent abuse. A simple approach: track request counts per API key in a file or shared memory (APCu). Use a sliding window or token bucket algorithm. For single-server deployments, a file-based counter works. For distributed systems, move to Redis.
Beyond rate limiting, enforce basic security: always sanitize output with htmlspecialchars if you ever return HTML, use prepared statements for every database query, and validate that incoming data matches expected types. Never trust client input. Use environment variables for secrets — never hardcode them.
File-based rate limiting won't work across multiple servers. For scalable production, use Redis with INCR and EXPIRE. This example is for single-server or dev environments only.
Production Insight
A missing rate limit can lead to accidental DDoS from a rogue client or misconfigured webhook.
We once saw a client retry a POST request 10,000 times in 5 seconds, bringing down the database.
Rule: always have a per-IP and per-key rate limit, even if low, in production.
Key Takeaway
Rate limiting is not optional in production — even a simple file-based limiter buys you time.
Always sanitize output (htmlspecialchars) and use prepared statements.
Security is a process, not a single feature.
Testing Your Pure PHP REST API
You can't ship an API you haven't tested. For pure PHP, set up a test suite using PHPUnit that starts PHP's built-in server, runs requests against it, and asserts responses. This catches regression before they hit production. Key: start the server with php -S localhost:8000 -t public/ and wait for the socket to open.
For unit tests, mock the request globals using a test double. But integration tests that hit real HTTP endpoints are more reliable — they verify routing, headers, and body parsing end-to-end. Use curl or Guzzle in your test cases.
Use process isolation if you mock superglobals. But integration tests with a real server are more reliable — they catch header and router bugs that unit tests miss.
Production Insight
Integration tests that use the built-in server can fail due to port conflicts if another test process is running.
Use a random port or a lock file to avoid collisions. We lost 2 hours debugging a test that failed only on CI because port 8000 was occupied.
Rule: allocate ports dynamically or use a dedicated CI environment.
Key Takeaway
Test your API with real HTTP requests, not just mocked superglobals.
Start a PHP built-in server in test setup and hit it with curl or Guzzle.
Catch the 415 and 400 errors before your mobile client does.
Testing Approach Decision
IfYou need fast feedback during development
→
UseWrite unit tests with mocked $_SERVER and php://input — fast but may miss integration issues.
IfYou want to catch real HTTP bugs before production
→
UseWrite integration tests that start the built-in server — slower but catches routing, headers, and body parsing.
Deploying Your Pure PHP REST API
Development server (php -S) is fine for testing, but production requires PHP-FPM and a web server like Nginx. The built-in server is single-threaded and not designed for concurrent requests.
Configure Nginx to proxy requests to PHP-FPM. Use environment variables for database credentials and API keys. Enable OPCache for performance and set error logging to files.
A common pitfall: forgetting to set document root correctly, leading to 404 errors for all routes. Nginx must point to your public directory and route everything to index.php.
Use a Dockerfile with php:8.3-fpm and nginx:alpine for consistent deployments. Combine with a docker-compose.yml for local development.
Production Insight
The most common deployment failure is forgetting to disable display_errors in php.ini.
Stack traces from uncaught exceptions leak database credentials and file paths.
Rule: always set display_errors = 0 and log_errors = 1 in production.
Key Takeaway
Never use the built-in PHP server in production.
Deploy with PHP-FPM + Nginx for concurrency and isolation.
Always disable display_errors and manually handle exceptions.
Deployment Strategy Decision
IfLow traffic internal API (<100 req/s)
→
UseUse PHP built-in server behind a reverse proxy (nginx) for simplicity.
IfHigh traffic public API (>100 req/s)
→
UseUse PHP-FPM with process tuning (pm.max_children, pm.start_servers) and OPCache.
● Production incidentPOST-MORTEMseverity: high
The Missing Content-Type Header That Killed a Mobile App
Symptom
Mobile app sends POST request with JSON body. Server returns HTTP 200 OK but response body is empty.
Assumption
The developer assumed PHP automatically recognizes JSON payloads and populates $_POST.
Root cause
PHP only populates $_POST automatically for application/x-www-form-urlencoded or multipart/form-data requests. For JSON, the raw body must be read via php://input. The response was empty because the server processed no data and returned nothing.
Fix
Read the request body with file_get_contents('php://input') and decode with json_decode(). Set response Content-Type header to application/json and always include a meaningful response body.
Key lesson
Never assume request body parsing — always check Content-Type header and read raw input for JSON.
Always return a JSON body even on success, with at least a status field.
Add early validation: if expected Content-Type is mismatched, reject with 415 Unsupported Media Type.
Log incoming request headers during development to catch these mismatches immediately.
Production debug guideSymptom → Action guide for common failures4 entries
Symptom · 01
API returns 404 for valid routes
→
Fix
Check router logic: are you parsing REQUEST_URI with full path or only the script name? Use parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) to strip query strings. Log the parsed route to confirm.
Symptom · 02
POST/PUT request returns 200 but data not saved
→
Fix
Check if you're reading the body. Use file_get_contents('php://input') and verify Content-Type header matches your parser (application/json). If using $_POST, switch to raw input.
Symptom · 03
API returns empty JSON object on error
→
Fix
You're likely not sending an error response. After catch block, always http_response_code(500) and echo json_encode(['error' => 'Internal Server Error']). Enable PHP error reporting during dev: error_reporting(E_ALL); ini_set('display_errors', 1).
Symptom · 04
CORS error in browser when calling API from frontend
→
Fix
Add CORS headers in a bootstrap file: header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type, Authorization'); Handle OPTIONS preflight requests early.
★ Pure PHP REST API Debugging Cheat SheetQuick commands and fixes for the most common production issues.
Empty response body−
Immediate action
Check response headers via curl -v
Commands
curl -v -X POST http://your-api/resource -H 'Content-Type: application/json' -d '{"key":"value"}'
tail -f /var/log/php_errors.log
Fix now
Ensure you echo json_encode($response) after headers. Add header('Content-Type: application/json'); before output.
Wrap your route handler in try-catch, log exception with error_log($e->getMessage()), and return 500 with generic message.
PUT/DELETE not recognized+
Immediate action
Verify the method is sent correctly
Commands
echo $_SERVER['REQUEST_METHOD']; die;
Check client library — some only support GET/POST by default
Fix now
Add a simple method override: if (isset($_SERVER['HTTP_X-HTTP-Method-Override'])) { $_SERVER['REQUEST_METHOD'] = $_SERVER['HTTP_X-HTTP-Method-Override']; }
Pure PHP vs Framework Approaches
Aspect
Pure PHP
Laravel / Symfony
Routing
Manual regex or if/else
Declarative routes.php / annotations
Request Parsing
Read php://input, validate manually
Request object with built-in JSON parsing
Validation
Custom validation classes
FormRequest / Validator with rules
Authentication
Manual header check with hash_equals
Guards, Sanctum, Passport
Database
Raw PDO with prepared statements
Eloquent ORM with relationships
Error Handling
Custom try-catch and response building
Exception handler with debug page
Learning Curve
Steeper at start, deeper understanding
Shorter ramp-up, but magic hides details
Common mistakes to avoid
7 patterns
×
Using $_POST for JSON requests
Symptom
API returns 200 OK but data fields are empty or missing.
Fix
Read raw body with file_get_contents('php://input'), decode with json_decode(), and parse parameters from the resulting array instead of relying on $_POST.
×
Not setting Content-Type header on responses
Symptom
Mobile apps or TypeScript fetch() receive an empty response or fail to parse body.
Fix
Always set header('Content-Type: application/json'); before echoing output. Place this in a central bootstrap file or response helper.