151 lines
6.4 KiB
Markdown
151 lines
6.4 KiB
Markdown
---
|
|
title: "PHP — display_errors Leaking Warnings into JSON API Responses"
|
|
aliases: [php-display-errors, php-json-api-errors, php-unexpected-token, php-ini-set-override]
|
|
tags: [php, debugging, api, json, backend, production]
|
|
sources:
|
|
- "daily/2026-04-27.md"
|
|
created: 2026-04-27
|
|
updated: 2026-04-27
|
|
---
|
|
|
|
# PHP — display_errors Leaking Warnings into JSON API Responses
|
|
|
|
When PHP's `display_errors` is enabled, any warning, notice, or error emitted before the JSON response body is prepended to the output buffer. The API client receives something like `<br/><b>Warning</b>: ...{"result": "..."}` — valid JSON is now invalid because it's prefixed with HTML. JavaScript's `JSON.parse()` throws "Unexpected token '<'", which is the reliable diagnostic signal.
|
|
|
|
## Key Points
|
|
|
|
- **"Unexpected token '<'" in JSON.parse** = PHP warning or error is being emitted before the JSON body — not a data problem, a `display_errors` problem
|
|
- **`ini_set('display_errors', 0)` in `api.php` does NOT override `display_errors = 1` in an included config file loaded after it** — PHP processes `ini_set` sequentially; a config file with `display_errors = 1` loaded by `require_once('config.php')` after the ini_set undoes the suppression
|
|
- **`config.example.php` committed with `display_errors = 1`** is a common footgun — developers copy it to `config.php` on the server without changing the value
|
|
- **The fix**: set `display_errors = 0` directly in `php.ini` or `.htaccess`, or ensure the config file is loaded BEFORE the ini_set call, or use an output buffer (`ob_start`) to catch stray output before sending JSON
|
|
- Probabilistic/intermittent bugs (10% of requests) that delete required directories are especially dangerous — they interact with display_errors to produce sporadic failures
|
|
|
|
## Details
|
|
|
|
### How the Leak Happens
|
|
|
|
```php
|
|
// api.php — attempts to suppress errors
|
|
ini_set('display_errors', 0);
|
|
|
|
// Later in the request lifecycle:
|
|
require_once('config.php'); // sets display_errors = 1 — overrides the ini_set above
|
|
|
|
// Later still, something triggers a warning:
|
|
file_put_contents('/path/that/was/deleted', $data);
|
|
// PHP emits: "<br/><b>Warning</b>: file_put_contents(...)..."
|
|
|
|
// api.php then sends JSON:
|
|
header('Content-Type: application/json');
|
|
echo json_encode($result);
|
|
// Output: "<br/><b>Warning</b>: file_put_contents(...)... {\"key\":\"value\"}"
|
|
```
|
|
|
|
The client receives this mixed output, `JSON.parse()` sees `<` as the first character, and throws:
|
|
|
|
```
|
|
SyntaxError: Unexpected token '<', "<br><b>War"... is not valid JSON
|
|
```
|
|
|
|
### Diagnosing the Root Cause
|
|
|
|
When a PHP API returns "Unexpected token '<'":
|
|
|
|
1. **Check `display_errors` in `php.ini`:** `php -r "echo ini_get('display_errors');"` — should be `0` in production
|
|
2. **Check included config files:** `grep -r "display_errors" /path/to/project/` — find every location it's set
|
|
3. **Check load order:** if `config.php` is loaded after `ini_set('display_errors', 0)`, the config wins
|
|
4. **Test with curl to see raw output:** `curl -i https://yourapi.com/endpoint` — the HTML warning appears before the JSON
|
|
|
|
### The Fix: Three Options
|
|
|
|
**Option 1: Fix in `php.ini` or `.htaccess` (recommended)**
|
|
```ini
|
|
; php.ini
|
|
display_errors = Off
|
|
log_errors = On
|
|
error_log = /var/log/php/errors.log
|
|
```
|
|
```apache
|
|
; .htaccess
|
|
php_flag display_errors Off
|
|
```
|
|
This applies regardless of what application code does.
|
|
|
|
**Option 2: Set in config.php before any other code**
|
|
```php
|
|
// config.php — must be the FIRST setting, before any require_once
|
|
ini_set('display_errors', 0);
|
|
error_reporting(E_ALL);
|
|
ini_set('log_errors', 1);
|
|
ini_set('error_log', '/var/log/php/errors.log');
|
|
```
|
|
|
|
**Option 3: Output buffer (belt-and-suspenders)**
|
|
```php
|
|
// api.php
|
|
ob_start(); // catch any stray output
|
|
require_once('config.php');
|
|
// ... application code ...
|
|
$output = ob_get_clean(); // clear the buffer
|
|
if (strpos($output, '<') !== false) {
|
|
error_log("Stray HTML in API output: " . substr($output, 0, 200));
|
|
}
|
|
echo json_encode($result); // only the JSON goes to the client
|
|
```
|
|
|
|
### The Probabilistic Cleanup Bug (2026-04-27 Incident)
|
|
|
|
The actual bug that triggered the display_errors issue:
|
|
|
|
```php
|
|
// ❌ DANGEROUS — auto-cleanup deletes the images/ directory itself, not just old files
|
|
function autoCleanupExpiredImages() {
|
|
if (rand(1, 10) === 1) { // 10% chance per request
|
|
$files = glob('images/*');
|
|
if (count($files) > 50) {
|
|
array_map('unlink', $files);
|
|
rmdir('images/'); // deletes the directory!
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
The cleanup ran in the same request as `saveImage()`, deleted the `images/` directory, then `saveImage()` tried to write there and emitted a PHP warning. With `display_errors = 1`, that warning prepended the JSON response.
|
|
|
|
The defensive fix in `saveImage()`:
|
|
```php
|
|
function saveImage($filename, $data) {
|
|
if (!is_dir('images/')) {
|
|
mkdir('images/', 0755, true); // recreate if missing
|
|
}
|
|
file_put_contents('images/' . $filename, $data);
|
|
}
|
|
```
|
|
|
|
The deeper fix: cleanup functions should delete file contents, not the parent directory.
|
|
|
|
### config.example.php as a Footgun
|
|
|
|
Any `config.example.php` committed to version control with `display_errors = 1` becomes the template for production deployments. Developers copy it to `config.php` and never change the value. This is how `display_errors = 1` ends up in production.
|
|
|
|
Correct `config.example.php`:
|
|
```php
|
|
// config.example.php — safe defaults for production
|
|
define('DISPLAY_ERRORS', false);
|
|
ini_set('display_errors', 0);
|
|
ini_set('log_errors', 1);
|
|
ini_set('error_log', __DIR__ . '/logs/errors.log');
|
|
|
|
// API Keys — replace with actual values
|
|
define('GEMINI_API_KEY', 'YOUR_KEY_HERE');
|
|
```
|
|
|
|
## Related Concepts
|
|
|
|
- [[wiki/concepts/shell-static-deploy-patterns]] — deploy script patterns; similar "safe defaults in committed config" principle applies
|
|
- [[wiki/concepts/monorepo-deploy-script-pitfall]] — another class of "silent failure from config oversight"
|
|
- [[wiki/tech-patterns/nodejs-vanilla-proxy]] — Node.js API alternative where this class of PHP error doesn't apply
|
|
|
|
## Sources
|
|
|
|
- [[daily/2026-04-27.md]] — Lux Studio (AI Cinematography): `JSON.parse` "Unexpected token '<'" error; root cause was two bugs interacting: `autoCleanupExpiredImages()` deleting `images/` directory (10% chance) + `config.php` having `display_errors = 1` from `config.example.php` template; fixes: `saveImage()` recreates directory + `display_errors = 0` in config
|