Skip to content

← Writing

engineering

FrankenPHP on macOS: Dynamic Vhosts and Legacy PHP Pinning

· Jerwin Arnado · 24 min read ·

This is a complete FrankenPHP setup for local development on macOS: one config file that serves every project in /var/www/sites, a launchd daemon that survives reboot, and a helper that routes legacy projects to older PHP versions.

The goal is a stack with no per-site configuration. Drop a project in /var/www/sites/myapp.test, add a line to /etc/hosts, and it serves — no vhost to write, no server to reload. Web server and PHP live in a single binary, so there’s one process to run instead of a web server plus a php-fpm pool per PHP version.

The catch: the FrankenPHP binary embeds exactly one PHP version. Legacy projects stuck on PHP 8.1 or 7.4 can’t run on it directly — so there’s a small fp-pin script that routes any single site out to a Homebrew PHP-FPM pool over FastCGI, while everything else stays on the fast embedded path. Those pools follow the port convention from the Homebrew PHP-FPM setup guide.

Already running Nginx or Apache? Something already owns port 80, and it probably comes back at boot even after you stop it. Read section 10 first — it covers evicting the old server and translating its config.

1. Install FrankenPHP

It lives in its own tap:

brew install dunglas/frankenphp/frankenphp

Verify what you got — note the embedded PHP version, it matters later:

frankenphp version
# FrankenPHP v1.12.6 (Homebrew) PHP 8.5.8 Caddy v2.11.4

FrankenPHP is Caddy with PHP compiled in, so the config file is a Caddyfile and all Caddy directives work.

One thing the Homebrew build does not ship is Brotli. If you carry encode zstd br gzip over from a Caddy config, startup dies with:

Error: adapting config using caddyfile: parsing caddyfile tokens for 'encode':
finding encoder module '': module not registered: http.encoders.br

Drop the br. Between gzip and zstd every browser is covered. Same applies to any other optional Caddy module — check frankenphp list-modules before assuming a directive exists in this build.

2. Directory Layout

/etc/frankenphp/
├── Caddyfile                      # global options + dynamic catch-all
└── sites-enabled/
    ├── 000-placeholder.caddyfile  # keeps the import glob non-empty
    └── lucas-pro.caddyfile        # per-site override (legacy PHP pin)

Two rules drive everything:

  1. The catch-all serves every site from /var/www/sites/<host>/public using the embedded PHP.
  2. Anything in sites-enabled/ wins over the catch-all — Caddy picks the most specific host match, so a per-site file overrides the wildcard without touching it.

Both files end up owned by root if you create them with sudo, which means every future edit is a sudo round trip. The server runs as root regardless, and it only reads these at start/reload, so hand them to yourself:

sudo chown "$USER" /etc/frankenphp/Caddyfile /etc/frankenphp/sites-enabled

3. The Dynamic Catch-All

The full /etc/frankenphp/Caddyfile:

{
	frankenphp {
		# num_threads 8
		# php_ini memory_limit 512M
	}

	# .test domains are not publicly resolvable -> no ACME.
	auto_https off

	# One access log for all vhosts (Caddy cannot template log file paths).
	log {
		output file /var/log/frankenphp/access.log {
			roll_size 50MiB
			roll_keep 5
		}
		format console
	}
}

