D
P
0
← All articles Baca dalam Bahasa Indonesia

WordPress & PHP in Production

WordPress Form Stuck on a Blank `admin-post.php`? An `open_basedir restriction in effect` Warning Beat the Headers

· · 6 min read
WordPress Form Stuck on a Blank `admin-post.php`? An `open_basedir restriction in effect` Warning Beat the Headers

The row was saved, but the success page never came. That is what happened on 11 October 2026 on a client's WordPress site I was working on in WordPress Studio, the app for running WordPress on your own machine. Every form submission ended on a blank admin-post.php, with no redirect.

A half-finished symptom like that looks exactly like a broken handler. The save worked, so the submission clearly reached the handler. The redirect never happened, so it is natural to suspect the rest of the handler code, or the theme. The cause turned out to sit outside both: a temporary test mu-plugin that was logging wp_mail calls that day to a log file under /private/tmp.

The raw response pointed at the test tool

The clue was in the raw response, not on the blank page: a PHP warning printed before the headers. It read like this. I replaced the site folder with a placeholder and left the parts my notes had already cut short exactly as they were.

file_put_contents(): open_basedir restriction in effect ... is not within the allowed path(s):
(<site root>:/var/folders/.../T:/Applications/Studio.app/Contents/Resources/cli/php/router.php:...)

The warning came from file_put_contents(), from the test mu-plugin writing its log. It states that an open_basedir restriction is in effect and that the target path is not within the allowed paths. open_basedir is a PHP setting that limits which directories file functions are allowed to open, and PHP in Studio runs with open_basedir limited to the site root and one temp folder.

The allowed list in that warning holds the site root, a folder under /var/folders/.../T, and router.php inside Studio.app, and then it is cut off. The folder under /var/folders is the per-user temp folder on macOS, and it is not /tmp. On macOS, /tmp is only a symlink to /private/tmp, and according to the warning itself, a log file under /private/tmp sits outside the allowed paths. The most ordinary place for a temporary file turned out to be exactly the place PHP in Studio may not touch.

Why the row was saved but the redirect vanished

Studio displays warnings, so the warning text went out as output. HTTP headers have to leave before the body, and once any output has actually been sent, the headers go out with it. A header that someone tries to add after that point is too late. That is what happened to wp_safe_redirect here: it could no longer send its Location header. So the redirect was not pointing the wrong way. It never got sent at all.

The other half is the order inside the handler. Two forms on that site, a signup form in the landing template and a newsletter form in the footer, both POST to admin-post.php with the same hidden action field. The handler is attached to two hooks, one for visitors who are not logged in and one for those who are. A simplified version, with the action and handler names replaced:

// Simplified. Action and handler names have been replaced.
add_action( 'admin_post_nopriv_demo_signup', 'demo_handle_signup' );
add_action( 'admin_post_demo_signup', 'demo_handle_signup' );
 
// Inside the handler, the order is:
$back = wp_get_referer() ? wp_get_referer() : home_url( '/' );
// ...
$post_id = wp_insert_post( $postarr, true ); // entry saved as a 'private' post
// ...
wp_mail( /* ... */ );
wp_safe_redirect( add_query_arg( $flag_key, 'success', $back ) . $anchor );
exit;

In the branch that writes the full entry, the entry goes in first, through wp_insert_post, as a post with private status. The storage block ends at line 233, while wp_mail is only called at line 293, sixty lines later (293 minus 233). Because the test mu-plugin logged wp_mail calls, its log was only written when wp_mail ran, and by that time the entry was already in the database. wp_safe_redirect with the success flag comes right after wp_mail, which puts it right behind the warning. Every submission got as far as saving its row and then lost its redirect, and a single warning from a test tool was enough to break every redirect that came after it.

The PHP serving the form is not the PHP in the terminal

One more detail is easy to miss. The x-powered-by response header reads PHP/8.3.33, so web requests in Studio are served by PHP 8.3, not by the 8.4 CLI binary used for linting. php -l only checks syntax, and even that check runs on a different PHP. Code that passes lint in the terminal can still trip over the runtime rules of the PHP serving the request, such as open_basedir.

The terminal is still useful for reading the site, though. php -r, or a script that requires wp-load.php, works with Studio's CLI binary and the SQLite drop-in installed on that site, which is wp-content/db.php from SQLite integration version 1.8.0. That path was used to list and delete test rows.

Rules for test code in Studio

There are three rules I now keep whenever I add server-side test code in Studio.

First, a test mu-plugin, or any server-side debug output, writes inside the site root, for example under uploads, and never to /private/tmp. In this site's repo, wp-content/uploads/ is already in .gitignore, so log files there do not get committed.

// Example, not a project file. The test log goes inside the site root,
// which is within the paths open_basedir allows, not to /private/tmp.
file_put_contents( WP_CONTENT_DIR . '/uploads/debug-test.log', $line . "\n", FILE_APPEND );

Second, when a local form suddenly "fails" after test code has been added, resend the POST with curl -D - and read the body before suspecting the theme. The -D - option writes the response headers to standard output, so the status, the headers, and the body read in order on one screen. A healthy redirect shows up as a Location header. If a warning got out ahead of the headers, its text is in the body and the expected Location header is missing.

# Simplified. Fill --data with exactly the same fields the real form submits.
curl -s -D - --data "action=demo_signup&..." "$SITE/wp-admin/admin-post.php"

Third, the test mu-plugin and its log file are deleted as soon as they have done their job. Mu-plugins load on every request, production included if one ever gets uploaded. In this repo the wp-content/mu-plugins/ folder is in .gitignore as well, so a file left behind there never shows up in git status, and nothing reminds you that it is still there. As of 14 October, that folder holds only 99-studio-loader.php, Studio's own mu-plugin loader, and the sqlite-database-integration folder. The test mu-plugin is no longer there.

To put it briefly, this was not really about a strict open_basedir. Mu-plugins load on every request, so test code runs in the same request as the code it is testing and answers to the same PHP rules. When a local form breaks right after test code goes in, the first thing to read is the raw response, not the theme.