---
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
