If you’re running a PHP project inside Docker and using Zed as your editor, you’ve probably noticed the play button next to your test classes. You click it, expecting your tests to run, and it tries to execute vendor/bin/phpunit locally. Since your project lives inside Docker, that fails immediately.
The fix is Zed’s task system combined with a feature called runnable tags, which lets you override what that play button actually does.
The task file
Create a .zed/tasks.json file at the root of your project:
[
{
"label": "PHPUnit: Run Test Method (Docker)",
"command": "docker compose exec -T php php artisan test --filter $ZED_SYMBOL",
"tags": ["phpunit-test"],
"reveal": "always",
"allow_concurrent_runs": false,
"cwd": "$ZED_WORKTREE_ROOT"
},
{
"label": "PHPUnit: Run Test File (Docker)",
"command": "docker compose exec -T php php artisan test $ZED_RELATIVE_FILE",
"reveal": "always",
"cwd": "$ZED_WORKTREE_ROOT"
},
{
"label": "PHPUnit: Run All Tests (Docker)",
"command": "docker compose exec -T php php artisan test",
"reveal": "always",
"cwd": "$ZED_WORKTREE_ROOT"
}
]The key parts:
tags: ["phpunit-test"]connects this task to the play button next to PHPUnit test classes and methods. This is the tag Zed’s PHP extension uses in itsrunnables.scmfile. By adding it to your task, you’re telling Zed “use my command instead of the default.”$ZED_SYMBOLis the test method or class name from the editor’s breadcrumb, so clicking play on a specific test method runs only that method.$ZED_RELATIVE_FILEis the file path relative to the project root, useful for running all tests in a file.-Tdisables pseudo-TTY allocation ondocker compose exec. Without this flag, the command can hang in Zed’s terminal because of how TTY allocation interacts with embedded terminal emulators.cwdensures the command runs from the project root where yourdocker-compose.ymllives.
The first task (with the phpunit-test tag) is what the play button triggers. The other two are available through the task picker with Ctrl+Shift+R.
Adapting the command
If you’re not using Laravel’s
php artisan testwrapper, replace the command with whatever runs PHPUnit in your container. For example:docker compose exec -T php vendor/bin/phpunit --filter $ZED_SYMBOL. The task system doesn’t care what the command is, it just runs it.
If nothing happens when you click play
You set everything up, click the play button, the terminal opens with the right title, but nothing executes. The terminal just sits there, blank.
If you’re running tmux and your shell auto-starts it, this is almost certainly the problem. When Zed opens a terminal to run a task, it starts an interactive shell. If that shell immediately jumps into a tmux session, the task command gets lost.
Here’s what a typical auto-start looks like in a zsh config:
if [[ -o interactive ]] && [[ -z "$TMUX" ]] && command -v tmux >/dev/null 2>&1; then
tmux new-session -s "term-$$"
fiThe fix is to skip tmux when running inside Zed’s terminal. Zed sets the ZED_TERM environment variable in its integrated terminal, so you can check for it:
if [[ -o interactive ]] && [[ -z "$TMUX" ]] && [[ -z "$ZED_TERM" ]] && command -v tmux >/dev/null 2>&1; then
tmux new-session -s "term-$$"
fiThe only addition is && [[ -z "$ZED_TERM" ]]. When Zed spawns its terminal, $ZED_TERM is set and tmux won’t start, so the task runs directly in the shell.
NixOS users
If you manage your zsh config through Home Manager (see my NixOS setup article), this snippet lives in your
programs.zsh.initContentblock. After editing, rebuild withsudo nixos-rebuild switch --flake ...and restart Zed.
How runnable tags work
Zed uses Tree-sitter queries to detect runnable code in your editor. Each language extension ships a runnables.scm file that defines patterns for things like test functions, main methods, or executable scripts.
For PHP, the extension defines two tags:
phpunit-test: matches PHPUnit test classes (names ending in “Test”) and test methods (prefixed withtestor annotated with@test/#[Test])pest-test: matches Pest framework calls (it(),test(),describe())
When Zed finds a match, it shows the play button in the gutter. Clicking it looks for a task with a matching tags entry. If it finds one, it runs your custom command instead of the default.
You can use this same pattern for other frameworks. If you’re using Pest instead of PHPUnit:
[
{
"label": "Pest: Run Test (Docker)",
"command": "docker compose exec -T php vendor/bin/pest --filter $ZED_SYMBOL",
"tags": ["pest-test"],
"reveal": "always",
"cwd": "$ZED_WORKTREE_ROOT"
}
]