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.
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
phpcomes first on yourPATH. 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.
- 1Add the tap
This is a one-off.
brew tap shivammathur/php
- 2Install the versions you need
Each version installs into its own keg, with its own binaries,
php.iniand extension directory.brew install shivammathur/php/php@7.4 brew install shivammathur/php/php@8.2 brew install shivammathur/php/php@8.4
- 3Choose which version the CLI uses
Unlink the current version and link the one you want. Versioned formulas are keg-only, so
--forceis 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
- 4Find 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/
- 5Give 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'sphp-fpm.d/www.confso 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
- 6Start 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
- 7Point 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_passline.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 migrateis ugly but reliable. A shell alias such asphp74makes it bearable. - Adjust PATH per folder. A tool like direnv can put
$(brew --prefix shivammathur/php/php@7.4)/binat the front ofPATHwhen youcdinto the project. - Pin Composer's platform. Setting
config.platform.phpincomposer.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 -vstill shows the old version. Your shell has cached the old path (hash -ror open a new tab), or something earlier onPATHwins.which -a phpshows 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
listenport, then runbrew services restartfor both. phpinfo()andphp -vdisagree. 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 withphp -mfor each binary. For Xdebug specifically, see Xdebug on Mac. brew upgrademoved you to a new patch release. That's normal. Yourphp.iniand 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 linkstep. 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.
- 1Install a version
bothy php install 8.2
- 2Switch a site to it
Only that site's pool moves. Other sites stay on their versions.
bothy site php shop 8.2
- 3Run CLI tools with the site's version
site execruns in the site folder with that site's PHP first onPATH, so Composer, Artisan, WP-CLI and PHPUnit match what the browser sees.bothy site exec shop -- composer install bothy site exec shop -- php -v
- 4Tune php.ini per site or per version
bothy site ini shop memory_limit 1G bothy php ini 8.3 upload_max_filesize 256M
Questions
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.
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.
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.
The browser goes through php-fpm and the terminal uses the linked CLI binary. They're separate processes and can be different versions.
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.