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:
- The catch-all serves every site from
/var/www/sites/<host>/publicusing the embedded PHP. - 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 leadingwww.sowww.foo.testandfoo.testresolve 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—.testdomains can’t pass an ACME challenge; without this Caddy tries and logs noise forever.www+marker redirects — a Laravel app that wants thewww.prefix gets an empty file namedwww+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 totry_files+fastcgi_pass+file_serverin 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 noUpgrade/Connectionheader dance like Nginx needed. -
The
.htdenial is ahandleblock, deliberately. The obvious translation of Nginx’slocation ~ /\.ht { deny all; }isrespond @dotht 403on its own line — and it silently doesn’t work. Caddy sorts directives into a fixed order, andrespondruns afterhandle, so the catch-allhandle { php_server }claims the request first andfile_servercheerfully serves your.htaccesswith a 200. Wrapping it in its ownhandlefixes it, becausehandleblocks 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:
- It’s a LaunchAgent, so it runs as your user — which cannot bind port 80. With
KeepAliveset, it fails and retries forever. --config /opt/homebrew/etc/Caddyfilepoints 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. Theimportglob must match at least one file or FrankenPHP refuses to start. An emptysites-enabled/takes the whole server down. - Always
http://in per-site addresses. Bare hostnames default to:443and silently fall through to the catch-all (section 7). - No Brotli in the Homebrew build.
encode zstd gzip, neverbr(section 1). respondloses tohandle. Anything you’d write as a barerespondin a config that also has a catch-allhandleneeds its ownhandleblock, or it never runs (section 3).validateneedssudo. 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, andapachectl startpersists an override — both take:80back 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-pinchecks 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
workerfile path, which the computed-root catch-all can’t provide. Give the app its ownsites-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_servervsphp_fastcgi. These look interchangeable and aren’t:php_serverruns the embedded PHP,php_fastcgiproxies 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_passport 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.