Guide

How to run multiple PHP versions on Mac.

Most PHP developers end up with one client on PHP 7.4, another on 8.2 and their own work on the latest release. This guide shows how to install several versions side by side with Homebrew, switch between them, and give each project its own version. At the end I show how Bothy, my Mac app for local PHP development, does the same thing per site.

In short

Install each version from the shivammathur/php Homebrew tap, switch the command-line php with brew link, and run one php-fpm per version on its own port so each project's web server can point at the version it needs.

Two separate problems: the CLI and the web server

"Which PHP am I running?" has two answers on a Mac, and they're often different:

  • The command line. Whatever php comes first on your PATH. Composer, Artisan, WP-CLI and PHPUnit all use it.
  • The web server. nginx, Apache or Caddy hands requests to a php-fpm process, and that process belongs to one PHP version. Which version serves a site depends on which FPM socket or port its vhost points at.

Switching the CLI changes nothing about what your browser sees, and the reverse is also true. Most "I switched versions but it didn't work" problems come down to that. You need to handle both, and to do it per project you need a way to tie a folder to a version for each.

If you haven't installed PHP on this Mac yet, start with how to set up PHP locally on macOS.

Why use the shivammathur/php tap

Homebrew's core repository ships the current PHP as php and keeps a few recent php@X.Y formulas. Versions that reach end of life are deprecated and then removed, which is a problem when a client site is still on 7.4.

The shivammathur/php tap is maintained by the author of the setup-php GitHub Action. It builds old and new versions, from PHP 5.6 up to the current release, for Apple Silicon and Intel. A companion tap, shivammathur/extensions, supplies extensions such as Xdebug, Redis and imagick for each version. Use fully qualified formula names so Homebrew doesn't mix up the tap's formulas with core's.

Install and switch PHP versions with Homebrew

These commands work on Apple Silicon (/opt/homebrew) and Intel (/usr/local). $(brew --prefix) picks the right path.

  1. 1
    Add the tap

    This is a one-off.

    brew tap shivammathur/php
  2. 2
    Install the versions you need

    Each version installs into its own keg, with its own binaries, php.ini and extension directory.

    brew install shivammathur/php/php@7.4
    brew install shivammathur/php/php@8.2
    brew install shivammathur/php/php@8.4
  3. 3
    Choose which version the CLI uses

    Unlink the current version and link the one you want. Versioned formulas are keg-only, so --force is needed.

    brew unlink php php@7.4 php@8.2 php@8.4 2>/dev/null
    brew link --overwrite --force shivammathur/php/php@8.2
    hash -r
    php -v
  4. 4
    Find each version's php.ini

    Every version reads its own file. Extensions you enable for 8.2 aren't enabled for 8.4.

    php --ini
    # e.g. /opt/homebrew/etc/php/8.2/php.ini
    #      /opt/homebrew/etc/php/8.2/conf.d/
  5. 5
    Give each php-fpm its own port

    Every Homebrew PHP ships a pool that listens on 127.0.0.1:9000. If you start two versions, they fight over that port. Edit each version's php-fpm.d/www.conf so each gets a unique port, for example 9074, 9082 and 9084.

    # $(brew --prefix)/etc/php/8.2/php-fpm.d/www.conf
    listen = 127.0.0.1:9082
  6. 6
    Start php-fpm for each version

    Each one runs as its own launchd service and starts again at login.

    brew services start shivammathur/php/php@7.4
    brew services start shivammathur/php/php@8.2
    brew services start shivammathur/php/php@8.4
    brew services list
  7. 7
    Point each site at the version it needs

    This is where per-project versions happen for the web server. In Caddy, each site block sends PHP to a different FPM port. In nginx, it's the fastcgi_pass line.

    legacy.test {
      root * /Users/you/Sites/legacy
      php_fastcgi 127.0.0.1:9074
      file_server
    }
    
    shop.test {
      root * /Users/you/Sites/shop/public
      php_fastcgi 127.0.0.1:9084
      file_server
    }

Switching the PHP version per project

