A patchy little PHP dev server for macOS (running Apache).
Apache was originally a pun on "a patchy server" — it was built from patches on top of NCSA HTTPd. Patchy is a patchy little CLI for managing local Apache + mod_php sites on your Mac.
Most local PHP dev environments — Valet, Herd, Lando — happen to default to Nginx. That's a great fit for some stacks; it's the wrong fit for others. If your production runs on Apache, or your app expects .htaccess to Just Work — Total CMS, WordPress, Drupal, Symfony with the Apache pack, or any of the countless PHP apps that target Apache — you want a local dev environment that matches.
Patchy is that environment: Apache 2 + mod_php, local HTTPS, friendly .test hostnames, one command per site.
- Per-site Apache vhost generated in a single command
- Local HTTPS via mkcert — no browser warnings
- Friendly hostnames via
/etc/hosts— every configured site resolves the instant Patchy adds it, and stops resolving the instantpatchy stopruns - Per-site error and access logs
$_SERVER['PATCHY']set for every request, so apps can detect local dev- Painless PHP version switching across installed Homebrew PHPs
- One-shot PECL extension setup (
redis,apcu,imagick,yaml,pcov,memcached) - A plain
httpd.confyou can read and edit — no black box
brew install joeworkman/patchy/patchyPrefer an app? Patchy UI is a native macOS app for Patchy, made by @jeso87: add sites, switch PHP and share sites without opening Terminal.
That's it — the formula pulls in all dependencies (httpd, php, mkcert, jq) and configures Apache (ports 80/443, required modules, PHP handler) to run as your user during install.
git clone https://github.com/joeworkman/patchy
cd patchy
sudo ln -s "$PWD/bin/patchy" /usr/local/bin/patchy
patchy setup# Add a site — docroot defaults to ~/Websites/example.test
patchy add example.test
# Add a site — provide path to site docroot
patchy add example.test developer/example.test
# Open https://example.test in your browser
# When you're done
patchy rm example.testAdding a site under a real TLD (say patchy add mywebsite.com) routes just
that exact domain to your Mac via a 127.0.0.1 mywebsite.com # patchy line
in /etc/hosts — subdomains and the rest of .com stay on public DNS. Patchy
asks for confirmation because the live site becomes unreachable on this Mac
while the route exists. Both adding and removing a real-TLD site edit
/etc/hosts, which needs a sudo password — run patchy trust once to stop
those prompts (see "Trust: passwordless /etc/hosts updates" below).
To get back to the live site, remove the site:
patchy rm mywebsite.com # removes the vhost, certs, and the /etc/hosts entry/etc/hosts changes take effect the instant they're written — no DNS cache
to flush, no wait. Browsers keep their own DNS caches and connection pools,
though, so a hard reload (or browser restart) can still help right after
switching a domain between local and live.
Adding an apex domain (example.com, foo.test — exactly two labels) asks
Also serve www.<domain>? [Y/n]; Enter accepts, n declines, and a
non-interactive add (piped from a script) takes the default and serves
www. Subdomain sites (shop.example.com) are never asked. Accepting adds
its own /etc/hosts line and cert SAN alongside the base domain, and
rm/stop clean the alias up with the site — nothing extra to manage.
Existing sites can pick up www by removing and re-adding:
patchy rm example.com && patchy add example.com <docroot>patchy share <domain> puts a local site on a public link until you press
Ctrl-C — handy for showing a client a preview without deploying. Out of the
box it gives an anonymous link through a free
Cloudflare quick tunnel, with no Cloudflare
account needed; cloudflared is installed with Patchy.
patchy share example.test
# Requesting a public link for example.test...
# Sharing example.test at https://quiet-river-banana.trycloudflare.com
# Anyone with this link can see the site. Press Ctrl-C to stop sharing.The link works while the command runs; Ctrl-C ends it. Anyone with the link can see the site. cloudflared verifies the site's mkcert certificate against mkcert's root CA rather than skipping the TLS check to Apache.
The site's PHP still sees its local domain as the host. Plain PHP and static
sites work as-is; apps with a fixed site URL (WordPress's siteurl/home,
for example) may redirect visitors to example.test, which they can't reach.
If you have a domain on Cloudflare, each site can get a stable address a
client can bookmark, like harbor.example.com, with an optional password:
patchy share setup example.com # once: approve example.com in the browser
patchy share harbor.test --auth
# Setting up sharing for harbor.test (first share only)...
# Sharing harbor.test at https://harbor.example.com
# Username: preview Password: k7Rm-4vQx-92LdThe address only exists while you share. Each share creates its DNS record,
and stopping deletes it again, so nothing is left on your domain. Only one
share of a site runs at a time. Each site
also gets a Cloudflare tunnel on its first share; it's invisible and free,
and stays until patchy rm or patchy share reset. Patchy never replaces or
deletes a DNS record that isn't its own, so your live subdomains are safe.
The address is an alias of the site's own vhost, so the public site behaves
exactly like the local one, and the site sees the public address as its host.
The first share adds one IncludeOptional line to the site's
<VirtualHost *:443> block, pointing at
$(brew --prefix)/etc/httpd/share/<site>.conf. Only visitors at the public
address are asked for the password; the local domain never is.
| Option | |
|---|---|
--name <word> |
Use <word>.example.com (default: the local name, dots as hyphens). Remembered per site. |
--auth |
Require a password for this share: username preview and a new password each time. |
--auth user:pass |
Require your own username and password instead. It ends up in your shell history. |
--temp |
A random address on your domain (k7rm4vqx.example.com), new each share and never saved. Works with --auth. |
--anonymous |
An anonymous trycloudflare.com link instead, as without a domain. Can't be combined with --name or --auth. |
Addresses are one level deep (harbor.example.com, not
harbor.preview.example.com) because Cloudflare's free certificates only
cover one level. patchy share reset disconnects the domain.
If a share is killed outright (a crash, a closed laptop lid mid-share), its
DNS record stays behind. The next share of that site, or patchy rm, removes
it: Patchy deletes any record pointing at the site's tunnel, and nothing
else. A tunnel deleted in the Cloudflare dashboard is recreated on the next
share. Someone who visits while you aren't sharing gets "no
such host", and their network may remember that for a while, so the address
can take a few minutes to work for them after you start sharing.
Passwords are never stored: Apache's password file exists only while the share runs.
Patchy keeps its Cloudflare login and tunnel credentials in
~/.config/patchy/share, not ~/.cloudflared, so your own cloudflared setup
and anonymous links keep working.
| Command | Description |
|---|---|
patchy add <domain> [dir] |
Add a new site. Docroot defaults to ~/Websites/<domain>. --www / --no-www answer whether to also serve www.<domain>; --yes skips the question asked before routing a real domain. |
patchy folder <domain> <dir> |
Serve a site from another folder. |
patchy rm <domain>... |
Remove one or more sites. |
patchy share <domain> |
Share a site at a public link until Ctrl-C: an anonymous trycloudflare.com link, or an address on your own domain after patchy share setup. |
patchy list |
List configured sites. |
patchy start / stop |
Start/stop Apache. start routes every configured site to this Mac via /etc/hosts; stop releases those entries, so real domains resolve normally again immediately. |
patchy restart |
Restart Apache and repair /etc/hosts routing if any entries went missing. |
patchy status |
Show whether Apache is running, how many sites /etc/hosts is routing, and whether trust is enabled. |
patchy check |
Verify Apache and PHP config. |
patchy info |
Show Apache and PHP version info. |
patchy version |
Show the Patchy version. |
patchy php <version> |
Switch the active PHP version (e.g. patchy php 8.4). |
patchy pecl |
Install common PECL extensions for the current PHP. |
patchy config |
Open httpd.conf in your editor. |
patchy ini |
Open the active php.ini in your editor. |
patchy logs |
Tail all Apache logs. |
patchy errors [site] [lines|-f] |
Show the last N lines of the Apache error log, or of one site's (default 20), or follow with -f. Sites added by older versions of patchy share Apache's main log. |
patchy refresh-certs |
Regenerate all local SSL certificates, and make sure macOS trusts mkcert's root CA. |
patchy setup |
One-time setup: Apache user, ports 80/443, required modules, PHP handler, vhost include, /etc/hosts routing. Idempotent; auto-run by brew install. Re-run it after upgrading from a dnsmasq-based install to migrate away from dnsmasq. |
patchy trust |
Install a root-owned helper so /etc/hosts updates on start/stop/add/rm stop prompting for a password. |
patchy untrust |
Remove the passwordless helper; /etc/hosts updates prompt for a password again. |
patchy help |
Print all of the above. |
patchy docs |
Print this documentation, matching the installed version. |
patchy alfred |
Install the Alfred workflow, matching the installed version. |
config and ini open files in $VISUAL, then $EDITOR, falling back to nano (preinstalled on macOS).
To use a different editor, add this to your shell profile (e.g. ~/.zshrc or ~/.bashrc):
export EDITOR=vim # or nvim, subl, cursor, etc.
# or, to wait for the editor to close before returning:
export VISUAL="code -w"The Patchy workflow for Alfred opens your sites, starts and stops Apache, and switches PHP without a terminal. It needs Alfred 5 with the Powerpack.
- Install: run
patchy alfred, and Alfred asks to install the workflow. Run it again after upgrading Patchy to update the workflow. Or downloadPatchy.alfredworkflowfrom the latest release. - Open a site: type
patchyand pick a site, or keep typing to filter. Enter opens it in your browser. - Start, stop or restart Apache: the top of the same list shows whether Apache is running and what you can do.
- Switch PHP: type
patchy phpand pick a version.
The result shows as a notification. When Patchy needs your password to update
/etc/hosts, a dialog asks for it; run patchy trust once to stop that. To
change the keyword, or to point the workflow at a patchy outside
/opt/homebrew/bin and /usr/local/bin, open the workflow's configuration
in Alfred.
Patchy works well driven by a script or an AI coding agent. The rules:
- Add
--jsonto every command that takes it (most do). The output is only JSON, nothing ever prompts, and failures carry a stableerrorcode (see below).patchy docsprints this whole document. - Read state through commands, never patchy's files. Its config files and
folders are internal and change between releases.
status --jsonandlist --jsoncover what's running, each site's folder, certificate, logs and share. - Don't rely on prompts. Use
add <domain> --yes --no-www(or--www). sudo_requiredneeds the person. Patchy edits/etc/hostswith sudo; ask them to runpatchy trustonce in Terminal, then retry.- Some commands never finish.
shareruns until it is stopped: run it in the background, read lines until thereadyevent, and stop it with SIGTERM. Don't runlogsorerrors -f; useerrors <site> 50 --json. - When a site returns errors, run
check --json, thenerrors <site> 50 --json.
--json gives output for scripts, apps and AI tools. It is the supported
interface: patchy's config files and folders are internal and can change in
any release, so read state through these commands rather than from the files.
patchy version --json reports the version and what this patchy can do, so
an app can check for a feature instead of comparing version numbers:
patchy version --json
# {"version": "1.3.0", "features": ["json", "add_yes", "share", "share_temp",
# "share_anonymous", "share_name", "share_auth"]}list, status and info print one JSON document, and errors [site] [lines] prints the last lines of Apache's or a site's error log (errors -f
has no JSON form):
patchy status --json
# {"schema": 1, "apache": {"running": true},
# "php": {"active": "8.4", "installed": [{"formula": "php", "full": "8.5.12", "version": "8.5"},
# {"formula": "php@8.4", "full": "8.4.27", "version": "8.4"}]},
# "hosts": {"names": 28, "routed": 28, "missing": []},
# "trust": "enabled", "certificate_authority": {"exists": true, "trusted": true},
# "share": {"domain": "example.com", "cloudflared": true},
# "paths": {"error_log": "/opt/homebrew/var/log/httpd/error_log",
# "httpd_conf": "/opt/homebrew/etc/httpd/httpd.conf",
# "php_ini": "/opt/homebrew/etc/php/8.4/php.ini"}}
patchy list --json
# {"schema": 1, "sites": [{"domain": "harbor.test", "docroot": "/Users/you/Websites/harbor",
# "docroot_exists": true, "aliases": ["www.harbor.test"],
# "certificate": {"state": "ok", "days_left": 753, "expires": "2028-11-20T19:29:15Z"},
# "routed": true,
# "logs": {"error": "/opt/homebrew/var/log/httpd/harbor.test-error_log",
# "access": "/opt/homebrew/var/log/httpd/harbor.test-access_log"},
# "share": {"address": "https://harbor.example.com",
# "running": {"url": "https://harbor.example.com", "mode": "stable", "password": true}}}]}
patchy info --json
# {"apache": {"version": "2.4.69", "mpm": "prefork", "built": "Sep 29 2026 12:44:27"},
# "php": {"version": "8.2.35", "ini": "/opt/homebrew/etc/php/8.2/php.ini"}}
patchy errors harbor.test 50 --json
# {"schema": 1, "site": "harbor.test", "log": "/opt/homebrew/var/log/httpd/harbor.test-error_log",
# "lines": ["[Thu Oct 29 ...] ..."]}Every top-level JSON object, including each share event, has "schema": 1.
The number goes up only when a change could break a caller.
In status, php.active is the PHP that patchy php last switched to (its
version matches installed[].version). trust is enabled, outdated
(the helper predates this patchy) or off. certificate_authority.trusted
is false until macOS trusts mkcert's CA; making it trusted shows a macOS
password dialog, so add and refresh-certs may ask once. share.domain is
null until patchy share setup.
In list, docroot_exists is false when a site's folder has been moved or
deleted. A certificate's state is ok, expiring (30 days or fewer),
expired, invalid or none. routed is true when /etc/hosts points
the domain and every alias at this Mac. logs are the files Apache writes
for the site (Apache's main logs for sites added before per-site logs).
share.address is the site's stable address once it has been shared on your
own domain; share.running is null unless a patchy share for the site is
running, and its url is null until the link is ready.
add, rm, folder, start, stop, restart, check, php,
refresh-certs, trust, untrust and share reset print only one result
line:
patchy add harbor.test --json
# {"schema":1,"ok":true,"warnings":[]}
patchy add harbor.com --json
# {"schema":1,"ok":false,"error":"confirmation_required","message":"harbor.com looks like a real domain, ...","details":null,"warnings":[]}
patchy check --json
# {"schema":1,"ok":true,"warnings":[{"code":"docroot_missing","message":"export.test's folder /Users/you/Desktop/export is missing","site":"export.test"}]}error is a code that stays the same between releases; message is for
people and can change. details holds extra lines, such as Apache's startup
error, or is null. warnings lists problems that didn't stop the command,
each with a code, a message and, when it's about one site, the site.
The warning codes are hosts_update_failed and sudo_required (/etc/hosts
wasn't updated), docroot_missing (from check) and tunnel_delete_failed
(from share reset and rm, when Cloudflare kept a tunnel).
With --json patchy never asks questions in the terminal:
addstops withconfirmation_requiredbefore routing a real domain (anything but.testand.local). Add--yesto go ahead. It serveswww.<domain>too for a two-part domain likeharbor.test, unless you add--no-www.- Commands that need
sudo(editing/etc/hostswithoutpatchy trust, andtrustanduntrustthemselves) stop withsudo_requiredinstead of asking for a password.
To let an app ask for the password itself, set SUDO_ASKPASS to a program
that prints it: patchy then runs sudo -A, so sudo asks that program, and
patchy never sees the password. This also lets trust and setup run
without a terminal. mkcert's first -install is the exception: macOS shows
its own password dialog for that, whatever SUDO_ASKPASS says.
share --json runs until stopped, like share, and prints nothing but one
JSON event per line, so it stays parseable even with stderr merged in. The
link isn't copied to the clipboard.
patchy share harbor.test --auth --json
# {"schema":1,"event":"starting","site":"harbor.test"}
# {"schema":1,"event":"ready","site":"harbor.test","url":"https://harbor.example.com","mode":"stable","auth":{"username":"preview","password":"k7Rm-4vQx-92Ld"}}
# {"schema":1,"event":"stopped","site":"harbor.test"}starting comes first, straight away. ready comes once the link works: mode is stable, temp or
anonymous, and auth is null without --auth. The last event is
stopped (exit 0) or error (exit 1), which can also come before ready.
error has the same error code, message and details as above. To stop
a share, send patchy SIGINT or SIGTERM; it cleans up before stopped.
| Code | Meaning |
|---|---|
missing_argument, invalid_argument, unknown_option, conflicting_options |
The command line is wrong |
site_exists, site_not_found |
The site is already set up, or isn't |
folder_not_found |
folder was given a folder that doesn't exist |
confirmation_required |
add needs --yes for a real domain |
sudo_required |
A step needs a password; run patchy trust once |
hosts_update_failed |
/etc/hosts couldn't be changed |
apache_start_failed, apache_not_running |
Apache didn't start (details has its error), or isn't running for a share |
port_in_use |
Apache didn't start because something else holds port 80 or 443; details names it |
service_stuck |
Apache runs fine directly but its launchd service is stuck; patchy stop, then patchy start |
config_invalid |
Apache rejected the configuration |
cert_failed, certificate_missing |
A certificate couldn't be made, or the site has none |
php_not_installed, php_link_failed |
Switching PHP failed |
php_module_missing |
Apache is set up for a PHP that's no longer installed; details says which patchy php to run |
trust_failed, unsafe_path, terminal_required |
trust or untrust failed |
cloudflared_missing, cloudflare_error, cloudflare_login_expired |
cloudflared or Cloudflare failed |
share_domain_required, invalid_name, name_taken |
--name/--auth need your own domain, or the name can't be used |
already_sharing, password_failed, vhost_unsupported |
The share couldn't start |
tunnel_failed, tunnel_stopped, tunnel_deleted |
The tunnel failed to start, stopped, or was deleted on Cloudflare |
json_unsupported |
The command doesn't take --json |
cancelled, failed |
Cancelled at a prompt, or an unexpected failure |
| Site docroots | ~/Websites/<domain> (by convention) |
| Vhost configs | $(brew --prefix)/etc/httpd/sites/<domain>.conf |
| SSL certificates | $(brew --prefix)/etc/httpd/certs/ |
| Per-site logs | $(brew --prefix)/var/log/httpd/<domain>-{access,error}_log |
| Main Apache config | $(brew --prefix)/etc/httpd/httpd.conf |
| Patchy-managed Apache config | $(brew --prefix)/etc/httpd/patchy.conf |
| Patchy | Laravel Valet | Laravel Herd | |
|---|---|---|---|
| Web server | Apache 2 + mod_php | Nginx + PHP-FPM | Nginx + PHP-FPM |
.htaccess & mod_rewrite |
✅ native | ✗ (needs conversion) | ✗ (needs conversion) |
| Local HTTPS | ✅ mkcert | ✅ | ✅ |
| PHP switching | ✅ | ✅ | ✅ |
| GUI | ✅ Patchy UI (community) | — | ✅ |
| License | MIT | MIT | freemium |
If your stack targets Nginx + FPM, Valet or Herd will probably serve you better. If your stack targets Apache — or you just prefer Apache — Patchy is for you.
- macOS
- Homebrew
sudoaccess — needed to edit/etc/hostsduringpatchy setup,start,stop,add, andrm, unless you runpatchy trustonce (see below)zsh(the default shell on modern macOS)
Note: Apache binds ports 80/443 with a wildcard bind, which needs no root on modern macOS but is reachable from your local network (handy for testing from a phone). The macOS firewall may ask once to allow
httpd.
Upgrading from hostess-based Patchy (≤ v0.3.0) or dnsmasq-based Patchy (≤ v0.9.x)? Run
patchy setuponce after upgrading. It migrates any old/etc/hostsentries and, if it finds a dnsmasq-based install, removes the dnsmasq resolver files and Patchy's dnsmasq config, stops the dnsmasq service, and tells you it's safe tobrew uninstall dnsmasq(dnsmasq itself is left installed — removing software is your call). One sudo prompt covers the whole migration. Until you do, sites keep working the old way, but DNS won't stop withpatchy stop.
Every configured site — .test, .local, and real TLDs alike — gets a
127.0.0.1 <domain> # patchy line in /etc/hosts. patchy add writes the
line before creating the vhost; patchy start (re)writes the line for every
configured site, self-healing any that were manually deleted; patchy stop
removes every # patchy line. Only the exact domain is routed — subdomains
and unregistered names are not, and aren't wildcard-matched the way an old
/etc/resolver entry would.
Because /etc/hosts is a plain file mDNSResponder watches directly, changes
take effect the instant they're written — no daemon, no cache to flush, no
stale TTLs. This also means patchy stop releases real-TLD domains back to
public DNS immediately: a site like mywebsite.com starts loading its live,
production version again as soon as Apache stops, and a stop → start
cycle can't leave a site pinned to a stale answer.
The tradeoff for this simplicity is that editing /etc/hosts needs root, so
start/stop/add/rm all touch sudo. Run patchy trust once to remove
the prompts.
patchy trust installs two small, root-owned pieces so day-to-day commands
stop asking for your password:
/usr/local/libexec/patchy-hosts— a minimal root-owned helper script that does exactly one thing: rewrite the# patchy-marked lines of/etc/hoststo match the domain list it's given. It validates every argument as a bare hostname and refuses anything else./etc/sudoers.d/patchy— aNOPASSWDrule scoped to that helper only, validated withvisudo -cfbefore it's installed.
Sudoers is never pointed at bin/patchy itself — the script is a plain file
you (or anyone) can edit, so trusting it with passwordless root would let any
edit run as root unattended. The helper is the only thing sudoers whitelists,
it's root-owned so you can't modify it without sudo, and it can't be talked
into doing anything beyond rewriting Patchy's own /etc/hosts lines.
Run patchy untrust to remove both files; password prompts return.
.local sites are special. macOS reserves .local for Bonjour/mDNS
(printers, AirDrop, other Macs), which is why .local sites have always
used /etc/hosts rather than DNS — that behavior is now simply how every
site works.
# (Optional) remove sites first — cleans up certs and /etc/hosts entries
patchy list # see what's configured
patchy rm <domain> [<domain>...] # remove one or more sites
# Stop Apache and release any remaining /etc/hosts entries
patchy stop
# If you ran 'patchy trust', remove the passwordless helper
patchy untrust
# Remove any patchy-managed /etc/hosts entries that are still present
sudo sed -i '' '/# patchy$/d' /etc/hosts
# Uninstall Patchy
brew uninstall joeworkman/patchy/patchy
# (Optional) remove Homebrew dependencies nothing else is using
brew autoremove
# (Optional) untap the formula
brew untap joeworkman/patchyIf you installed from source: sudo rm /usr/local/bin/patchy.
Issues and pull requests welcome. This is a tool I use every day for real work — improvements that make it more useful or more reliable are gladly accepted. Patchy is bin/patchy (globals, help and command dispatch) plus one module per area in libexec/. Please run for f in bin/patchy libexec/*.zsh; do zsh -n "$f"; done and shellcheck bin/patchy before opening a PR.
The code is MIT — see LICENSE.
The Patchy name and logo (everything in assets/logo/) are not covered by the MIT license. You're welcome to use them to refer to Patchy, for example in a blog post, a tutorial or a link to this repo. Please don't use them for other software or services, or in a way that suggests your project is official or endorsed. Projects built on Patchy, like Patchy UI, can use the logo with permission.