A property booking platform I maintain for a client has one form that matters a lot: the owner "edit listing" page. An owner opens it, changes the description, the nightly price, the house rules, then hits save. But the problem was never the save button. The problem was that the edit page itself returned HTTP 500 the moment it loaded. Not occasionally. Every time, for every listing.
The confusing part: the 500 arrived late. For a split second I could see the form almost fully rendered, name, price, and description fields all populated, before the screen flipped to a generic "critical error" page. So this was not a crash at the top of the request that leaves you with a blank page. It was a crash that detonated after most of the page had already rendered.
The symptom
My first instinct was wrong. Because this is an edit form, I assumed the save handler was broken, so I poked at the POST path first. But the 500 fired on the GET, the instant the page opened, before any data was sent back. Save was never even reached.
My second guess: one listing with corrupt data. I opened a different listing, a freshly created one, an old one. All the same. Every edit page 500'd. When every listing breaks identically, it is not one rotten row of data, it is code that runs on every render.
At this point I needed one thing: the actual error message. And this is where the client site fought back. The hosting was locked down: no SSH, no FTP, and the production error_log was out of reach. The default debug file under wp-content was blocked from HTTP access, so I could not just open the log in a browser. I was flying blind. WordPress served nothing but a generic "critical error" page with zero detail.
Capturing the fatal without SSH
Since I could not read the log the normal way, I made my own path. I uploaded a temporary mu-plugin whose only job was to redirect the error log to a file I could fetch over HTTP, under a random name so nobody else could guess it.
// wp-content/mu-plugins/00-capture.php (temporary, delete when done)
ini_set( 'log_errors', '1' );
ini_set( 'error_log', WP_CONTENT_DIR . '/uploads/dbg-8f3a91.log' );
error_reporting( E_ALL );An mu-plugin loads early and cannot be switched off from the dashboard, so it is a safe place to catch a fatal that happens before other plugins get involved. I reopened the failing edit page, then pulled the log file straight from the browser:
https://a-client-site.example/wp-content/uploads/dbg-8f3a91.logAnd finally the real message came out, clear and unambiguous:
PHP Fatal error: Uncaught TypeError: esc_textarea(): Argument #1 ($text)
must be of type string, array given, called in
.../template-owner-add-property.php on line ...The root cause: house_rules was an array
The clue was complete. The form template called esc_textarea( $property['house_rules'] ) to fill the house-rules textarea. My assumption, and the assumption of whoever wrote that line, was that house_rules is a plain string. It was not.
This listing data round-trips through a REST layer, and there the originally freeform house_rules text gets cast into an array. The value was not "No smoking", it was [''], an array of lines. So by the time the template read it back, $property['house_rules'] was an array, not a string.
In PHP 8, esc_textarea() type-hints its argument as string. Hand it an array and it does not merely warn, it throws a TypeError. An uncaught TypeError is a fatal error. And that explains the "late 500" symptom: the fatal did not stop the request at a tidy, visible spot. WordPress runs its fatal handler during the shutdown phase via register_shutdown_function, after PHP has finished executing and part of the output has already been flushed. The fields above the house-rules textarea had already rendered and shipped to the browser, then the crash turned the tail of the response into a 500. That is why the page looked almost done for a split second before becoming an error.
The fix
The fix is small and sits right at the point of use: force house_rules into a string before handing it to esc_textarea().
// house_rules can come back as [''] from a REST cast -> esc_textarea(array) = TypeError
$house_rules = is_array( $property['house_rules'] )
? implode( "\n", $property['house_rules'] )
: $property['house_rules'];
echo esc_textarea( $house_rules );implode( "\n", ... ) joins the array into the multi-line text a textarea actually wants, and the is_array() branch keeps it safe for both shapes, string or array. The 500 disappeared across every edit page at once.
Then the last step you must not skip: delete the capture mu-plugin and its log file. Leaving a publicly fetchable log sitting in uploads is an information leak. A random filename only delays discovery, it does not secure anything.
Takeaways
- A
TypeErrorsurfaced through WordPress's shutdown handler feels intermittent and mysterious while being 100 percent deterministic. When a 500 arrives after the page is nearly complete, suspect a fatal mid-render, not a crash at the top of the request. - Never assume a field is always a string. Data passing through REST or any serialization layer can silently change type, and
['']looks close enough to a string until a strict-typed function rejects it. - In PHP 8,
esc_textarea(),esc_html(), and friends type-hintstring. Give them an array and you get a fatal, not the gentle warning older PHP would emit. - Coerce at the point of use:
is_array( $v ) ? implode( "\n", $v ) : $vbeforeesc_textarea(). Cheap, local, and it does not disturb how the data is stored. - When production has no SSH or FTP and the log is HTTP-blocked, a temporary mu-plugin that
ini_setserror_logto a randomly named file inuploadsis a fast way to read the fatal. Always remove it afterward.
