I write an article for BuiltFast.dev about how I manage Herd PHP versions using Direnv.


I love the simplicity of Herd for using PHP on my Macs. One thing that is slightly annoying is that you have to use Herd specific commands to make sure you get the right PHP version for each project. Here’s a way you can use Direnv to set the version automatically when you cd into a project.

“But wait, why does that matter, Josh? I ran herd use 8.3 once, isn’t that enough?”. Well I’m glad you asked.

If you follow Herd’s docs, and use herd php, herd composer, etc, then it works. But if you just run php, composer, or any other PHP based tool, you’ll get whatever version is set as the global default. This can lead to some confusion if you have multiple projects using different PHP versions. You you run ./vendor/bin/pest you might get a different version of PHP than composer exec pest or herd php ./vendor/bin/pest. That might not be an issue immediately, but tools like Pint and PHPStan behave differently depending on the PHP version.

Before we get into Direnv, let’s talk about PATH. On macOS, or any Linux system, when you run a command like php, the system looks through the directories listed in the PATH environment variable, in order, to find an executable file named php. The first one it finds is the one that gets run.

Standard paths on macOS include /usr/bin, /bin, /usr/sbin, /sbin, and for Homebrew installs, /opt/homebrew/bin and /opt/homebrew/sbin. Herd adds its own binaries in ~/Library/Application Support/Herd/bin. This is how running php with Herd installed knows to use their version (assuming you don’t have a Homebrew PHP version installed).

So, knowing how PATH works, we can use Direnv to modify it when we enter a project directory. Direnv allows you to create a .envrc file in your project directory. This file can contain shell commands that are executed when you enter the directory. We can use this to add the appropriate Herd PHP version to the front of our PATH.

A minor complication is that PATH needs a directory, and not a specific file. Herd keeps it’s PHP binaries as files like php81, php82, php83, etc. So we need to work around that some how. We need to create a “shim” directory that contains the version number and then a php file inside of it that points to the right Herd binary.

Before we can do that, we need to create a function that Direnv can use to set the right PHP version. You can put this function in your ~/.direnvrc file so it’s available to all your projects. I’ll explain inline what each part does.

# Sets PATH for Herd PHP versions.
#
# Usage in .envrc:
#
#   use herd_php [system]
#   use herd_php 8.3
use_herd_php() {
  # Path where Herd support files are installed
  local herd_dir="$HOME/Library/Application Support/Herd"

  # If the Herd directory doesn't exist, log an error and exit. This means
  # Herd is not installed.
  if [[ ! -d "$herd_dir" ]]; then
    log_error "Herd not found"
    return 1
  fi

  # Get the version argument passed to the function, if one was passed.
  local version="${1-}"

  # Directory where we will create shims for Herd PHP versions
  local shim_dir="$HOME/.local/share/herd"

  # If no version was passed, check for a .php-version file in the current
  # directory. If it exists, read the version from there.
  if [[ -z "$version" ]] && [[ -f .php-version ]]; then
    version=$(< .php-version)
    log_status "Using PHP version from .php-version: $version"
  fi

  # Initialize an empty variable to hold the version number without dots
  local number

  # Here, we convert version numbers like 8.3.1, 8.3.29, 8.3, to 83.
  #
  # If the version looks like major.minor.patch (i.e. 8.1.3)
  if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
    # delete after the last dot
    version="${version%.*}"

    # remove the dot
    number="${version/./}"
  # If it instead looks like a major.minor version
  elif [[ "$version" =~ ^[0-9]+\.[0-9]+$ ]]; then
    # remote the dot
    number="${version/./}"
  # If it instead is "system", "default", or empty
  elif [[ "$version" =~ ^(system|default)$ ]] || [[ -z "$version" ]]; then
    version="default"
    number=""
  # We found an invalid or unsupported version, log en error and exit
  else
    log_error "Invalid PHP version '$version'"
    log_error "Use 'system', 'default' or a version like '8.3' or '8.3.1'"

    return 1
  fi

  # Get path to relevant Herd PHP binary
  local target_bin="$herd_dir/bin/php${number}"

  # If the file is not executable (it probably doesn't exist). Log an error
  # and suggest some valid versions to use.
  if [[ ! -x "$target_bin" ]]; then
    log_error "Herd PHP $version binary '$target_bin' not found"
    log_error "Try one of these .envrc configurations:"
    log_error
    log_error "- use_herd_php system (Herd default configured version)"

    local suggested

    # Loop through all files in the Herd bin directory
    for f in "$herd_dir"/bin/*; do
      # If the file name matches the pattern php[0-9]+ (eg: php81, php82)
      if [[ "${f##*/}" =~ ^php[0-9]+$ ]]; then
        # strip away everything before "php" and insert a dot after the first
        # character to convert php81 to 8.1, php83 to 8.3, etc.
        suggested="${f##*/php}"
        suggested="${suggested:0:1}.${suggested:1}"

        log_error "- use_herd_php $suggested"
      fi
    done

    return 1
  fi

  # Get path to the target shim file we will create
  local target_shim="$shim_dir/php$number/bin/php"

  # If the shim file doesn't already exist, create it
  if [[ ! -L "$target_shim" ]]; then
    log_status "Creating Herd PHP $version shim at ${target_shim/$HOME/\~}"

    # Make sure the target directory exists
    mkdir -p "${target_shim%/*}"

    # Link the Herd PHP binary to the shim location
    ln -s "$target_bin" "$target_shim"
  fi

  # Add the shim directory to the front of PATH
  PATH_add "$shim_dir/php$number/bin"

  # Add vendor/bin to PATH if it exists, so project specific tools are found
  if [[ -d vendor/bin ]]; then
    PATH_add vendor/bin
  fi

  log_status "using Herd PHP $version shim at ${target_shim/$HOME/\~}"
}

Then, in your .envrc file in the project root, you can use the function like this:

use herd_php 8.3

Then another project might use:

use herd_php 8.4

Any time you cd into either one, Direnv will automatically set the right PHP version for you. And when you run php it will use the correct version.

This isn’t perfect. You do still need to configure Herd in each project to use the PHP version that you want, so that PHP-FPM uses the correct version for you. But in the CLI, this has been working great for me.