This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
PHP DevForge is infrastructure, not an application: a Docker Compose stack (Apache + PHP-FPM 8.3/8.4 + dnsmasq + Postgres) that serves any PHP project dropped into /home/php-devforge/public_html under an auto-generated wildcard HTTPS domain. There is no PHP application code, no test suite, and no build system here — only Dockerfiles, server config, Lua scripts, and shell installers.
Nothing hardcodes where the repo lives — aliases.bash resolves its own location dynamically via BASH_SOURCE, and bin/forge does the same via $0. $HOME/php-devforge-config is only bootstrap.sh's default clone target for the one-line install (--dir=/PHP_DEVFORGE_HOME override it); a manual git clone can go anywhere.
forge start # start
forge stop # stop
forge logs 8.4 # follow one version's logs
forge restart apachedev # recreate one container
forge help # every command
docker compose build apachedev # rebuild one image
docker compose config # validate compose + .env interpolation
./install_cert.sh # install mkcert + generate wildcard cert into ./certificates
./setup-local-dns.sh # point system resolver at 127.0.0.1 (dnsmasq)
./setup-local-dns.sh --show # inspect current DNS config
./setup-local-dns.sh --restore ~/.dns-backup-<timestamp> # undo DNS changesforge is the interface; reach for docker compose only for the two things it
does not wrap. The forge:start / forge:use:php84 aliases this file used to
teach no longer exist — they only worked in bash, and bin/forge replaced
them. aliases.bash survives as a one-line PATH helper for when ~/.local/bin
is not on PATH, or for a second checkout; it defines no aliases.
Switching the default PHP version is forge use 8.4, which writes PHP_VERSION
in .env and recreates the containers. It has to live in .env rather than an ad
hoc export: it is read at compose-parse time (depends_on: php${PHP_VERSION}dev)
and again at runtime by the web server.
Inside a PHP container (forge shell 8.4, or forge run <command> for one-offs):
xdebug # toggle Xdebug for CLI + FPM
xdebug --force-activate # or --force-deactivate
xdebug /path/script.php # run one script with Xdebug on, then turn it back offThis is the core mechanism and it spans three files.
- DNS —
dnsmasqresolves*.${DEV_DOMAIN}(defaultphpforge.dev) to127.0.0.1.setup-local-dns.shinserts127.0.0.1as the system's first nameserver (NetworkManager → systemd-resolved → rawresolv.conf, in that order of preference). - Docroot resolution —
docker-library/httpd/config_files/resolve_docroot.luaruns asLuaHookTranslateName. It strips the--pNNsuffix and the base domain from the Host header, splits the remainder on--(or on.), reverses the segments, and joins with/under/home/php-devforge/public_html. Sopublic--site--laravel.phpforge.dev→/home/php-devforge/public_html/laravel/site/public. - PHP version routing —
resolve_docroot.lua(set_php_handler, aLuaHookFixups) picksproxy:fcgi://phpNNdev:9000from the--pNNsuffix, falling back toPHP_VERSION. Each version is a compose profile (php83,php84,php85) listed inCOMPOSE_PROFILES, so only the installed ones run; the same variable reaches Apache and nginx asENABLED_PROFILES, and the Lua answers a request for a version that is not installed with a page instead of a bare 503.COMPOSE_PROFILESalways contains the default version —forge php offrefuses to remove it, and a.envwith nophp*entry is migrated byforge.
docker-library/httpd/config_files/php-fpm.conf is a legacy/unused fallback — the active handler selection lives in the two vhost files. Both vhosts are duplicates apart from the SSLEngine block; changes to routing must be applied to both.
An nginx/OpenResty alternative exists under docker-library/nginx/ (same reversal logic, in resolve_docroot.lua's rewrite_by_lua_file hook) as the nginxdev service, gated behind the nginx profile — it replaces apachedev rather than adding to it, since both want ports 80 and 443. It is equivalent to the Apache path, including the tunnel mechanism (.tunnel/ lookup and --php pin): site.conf.tpl carries the same default_server catch-all devlocal-common.conf needed on the Apache side, for the same reason (an unmatched host otherwise falls to the first server block in the file, Mailpit's).
docker-compose.yml—mkcert(one-shot,toolsprofile),dnsmasq,apachedev,nginxdev(nginxprofile — it replaces Apache, both want 80 and 443), andphp83dev/php84dev/php85dev, one profile each.docker-compose.override.ymladds the optional services:postgres16/17/18dev(127.0.0.1:5416/5417/5418),mariadb11/12dev(3311/3312),mailpit,cloudflared(tunnelprofile),es8143devandkibana. The three files are joined viaCOMPOSE_FILEin.env, withdocker-compose.local.ymllast so your own overrides win.- Code sharing: a named volume
dataphp-devforgemounts at/home/php-devforgein every container, andPROJECTS_DIRfrom.envis bind-mounted over/home/php-devforge/public_html. Apache gets/home/php-devforgeread-only butpublic_htmlread-write. - The PHP user is
php-devforge, UID/GID 1000 in the image, remapped at startup toPUID/PGIDfrom.env(usermod -o -u) so host and container ownership line up. That is why nochownis needed: the README's oldsudo chown yourUser:www-data -R /home/php-devforgewas removed inf7ead35when this replaced it. The php-fpm master runs as root so it can drop its workers tophp-devforge—zz-docker.confsets that and is read after the upstreamwww.conf, which still sayswww-data. Workers never run as root. - One Dockerfile serves every PHP version (
docker-library/php/Dockerfile,PHP_TAGbuild arg). What the services share lives inphp-base.yml, pulled in withextends:rather than a YAML anchor — an anchor merge replaces a service'svolumeslist,extendsappends to it, which is what lets each version add its own./custom/<version>mount.custom/php.dapplies to every version;custom/<version>/php.dis read after it and wins.ensure_custom_dirs()inlib/compose.shcreates those folders before compose can, since Docker would create a missing bind source as root. docker-library/php/config_files/docker-php-entrypointreplaces the upstream entrypoint: it remaps the user toPUID/PGID, injects the shared bashrc for both the dev user and root, force-activates Xdebug whenPHP_XDEBUG=yes, installs~/script/cron_php-devforgeas aphp-devforgecrontab (crontab -u php-devforge), starts cron, and ends in a plainexec "$@"as root — see the note above on why the master needs it.
There is no TASKS.md (retired) and no issue-tracking convention in this repo
beyond git history and PR descriptions. Open questions, decisions, and
in-progress/Needs discussion/Not doing items live in a GitHub Project:
https://github.com/users/zelti/projects/2 ("PHP DevForge Roadmap"), owned by
the repo owner's personal account, not a repo-linked project. Each item is a
draft issue (title + full write-up in the body, no separate Issue page) with a
Status of Needs discussion, Todo, In Progress, Done, or Not doing.
Read it with gh project item-list 2 --owner zelti; it needs the project
OAuth scope (gh auth status shows current scopes; gh auth refresh -s project adds it if missing).
- Comments and script output in this repo are in Spanish;
README.mdandDOCS.mdare in English,README.es.mdandDOCS.es.mdtheir translations. Edit a page and its translation together. Match the surrounding language when editing a file. - Rebuilding a PHP image is expensive (compiles GD/intl/etc., PECL builds, clones nvm, installs Node 24). Prefer changes to mounted config over Dockerfile changes when both are possible.
The tracker is the GitHub Project linked below, not a list in this file. A list
here cannot be closed by a commit, so it only ever drifts: the five entries that
used to sit in this spot were all fixed long ago and still read as live warnings
— a wrong certificate name, a broken mkcert branch, a forge: alias that no
longer exists, an Xdebug port that had already moved from 9000 to 9003. Anyone
who trusted them went looking for bugs that were not there.
Check the tree first and the Project second; add a finding to the Project rather than to this page.