Emit a dispatch-free prepend artifact for host-routed PHP runtimes
WordPress Studio's native-PHP pull-reprint boots pulled sites with its own php -S router and injects reprint's runtime.php via auto_prepend_file. Because that file is a full router, WordPress falls back to MySQL and the site fails to start. The fix is small — and reprint already has the pieces.
This was fixed in Studio with a one-line change — request reprint's
existing nginx-fpm runtime target instead of php-builtin for
native PHP:
- const reprintRuntime = runtime === SITE_RUNTIME_NATIVE_PHP ? 'php-builtin' : 'playground-cli';
+ const reprintRuntime = runtime === SITE_RUNTIME_NATIVE_PHP ? 'nginx-fpm' : 'playground-cli';
reprint's nginx-fpm applier already emits a dispatch-free
runtime.php (generate_runtime_php() with no routing tail), built to
be injected via auto_prepend_file because nginx owns routing. Studio's native
php -S … router.php pool is the same "host owns routing" contract, so it consumes
that runtime directly. Verified against stock released reprint v0.8.2: the
pulled site starts and serves on native PHP with no DB error.
So the reprint-side change recommended below is not needed. The analysis
is kept as background on the root cause. (A dedicated --runtime=php-prepend /
host-routed target would read a little cleaner than reusing nginx-fpm
— which also drops an unused nginx.conf — but that's a nice-to-have, not
required.)
1Context
WordPress Studio is adding native-PHP support to studio pull-reprint
(Automattic/studio#3881, branch stu-1814-make-pull-reprint-work-with-native-php).
For native PHP it runs:
reprint pull … --runtime=php-builtin --start-runtime=none
…and then boots the pulled site with its own PHP server: a pool of
php -S … router.php workers, injecting reprint's generated
runtime.php via -d auto_prepend_file=runtime.php.
This fails: every pulled site dies at startup with WordPress's "Error establishing a database connection" (it falls back to MySQL) and the PHP worker exits 1.
2Root cause
The php-builtin applier generates runtime.php as a
complete standalone php -S router, not a passive prepend.
It concatenates two parts.
class-php-builtin-applier.php · apply() (~line 26–30):
$runtime = generate_runtime_php($manifest, $fs_root); // base layers: constants, SQLite $wpdb shim, uploads proxy
$runtime .= $this->generate_cli_server_routing($options); // ← "// CLI-server routing (php -S only)." tail: require()s index.php
write_runtime_file($runtime_path, $runtime);
The tail (generate_cli_server_routing(), ~line 57) resolves the request
path and dispatches WordPress itself (require …/index.php).
That is correct when reprint owns the server (bash start.sh →
php -S … runtime.php).
When a host injects this file as auto_prepend_file and runs its
own router, the file dispatches WordPress during the prepend phase
and/or in the wrong scope, so the SQLite $wpdb shim no longer governs the
request that actually serves the page → WordPress instantiates the real MySQL
wpdb → "Error establishing a database connection"
→ worker exits 1.
3Evidence (verified against a live pull)
- The native PHP binary has
pdo_sqlite/sqlite3— not an extension gap. - Run as designed (
runtime.phpas thephp -Sscript), the site renders fine on SQLite. - Prototype fix proven on the Studio side: feeding PHP only the part of
runtime.phpbefore the// CLI-server routing (php -S only).marker (constants + SQLite shim + uploads proxy) asauto_prepend_file, and letting Studio'srouter.phpdispatch, makes the site start and serve correctly:php -S … -d auto_prepend_file=<stripped> router.php→GET /wp-login.php→200 OK, renders the real WP login page, no DB error.- End-to-end, the pull now reaches
site-startedand the live site serves on its local port.
So the split is the fix; the only question is doing it properly in reprint instead of string-stripping downstream.
4The clean part: reprint already separates these concerns
generate_runtime_php()(functions.php:45) returns a complete, self-contained<?php …file containing only the base layers (error handler, DB constants, the lazy SQLite$wpdbloader, the on-the-fly thumbnail + remote-uploads proxy). No routing.generate_cli_server_routing()is the only thing that adds dispatch, and it is appended after the base layers.- Precedent: the nginx-fpm applier
(
class-nginx-fpm-applier.php) already writesruntime.php = generate_runtime_php(...)with no routing tail, and wires it asauto_prepend_file={runtime_path}viafastcgi_param PHP_VALUE. That is exactly the "host owns routing, reprint provides environment-only prepend" contract Studio needs.
5Recommended change
Primary — smallest, additive, backward-compatible
Have the php-builtin applier also emit a prepend-only artifact. In
class-php-builtin-applier.php::apply():
$base = generate_runtime_php($manifest, $fs_root);
// Full router — for `php -S runtime.php` / start.sh (unchanged behavior).
write_runtime_file($output_dir . '/runtime.php', $base . $this->generate_cli_server_routing($options));
// Prepend-only — for hosts that own request dispatch (an external php -S
// router, FPM, etc.) and inject this via auto_prepend_file. No routing tail.
write_runtime_file($output_dir . '/runtime.prepend.php', $base);
$summary[] = "Wrote {$output_dir}/runtime.prepend.php";
Studio then points auto_prepend_file at runtime.prepend.php instead
of runtime.php. runtime.php and start.sh are untouched,
so standalone usage is unaffected.
Alternative — cleaner abstraction: a dedicated runtime target
Studio's server is not really "php-builtin" (that means reprint owns the
php -S router). Consider a target like --runtime=php-prepend (or
auto-prepend / host-routed) whose applier writes only
generate_runtime_php() to a single prepend file and no server/router/config —
purpose-built for "the host owns routing." Studio's native path would pass that instead of
php-builtin. (The nginx-fpm applier is the model; this is the FPM case minus the
nginx.conf.)
The generate_cli_server_routing() docblock claims the routing "only runs
under php -S (guarded by php_sapi_name check)" — there is no such guard in
the emitted code; the tail runs unconditionally. Either make the comment accurate or add a
real guard. A reliable guard that makes a single runtime.php safe in both modes
is to skip the routing tail when the file is the active auto-prepend:
$__ap = ini_get('auto_prepend_file');
if (!($__ap && realpath($__ap) === realpath(__FILE__))) {
// … CLI-server routing tail …
}
php_sapi_name() is cli-server in both cases (an
external php -S router with this file as auto_prepend is still
cli-server), so a SAPI check does not distinguish them —
auto_prepend_file does. Optional if you ship the separate prepend file, but it
hardens against future misuse.
6Suggested tests
- Unit / snapshot: php-builtin
apply()writes bothruntime.php(ends with the routing tail) andruntime.prepend.php(ends after the uploads-proxy block, no// CLI-server routingmarker), bothphp -l-clean. - Behavioral: with the prepend file as
auto_prepend_fileand a trivial external router thatrequires the WP entry, a request resolves$GLOBALS['wpdb']to the SQLite integration (e.g. assertget_class($GLOBALS['wpdb'])is the SQLite db class and aSELECTsucceeds) — i.e. it never reaches MySQL.
7Out of scope — separate, unrelated issue
After the start fix, the Studio pull now gets all the way up and then fails on the
deferred file sync (files-pull --filter=skipped-earlier) with:
Invalid response: missing multipart boundary. Body: 0%);--wp--preset--gradient--…
reprint.phar/packages/reprint-importer/src/import.php:10175
This is a reprint exporter/transport problem (a response body without a multipart boundary), independent of the runtime split. Flagging it as a separate item worth a look — it may be transient or a real bug in the skipped-earlier download path.
8Key files
packages/reprint-importer/src/lib/target-runtime/class-php-builtin-applier.php
— apply(), generate_cli_server_routing() — the file to change.
packages/reprint-importer/src/lib/target-runtime/functions.php
— generate_runtime_php() (line 45, the prepend-safe base layers), write_runtime_file() (line 357).
packages/reprint-importer/src/lib/target-runtime/class-nginx-fpm-applier.php
— precedent: base-only runtime.php consumed via auto_prepend_file.
Studio consumer (reference)
— apps/cli/lib/pull/runtime-start-options.ts → loadImportedRuntimeStartOptionsNative, and the --runtime=php-builtin selection in apps/cli/commands/pull-reprint.ts.