# Per-site overrides. Most specific host match wins, order is irrelevant.
import /etc/frankenphp/sites-enabled/*.caddyfile

# Catch-all: every Host header on :80
:80 {
	# Strip a leading "www." to get the site directory name.
	map {host} {domain} {
		~^www\.(.+)$ "${1}"
		default "{host}"
	}

	root * /var/www/sites/{domain}/public

	request_body {
		max_size 20MB
	}

	encode zstd gzip

	# --- www <-> non-www, driven by a /var/www/sites/<domain>/www+ marker ---
	@needs_www {
		not host www.*
		file {
			root /var/www/sites
			try_files {domain}/www+
		}
	}
	redir @needs_www http://www.{domain}{uri} 301

	@needs_nowww {
		host www.*
		not file {
			root /var/www/sites
			try_files {domain}/www+
		}
	}
	redir @needs_nowww http://{domain}{uri} 301

	# --- deny .ht* --- must be a handle, not a bare `respond`: see below
	@dotht path_regexp /\.ht
	handle @dotht {
		respond 403
	}

	# --- /app and /apps -> Reverb/websocket backend on :8080 ---
	handle /app* {
		reverse_proxy 127.0.0.1:8080 {
			header_up Host {host}
			header_up Scheme {scheme}
			header_up SERVER_PORT {server_port}
			header_up REMOTE_ADDR {remote_host}
		}
	}

	# --- everything else: PHP front controller + static files ---
	handle {
		php_server
	}
}

What each piece does:

  • map {host} {domain} — strips a leading www. so www.foo.test and foo.test resolve to the same directory. This is the whole trick behind “no per-site config”: the docroot is computed from the Host header.
  • auto_https off.test domains can’t pass an ACME challenge; without this Caddy tries and logs noise forever.
  • www+ marker redirects — a Laravel app that wants the www. prefix gets an empty file named www+ in its site directory. Present: non-www redirects to www. Absent: www redirects to non-www. State lives with the project, not in the server config.
  • php_server — FrankenPHP’s directive; equivalent to try_files + fastcgi_pass + file_server in one line, against the embedded PHP.
  • The /app* proxy — Laravel Reverb websockets on :8080, headers forwarded the same way the old Nginx block did. Caddy handles the websocket upgrade itself, so there’s no Upgrade/Connection header dance like Nginx needed.
  • The .ht denial is a handle block, deliberately. The obvious translation of Nginx’s location ~ /\.ht { deny all; } is respond @dotht 403 on its own line — and it silently doesn’t work. Caddy sorts directives into a fixed order, and respond runs after handle, so the catch-all handle { php_server } claims the request first and file_server cheerfully serves your .htaccess with a 200. Wrapping it in its own handle fixes it, because handle blocks are mutually exclusive and evaluated top to bottom. Verify rather than assume:

    curl -s -o /dev/null -w '%{http_code}\n' http://myapp.test/.htaccess   # want 403
    

4. Run It

sudo mkdir -p /var/log/frankenphp
sudo frankenphp run --config /etc/frankenphp/Caddyfile

sudo isn’t optional — port 80 is privileged.

Config sanity check and zero-downtime reload after edits:

sudo frankenphp validate --config /etc/frankenphp/Caddyfile
sudo frankenphp reload --config /etc/frankenphp/Caddyfile

validate needs sudo too, which is unintuitive for a command that only reads a file. It doesn’t just parse — it provisions the config, including opening the log file. Without root you get a failure that has nothing to do with your syntax:

Error: setting up default log: opening log writer using ...
open /var/log/frankenphp/access.log: permission denied

5. Make It Survive Reboot

Foreground frankenphp run dies with your terminal. Homebrew ships a service file, but it can’t work as-is:

brew services list | grep frankenphp
# frankenphp  error  1  jerwin  ~/Library/LaunchAgents/homebrew.mxcl.frankenphp.plist

Two independent reasons for that error 1:

  1. It’s a LaunchAgent, so it runs as your user — which cannot bind port 80. With KeepAlive set, it fails and retries forever.
  2. --config /opt/homebrew/etc/Caddyfile points at a file that doesn’t exist.

The second is a one-line fix, the first isn’t fixable at all: an agent can’t be promoted to a daemon. Agents are per-user and per-login; you need a LaunchDaemon, which runs as root at boot with nobody logged in. Write /Library/LaunchDaemons/com.frankenphp.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>Label</key>
	<string>com.frankenphp</string>

	<key>ProgramArguments</key>
	<array>
		<string>/opt/homebrew/opt/frankenphp/bin/frankenphp</string>
		<string>run</string>
		<string>--config</string>
		<string>/etc/frankenphp/Caddyfile</string>
	</array>

	<key>RunAtLoad</key>
	<true/>
	<key>KeepAlive</key>
	<true/>

	<key>WorkingDirectory</key>
	<string>/var/www/sites</string>

	<key>EnvironmentVariables</key>
	<dict>
		<key>HOME</key>
		<string>/var/root</string>
		<key>XDG_DATA_HOME</key>
		<string>/opt/homebrew/var/lib</string>
		<key>PATH</key>
		<string>/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
	</dict>

	<key>StandardOutPath</key>
	<string>/var/log/frankenphp/daemon.log</string>
	<key>StandardErrorPath</key>
	<string>/var/log/frankenphp/daemon.log</string>

	<key>ProcessType</key>
	<string>Interactive</string>
</dict>
</plist>

Retire the broken agent and install the daemon:

brew services stop frankenphp
launchctl bootout "gui/$(id -u)/homebrew.mxcl.frankenphp" 2>/dev/null
rm -f ~/Library/LaunchAgents/homebrew.mxcl.frankenphp.plist

sudo chown root:wheel /Library/LaunchDaemons/com.frankenphp.plist
sudo chmod 644 /Library/LaunchDaemons/com.frankenphp.plist
sudo plutil -lint /Library/LaunchDaemons/com.frankenphp.plist
sudo launchctl enable system/com.frankenphp
sudo launchctl bootstrap system /Library/LaunchDaemons/com.frankenphp.plist

launchd refuses plists that aren’t root:wheel and no more permissive than 644, and it says so with an unhelpfully generic error — plutil -lint first rules out the other possibility, a typo in your XML.

If another web server is installed on this machine, stopping it now is not enough — it likely re-launches at boot and wins the race for :80, leaving FrankenPHP to crash-loop behind it. That’s a failure you won’t see until your next restart. Section 10 covers disabling it permanently.

Confirm it’s really supervised

You can’t test a reboot without rebooting, but you can test the thing that makes reboot work — that launchd owns the process and restarts it:

sudo launchctl print system/com.frankenphp | grep -E 'state =|pid ='
# state = running
# pid = 25649

sudo kill -9 25649
sleep 3
sudo launchctl print system/com.frankenphp | grep 'pid ='
# pid = 29256      <- new pid, KeepAlive did its job

A new PID and a working curl means RunAtLoad will do the same thing at boot. Also confirm nothing is disabled behind your back:

sudo launchctl print-disabled system | grep -E 'com.frankenphp|nginx'
# "com.frankenphp" => enabled
# "homebrew.mxcl.nginx" => disabled

Day-to-day, reload still works against the daemon and keeps the same PID, so config edits stay zero-downtime. Full restart when you need one:

sudo launchctl kickstart -k system/com.frankenphp

6. Adding a New Site

The payoff — three steps and none of them are server config:

mkdir -p /var/www/sites/myapp.test/public
echo '127.0.0.1 myapp.test' | sudo tee -a /etc/hosts
open http://myapp.test

For a Laravel project, /var/www/sites/myapp.test is the repo and public/ is already there. Nothing to reload — the catch-all never changes.

7. The One-PHP Problem

The binary embeds a single PHP build. Mine is 8.5.8 — every site behind the catch-all runs on it. A legacy client project pinned to PHP 8.1 will fatal on day one.

It’s not only the obviously-legacy projects

The embedded version is whatever the current release shipped with, which is very likely newer than the PHP your projects were built and tested against. So the sites that break aren’t only the ancient ones — every project you don’t pin silently moves to the newest PHP at once. Nothing errors at startup. You find out days later, in one project, on one route.

(Coming from an existing stack this is sharper than it sounds: my old Nginx config sent every site to a single pool on 9084 — PHP 8.4 — so switching to the embedded 8.5.8 bumped all thirty-odd projects a minor version in one move.)

The instinct is to check composer.json and relax — and it’s the wrong signal. Every one of my projects declares something like "php": "^8.1" or "^8.3", and a caret constraint means >=8.1 <9.0. 8.5 satisfies all of them. Composer will never say a word.

The real ceiling is what the framework was tested against:

Laravel Supported PHP On embedded 8.5
10.x 8.1 – 8.3 breaks
11.x 8.2+ untested, mostly fine
12.x 8.2+ untested, mostly fine
13.x 8.3+ fine

So audit by framework version, not by PHP constraint:

cd /var/www/sites
for d in */; do
  d="${d%/}"
  [ -e "$d/composer.json" ] || continue
  php -r '
    $j = json_decode(file_get_contents($argv[1]."/composer.json"), true);
    printf("%-34s php:%-10s laravel:%s\n", $argv[1],
      $j["require"]["php"] ?? "-",
      $j["require"]["laravel/framework"] ?? "-");
  ' "$d"
