Running Laravel Octane + FrankenPHP on Windows Without WSL2

This is a native-Windows story. If you've got WSL2 set up, you probably don't need any of this — pcntl exists there, FrankenPHP builds against your real PHP config, and neither of the two bugs below will ever show up. I didn't have that option, so here's what actually got php artisan octane:start running on plain Windows PHP — no WSL2, no Docker, just a native PHP build (Laragon, in my case) and FrankenPHP's own Windows installer.

The setup is simple on paper: a Laravel 12 app running on Octane, FrankenPHP as the server. Install FrankenPHP through its official PowerShell installer, it drops itself into ~/.frankenphp, then:

php artisan octane:start --watch

Mine didn't. It failed twice, with two completely unrelated bugs stacked on top of each other — fix the first one and you just get further into the second.

Bug 1: Undefined constant "Laravel\Octane\Commands\Concerns\SIGINT"

First run, instant fatal. Doesn't matter which shell you launch it from — Git Bash, PowerShell, cmd, all identical, because underneath it's the same native Windows PHP binary either way.

Root cause: pcntl is a POSIX-only extension. It has never been compiled for Windows PHP, in any build — Laragon, XAMPP, whatever. This isn't a "forgot to enable it in php.ini" situation, the constants genuinely don't exist on Windows.

Laravel Octane's start commands (FrankenPHP, Swoole, RoadRunner — all of them) implement Symfony Console's SignalableCommandInterface. Symfony's Application::doRunCommand() calls getSubscribedSignals() with no guard at all, and Octane's implementation (InteractsWithServers.php) unconditionally returns [SIGINT, SIGTERM, SIGHUP]. Those constants only exist if the pcntl extension defined them — so on Windows, PHP throws "Undefined constant" before the server even starts. Every server backend, --watch or not.

The fix isn't just faking the constants. I tried that first, and the crash just moves one line later — Application::doRunCommand() goes on to call SignalRegistry::register(), which calls the real pcntl_signal_get_handler() function next. That one doesn't exist either. The actual fix is to make the method return an empty array when pcntl isn't available:

// vendor/laravel/octane/src/Commands/Concerns/InteractsWithServers.php
public function getSubscribedSignals(): array
{
    return \function_exists('pcntl_signal') ? [SIGINT, SIGTERM, SIGHUP] : [];
}

Bug 2: worker ... has not reached frankenphp_handle_request()

With bug 1 patched, the server got further — and immediately hit a new wall. Caddy (FrankenPHP runs on top of it) just reports a generic "too many consecutive failures" with no real explanation.

Here's the part that isn't obvious: FrankenPHP embeds its own separate PHP runtime, statically linked into frankenphp.exe. It is not the same PHP that runs your artisan commands. You can prove this with FrankenPHP's built-in debug subcommand, which runs a script through the embedded runtime directly:

frankenphp.exe php-cli dump-extensions.php

On my machine that came back with php.ini loaded: (none) and about 28 minimal extensions — no openssl, no mbstring, no pdo_mysql, no curl, no gd, no intl, no sodium, no zip. Laravel needs at least openssl + mbstring + pdo_mysql just to boot the encrypter and hit the database. The Octane worker script fatals partway through bootstrap.php, before it ever reaches frankenphp_handle_request() — which is exactly the function Caddy is waiting on, hence the vague "consecutive failures" message instead of a real stack trace.

The frustrating part: every extension .dll you need is already sitting in ~/.frankenphp/ext/. The installer ships them all — it just doesn't load any of them, because there's no php.ini for the embedded runtime to pick up. PHP's default behavior is to look for php.ini next to the executable, so:

cp ~/.frankenphp/php.ini-production ~/.frankenphp/php.ini

Then in that file, uncomment extension_dir = "ext" and add whatever your app needs:

extension=openssl
extension=mbstring
extension=pdo_mysql
extension=curl
extension=fileinfo
extension=gd
extension=intl
extension=sodium
extension=zip

Re-run the php-cli dump-extensions.php trick to confirm they're actually loading before you go back to octane:start. Once both fixes are in, you should get a clean INFO Server running… and a worker that survives real requests.

Making the pcntl fix survive composer install

A hand-edit inside vendor/ doesn't survive the next composer install or composer update — Composer will happily overwrite it back to the broken version. If you want this to stick, wire it through cweagans/composer-patches: add it as a dependency, point extra.patches at a real unified diff of the getSubscribedSignals() change, and allow the plugin in composer.json.

One gotcha that cost me a rebuild: after adding extra.patches, a plain composer update laravel/octane does not re-apply the patch. The plugin caches resolved patches in patches.lock.json and only re-resolves when you explicitly ask it to:

composer patches-relock
composer patches-repatch

(or just delete patches.lock.json and let it rebuild). The php.ini fix from bug 2 lives entirely outside git/Composer, so it's unaffected by any of this — it's a one-time thing per machine.

If you're on WSL2, none of this applies

Worth saying plainly: this is a native-Windows-only problem. Under WSL2, or any real Linux/macOS PHP, pcntl exists natively and FrankenPHP builds against your system's actual PHP config instead of shipping its own disconnected runtime. If WSL2 is available to you, running Octane there sidesteps both bugs entirely — this whole post is for the case where that's not an option.

Subscribe to Building software. Writing what I learn.

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
[email protected]
Subscribe