A small PHP version manager for Linux. TUI in the terminal, optional system tray app, per-shell switching, and a cd-hook that picks the right PHP for the project you just stepped into.
If you've been juggling update-alternatives --set php by hand every time you switch between a Laravel 9 app on 8.1 and a fresh Symfony repo on 8.3, this is for you.
- Interactive TUI version picker, arrow keys, Enter, done.
- A tray icon (and a separate GTK window if you'd rather not live in the panel) with per-version badges: which SAPIs are available, whether xdebug is loaded, whether FPM is running, whether the version is EOL.
.php-version(orcomposer.json'srequire.php) drives a per-project version. Walks up the tree likenvmdoes.- A
cd-hook for bash / zsh / fish that runsphpvm --autoso the right PHP is loaded by the time the prompt comes back. - Per-shell switching:
phpvm shell 8.2pins a version for the current terminal only via aphpshim onPATH. Two terminals can run two PHP versions at once, no sudo. phpvm install <ver>adds a new PHP version straight from the upstream repo (OndΕej SurΓ½'s PPA on Ubuntu, deb.sury.org on Debian) without hand-runningapt install.- An installer that asks the obvious questions (CLI? GUI? wire up the shell hook? passwordless sudo?) and an uninstaller that backs up your shell rc before touching it.
One-liner (no local clone needed):
curl -fsSL https://raw.githubusercontent.com/rijverse/phpvm/main/install.sh | sudo bashPin a specific tag or branch by adding PHPVM_REF to that command, e.g. ... | sudo PHPVM_REF=v2.6.1 bash.
Or clone and run it interactively:
git clone https://github.com/rijverse/phpvm.git
cd phpvm && sudo bash install.shIt's interactive even through the curl | sudo bash pipe: pick CLI, GUI, or both, then yes/no on the shell hook and the passwordless-sudo rule. With no terminal at all (headless CI, nohup) it falls back to non-interactive defaults.
New terminals pick up the shell hook automatically. Already-open ones don't. Run
source ~/.bashrc(or your shell's rc) in those, orphpvm shellwill tell you to reload until you do.
To remove it, see Uninstalling below.
phpvm --self-updatePulls the latest from the repo URL captured at install time and re-runs the installer in --upgrade mode: same paths, same CLI/GUI choice, no re-prompt for sudoers or the hook. If you installed from a tarball (no recorded URL), pass one explicitly, optionally with a tag or branch:
phpvm --self-update https://github.com/rijverse/phpvm.git v2.2.0- Linux with
update-alternatives. Tested on Ubuntu 20.04 / 22.04 / 24.04 in CI, and Debian 11+ and Ubuntu derivatives (Mint, Pop!_OS, Zorin, elementary) on the equivalent releases should work too. - Bash 4.3+ (uses
local -n). - For the GUI:
python3-gi, GTK3, AppIndicator3. The install command is in the GUI section below.
Keyboard-driven picker right where you live. β/β to move, Enter to pin the current shell, g for a system-wide switch, p to pin the project, q to bail.
| Command | What it does |
|---|---|
phpvm |
Opens the TUI |
phpvm --list |
Lists installed PHP versions |
phpvm --list --paths |
Same list with the absolute binary path next to each version (handy for IDE setup) |
phpvm --list --json |
Emits [{version,path,active}, ...] for scripts and IDE tooling |
phpvm which 8.2 |
Prints the absolute path (e.g. /usr/bin/php8.2). Matches nvm which / pyenv which |
phpvm --current |
Shows the effective version plus the shell / project / global breakdown |
phpvm shell 8.2 |
Switches this terminal only, no sudo (see Per-shell switching) |
phpvm shell --unset |
Drops the per-shell pin |
phpvm local 8.2 |
Pins the project: writes .php-version, no sudo |
phpvm global 8.2 |
Switches the system default via update-alternatives (sudo) |
phpvm install 8.3 |
Installs PHP 8.3 from OndΕej SurΓ½'s repo (see Installing PHP versions) |
phpvm --auto |
Reads .php-version / composer.json and switches |
phpvm --auto --print [dir] |
Prints the resolved project PHP version without switching |
phpvm --enable-hook [shell] |
Adds the shell hook + shim to your rc |
phpvm --disable-hook [shell] |
Removes it (rc is backed up first) |
phpvm --window |
Launches the GTK picker window, then frees the terminal |
phpvm-gui |
Tray applet (see The GUI) |
phpvm-gui --window |
Standalone GTK picker window, no tray |
phpvm --self-update |
Re-runs the installer against the latest commit |
phpvm --doctor |
Full diagnostic: CLI install, PHP runtimes, FPM, sudoers, shell hook, shim, GUI, project |
phpvm --help |
Everything else |
--set is kept as an alias for global, and --set-project for local, so old muscle memory and scripts keep working. Vim users get k/j too.
phpvm install drives the upstream PHP repos so you don't have to add a PPA and apt install by hand:
phpvm install 8.3 # cli + common + fpm
phpvm install 8.3 --minimal # cli + common, no fpm
phpvm install 8.3 --with curl,mbstring,xml # add extensions
phpvm install 8.3 --use # install, then switch to it
phpvm install latest # highest version the repo offers
phpvm install 8.3 --print # dry-run: show the repo + packages, touch nothingIt picks the repo from your distro:
- Ubuntu (and derivatives like Mint, Pop!_OS): OndΕej SurΓ½'s PPA,
ppa:ondrej/php. - Debian: the deb.sury.org repo, keyring under
/etc/apt/keyrings/and a[signed-by=...]source list pinned to your release.
Versions are X.Y (or latest), and patch levels like 8.2.13 are rejected. apt runs under a normal sudo password prompt. install never touches the passwordless sudoers rule, which stays scoped to update-alternatives --set. After installing it offers to switch (or pass --use / --yes for non-interactive runs).
Other distros aren't supported: install PHP with your own package manager and phpvm will pick it up via update-alternatives.
The everyday path is automatic, with no command to run. With the shell hook enabled (the installer default), each cd reads the project's .php-version (or composer.json's require.php) and points php at the matching version, for that terminal only, no sudo. Leave the project and it falls back to the global default. Each terminal is independent:
cd ~/work/legacy-app # .php-version says 7.4 -> php is 7.4 here
cd ~/work/new-api # composer wants ^8.2 -> php is 8.2 here
cd ~ # no project -> back to the global defaultphpvm shell is the manual override for the current terminal, for when you want a version that differs from the project:
phpvm shell 8.2 # force this terminal to 8.2, whatever the project says
phpvm shell --unset # drop the override, back to the project / global defaultBoth paths use the same mechanism as rbenv, pyenv, and asdf: a tiny php shim on your PATH execs the matching /usr/bin/phpX.Y. It needs the shell hook enabled (installer default, or phpvm --enable-hook).
phpvm --current shows the three layers the shim checks, highest priority first, and uses the first one that's set:
- shell: your manual
phpvm shelloverride (PHPVM_SHELL_VERSION).not pinnedjust means the layer below is in charge. - project: the auto version from
.php-version/composer.json, set by the cd-hook on everycd. - global: the system default from
update-alternatives(/usr/bin/php). What cron, systemd, other users, and PHP-FPM see, since none of them load your shell hook. Set it withphpvm global <v>(sudo, still aliased asphpvm --set).
To pin a version for the whole project (not just this terminal), phpvm local <v> writes .php-version. Aliased as phpvm --set-project.
echo "8.1" > .php-version # or: phpvm local 8.1phpvm walks up the tree for .php-version, falling back to composer.json's require.php. Caret, tilde, ranges, and | unions all resolve to the highest installed version that fits.
The easy way:
phpvm --enable-hook # detects $SHELL
phpvm --enable-hook zsh # or name it
phpvm --disable-hook # undo, rc backed upIf you'd rather edit your rc yourself
System install lives under /etc/phpvm, and user install lives under ~/.phpvm. Source whichever exists:
# bash
source /etc/phpvm/php-auto.bash # or ~/.phpvm/php-auto.bash
# zsh
source /etc/phpvm/php-auto.zsh # or ~/.phpvm/php-auto.zsh
# fish
source /etc/phpvm/php-auto.fish # or ~/.phpvm/php-auto.fishA tray indicator sits in your panel showing the system-wide (global) PHP. A tray app isn't attached to a terminal, so it works at the global level: clicking a version runs the same switch as phpvm global. A terminal pinned with phpvm shell can sit above it, so the tray and a given shell may legitimately differ.
Click it and you get a menu for a one-click global switch:
Two shapes, same binary.
sudo apt install python3-gi gir1.2-gtk-3.0 gir1.2-ayatana-appindicator3-0.1
phpvm-gui # tray applet
phpvm-gui --window # detached GTK picker window, no tray
phpvm --window # same window, launched from the shell (terminal freed)The window view shows each version with which SAPIs are available (cli, fpm, apache2), whether xdebug is enabled, whether php-fpm for that version is running, and a red marker if it's EOL. Each row gets Switch and Restart FPM buttons, plus a project auto-detect button and a folder picker for one-off switches. Hover a row and the tooltip tells you which php.ini it would load.
FPM restart tries passwordless sudo first, and if that fails it pops the polkit auth dialog (pkexec). Either works, and nothing else is needed.
phpvm shell switches your terminal, but a GUI IDE doesn't read shell env vars. It pins its interpreter in its own settings at startup. phpvm gives you three primitives so any IDE or tool can wire in:
phpvm which 8.2 # /usr/bin/php8.2 (single path, parseable)
phpvm --list --paths # table: version + absolute path, active marker
phpvm --list --json # [{"version":"8.2","path":"/usr/bin/php8.2","active":true}, ...]phpvm --list --json is the contract for tooling: stable schema (version, path, active), one entry per installed PHP, dependency-free (no jq required to produce it, only to filter).
See docs/IDE.md for per-IDE setup (PhpStorm, VS Code, Sublime/Zed/Helix, NetBeans) and CI recipes.
Only the global switch needs sudo. phpvm global (and its --set alias) moves the system-wide /usr/bin/php symlink via sudo update-alternatives --set php .... Per-shell (phpvm shell) and per-project (phpvm local) switching touch only your own environment, so they never ask for a password.
For the global switch, the installer offers to drop a sudoers rule so you don't get a prompt:
# /etc/sudoers.d/phpvm
username ALL=(ALL) NOPASSWD: /usr/bin/update-alternatives --set php /usr/bin/php[0-9].[0-9]
The glob is intentionally narrow, it matches php8.2 but not phpunit or php-config.
If you skip the sudoers rule, phpvm global just asks for a password the normal way (and labels the prompt so you know who's asking). The GUI, which is global by nature, tries passwordless sudo first, then falls back to the polkit dialog.
What phpvm --doctor looks like
--doctor walks every subsystem so you can spot what's wrong without grepping logs. Sample output on a healthy install:
phpvm --doctor v2.6.1
user=alice shell=bash pwd=/home/alice/work/api
βΈ CLI install
β phpvm at /usr/local/bin/phpvm (v2.6.1)
β bash 5.1.16(1)-release
βΈ PHP runtimes
β update-alternatives: /usr/bin/update-alternatives
β 3 PHP runtime(s) registered
php8.2 β 8.2.31
php8.3 β 8.3.31
php8.4 β 8.4.21
β Active: php8.2 (/usr/bin/php8.2)
β composer: Composer version 2.7.2
βΈ PHP-FPM
β php8.2-fpm.service active=active enabled=enabled
β php8.3-fpm.service active=active enabled=enabled
βΈ Sudo (auto-switch)
β Sudoers rule at /etc/sudoers.d/phpvm
β sudo -n update-alternatives works (passwordless)
βΈ Shell hook (auto-switch on cd)
β Hook dir /etc/phpvm
β bash hook sourced in ~/.bashrc
βΈ Per-shell switching (shim)
β Shim at /etc/phpvm/shims/php
β Shim dir on PATH
βΈ GUI / tray (optional)
β phpvm-gui at /usr/local/bin/phpvm-gui
β python3-gi importable, AppIndicator3 present
βΈ Project (cwd)
β .php-version says 8.3 β php8.3 resolved
It walks eight subsystems (CLI install, PHP runtimes, FPM, sudo, hook, shim, GUI, and the current project) and tallies the result. Each row is β (ok), ! (warn), β (fail), or - (skipped, e.g. GUI not installed).
If phpvm reports no versions installed
You probably haven't registered them with update-alternatives yet:
sudo update-alternatives --install /usr/bin/php php /usr/bin/php8.3 83
sudo update-alternatives --install /usr/bin/php php /usr/bin/php8.2 82
sudo update-alternatives --install /usr/bin/php php /usr/bin/php8.1 81The number at the end is the priority, where higher wins when nothing is explicitly selected.
Project layout
phpvm/
βββ phpvm.sh CLI + TUI
βββ phpvm-gui.py tray + window GUI
βββ shell/
β βββ php-auto.bash
β βββ php-auto.zsh
β βββ php-auto.fish
β βββ shim-php php resolver, installed to <hook dir>/shims/php
βββ install.sh
βββ uninstall.sh
One-liner (no local clone needed):
curl -fsSL https://raw.githubusercontent.com/rijverse/phpvm/main/uninstall.sh | sudo bashOr from a local clone:
sudo bash uninstall.shWhat it removes:
phpvmandphpvm-guibinaries from both/usr/local/binand~/.local/bin- Hook directory (
/etc/phpvmor~/.phpvm) - Sudoers rule (
/etc/sudoers.d/phpvm) - Desktop entry and autostart file
- Icon from the hicolor theme (and refreshes the icon cache)
- The
source .../php-auto.*lines from~/.bashrc,~/.zshrc, and~/.config/fish/config.fish
Shell RCs are backed up as <file>.phpvm-backup before any edits. Running under sudo also cleans the invoking user's home, not just root's.
A few things phpvm doesn't handle yet. Some are on the Roadmap, some are out of scope for now.
- Per-shell pins are shell-only:
phpvm shelllives in your interactive shell's environment, so it's invisible to cron, systemd, other users, non-interactive scripts, and the GUI. Those all follow the global default, which is whatphpvm globalis for. - Distros without
update-alternatives: Arch, Fedora, RHEL, openSUSE aren't supported. Adding a backend is welcome as a contribution. - Web server config: Apache/Nginx still point at whatever socket or module you wired up. FPM restart is per-version and assumes
systemctl restart phpX.Y-fpmstyle unit names. - Patch-level pinning: everything is
X.Y. If you need8.2.13exactly, you'll want a different tool. - Polkit without a desktop session: headless boxes fall back to the regular
sudopassword prompt instead.
What's next, roughly in priority order. See ROADMAP.md for the detailed specs. Open an issue if you want to push one up the stack or claim one.
- Extension manager:
phpvm ext install xdebug redis imagickper version, with the matchingphp<ver>-<ext>packages and ini wiring. None of the existing PHP version managers do this well. -
phpvm exec <ver> <cmd>: run a one-off in a specific version without switching, likenvm exec. Handy for CI and quick sanity checks. - Shell completion: bash/zsh/fish completion for
shell,global,install, etc. sophpvm global <TAB>lists installed versions. -
phpvm install --ltsalias: track the moving LTS target without remembering version numbers. Needs a maintained EOL table.
Already shipped: phpvm install (v2.4.0) and per-shell switching (v2.5.0).
Patches welcome. See CONTRIBUTING.md. Two ground rules: no runtime dependencies beyond what's already there, and shellcheck clean.




