My PHP projects don't run PHP on the host. This site runs in DDEV, CaRMen runs in a docker compose stack, and whatever PHP the host has is irrelevant to both. That's fine for the application and awkward for the editor: running a test, PHPStan or the code-style fixer from Emacs means running it inside the right container, from the right directory, and then making sense of file paths that only exist inside that container.
In early October I added a small Emacs file, php-tools.el, that handles this in one place. It's about 260 lines. The ideas in it are useful even if you never copy a line of it.
One function that knows where PHP lives
Everything goes through a single function that turns a list of arguments into a shell command for the current project. It decides where to run things like this:
- a project with a
.ddev/directory runs commands throughddev exec, and its code lives at/var/www/htmlinside the container - a docker compose project says how to reach its PHP container in its own
.dir-locals.el, for example("docker" "compose" "exec" "-T" "php")and a container root of/app - anything else runs on the host
The project root is the nearest directory with a composer.json, falling back to whatever project.el thinks the project is. Tests, PHPStan, the fixers, Composer and Xdebug all call the same function, so there's exactly one place that knows about containers.
The two variables a compose project sets are marked with a safe-local-variable predicate: a list of strings for the command, a string for the root. That lets Emacs apply them from .dir-locals.el without asking every time, while anything that doesn't match those shapes still triggers the usual prompt.
Quoting through ddev exec
The one fiddly part was quoting, because ddev exec doesn't pass its arguments through untouched. Testing ddev 1.25.4 shows what it does: it builds a single bash -c command line inside the container, puts double quotes around arguments that contain spaces, and inserts the others as they are. So ddev exec printf '<%s>\n' 'two words' fails, because the unquoted <%s> becomes a redirect, and an argument like '$HOME' still gets expanded by the container's shell.
That matters for running a single test, because the filter is a regular expression full of characters a shell cares about. So for DDEV, the helper quotes every argument itself and hands ddev exec the whole command as one argument. Run through a shell exactly as Emacs runs it, the command printed both a two-word argument and the regular expression intact.
Running the test at point
Running one test needs to know which test the cursor is in. The file uses tree-sitter for that: from the node at point, it walks up the syntax tree to the enclosing method declaration, or for Pest, to the enclosing it(...) or test(...) call and its description string.
For a PHPUnit method named testLogin, the filter becomes ::testLogin( with data set .*)?$. The optional part is there because PHPUnit names each run of a test that uses a data provider with the data set appended to the name, as in testLogin with data set #0 or testLogin with data set "name", and the filter should match all of them but not testLoginFails. With no test at point, it runs the whole file. The last command is remembered, so rerunning is one key.
Clickable errors with container paths
Output goes to a compilation buffer, so file names and line numbers in PHPUnit and PHPStan output become links. The catch is that the tools print paths as they exist inside the container: /var/www/html/src/... under DDEV, /app/src/... in the compose project. Emacs can't open those.
Emacs has a variable for exactly this, compilation-transform-file-match-alist: a list of regular expression and replacement pairs applied to file names in compiler messages before Emacs tries to open them, while the buffer keeps showing the original text. The helper adds one entry that strips the container root from the front of the path. The relative path that remains resolves against the project root on the host, and the links work.
There's one more detail: the output buffer isn't visiting a file, so directory-local variables wouldn't normally apply in it. The compilation mode calls hack-dir-local-variables-non-file-buffer first, which looks them up from the buffer's directory, so a compose project's container root is known there too.
Fixers and their exit codes
Reformatting picks the project's own tool: php-cs-fixer if the project has its config file, phpcbf if it has a phpcs.xml, and the language server's formatter otherwise. phpcbf needed one exception. In PHP_CodeSniffer 3.6, the version I checked, it exits with 0 when there was nothing to fix, 1 when it fixed everything it could, and 2 when some problems remain. Treating any non-zero exit as failure would report an error on every successful fix, so the helper accepts 0 and 1.
Debugging
Xdebug goes through dape, whose built-in Xdebug configuration listens on port 9003. The helper adds a project variant with path mappings from the container root to the project directory on the host, and a command that runs ddev xdebug on in DDEV projects before it starts listening. I've written about the Xdebug side of a Docker setup before; this is the editor half.
All of it hangs off a small menu on C-c m in PHP buffers, plus a few keys borrowed from PhpStorm. The main point isn't the keys. It's that one function decides where PHP runs and one setting maps the paths back, so no individual command has to know about containers.
Leave a comment
Comments are reviewed before they appear.