⏻
HELGE SVERREAll-stack Developer
Bergen, Norway • v13.0
est. 2012  |  197 repos  |  12.8k+ contributions
Tools  |   Theme:
Switch PHP Version When Entering an Isolated Herd Site
September 27, 2026

herd isolate 8.3 pins a PHP version for a site, but only for the web server. In the terminal, in the same directory, php is still Herd's global default:

$ cd ~/Herd/my-project
$ herd isolated | grep my-project
| my-project.test | 8.3 |
$ php -v
PHP 8.4.25 (cli) ...

So composer require can resolve packages for the wrong PHP version, and php artisan runs on a version production does not have.

This guide sets up a shell hook that switches php (and with it composer, artisan and vendor/bin tools) to the isolated version when you cd into the site, and back when you leave. Herd runs on macOS and Windows only; Linux readers can skip to Linux.

Terminal recording: in ~/Herd/my-project, herd isolate 8.3 succeeds but php -v still reports 8.4.25; after loading the hook, php and composer report 8.3.33, also in app/Models; cd other-project switches back to 8.4.25

If you would rather install it than paste it into your shell config, the hooks are packaged as herd-php-autoswitch. It installs through antidote, zinit, oh-my-zsh or Fisher, and also covers Nushell and Windows PowerShell 5.1. It finds sites the way Herd does, so linked and parked sites work (see Limitations).

The rest of this guide shows how the hook works and gives a standalone version for each shell.

Before you start

  1. Isolate the site, if you have not already. The PHP version must be installed in Herd (Herd → Settings → PHP):

    cd ~/Herd/my-project
    herd isolate 8.3
    
  2. Find out which shell you use. On macOS, run echo $SHELL; the default is zsh. On Windows, use the PowerShell section.

The hook works by changing PATH, the ordered list of folders your shell searches for commands. The first php it finds is the one that runs. Each time you change directory, the hook puts the isolated version's folder first, or removes it again.

What Herd gives you out of the box

Herd ships proxy commands that already respect isolation:

herd php -v          # PHP 8.3.33
herd php artisan migrate
herd composer install
herd which-php       # path to the isolated binary

These only work from the site's root directory. Herd looks the site up by the current directory (its link or its name), so in a subdirectory they fall back to the global version:

$ cd ~/Herd/my-project/app/Models
$ herd php -r 'echo PHP_VERSION;'
8.4.25

As of Herd 1.30 there is no setting that makes plain php follow isolation. (herd isolate-node writes an .nvmrc file, which nvm or fnm pick up in the terminal. There is no PHP equivalent.)

If you always run commands from the project root and don't mind the herd prefix, that is enough. Otherwise, set up the hook for your shell below, or install herd-php-autoswitch.

zsh (macOS)

Create ~/.config/zsh/herd-isolate.zsh (run mkdir -p ~/.config/zsh first) with this content:

_herd_isolate_valet="$HOME/Library/Application Support/Herd/config/valet"
_herd_isolate_bin="$HOME/Library/Application Support/Herd/bin"
_herd_isolate_shims="$HOME/.cache/herd-isolate"
_herd_isolate_tld=$(sed -n 's/.*"tld": *"\([^"]*\)".*/\1/p' "$_herd_isolate_valet/config.json" 2>/dev/null)
: ${_herd_isolate_tld:=test}

