A bulk save works on staging but silently drops rows on production. Diagnose the cause.
A bulk-edit screen posts 900 rows as one HTML form. On staging all 900 are saved. On production only the first 500 are — nothing is thrown, the application log is clean, and the endpoint still answers 204.
Constraints: same release tag, same database, same code on both boxes. The two servers were provisioned by different people. display_errors is off in production.
// POST body: items[0][id]=..&items[0][qty]=..&items[1][id]=.. ... 900 rows
public function bulkUpdate(Request $request): Response
{
$items = $request->input('items', []);
foreach ($items as $item) {
$this->repo->setQty((int) $item['id'], (int) $item['qty']);
}
return response()->noContent();
}
Diagnose the cause.
PHP stops parsing the body once it has built max_input_vars variables — 1000 by default. Each items[i][id] and items[i][qty] is one variable, so 900 rows are 1800 and everything past the first 500 rows is dropped before the controller runs; only a warning reaches the FPM error log. Staging had the limit raised by hand. Post JSON, which the limit does not touch, and ship php.ini with the release.
- ✗Assuming a truncated
$_POSTmust throw — PHP drops the excess input silently - ✗Counting form rows rather than input variables, so 900 rows look safely below a limit of 1000
- ✗Leaving
php.inias hand-made server state, so staging and production drift apart unnoticed
- →Why does a JSON body read from
php://inputescapemax_input_varsentirely? - →What happens to
$_POSTwhen the body exceedspost_max_size, and how is that different from this?
Count input variables, not rows.
Each row of the form sends items[i][id] and items[i][qty] — that is two variables. 900 rows = 1800 variables. max_input_vars defaults to 1000: once the parser has built a thousand, it stops parsing the body and silently discards the rest. A thousand variables is exactly 500 complete rows — precisely what you are seeing.
No exception, no error: PHP emits only a warning, and only into the engine's error log.
grep 'Input variables exceeded' /var/log/php8.3-fpm.log
# PHP Warning: Unknown: Input variables exceeded 1000. To increase the limit
# change max_input_vars in php.ini.
php -i | grep max_input_vars # prod -> 1000 (the default)
php -i | grep max_input_vars # staging -> 5000 (someone raised it by hand)
Raising the limit in production only patches the symptom — the next 3000-row batch dies the same way. The real fix has two halves.
// 1. Send the body as JSON: max_input_vars applies to urlencoded and multipart parsing,
// while json_decode() reads php://input and never goes through that limit at all.
$items = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR)['items'];
// 2. Stop trusting the batch size — validate it and page it explicitly.
And organisationally: php.ini must ship with the release (an image, config management), not be typed in by hand on each box. While it stays server state, staging and production will drift, and the drift will keep surfacing as exactly this kind of silent data loss.