done | sort
accounting_api.test    php:^8.1    laravel:^10.0
courtpulse.test        php:^8.4    laravel:^13.8
event_kaayuhan.test    php:^8.1    laravel:^10.10
markdown.test          php:^8.3    laravel:^13.8
...

Every laravel:^10 row is a site to pin. Mine turned up seven, and they went to 8.1 in one loop:

for h in accounting_api.test event_kaayuhan.test everbreed-api.test; do
  fp-pin "$h" 8.1
done

The blunt alternative is pointing the catch-all’s handle at a single FPM pool — php_fastcgi 127.0.0.1:9084 — so nothing uses the embedded PHP. Zero risk, but then FrankenPHP is just a Caddy proxy and you’ve thrown away the reason you installed it. I’d rather pin the handful of stragglers and let the other two dozen sites get the fast path.

Pinning a single site

The fix is the same pattern Nginx used: send that one site to a Homebrew php-fpm pool over FastCGI. The pools follow the port convention from the PHP-FPM guide:

PHP Version PHP-FPM Port
PHP 7.4 9074
PHP 8.1 9081
PHP 8.2 9082
PHP 8.3 9083
PHP 8.4 9084

A pinned site is just a file in sites-enabled/ that replaces php_server with php_fastcgi:

# lucas-pro.test pinned to PHP 8.1 (php-fpm on 9081), not FrankenPHP's embedded PHP.

