Your friendly PHP dev stack helper — a bash CLI that wraps Docker Compose to manage local PHP development stacks. Runs nginx-proxy as a shared reverse-proxy so multiple sites can be served under .test domains simultaneously. Templates, presets, and built-in commands are PHP-oriented; other stacks can be managed but are not first-class.
mise is the only required dependency. It manages all other tools (just, shellcheck, shfmt, bats, lefthook).
curl https://mise.run | shThen add mise activation to your shell config (~/.zshrc, ~/.bashrc):
eval "$(mise activate zsh)" # or bashmise install # install toolchain
just install # symlink butler to /usr/local/bin, install autocomplete and git hooksCopy .env.dist to .env and set your paths:
cp .env.dist .envBUTLER_SITES_DIR=~/Sites/ # where Docker Compose site configs live
BUTLER_PROJECTS_DIR=~/Projects/ # where Git repos live
BUTLER_REQUIRED_CONTEXT=false # if true, site add requires a context subdirectory
BUTLER_TLD=test # local domain TLD (sites served at <name>.test)
NGROK_AUTHTOKEN=changeme # ngrok token for butler proxy
MYSQL_PASSWORD=secret # shared MySQL passwordbutler COMMAND [SITE] [ARGS...]If SITE is omitted from site commands, butler infers the site from your current working directory.
| Command | Description |
|---|---|
butler site add |
Add a new site |
butler site list |
List all sites |
butler site clone [-c CONTEXT] [SITE] <REPO> |
Clone a repo and link it to an existing site |
butler site add --multi |
Add a new multi-project site |
butler site convert [SITE] |
Convert single-project site to multi-project |
butler site cd |
Change directory to a site |
butler site status [SITE] |
Show status of site(s) |
butler site link [SITE] |
Link or repair app symlinks; omit SITE to process all sites |
butler dns install |
Configure dnsmasq for automatic *.test resolution |
butler dns uninstall |
Remove butler DNS configuration |
butler dns status |
Show DNS configuration and resolution state |
| Command | Description |
|---|---|
butler up [SITE] |
Start container stack |
butler down [SITE] |
Stop container stack |
butler restart [SITE] |
Restart container stack |
butler exec [SITE] [PROJECT] <cmd> |
Execute command on app container |
butler shell [SITE] [PROJECT] |
Open a shell in the app container |
butler php [SITE] <args> |
Run php on the container |
butler composer [SITE] [PROJECT] <args> |
Run composer on the container |
butler run [SITE] <script> |
Run a custom script |
butler scripts [SITE] |
List available scripts for the current site |
butler proxy [SITE] |
Proxy site through ngrok |
| Command | Description |
|---|---|
butler templates |
List available site templates |
butler mysql |
Open a MySQL shell |
butler ftp |
Run an ephemeral FTP server |
butler sftp |
Run an ephemeral SFTP server |
Butler can configure dnsmasq to resolve all *.<BUTLER_TLD> domains to 127.0.0.1 automatically — no manual /etc/hosts entries needed after butler site add.
butler dns install # configure dnsmasq for *.test resolution
butler dns uninstall # remove butler DNS configuration
butler dns status # show configuration and resolution stateInstall dnsmasq first if it is not already present (brew install dnsmasq, sudo pacman -S dnsmasq, sudo apt install dnsmasq, etc.), then run butler dns install.
| Platform | Routing mechanism |
|---|---|
| macOS | /etc/resolver/<tld> pointing to dnsmasq on port 5300 |
| Linux + systemd-resolved | /etc/systemd/resolved.conf.d/butler.conf routing ~<tld> to dnsmasq |
| Linux + NetworkManager | /etc/NetworkManager/dnsmasq.d/butler.conf |
| WSL2 | Requires systemd enabled — butler dns install will guide you if it is not |
Without DNS configured, add entries to /etc/hosts manually:
127.0.0.1 mysite.test
Butler includes a watcher service that boots a site automatically when you browse to its .test domain — no need to butler up first.
When nginx-proxy receives a request for a domain with no running container, it falls through to the watcher. The watcher reads the Host header, maps it to a site directory in BUTLER_SITES_DIR, runs docker compose up -d in the background, and returns a page that auto-refreshes in five seconds once the stack is ready.
The watcher starts automatically alongside nginx-proxy the first time you run any docker-compose command.
Butler maintains two separate directory trees:
- Sites (
BUTLER_SITES_DIR) — Docker Compose configs, one per site. Each containsdocker-compose.yml, optionalhooks/(lifecycle hooks sourced before the matching compose command), optionalscripts/(user-facing commands exposed viabutler run), and anappsymlink (single-project) orapp/directory of named symlinks (multi-project) pointing to the project(s). - Projects (
BUTLER_PROJECTS_DIR) — Git repository clones. Optionally nested under context subdirs (e.g.Projects/work/mysite) whenBUTLER_REQUIRED_CONTEXT=true.
Butler resolves the current site by scanning app symlinks in BUTLER_SITES_DIR — so running butler shell from anywhere inside a project directory just works.
A site's app is a symlink to one project directory. The project name is inferred from the current working directory, or overridden in site.env:
# Sites/mysite/site.env
BUTLER_PROJECT=custom-name # override inferred project name
BUTLER_PROJECT_DIR=/custom/path # override resolved project directory
BUTLER_APP_CONTAINER=php # container name for exec/shell/composerUse .env in the site directory for local overrides (passwords, domain overrides, extra compose files) that should not be committed.
# Sites/mysite/.env
BUTLER_COMPOSE_FILES=docker-compose.xdebug.yml # colon-separated extra compose filesBUTLER_COMPOSE_FILES accepts relative paths (resolved against the site directory) or absolute paths. Useful for layering Xdebug, local volume mounts, or other per-developer overrides on top of the committed docker-compose.yml.
A multi-project site has app/ as a directory of named symlinks, one per project. Declare the projects in site.env:
# Sites/mysite/site.env
BUTLER_PROJECTS=project-a,project-bButler automatically exports BUTLER_PROJECT_<NAME> for each project, which docker-compose.yml can use for domains — the same way single-project sites use $BUTLER_PROJECT:
services:
project-a:
environment:
VIRTUAL_HOST: "${BUTLER_PROJECT_PROJECT_A}.${BUTLER_TLD:-test}"
project-b:
environment:
VIRTUAL_HOST: "${BUTLER_PROJECT_PROJECT_B}.${BUTLER_TLD:-test}"Override a domain without changing docker-compose.yml by setting the var in .env:
# Sites/mysite/.env
BUTLER_PROJECT_PROJECT_A=other-domain # serves as other-domain.test insteadFor exec, shell, and composer, butler selects the container automatically when run from inside a project directory. From outside, pass the project name as an argument or choose from the picker:
butler shell mysite project-a # explicit
butler shell mysite # fzf/numbered pickerjust fmt # format all shell scripts with shfmt
just lint # lint all shell scripts with shellcheck
just test # run bats test suiteFormatting and linting are enforced automatically on every commit via lefthook.