_herd_isolate_apply() {
  local dir=$PWD version= first_line conf

  while [[ -n $dir && $dir != / ]]; do
    conf="$_herd_isolate_valet/Nginx/${dir:t}.$_herd_isolate_tld"
    if [[ -r $conf ]]; then
      read -r first_line < $conf
      [[ $first_line == '# ISOLATED_PHP_VERSION='* ]] && version=${first_line#*=}
      break
    fi
    dir=${dir:h}
  done

  path=(${path:#$_herd_isolate_shims/*})

  if [[ -n $version ]]; then
    local shim="$_herd_isolate_shims/${version//./}"
    if [[ ! -x $shim/php ]]; then
      mkdir -p $shim && ln -sf "$_herd_isolate_bin/php${version//./}" $shim/php
    fi
    path=($shim $path)
  fi
}

autoload -Uz add-zsh-hook
add-zsh-hook chpwd _herd_isolate_apply
_herd_isolate_apply

Open ~/.zshrc (open -e ~/.zshrc) and add this as the last line:

source ~/.config/zsh/herd-isolate.zsh

It has to come after the line Herd added, which looks like this:

export PATH="/Users/you/Library/Application Support/Herd/bin/":$PATH

If you use oh-my-zsh, do not put the file in $ZSH_CUSTOM: oh-my-zsh loads that folder before Herd's PATH line.

Reload the shell:

exec zsh

bash (macOS)

bash has no hook for directory changes, so this version runs from PROMPT_COMMAND and returns immediately when the directory has not changed. It works with the bash 3.2 that ships with macOS and with bash 5.

Create ~/.config/bash/herd-isolate.bash (run mkdir -p ~/.config/bash first) with this content:

_herd_isolate_valet="$HOME/Library/Application Support/Herd/config/valet"
_herd_isolate_bin="$HOME/Library/Application Support/Herd/bin"
_herd_isolate_shims="$HOME/.cache/herd-isolate"
_herd_isolate_tld=$(sed -n 's/.*"tld": *"\([^"]*\)".*/\1/p' "$_herd_isolate_valet/config.json" 2>/dev/null)
: "${_herd_isolate_tld:=test}"

_herd_isolate_apply() {
  local rc=$?
  [[ $PWD == "$_herd_isolate_pwd" ]] && return $rc
  _herd_isolate_pwd=$PWD
  local dir=$PWD version= first_line conf p new= IFS=:
  while [[ -n $dir ]]; do
    conf="$_herd_isolate_valet/Nginx/${dir##*/}.$_herd_isolate_tld"
    if [[ -r $conf ]]; then
      read -r first_line < "$conf"
      [[ $first_line == '# ISOLATED_PHP_VERSION='* ]] && version=${first_line#*=}
      break
    fi
    dir=${dir%/*}
  done
  for p in $PATH; do
    [[ $p == "$_herd_isolate_shims"/* ]] || new=${new:+$new:}$p
  done
  PATH=$new
  if [[ -n $version ]]; then
    local shim="$_herd_isolate_shims/${version//./}"
    [[ -x $shim/php ]] || { mkdir -p "$shim" && ln -sf "$_herd_isolate_bin/php${version//./}" "$shim/php"; }
    PATH="$shim:$PATH"
  fi
  return $rc
}
[[ $PROMPT_COMMAND == *_herd_isolate_apply* ]] ||
  PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND;}_herd_isolate_apply"

Add this as the last line of ~/.bashrc:

source ~/.config/bash/herd-isolate.bash

Terminal.app starts bash as a login shell, which reads ~/.bash_profile and not ~/.bashrc. If your ~/.bash_profile does not already load ~/.bashrc, add this to it:

[[ -f ~/.bashrc ]] && source ~/.bashrc

Open a new terminal tab to load it.

An existing PROMPT_COMMAND (from starship, direnv, etc.) is kept. The hook adds itself at the end, so it runs after tools that change PATH on each prompt, and it passes through the exit status of your last command, so prompts that show failures keep working.

The switch happens when the next prompt is drawn, not during cd. A one-liner like cd ~/Herd/other-project && php -v still runs on the previous directory's version. Run the cd and the command on separate lines.

This section uses macOS paths. It does not apply to Git Bash on Windows.

fish (macOS)

Requires fish 3.5 or later.

Create ~/.config/fish/conf.d/herd-isolate.fish with this content. fish loads files in conf.d automatically:

set -g __herd_isolate_valet "$HOME/Library/Application Support/Herd/config/valet"
set -g __herd_isolate_bin "$HOME/Library/Application Support/Herd/bin"
set -g __herd_isolate_shims "$HOME/.cache/herd-isolate"
set -g __herd_isolate_tld (cat $__herd_isolate_valet/config.json 2>/dev/null | string match -rg '"tld": *"([^"]*)"')
set -q __herd_isolate_tld[1]; or set __herd_isolate_tld test

# PWD covers cd; fish_preexec re-applies right before each command, after direnv and
# similar tools have updated PATH on the prompt, and after config.fish has run.
function __herd_isolate_apply --on-variable PWD --on-event fish_preexec
    set -l dir $PWD
    set -l php_version
    while test "$dir" != /
        set -l conf "$__herd_isolate_valet/Nginx/"(path basename $dir).$__herd_isolate_tld
        if test -r $conf
            read -l first_line <$conf
            set php_version (string replace -f '# ISOLATED_PHP_VERSION=' '' -- $first_line)
            break
        end
        set dir (path dirname $dir)
    end
    set -gx PATH (string match -v -- "$__herd_isolate_shims/*" $PATH)
    if set -q php_version[1]
        set -l shim $__herd_isolate_shims/(string replace -a . '' $php_version)
        if not test -x $shim/php
            mkdir -p $shim; and ln -sf $__herd_isolate_bin/php(string replace -a . '' $php_version) $shim/php
        end
        set -gx PATH $shim $PATH
    end
end

Reload:

exec fish

The function runs on every directory change (--on-variable PWD), including cd in one-liners, and again right before each command (fish_preexec). The second trigger makes it run after config.fish and after tools such as direnv that update PATH when the prompt is drawn.

The variable is named php_version and not version on purpose: version is a read-only variable in fish that holds fish's own version number.

PowerShell (Windows)

Herd for Windows uses the same herd isolate command and keeps the same kind of nginx file per site, in $HOME\.config\herd\config\valet\Nginx, with a .conf extension. Each PHP version has its own folder, such as $HOME\.config\herd\bin\php83\php.exe. Instead of creating symlinks, which on Windows need admin rights or Developer Mode, this version puts the right folder first on PATH.

This snippet needs PowerShell 7 (pwsh). Windows PowerShell 5.1 has no LocationChangedAction, so the snippet does nothing there; the herd-php-autoswitch module falls back to a prompt hook on 5.1.

Create your profile if it does not exist yet, and open it. CurrentUserAllHosts also covers the terminal inside VS Code:

if (-not (Test-Path $PROFILE.CurrentUserAllHosts)) { New-Item -ItemType File -Path $PROFILE.CurrentUserAllHosts -Force }
notepad $PROFILE.CurrentUserAllHosts

Add this to the profile:

$HerdIsolate = @{ Valet = "$HOME\.config\herd\config\valet"; Bin = "$HOME\.config\herd\bin"; Added = $null }
$HerdIsolate.Tld = (Get-Content "$($HerdIsolate.Valet)\config.json" -Raw -ErrorAction Ignore | ConvertFrom-Json).tld
if (-not $HerdIsolate.Tld) { $HerdIsolate.Tld = 'test' }

function Update-HerdIsolatePath {
    $version = $null
    $dir = if ($PWD.Provider.Name -eq 'FileSystem') { $PWD.ProviderPath }
    while ($dir -and -not $version) {
        $site = "$($HerdIsolate.Valet)\Nginx\$(Split-Path $dir -Leaf).$($HerdIsolate.Tld)"
        $conf = "$site.conf", $site | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
        if ($conf) {
            if ((Get-Content -LiteralPath $conf -TotalCount 1) -match '^# ISOLATED_PHP_VERSION=(\S+)') {
                $version = $Matches[1] -replace '\.'
            }
            break
        }
        $dir = Split-Path $dir -Parent
    }
    $sep = [IO.Path]::PathSeparator
    $paths = ($env:Path -split $sep) -ne $HerdIsolate.Added
    $HerdIsolate.Added = if ($version) { "$($HerdIsolate.Bin)\php$version" }
    $env:Path = (@($HerdIsolate.Added) + $paths | Where-Object { $_ }) -join $sep
}

$ExecutionContext.SessionState.InvokeCommand.LocationChangedAction = [Delegate]::Combine(
    $ExecutionContext.SessionState.InvokeCommand.LocationChangedAction,
    [EventHandler[System.Management.Automation.LocationChangedEventArgs]] { Update-HerdIsolatePath })
Update-HerdIsolatePath

Reload:

. $PROFILE.CurrentUserAllHosts

LocationChangedAction runs after every cd, Set-Location, Push-Location and Pop-Location. The handler is added to any existing one, and the prompt function is not touched, so oh-my-posh and starship keep working. composer.bat runs php from PATH, so it follows the switch.

Tested with PowerShell 7 on Windows, macOS and Linux against a copy of Herd for Windows' folder layout, not with Herd for Windows itself. The file locations come from Herd's Windows documentation. Two details are assumed from the macOS version: that the first line of the nginx file is # ISOLATED_PHP_VERSION=8.3, and that the TLD is in config\valet\config.json (if it is not, the snippet uses test). Check the first one on your machine:

Get-Content "$HOME\.config\herd\config\valet\Nginx\my-project.test.conf" -TotalCount 1

It should print # ISOLATED_PHP_VERSION=8.3. If it prints something else, the hook cannot detect the site.

Verify

Open a new terminal, including any terminal inside PhpStorm or VS Code, which only load the new config on restart.

macOS:

$ cd ~/Herd/my-project
$ php -v | head -1
PHP 8.3.33 (cli) ...
$ composer --version 2>&1 | grep 'PHP version'
PHP version 8.3.33 (.../Herd/bin/php83)
$ cd app/Models && php -r 'echo PHP_VERSION;'
8.3.33
$ cd ~ && php -r 'echo PHP_VERSION;'
8.4.25

(In bash, run each cd on its own line; see the bash section.)

Windows:

cd ~\Herd\my-project
(Get-Command php).Source   # ...\.config\herd\bin\php83\php.exe
php -v

How it works

Herd records isolation in the site's nginx config. After herd isolate 8.3, the first line of ~/Library/Application Support/Herd/config/valet/Nginx/my-project.test is:

# ISOLATED_PHP_VERSION=8.3

herd php and herd which-php read the same line (Site::customPhpVersion() in the Valet CLI bundled with Herd).

On every directory change, the hook:

  1. Walks up from the current directory until it finds a directory with a matching nginx config (<dirname>.<tld>). The TLD is read once from Herd's config.json, so a custom TLD works.
  2. Reads the first line of that file. If it has ISOLATED_PHP_VERSION, it takes the version.
  3. Removes any earlier shim directory from PATH, then puts ~/.cache/herd-isolate/83 in front. That directory contains one symlink, php, pointing to Herd's php83. (PowerShell puts Herd's own bin\php83 folder in front instead.)

composer, php artisan, and scripts in vendor/bin (Pest, Pint, PHPStan) start with #!/usr/bin/env php or call php directly, so they all run on whichever php is first on PATH.

The hook does not call the herd binary, which takes about 300 ms. It reads one file, which takes 0.3 to 1.5 ms per directory change depending on the shell.

Limitations

  • The site is matched by directory name. A site linked under a different name (herd link other-name) is not detected; herd php still works there.
  • Herd keeps an nginx file for every secured (HTTPS) site, not only isolated ones. A subdirectory with the same name as another such site (for example docs when docs.test exists) matches that site instead.
  • herd-php-autoswitch avoids both of these: it looks sites up through Herd's links and parked paths, like Herd does. It also warns when a site is isolated to a PHP version that is not installed.
  • After running herd isolate or herd unisolate inside the site, refresh the hook: cd . in zsh, fish and PowerShell, unset _herd_isolate_pwd in bash.
  • Only herd isolate is read. A PHP version set in a .valetrc (php=8.3) or .valetphprc file is ignored.
  • It works alongside direnv and mise (tested with direnv 2.37 and mise 2026.9 in zsh, bash and fish, in either load order). With another tool that changes PATH, check which php wins with which -a php (zsh, bash), type -a php (fish) or Get-Command php -All (PowerShell).
  • It only changes your own shell. Other developers and CI still use whatever php they have.

Keep Composer on the right version for everyone

The hook fixes your terminal. To stop Composer from resolving packages for the wrong PHP version on any machine, set the platform version to the lowest PHP version you run in production:

composer config platform.php 8.3.30
composer update

That writes this to composer.json:

{
  "config": {
    "platform": {
      "php": "8.3.30"
    }
  }
}

Composer then resolves dependencies as if it were running PHP 8.3.30, whichever PHP actually runs it.

Linux

Herd is not available for Linux. For per-directory PHP versions, use a version manager that switches on cd, such as mise or phpenv. The Composer platform setting above works the same on Linux.

Uninstall

zsh: remove the source line from ~/.zshrc and delete ~/.config/zsh/herd-isolate.zsh.

bash: remove the source line from ~/.bashrc and delete ~/.config/bash/herd-isolate.bash.

fish: delete ~/.config/fish/conf.d/herd-isolate.fish.

On macOS, also remove the symlinks:

rm -rf ~/.cache/herd-isolate

PowerShell: remove the snippet from your profile. It creates no files.

Open a new terminal afterwards.




<!-- generated with nested tables and zero regrets -->