http://lucas-pro.test, http://www.lucas-pro.test {
	root * /var/www/sites/lucas-pro.test/public

	request_body {
		max_size 20MB
	}

	encode zstd gzip

	@dotht path_regexp /\.ht
	handle @dotht {
		respond 403
	}

	handle {
		php_fastcgi 127.0.0.1:9081
		file_server
	}
}

One gotcha that cost me an hour: write the address as http://lucas-pro.test, not bare lucas-pro.test. With auto_https off, a bare hostname defaults to :443 — the block loads fine, matches nothing on port 80, and the request silently falls through to the catch-all and runs on the wrong PHP. No warning at reload, no error in the log; the site just serves and serves the wrong version.

8. fp-pin — Pin a Site in One Command

Writing that override by hand every time invites typos, so it’s scripted. /opt/homebrew/bin/fp-pin:

#!/usr/bin/env bash
# fp-pin <host> <php-version> [webroot]
# Pin one FrankenPHP vhost to a brew php-fpm pool instead of the embedded PHP.
#   fp-pin lucas-pro.test 8.1
#   fp-pin statamic.test 8.2 public
set -euo pipefail

SITES_ENABLED=/etc/frankenphp/sites-enabled
CADDYFILE=/etc/frankenphp/Caddyfile
BASE=/var/www/sites

host="${1:-}"
ver="${2:-}"
web="${3:-public}"

if [[ -z $host || -z $ver ]]; then
	echo "usage: fp-pin <host> <php-version> [webroot]" >&2
	echo "  e.g. fp-pin lucas-pro.test 8.1" >&2
	exit 64
fi

port="90${ver//./}"   # 8.1 -> 9081, 7.4 -> 9074

if ! lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then
	echo "!! nothing listening on 127.0.0.1:$port" >&2
	echo "   start it:  brew services start php@$ver" >&2
	echo "   verify:    grep -h '^listen' /opt/homebrew/etc/php/$ver/php-fpm.d/*.conf" >&2
	exit 69
fi

[[ -d "$BASE/$host/$web" ]] || { echo "!! no webroot at $BASE/$host/$web" >&2; exit 66; }

out="$SITES_ENABLED/${host%%.test}.caddyfile"
cat > "$out" <<EOF
# $host pinned to PHP $ver (php-fpm on $port), not FrankenPHP's embedded PHP.
# Generated by fp-pin.

http://$host, http://www.$host {
	root * $BASE/$host/$web

	request_body {
		max_size 20MB
	}

	encode zstd gzip

	@dotht path_regexp /\\.ht
	handle @dotht {
		respond 403
	}

	handle {
		php_fastcgi 127.0.0.1:$port
		file_server
	}
}
EOF

frankenphp fmt --overwrite "$out" >/dev/null
sudo frankenphp reload --config "$CADDYFILE"

served=$(mktemp "$BASE/$host/$web/__fpver_XXXX.php")
printf '<?php echo PHP_VERSION;' > "$served"
got=$(curl -s "http://$host/$(basename "$served")" || true)
rm -f "$served"

echo "wrote $out"
echo "$host now served by PHP ${got:-<no response>}"

Usage:

fp-pin lucas-pro.test 8.1
# wrote /etc/frankenphp/sites-enabled/lucas-pro.caddyfile
# lucas-pro.test now served by PHP 8.1.34

