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
This article addresses a specific, maddening PHP bug: your REST API returns a 200 OK with an empty body, and the culprit is a missing Content-Type: application/json header. You'll learn why PHP's $_POST stays empty when JSON arrives without the correct header, and how to catch that with php://input instead.
The piece walks through building a minimal, framework-free REST API from scratch—routing requests manually, parsing JSON bodies, validating inputs, and returning proper HTTP status codes. It covers a lightweight API key authentication scheme using headers and database integration via PDO with prepared statements.
This is for developers who want to understand the raw mechanics of HTTP request handling in PHP without the abstraction of Laravel or Symfony. Don't use this approach for complex APIs with dozens of endpoints—you'll want a router library or micro-framework like Slim or Flight.
But for a single-purpose API, a learning exercise, or a situation where you can't install Composer dependencies, this gives you full control and zero 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.
<?php namespace io\thecodeforge; class Router { private array $routes = []; public function addRoute(string $method, string $pattern, callable $handler): void { $this->routes[] = [ 'method' => strtoupper($method), 'pattern' => '#^' . $pattern . '$#', 'handler' => $handler ]; } public function dispatch(): void { $method = $_SERVER['REQUEST_METHOD']; $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); // Normalize trailing slash $uri = rtrim($uri, '/') ?: '/'; foreach ($this->routes as $route) { if ($route['method'] !== $method) { continue; } if (preg_match($route['pattern'], $uri, $matches)) { array_shift($matches); call_user_func_array($route['handler'], $matches); return; } } http_response_code(404); echo json_encode(['error' => 'Not Found']); } }
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.
<?php namespace io\thecodeforge; class RequestHandler { public static function parseBody(): array { $contentType = $_SERVER['CONTENT_TYPE'] ?? ''; if (stripos($contentType, 'application/json') === false) { http_response_code(415); echo json_encode(['error' => 'Unsupported Media Type']); exit; } $raw = file_get_contents('php://input'); $data = json_decode($raw, true); if (json_last_error() !== JSON_ERROR_NONE) { http_response_code(400); echo json_encode(['error' => 'Invalid JSON']); exit; } return $data ?? []; } public static function jsonResponse(mixed $data, int $status = 200): void { http_response_code($status); header('Content-Type: application/json'); echo json_encode($data); exit; } }
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.
<?php namespace io\thecodeforge; class Validator { private array $errors = []; public function required(array $data, string $field): self { if (!isset($data[$field]) || trim($data[$field]) === '') { $this->errors[] = "Field '$field' is required"; } return $this; } public function email(array $data, string $field): self { if (isset($data[$field]) && !filter_var($data[$field], FILTER_VALIDATE_EMAIL)) { $this->errors[] = "Field '$field' must be a valid email"; } return $this; } public function hasErrors(): bool { return !empty($this->errors); } public function getErrors(): array { return $this->errors; } }
- 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 <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.
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.
<?php namespace io\thecodeforge; class AuthMiddleware { public static function authenticate(string $apiKey): void { $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; // Support both Bearer and X-API-Key if (preg_match('/^Bearer\s+(.+)$/i', $authHeader, $matches)) { $providedKey = $matches[1]; } else { $providedKey = $_SERVER['HTTP_X_API_KEY'] ?? ''; } if (!hash_equals($apiKey, $providedKey)) { http_response_code(401); echo json_encode(['error' => 'Unauthorized']); exit; } } }
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.
<?php namespace io\thecodeforge; use PDO; class Database { public static function connect(): PDO { $dsn = "mysql:host={$_ENV['DB_HOST']};dbname={$_ENV['DB_NAME']};charset=utf8mb4"; $pdo = new PDO($dsn, $_ENV['DB_USER'], $_ENV['DB_PASS'], [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC ]); return $pdo; } }
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.
<?php namespace io\thecodeforge; class Middleware { private static ?array $parsedBody = null; public static function cors(): void { 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; } } public static function logRequest(): void { $uri = $_SERVER['REQUEST_URI']; $method = $_SERVER['REQUEST_METHOD']; $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; $log = "[$method] $uri from $ip at " . date('c') . PHP_EOL; error_log($log, 3, __DIR__ . '/api.log'); } public static function parseBodyOnce(): array { if (self::$parsedBody !== null) { return self::$parsedBody; } $raw = file_get_contents('php://input'); self::$parsedBody = json_decode($raw, true) ?? []; return self::$parsedBody; } public static function sanitize(array $data): array { array_walk_recursive($data, function (&$item) { if (is_string($item)) { $item = strip_tags($item); } }); return $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.
<?php namespace io\thecodeforge; class RateLimiter { private string $key; private int $maxRequests; private int $windowSeconds; public function __construct(string $clientIdentifier, int $maxRequests = 100, int $windowSeconds = 60) { $this->key = 'ratelimit:' . $clientIdentifier; $this->maxRequests = $maxRequests; $this->windowSeconds = $windowSeconds; } public function allow(): bool { $file = sys_get_temp_dir() . '/' . md5($this->key) . '.txt'; $data = @file_get_contents($file); $records = $data ? explode(',', $data) : []; $now = time(); // Remove expired timestamps $records = array_filter($records, fn($t) => $now - (int)$t < $this->windowSeconds); if (count($records) >= $this->maxRequests) { return false; } $records[] = $now; file_put_contents($file, implode(',', $records)); return true; } }
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.
<?php namespace io\thecodeforge\tests; use PHPUnit\Framework\TestCase; class ApiTest extends TestCase { private static $serverProcess; private static string $baseUrl = 'http://localhost:8000'; public static function setUpBeforeClass(): void { $docRoot = __DIR__ . '/../public'; self::$serverProcess = proc_open( ['php', '-S', 'localhost:8000', '-t', $docRoot], [ 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w'] ], $pipes ); sleep(1); // Give server time to start } public static function tearDownAfterClass(): void { if (self::$serverProcess) { proc_terminate(self::$serverProcess); } } public function testGetUsersReturnsJson(): void { $ch = curl_init(self::$baseUrl . '/users'); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $this->assertEquals(200, $httpCode); $this->assertJson($response); } public function testPostWithoutContentTypeReturns415(): void { $ch = curl_init(self::$baseUrl . '/users'); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST'); curl_setopt($ch, CURLOPT_POSTFIELDS, '{"name":"test"}'); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $this->assertEquals(415, $httpCode); } }
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.
server {
listen 80;
server_name api.yourdomain.com;
root /var/www/api/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
location ~ /\.ht {
deny all;
}
}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.
// io.thecodeforge $isDebug = ($_ENV['DEBUG_MODE'] ?? 'false') === 'true'; register_shutdown_function(function () use ($isDebug) { if (!$isDebug) return; $error = error_get_last(); if ($error !== null) { // Prevents body corruption header('X-Debug-Log: ' . urlencode(json_encode($error))); } }); // Example route that triggers a fatal $_SERVER['REQUEST_URI'] = '/articles/1'; echo json_encode(['status' => 'hello']);
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.
// io.thecodeforge return [ [ 'method' => 'GET', 'path' => '/users', 'fields' => ['limit', 'offset'], 'desc' => 'List users with pagination' ], [ 'method' => 'POST', 'path' => '/users', 'fields' => ['name', 'email', 'password'], 'desc' => 'Create a new user' ], ]; // In router.php if ($_SERVER['REQUEST_URI'] === '/docs.json' && $_ENV['DOCS_ENABLED'] === 'true') { header('Content-Type: application/json'); echo json_encode(require 'routes.php'); exit; }
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.logerror_reporting(E_ALL); ini_set('display_errors', 1);Check /var/log/apache2/error.log or fpm-php logsecho $_SERVER['REQUEST_METHOD']; die;Check client library — some only support GET/POST by default| 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 |
| 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 |
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.Common mistakes to avoid
7 patternsUsing $_POST for JSON requests
json_decode(), and parse parameters from the resulting array instead of relying on $_POST.Not setting Content-Type header on responses
fetch() receive an empty response or fail to parse body.Hardcoding database credentials in source code
getenv() or $_ENV in code. Add .env to .gitignore.Skipping input validation for all fields
Exposing detailed error messages in production
error_log(), and return a generic 'Internal Server Error' with status 500.Logging the full request body without sanitization
Not handling CORS preflight OPTIONS requests
Interview Questions on This Topic
What is the difference between $_POST and php://input?
How would you handle CORS preflight requests in a pure PHP API?
Describe a production incident where a missing Content-Type header caused an empty response. How would you prevent it?
How do you implement rate limiting without a framework? What are the trade-offs?
Explain how to read JSON request body in pure PHP and why $_POST doesn't work.
json_decode() to parse it into an array. Always check the Content-Type header to ensure you're receiving JSON. Also note that php://input can only be read once per request.Frequently Asked Questions
The mobile app likely omitted the Content-Type: application/json header. PHP's $_POST only populates for form-encoded data, so json_decode(file_get_contents('php://input')) returns null. The API then processes an empty payload and returns a valid JSON response that's effectively empty. Always validate $_SERVER['CONTENT_TYPE'] and read php://input directly.
Use file_get_contents('php://input') to read the raw request body, then decode it with . This works for POST, PUT, and DELETE methods. Note that json_decode()php://input can only be read once per request, so capture it in a variable if you need to log or process it in middleware.
Parse $_SERVER['REQUEST_METHOD'] and $_SERVER['REQUEST_URI'] manually. Normalize the URI by stripping query strings with parse_url($uri, PHP_URL_PATH) and trimming trailing slashes. Use a dispatch table — an associative array mapping route patterns to handler functions — instead of long if/else chains for better maintainability.
Set CORS headers early in your entry point: header('Access-Control-Allow-Origin: *') for development, or restrict to specific origins in production. Handle preflight OPTIONS requests by returning 204 with appropriate Access-Control-Allow-Methods and Access-Control-Allow-Headers headers. Always call exit after the OPTIONS response to prevent further processing.
20+ years shipping production PHP systems at scale. Everything here is grounded in real deployments.
That's Advanced PHP. Mark it forged?
6 min read · try the examples if you haven't