PHP REST API — Missing Content-Type: Empty Response
Mobile app receives empty response from pure PHP REST API on JSON POST.
20+ years shipping production PHP systems at scale. Everything here is grounded in real deployments.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- Pure PHP REST APIs parse REQUEST_URI and REQUEST_METHOD directly from superglobals
- No framework means full control over routing, validation, and response format
- Production insight: missing Content-Type header causes silent empty-response failures
- Common mistake: using $_POST for JSON payloads — $_POST only works with form-encoded data
- Key components: router, request parser, response builder, and error handler
- Performance insight: regex dispatch adds ~1–2µs per route — 50 routes add <0.1ms overhead
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.
PHP REST API Without Content-Type: The Silent 200
A REST API in pure PHP is an HTTP endpoint that accepts a request, parses its intent (method, URI, headers), and returns a structured response — typically JSON — without any framework abstraction. The core mechanic is manual routing: you read $_SERVER['REQUEST_METHOD'] and $_SERVER['REQUEST_URI'], then dispatch to a handler that calls json_encode() and echos the result. No middleware, no autoloaded controllers — just raw PHP and header() calls.
In practice, the critical property is that the client and server must agree on the content type. If your API returns JSON but omits header('Content-Type: application/json'), the browser or HTTP client treats the response as text/html — and if the JSON is valid, it still renders as an empty page or triggers a silent parse failure. The same applies to incoming data: without checking $_SERVER['CONTENT_TYPE'], you might attempt json_decode(file_get_contents('php://input')) on a form-encoded body and get null with no error.
Use pure PHP for micro-endpoints, health checks, or when you control both client and server and need zero overhead. It matters in real systems because a missing Content-Type header is the #1 cause of “works in Postman, fails in production” — the API returns HTTP 200 with an empty body, and the client silently drops the response.
json_encode() and echo.json_last_error() after decoding request bodies — null from json_decode is not the same as valid JSON.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.
Another subtlety: query strings. REQUEST_URI includes them, so you must strip them before matching. parse_url() with PHP_URL_PATH is your friend. We've seen a production outage where a client appended ?debug=true to every request and every endpoint returned 404.
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.
Also: remember that DELETE requests sometimes include a body for soft-delete metadata. Many developers forget to parse it and lose audit information.
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.
Also consider: what if the JSON is valid but contains unexpected fields? Silent ignoring can hide typos. Consider strict mode where extra fields cause a 400 with a list of unexpected keys.
- The Required field check is like a 'must be 21 or older' sign.
- Email validation is the ID scanner — only valid formats pass.
- Error messages are the 'you can't enter' note with the reason.
- Returning 400 with field-specific errors lets the client fix exactly one thing at a time.
API Key Authentication – A Minimal Approach
For production without a framework, API key authentication is straightforward: expect a header like Authorization: Bearer
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.
Another nuance: key rotation. In production, you'll need to expire old keys without downtime. Include an expiration timestamp in your key storage and allow a grace period.
hash_equals() for key comparison — it prevents timing attacks. Never use == or === which can leak the key length and content via timing differences.hash_equals() to prevent timing attacks.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.
Also: when using transactions, remember that PDO auto-commits by default. You must explicitly beginTransaction() and handle commit/rollback. A missing commit after a series of inserts can leave partial data.
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.
Also: timing attacks are real. Logging the exact timestamp of each request can reveal information about processing times. Avoid logging sensitive delay data.
header() calls at the very top of your entry point, before any includes or logic.sanitize() middleware before storing or outputting data.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.
Also: consider using Content Security Policy headers to prevent XSS if your API serves any HTML. Even though it's a JSON API, future endpoints might serve user-generated content in responses.
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.
Also: test for edge cases like missing headers, empty body, malformed JSON, and large payloads. These are often the silent killers in production.
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.
Also: ensure your PHP-FPM pool settings are tuned for your traffic. The default pm.max_children is often too low for moderate traffic.
Remote Debugging Without Xdebug: Live Log Injection
You deployed to production and the API returns a 500. No stack trace. No access to error logs. That's when you learn to inject structured debug data into your response headers without breaking the JSON contract. Set a custom header like X-Debug-Log only when an internal flag is active. Use error_get_last() and serialize it into the header value. This works because HTTP headers can carry small payloads without corrupting the body. Never ship this to customers — it's a kill switch for your eyes only. The WHY: You cannot attach a debugger to a live production server, but you can tag every response with breadcrumbs. The HOW: Check a $_ENV['DEBUG_MODE'] flag at the entry point, capture errors in a shutdown handler, and append them as a comma-delimited string to the header. This saved my team's Saturday three times last year.
Auto-Generate API Docs From Your Router Table
You have ten endpoints. No documentation. The frontend team sends wrong payloads. You fix it every sprint. Stop. Pull the route metadata directly from your PHP router at runtime. Define each route as an array of method, path, required fields, and a one-line description. When a request hits /docs.json, loop that array and return it as JSON. The WHY: Documentation rots the second you write it by hand. Generated docs always match reality because they read the same array that drives routing. The HOW: Store your routes in a static config file. Add a DOCS_ENABLED flag in your .env. Expose a GET /docs endpoint that returns the table. No external tool. No parsing annotations. This approach also lets you build a Postman collection generator later with zero refactoring.
Modern PHP REST with Attributes (PHP 8+)
PHP 8.0 introduced attributes, a native way to add metadata to classes, methods, and properties. For REST APIs, attributes can replace docblock annotations for routing, middleware, and validation. This approach keeps configuration close to the code and is type-safe.
Consider a simple controller with attribute-based routing:
```php #[Route('/api/users', methods: ['GET'])] function getUsers(): void { header('Content-Type: application/json'); echo json_encode(['users' => ['Alice', 'Bob']]); }
#[Route('/api/users', methods: ['POST'])] function createUser(): void { $input = json_decode(file_get_contents('php://input'), true); // validation logic header('Content-Type: application/json'); echo json_encode(['created' => true]); } ```
To make this work, define a custom Route attribute:
``php #[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD)] class Route { public function __construct( public string $path, public array $methods = ['GET'] ) {} } ``
Then, in your entry point (e.g., index.php), use reflection to scan for functions or methods with the #[Route] attribute, match the request URI and method, and call the corresponding handler.
``php $routes = []; $reflectionFunctions = ``get_defined_functions()['user']; foreach ($reflectionFunctions as $funcName) { $refFunc = new ReflectionFunction($funcName); $attributes = $refFunc->getAttributes(Route::class); foreach ($attributes as $attribute) { $route = $attribute->newInstance(); $routes[] = ['path' => $route->path, 'methods' => $route->methods, 'handler' => $funcName]; } } // then match and dispatch
Attributes reduce boilerplate and make the API definition more readable. They also integrate well with modern PHP features like typed properties and constructor promotion.
PSR-7 HTTP Messages with slim/psr7
PSR-7 defines standard interfaces for HTTP messages (requests and responses). Using a PSR-7 implementation like slim/psr7 decouples your API from superglobals ($_SERVER, $_GET, etc.) and makes your code more testable and framework-agnostic.
Install via Composer: ``bash composer require slim/psr7 ``
Then, in your entry point, create a request object from the superglobals:
```php use Slim\Psr7\Factory\ServerRequestFactory; use Slim\Psr7\Response;
$request = ServerRequestFactory::createFromGlobals(); $response = new Response(); ```
Now you can work with the request in a standardized way:
``php $method = $request->getMethod(); $uri = $request->getUri(); $path = $uri->getPath(); $queryParams = $request->getQueryParams(); $body = $request->getBody()->getContents(); $parsedBody = json_decode($body, true); ``
To send a response, populate the PSR-7 response object and emit it:
```php $response->getBody()->write(json_encode(['message' => 'Hello'])); $response = $response->withHeader('Content-Type', 'application/json');
// Emit response http_response_code($response->getStatusCode()); foreach ($response->getHeaders() as $name => $values) { foreach ($values as $value) { header(sprintf('%s: %s', $name, $value), false); } } echo $response->getBody(); ```
Using PSR-7 makes it easy to swap out implementations (e.g., nyholm/psr7 or guzzlehttp/psr7) and integrates with middleware libraries. It also simplifies unit testing because you can create mock requests without touching globals.
laminas/laminas-httphandlerrunner to properly emit responses, handling output buffering and headers correctly.Routing with FastRoute or nikic/fast-route
FastRoute is a fast, lightweight routing library by Nikita Popov. It compiles routes into a regex-based matcher for high performance. Combined with PSR-7, it provides a clean way to dispatch requests.
Install via Composer: ``bash composer require nikic/fast-route ``
Define routes using a dispatcher:
``php $dispatcher = FastRoute\simpleDispatcher(function(FastRoute\RouteCollector $r) { $r->addRoute('GET', '/api/users', 'getUsers'); $r->addRoute('POST', '/api/users', 'createUser'); $r->addRoute('GET', '/api/users/{id:\d+}', 'getUser'); }); ``
Then, in your entry point, parse the request and dispatch:
```php $httpMethod = $_SERVER['REQUEST_METHOD']; $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$routeInfo = $dispatcher->dispatch($httpMethod, $uri); switch ($routeInfo[0]) { case FastRoute\Dispatcher::NOT_FOUND: // 404 break; case FastRoute\Dispatcher::METHOD_NOT_ALLOWED: $allowedMethods = $routeInfo[1]; // 405 break; case FastRoute\Dispatcher::FOUND: $handler = $routeInfo[1]; $vars = $routeInfo[2]; call_user_func($handler, $vars); break; } ```
You can also use closures or class methods as handlers. FastRoute supports route groups, placeholders with regex constraints, and caching for production (cache the compiled route data to a file).
Example with PSR-7:
``php $request = ServerRequestFactory::createFromGlobals(); $routeInfo = $dispatcher->dispatch($request->getMethod(), $request->getUri()->getPath()); ``
FastRoute is ideal for custom frameworks or when you want minimal overhead. It's used by Laravel and other major frameworks under the hood.
FastRoute\cachedDispatcher with a file cache to eliminate route compilation overhead. Also, combine with PSR-7 for a robust request handling pipeline.The Missing Content-Type Header That Killed a Mobile App
json_decode(). Set response Content-Type header to application/json and always include a meaningful response body.- 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.
curl -v -X POST http://your-api/resource -H 'Content-Type: application/json' -d '{"key":"value"}'tail -f /var/log/php_errors.log| File | Command / Code | Purpose |
|---|---|---|
| io | namespace io\thecodeforge; | Routing Requests Without a Framework |
| io | namespace io\thecodeforge; | Handling HTTP Methods and JSON Data |
| io | namespace io\thecodeforge; | Input Validation and Error Handling |
| io | namespace io\thecodeforge; | API Key Authentication – A Minimal Approach |
| io | namespace io\thecodeforge; | Database Integration with PDO |
| io | namespace io\thecodeforge; | Middleware |
| io | namespace io\thecodeforge; | Implementing Rate Limiting and Basic Security |
| tests | namespace io\thecodeforge\tests; | Testing Your Pure PHP REST API |
| nginx.conf | server { | Deploying Your Pure PHP REST API |
| index.php | $isDebug = ($_ENV['DEBUG_MODE'] ?? 'false') === 'true'; | Remote Debugging Without Xdebug |
| routes.php | return [ | Auto-Generate API Docs From Your Router Table |
| attributes_example.php | class Route { | Modern PHP REST with Attributes (PHP 8+) |
| psr7_example.php | require 'vendor/autoload.php'; | PSR-7 HTTP Messages with slim/psr7 |
| fastroute_example.php | require 'vendor/autoload.php'; | Routing with FastRoute or nikic/fast-route |
Key takeaways
on responses and validate $_SERVER['CONTENT_TYPE']` on incoming requests to prevent silent empty responses.file_get_contents('php://input') instead of relying on $_POST, which only works for form-encoded data.parse_url() and trimming trailing slashes to avoid 404 routing errors.exit to stop further processing.Interview Questions on This Topic
What is the difference between $_POST and php://input?
Frequently Asked Questions
20+ years shipping production PHP systems at scale. Everything here is grounded in real deployments.
That's Advanced PHP. Mark it forged?
9 min read · try the examples if you haven't