The web-server side is handled by the vhost above. The CLI side takes more work, because a brew link switch is global. There are a few ways to handle it:

  • Call the version explicitly. $(brew --prefix shivammathur/php/php@7.4)/bin/php artisan migrate is ugly but reliable. A shell alias such as php74 makes it bearable.
  • Adjust PATH per folder. A tool like direnv can put $(brew --prefix shivammathur/php/php@7.4)/bin at the front of PATH when you cd into the project.
  • Pin Composer's platform. Setting config.platform.php in composer.json (for example "7.4.33") makes Composer resolve dependencies for the production version, even if you run it with a newer PHP. It doesn't change the running PHP, but it stops you installing packages the server can't run.

How Valet and Herd isolate a site

Laravel Valet wraps this in valet isolate php@8.2 --site=legacy, with valet isolated to list isolated sites and valet unisolate to undo. Laravel Herd has herd isolate 8.3 or a setting in its Site Manager, and its herd php and herd composer wrappers then use the isolated version. Both run the FPM-per-version setup above for you. Herd vs Valet compares them in more detail.

Pitfalls and troubleshooting

  • php -v still shows the old version. Your shell has cached the old path (hash -r or open a new tab), or something earlier on PATH wins. which -a php shows every candidate in order.
  • 502 Bad Gateway after starting a second version. Two FPMs tried to bind port 9000 and one failed. Give each a unique listen port, then run brew services restart for both.
  • phpinfo() and php -v disagree. This is expected. The browser shows the FPM version the vhost points at, and the terminal shows the linked CLI.
  • An extension works in one version but not another. Extensions are compiled per version. Install them per version, for example brew install shivammathur/extensions/xdebug@8.2, and check with php -m for each binary. For Xdebug specifically, see Xdebug on Mac.
  • brew upgrade moved you to a new patch release. That's normal. Your php.ini and pool files in $(brew --prefix)/etc/php/X.Y/ are kept, but restart the service afterwards.
  • Upgrading changed which version is linked. Re-run the brew link step. The version you link is a choice you make, not something Homebrew remembers for you.

The Bothy way

Bothy uses the same shivammathur tap underneath. It runs one php-fpm master per version and one pool per site, and tracks ports and vhosts itself.

  1. 1
    Install a version
    bothy php install 8.2
  2. 2
    Switch a site to it

    Only that site's pool moves. Other sites stay on their versions.

    bothy site php shop 8.2
  3. 3
    Run CLI tools with the site's version

    site exec runs in the site folder with that site's PHP first on PATH, so Composer, Artisan, WP-CLI and PHPUnit match what the browser sees.

    bothy site exec shop -- composer install
    bothy site exec shop -- php -v
  4. 4
    Tune php.ini per site or per version
    bothy site ini shop memory_limit 1G
    bothy php ini 8.3 upload_max_filesize 256M

Questions

Can I run PHP 7.4 and PHP 8.4 at the same time on a Mac?

Yes. Install both from the shivammathur/php tap and run a php-fpm for each on a different port. Each site's web server config then picks one.

How do I change the PHP version on Mac?

For the terminal, brew unlink the current version and brew link --overwrite --force the one you want. For websites, point the vhost at that version's php-fpm port or socket.

Does Homebrew still ship old PHP versions?

Homebrew core drops versions after they reach end of life. The shivammathur/php tap keeps building them, which is why most multi-version setups use it.

Why does phpinfo() show a different version from php -v?

The browser goes through php-fpm and the terminal uses the linked CLI binary. They're separate processes and can be different versions.

Is it safe to develop on an end-of-life PHP version?

Locally, yes, as long as you only use it to keep an old project running while you upgrade it. EOL versions get no security fixes, so don't expose that setup to the internet.

Doing it with Bothy: bothy php install 8.2 followed by bothy site php shop 8.2. There are no pool files to edit and no ports to allocate, and the other sites don't notice. See per-site PHP in Bothy or the PHP docs.

Get Bothy for $69.
One payment for every 1.x release. Version 2 will be a separate purchase.
Buy now