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:

.zed/tasks.json
[
  {
    "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 its runnables.scm file. By adding it to your task, you’re telling Zed “use my command instead of the default.”
  • $ZED_SYMBOL is 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_FILE is the file path relative to the project root, useful for running all tests in a file.
  • -T disables pseudo-TTY allocation on docker compose exec. Without this flag, the command can hang in Zed’s terminal because of how TTY allocation interacts with embedded terminal emulators.
  • cwd ensures the command runs from the project root where your docker-compose.yml lives.

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 test wrapper, 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-$$"
fi

The 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-$$"
fi

The 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.initContent block. After editing, rebuild with sudo 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 with test or 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:

.zed/tasks.json
[
  {
    "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"
  }
]