It refuses to write a config that can’t work — before touching anything it checks the pool is actually listening (lsof on the derived port) and the webroot exists. Then it writes the override, formats it, reloads FrankenPHP, and proves the pin took: it drops a temp PHP file into the webroot, curls it through the front door, and prints the PHP_VERSION the site really served. If that last line says 8.5.8, the pin fell through and you know immediately — usually the missing http:// prefix problem from section 7.

Add the /etc/hosts entry before pinning. The probe curls the hostname, so without it you get a config that’s perfectly correct and a verification line that looks like a failure:

fp-pin legacy-app.test 8.1
# wrote /etc/frankenphp/sites-enabled/legacy-app.caddyfile
# legacy-app.test now served by PHP <no response>

Nothing is broken there — the name just doesn’t resolve yet. To confirm a pin on a host that isn’t in /etc/hosts, go in by Host header instead:

printf '<?php echo PHP_VERSION;' > /var/www/sites/legacy-app.test/public/__ver.php
curl -s -H 'Host: legacy-app.test' http://127.0.0.1/__ver.php   # 8.1.34
rm /var/www/sites/legacy-app.test/public/__ver.php

One naming caveat: the output filename comes from ${host%%.test}, so foo.test and foo.local would collide on foo.caddyfile. Fine for a .test-only setup, worth knowing if you mix suffixes.

Unpinning is deleting the file:

rm /etc/frankenphp/sites-enabled/lucas-pro.caddyfile
sudo frankenphp reload --config /etc/frankenphp/Caddyfile

The site drops back to the catch-all and the embedded PHP.

9. Gotchas

  • Keep 000-placeholder.caddyfile. The import glob must match at least one file or FrankenPHP refuses to start. An empty sites-enabled/ takes the whole server down.
  • Always http:// in per-site addresses. Bare hostnames default to :443 and silently fall through to the catch-all (section 7).
  • No Brotli in the Homebrew build. encode zstd gzip, never br (section 1).
  • respond loses to handle. Anything you’d write as a bare respond in a config that also has a catch-all handle needs its own handle block, or it never runs (section 3).
  • validate needs sudo. It provisions the config, log file included, so without root it fails on permissions rather than syntax (section 4).
  • Stopping the old web server isn’t disabling it. Nginx’s Homebrew daemon has RunAtLoad, and apachectl start persists an override — both take :80 back at the next boot (section 10).
  • One access log for everything. Caddy can’t template log paths per host the way Nginx templates access_log — filter by host field instead: grep 'myapp.test' /var/log/frankenphp/access.log.
  • frankenphp fmt --overwrite <file> keeps hand-edited configs formatted the same as generated ones.
  • Pinned sites need their pool running. fp-pin checks at pin time, but after a reboot: brew services list | grep php — a stopped pool means 502s on that site only, while everything else works fine. That asymmetry is the tell.
  • Worker mode can’t be dynamic. FrankenPHP’s big performance win needs a static worker file path, which the computed-root catch-all can’t provide. Give the app its own sites-enabled/ block to opt in per project.

10. Replacing Nginx or Apache

Everything above assumes port 80 is free. If you already run a web server, FrankenPHP won’t start — and the fix is not what most people do, which is stop the old server and move on. Both Nginx and Apache come back at boot, so you get a stack that works all afternoon and is broken tomorrow morning.

Find out what owns port 80

sudo lsof -nP -iTCP:80 -sTCP:LISTEN
# COMMAND     PID USER   FD   TYPE  ...  NAME
# nginx     14312 root   6u   IPv4  ...  *:80 (LISTEN)

Two things to settle for whatever you find: is it running now, and does it start itself at boot? They’re separate questions with separate commands, and only the second one matters for tomorrow.

Nginx (Homebrew)

sudo plutil -p /Library/LaunchDaemons/homebrew.mxcl.nginx.plist | grep RunAtLoad
# "RunAtLoad" => true

RunAtLoad => true means boot-start. Stop it and disable it:

sudo launchctl bootout system/homebrew.mxcl.nginx 2>/dev/null   # stop now
sudo launchctl disable system/homebrew.mxcl.nginx               # and at boot

Note that brew services stop nginx is not equivalent — it handles the user-level agent, and Homebrew’s Nginx typically has a root LaunchDaemon in /Library/LaunchDaemons/ as well, because it needs root for :80. Check both. Verify:

sudo launchctl print-disabled system | grep nginx
# "homebrew.mxcl.nginx" => disabled

Apache (macOS built-in)

macOS ships Apache — /usr/sbin/httpd, currently 2.4.66. Its plist is Disabled => true out of the box, so it only runs if someone started it:

