Skip to content
joeworkmanPublic

About

A patchy little PHP dev server for macOS (running Apache)

Resources

Stars

6 stars

Watchers

2 watching

Forks

Latest commit

 

History

50 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Patchy logo: a feather sewn from fabric patches

Patchy

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.

Why Patchy?

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.

What you get

  • 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 instant patchy stop runs
  • 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.conf you can read and edit — no black box

Install

brew install joeworkman/patchy/patchy

Prefer 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.

From source

git clone https://github.com/joeworkman/patchy
cd patchy
sudo ln -s "$PWD/bin/patchy" /usr/local/bin/patchy
patchy setup

Quick start

# 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.test

Using a real domain locally

Adding 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.

www alias

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>

Sharing a site

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.

Stable addresses on your own domain

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-92Ld

The 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.

Commands

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.

Editor

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"

Alfred

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 download Patchy.alfredworkflow from the latest release.
  • Open a site: type patchy and 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 php and 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.

Scripts and AI agents

Patchy works well driven by a script or an AI coding agent. The rules:

  • Add --json to every command that takes it (most do). The output is only JSON, nothing ever prompts, and failures carry a stable error code (see below). patchy docs prints this whole document.
  • Read state through commands, never patchy's files. Its config files and folders are internal and change between releases. status --json and list --json cover 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_required needs the person. Patchy edits /etc/hosts with sudo; ask them to run patchy trust once in Terminal, then retry.
  • Some commands never finish. share runs until it is stopped: run it in the background, read lines until the ready event, and stop it with SIGTERM. Don't run logs or errors -f; use errors <site> 50 --json.
  • When a site returns errors, run check --json, then errors <site> 50 --json.

JSON output

--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": "[email protected]", "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:

  • add stops with confirmation_required before routing a real domain (anything but .test and .local). Add --yes to go ahead. It serves www.<domain> too for a two-part domain like harbor.test, unless you add --no-www.
  • Commands that need sudo (editing /etc/hosts without patchy trust, and trust and untrust themselves) stop with sudo_required instead 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.

Error codes

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

Where things live

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

How it compares

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.

Requirements

  • macOS
  • Homebrew
  • sudo access — needed to edit /etc/hosts during patchy setup, start, stop, add, and rm, unless you run patchy trust once (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 setup once after upgrading. It migrates any old /etc/hosts entries 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 to brew 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 with patchy stop.

How routing works

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.

Trust: passwordless /etc/hosts updates

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/hosts to match the domain list it's given. It validates every argument as a bare hostname and refuses anything else.
  • /etc/sudoers.d/patchy — a NOPASSWD rule scoped to that helper only, validated with visudo -cf before 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.

Uninstall

# (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/patchy

If you installed from source: sudo rm /usr/local/bin/patchy.

Contributing

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.

License

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.

About

A patchy little PHP dev server for macOS (running Apache)

Resources

Stars

6 stars

Watchers

2 watching

Forks

Releases

Packages

Contributors

Languages