Target: Apple Silicon (arm64) Mac · Stack: Docker
Location: ~/devstack
This document records, in order, how to set up a Docker‑based environment
running 7 PHP versions at once (7.2 – 8.4) with a shared MySQL 8.0,
phpMyAdmin, Node (via fnm), clean .test hostnames, and trusted HTTPS — plus
how to run a real Laravel project on it.
- Prerequisites
- Design decisions
- Directory structure
- The PHP images (7.2–8.4)
- docker-compose stack
- nginx routing
- Building & starting
- The
stackhelper command - Node.js via fnm
- Clean
.testhostnames (dnsmasq) - Trusted HTTPS (mkcert)
- HTTP → HTTPS redirect
- Example: running a Laravel project
- Daily usage cheat sheet
- Adding a new project
- Troubleshooting & gotchas
- Manual (sudo) steps
- Homebrew
- Docker Desktop (must be running — start with
open -a Docker) - Native
arm64builds exist for all PHP images (7.2–8.4), so no emulation is needed.
| Question | Choice | Why |
|---|---|---|
| How to manage 7 PHP versions | Docker, one shared stack | EOL versions (7.2/7.3) "just work"; matches prod; run all at once |
| Do projects get dockerized individually | No | Projects are plain folders in www/; nginx routes to the right PHP |
| Location | ~/devstack |
self‑contained |
| Node | fnm | fast, switch Node per project |
| Database | MySQL 8.0 | modern, compatible with legacy apps |
Key idea: one nginx + one MySQL + 7 PHP‑FPM containers. A project is just a folder; the PHP version is chosen by the URL you use.
~/devstack/
├── docker-compose.yml # all services
├── .env # MySQL creds + timezone (gitignored)
├── php/
│ ├── Dockerfile # one image, reused per version via build arg
│ └── conf.d/zz-devstack.ini # shared PHP settings (upload size, xdebug off…)
├── nginx/
│ ├── conf.d/ # one vhost per version + per-project vhosts
│ │ └── laravel-example.conf.example # copy this for a Laravel app
│ └── certs/ # mkcert TLS cert + key (gitignored)
├── www/ # ← your projects live here
│ └── info/ # sample project (phpinfo + DB check)
├── data/mysql/ # persistent MySQL data (gitignored)
├── bin/stack # helper CLI (symlinked to /opt/homebrew/bin/stack)
├── README.md
└── DOCUMENTATION.md # this file
A single php/Dockerfile is reused for every version via a build arg. It
uses mlocati/php-extension-installer, which handles extension/version/arch
differences uniformly across 7.2 → 8.4:
ARG PHP_VERSION=8.2
FROM php:${PHP_VERSION}-fpm
COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions \
pdo_mysql mysqli gd zip intl bcmath mbstring opcache exif pcntl soap xdebug @composer
WORKDIR /var/www/htmlEvery version therefore includes: Composer, common extensions, and
Xdebug (installed but mode=off by default — see php/conf.d/zz-devstack.ini).
Versions built: 7.2, 7.3, 7.4, 8.0, 8.1, 8.2, 8.4 (no 8.3).
Gotcha: building all 7 in parallel exhausts memory and randomly fails (
EOF/ killed). Fix: build failing versions one at a time (docker compose build php81).
Services (docker-compose.yml):
php72 … php84— 7 PHP‑FPM containers, each built from the shared Dockerfilenginx— routes requests to the right PHP versionmysql— MySQL 8.0, data persisted to./data/mysqlphpmyadmin— web DB admin
Credentials live in .env (copy from .env.example):
MYSQL_ROOT_PASSWORD=root
MYSQL_DATABASE=app
MYSQL_USER=app
MYSQL_PASSWORD=app
TZ=UTC
Each version has a vhost in nginx/conf.d/phpNN.conf with three server blocks:
- port 80 → 301 redirect to HTTPS (for
*.NN.test) - port 443 → serves the project over TLS
localhost:80NN→ plain‑HTTP no‑DNS fallback
Access map:
| PHP | HTTPS hostname | Plain‑HTTP fallback |
|---|---|---|
| 7.2 | https://<proj>.72.test |
http://localhost:8072/<proj>/ |
| 7.3 | https://<proj>.73.test |
http://localhost:8073/<proj>/ |
| 7.4 | https://<proj>.74.test |
http://localhost:8074/<proj>/ |
| 8.0 | https://<proj>.80.test |
http://localhost:8080/<proj>/ |
| 8.1 | https://<proj>.81.test |
http://localhost:8081/<proj>/ |
| 8.2 | https://<proj>.82.test |
http://localhost:8082/<proj>/ |
| 8.4 | https://<proj>.84.test |
http://localhost:8084/<proj>/ |
Other services: phpMyAdmin http://localhost:8888 · MySQL 127.0.0.1:3306
# 1. Start Docker Desktop (must be running)
open -a Docker
# 2. Build the 7 PHP images (build one at a time if parallel fails)
cd ~/devstack && docker compose build
# 3. Start everything
docker compose up -dEvery version reports the correct PHP x.y.z and connects to MySQL 8.0
(tested via the info sample project).
bin/stack, symlinked to /opt/homebrew/bin/stack (on PATH):
stack new <ver> <name> [laravel] # scaffold a project folder + nginx vhost
stack up # start everything
stack down # stop
stack restart # restart containers
stack rebuild # rebuild PHP images + restart
stack ps # status
stack logs [service] # tail logs
stack php <ver> <project> ... # run php in a project e.g. stack php 8.2 myapp -v
stack composer <ver> <project> ... # composer e.g. stack composer 7.4 shop install
stack artisan <ver> <project> ... # laravel artisan e.g. stack artisan 8.2 myapp migrate
stack sh <ver> # shell into a PHP container
stack mysql # mysql client as root(<ver> accepts 8.2 or 82.)
brew install fnm
# add to ~/.zshrc:
eval "$(fnm env --use-on-cd --version-file-strategy=recursive)"
fnm install --lts # e.g. Node v24 LTSNode runs on the host (not in the PHP containers). --use-on-cd auto‑switches
Node version when a project has a .node-version/.nvmrc file.
Makes every *.test domain resolve to 127.0.0.1 with no /etc/hosts edits.
brew install dnsmasq
# config:
# /opt/homebrew/etc/dnsmasq.d/devstack-test.conf -> address=/.test/127.0.0.1
# (conf-dir line added to /opt/homebrew/etc/dnsmasq.conf)Manual step (needs sudo — run in a real Terminal):
sudo brew services start dnsmasq
sudo mkdir -p /etc/resolver
echo "nameserver 127.0.0.1" | sudo tee /etc/resolver/test
sudo dscacheutil -flushcache && sudo killall -HUP mDNSResponderVerify: info.82.test etc. resolve to 127.0.0.1. Survives reboots.
brew install mkcert nss
# one cert covering every version's wildcard + localhost:
cd ~/devstack/nginx/certs
mkcert -cert-file devstack.pem -key-file devstack-key.pem \
"*.72.test" "*.73.test" "*.74.test" "*.80.test" "*.81.test" "*.82.test" "*.84.test" \
localhost 127.0.0.1- Cert + key are mounted into nginx at
/etc/nginx/certs/(port 443 exposed). - Wildcards are one level deep (
a.82.testworks,a.b.82.testdoes not).
Manual step (needs sudo — run in a real Terminal), to trust the CA in browsers:
mkcert -installThen fully quit and reopen the browser.
If the browser says "Not secure", it's almost always because
mkcert -installhasn't been run — verify with:security find-certificate -c "mkcert" /Library/Keychains/System.keychain
Every .test vhost returns 301 → https on port 80; the localhost:PORT
fallbacks stay plain HTTP (no TLS on those ports by design).
Verified:
http://<proj>.82.test→301→https://<proj>.82.test→200http://localhost:8082/info/→ stays200(plain HTTP)
Say you have a Laravel app to run on PHP 8.2. Call it myapp.
# 1. Put the code in the www folder
cd ~/devstack/www
git clone <your-repo-url> myapp # or copy the project in
# 2. Install dependencies at the right PHP version
stack composer 8.2 myapp install
# Legacy project with old, advisory-flagged packages? Temporarily allow them:
# composer config policy.advisories.block false && composer install
# (revert composer.json afterwards to keep your tree clean)
# 3. Add an nginx vhost (copy the example template; .local.conf keeps it out of git)
cp nginx/conf.d/laravel-example.conf.example nginx/conf.d/myapp.local.conf
# edit: server_name myapp.82.test; root .../myapp/public; fastcgi_pass php82:9000;
stack restart
# 4. Create the database
stack mysql
# then: CREATE DATABASE myapp; GRANT ALL ON myapp.* TO 'app'@'%'; FLUSH PRIVILEGES;
# 5. Configure .env (point Laravel at the shared MySQL)
# APP_URL=https://myapp.82.test
# DB_HOST=mysql
# DB_DATABASE=myapp
# DB_USERNAME=app
# DB_PASSWORD=app
stack php 8.2 myapp artisan key:generate
# 6. Bring the schema up (fresh migrate OR import a dump)
stack artisan 8.2 myapp migrate
# — or, if the project is normally seeded from a DB dump:
# docker exec -i devstack-mysql-1 mysql -uroot -proot myapp < dump.sql
# 7. Laravel storage dirs are gitignored — create + make writable after clone
mkdir -p www/myapp/storage/framework/{sessions,views,cache/data} \
www/myapp/storage/logs www/myapp/bootstrap/cache
chmod -R 777 www/myapp/storage www/myapp/bootstrap/cache
# 8. Clear caches
stack php 8.2 myapp artisan config:clearOpen https://myapp.82.test.
- Migrations that don't run from scratch (e.g. foreign keys referencing columns a later migration adds): the project is likely meant to be set up from a DB dump rather than fresh migrations.
- Service providers that query the DB at boot: nothing (not even
artisan) works until the database is populated. Import data first. - Composer refuses old packages flagged by security advisories:
composer config policy.advisories.block false. storage/framework/*missing after clone: it's gitignored — recreate it.gitisn't inside the PHP containers: run git on the host.
stack up # morning: start the stack
stack ps # what's running
stack down # end of day: stop (data persists)
# a project
open https://myapp.82.test
stack artisan 8.2 myapp migrate:status
stack composer 8.2 myapp install
# database
open http://localhost:8888 # phpMyAdmin (root/root or app/app)
stack mysql # CLIFastest — use the scaffolder:
stack new 8.2 <name> # plain PHP project
stack new 7.4 <name> laravel # Laravel (web root = /public)It creates www/<name>/, writes nginx/conf.d/<name>.local.conf, and reloads
nginx. Then put your code in the folder and open the printed URL.
Manually — plain PHP project:
- Put code in
~/devstack/www/<name>/ - Open
https://<name>.82.test(or any version), orhttp://localhost:8082/<name>/
Manually — Laravel/Symfony project (web root is /public):
- Put code in
~/devstack/www/<name>/ cp nginx/conf.d/laravel-example.conf.example nginx/conf.d/<name>.local.conf, editserver_name,root .../public, andphp82→ desired versionstack restartstack composer <ver> <name> install, set up.env(DB_HOST=mysql), create/import the DB- Open
https://<name>.82.test
HTTPS works automatically for any new *.NN.test host — no cert regeneration.
| Symptom | Cause / Fix |
|---|---|
Cannot connect to the Docker daemon |
Docker Desktop not running → open -a Docker |
PHP image build fails randomly (EOF) |
Parallel build OOM → build one at a time |
bind: address already in use |
Another project uses the same host port → change its host port or stack down |
Browser "Not secure" on *.test |
mkcert -install not run, or browser not restarted |
"Not secure" on localhost:PORT |
Expected — those are plain HTTP by design; use the .test hostname |
Laravel 500 Failed to open stream: .../sessions |
storage/framework/* missing → mkdir -p + chmod -R 777 storage bootstrap/cache |
| Laravel DB errors at boot | DB empty/not imported, or .env DB_HOST wrong (should be mysql) |
| Composer refuses to install old packages | composer config policy.advisories.block false |
git not found inside container |
Run git on the host |
These require your admin password, so run them in a real Terminal (not a non‑interactive shell):
- dnsmasq activation — see §10.
- Trust the HTTPS CA (fixes "Not secure"):
then fully quit & reopen your browser.
mkcert -install
When cloning private repos, authenticate once in your own terminal so the
credential is stored in the macOS Keychain — don't hard‑code tokens into
.git/config or commit them. .env, nginx/certs/, and data/ are gitignored
for this reason.
End of documentation.