sudo launchctl print-disabled system | grep httpd
sudo apachectl stop
sudo launchctl disable system/org.apache.httpd

The trap here is apachectl itself: sudo apachectl start loads the daemon with -w, which writes a persistent override flipping that Disabled key. So an apachectl start you ran months ago is still in effect and Apache is still a boot-time service. sudo apachectl stop reverses it; the explicit launchctl disable makes it unambiguous.

Don’t try to delete /System/Library/LaunchDaemons/org.apache.httpd.plist — it’s SIP-protected. Disabling is the supported route.

Apache (Homebrew)

Same shape, different label:

brew services stop httpd
sudo launchctl bootout system/homebrew.mxcl.httpd 2>/dev/null
sudo launchctl disable system/homebrew.mxcl.httpd

Then start FrankenPHP and confirm the handover

sudo launchctl bootstrap system /Library/LaunchDaemons/com.frankenphp.plist
sudo lsof -nP -iTCP:80 -sTCP:LISTEN | tail -1
# frankenph 59557 root  12u  IPv6 ...  *:80 (LISTEN)

The process name on :80 is the only confirmation that counts.

Translating the old config

Most of a dynamic vhost config maps over directly:

Nginx Apache Caddy / FrankenPHP
root <path>; DocumentRoot <path> root * <path>
fastcgi_pass 127.0.0.1:9083; SetHandler proxy:fcgi://127.0.0.1:9083 php_fastcgi 127.0.0.1:9083
try_files $uri /index.php?$args; FallbackResource /index.php php_server (built in)
client_max_body_size 20M; LimitRequestBody 20971520 request_body { max_size 20MB }
gzip on; mod_deflate encode zstd gzip
location /\.ht { deny all; } <Files ".ht*"> Require all denied handle @dotht { respond 403 }
proxy_pass + Upgrade headers mod_proxy_wstunnel reverse_proxy (upgrade is automatic)
map $host $var { } VirtualDocumentRoot map {host} {var} { }
access_log .../$domain/access.log CustomLog per vhost one file — Caddy can’t template the path
include sites-enabled/*; IncludeOptional sites-enabled/* import sites-enabled/*.caddyfile

Three that don’t translate cleanly:

  • Per-host log files. Nginx interpolates variables into access_log; Caddy resolves the path once at provision time. One log, filter by host.
  • php_server vs php_fastcgi. These look interchangeable and aren’t: php_server runs the embedded PHP, php_fastcgi proxies out to a pool. The whole pinning mechanism in section 8 is choosing between them per site.
  • The PHP version your old config pointed at. This is the one that catches people, and it’s covered in section 7 — your fastcgi_pass port encoded a specific PHP version, and the embedded PHP is almost certainly not that version. Run the audit there before you consider the migration done.

Keeping the old server around

You don’t have to burn the boats. Leave Nginx installed and change its listen 80 to listen 8080, and you can start it side by side to compare behavior on a specific site. Re-enabling it as the boot-time server on :80 is the reverse of the eviction:

sudo launchctl bootout system/com.frankenphp
sudo launchctl disable system/com.frankenphp
sudo launchctl enable system/homebrew.mxcl.nginx
sudo launchctl bootstrap system /Library/LaunchDaemons/homebrew.mxcl.nginx.plist

I kept mine disabled-but-installed for a few weeks and never went back.

11. Quick Checklist

New site on the embedded PHP:

mkdir -p /var/www/sites/myapp.test/public
echo '127.0.0.1 myapp.test' | sudo tee -a /etc/hosts

Legacy site on an older PHP — hosts entry first, so fp-pin can verify itself:

brew services start [email protected]     # pool from the PHP-FPM guide, listening on 9081
fp-pin legacy-app.test 8.1

Config edit:

sudo frankenphp validate --config /etc/frankenphp/Caddyfile
sudo frankenphp reload --config /etc/frankenphp/Caddyfile

Service control:

sudo launchctl print system/com.frankenphp | grep -E 'state =|pid ='
sudo launchctl kickstart -k system/com.frankenphp

Coming from another web server:

sudo lsof -nP -iTCP:80 -sTCP:LISTEN                  # who owns it
sudo launchctl disable system/homebrew.mxcl.nginx    # or org.apache.httpd

One binary, one catch-all, a daemon that comes back after a reboot, and the legacy projects none the wiser.