Serving several PHP versions at once
A php-cgi process serves exactly one PHP build, so a per-site version picker has to be backed by one process per version, or it is decorative. How the supervisor and the config generator agree on a port map without sharing any state.
"This site runs 8.1 and that one runs 8.5" sounds like configuration. It is actually a process-management problem, and getting it wrong produces a feature that looks like it works and does not.
One process, one build
A php-cgi process serves exactly one PHP build. That is not a limitation of
FastCGI or of nginx: it is what the binary is. php-cgi.exe from a PHP 8.4
installation will answer every request with PHP 8.4, whatever the request says.
So a UI that lets each site choose a version has to be backed by one process per version. If it is not, the choice is decorative.
Ours was decorative. Every site was routed to fastcgi_pass 127.0.0.1:9000,
one process, and the per-site dropdown recorded a value that nothing read. We
found it by pinning phpMyAdmin to a version it supports and then checking what
it was actually running on. It was running on the default.
The shape of the fix
Two pieces have to agree: the supervisor that starts the processes, and the
config generator that writes the fastcgi_pass lines. If they disagree about
which version listens on which port, every site 502s.
The tempting approach is shared state: the supervisor assigns ports and tells the generator. That introduces an ordering requirement between two things that run at different times, which is a bug waiting for the right restart sequence.
Instead, both derive the map from the same input, purely:
public function phpPortMap(): Map
{
$versions = [$defaultVersion, ...every site's pinned version];
// Sorted, so the assignment is stable across restarts.
sort($versions);
return one entry per version: 9000, 9001, 9002, …
}
Same input, same function, same answer: no coordination needed.
Why sorting matters
This is the part that is easy to skip and expensive to skip.
If versions are assigned ports in the order they are encountered, adding a site
changes which port an existing version gets. The generated nginx.conf on disk
still points at the old port. Nginx keeps running with a config that references
a process that has moved, and the failure looks like a random 502 on a site
nobody touched.
Sorting makes the map a pure function of the set of versions, not the order they were discovered in.
Falling back rather than 502ing
A site can be pinned to a version that is no longer installed: someone removed PHP 8.1 but the project still asks for it. An unreachable upstream is a 502 with no explanation.
So the lookup falls back to the default version's port. The site is served by the wrong version, which is not ideal, but it renders and the PHP tab shows what is actually installed. A page that loads on the wrong version tells you more than a bare gateway error.
Cleaning up
Extra workers are stopped when the PHP service stops, and again on quit. This
matters more than it sounds: an orphaned php-cgi holds its port, and the next
launch finds that port taken and quietly relocates the whole environment to a
different one. You come back to your sites on a port you did not choose.
On Windows there are no signals, so a SIGTERM on a process that spawned
workers leaves the workers running. taskkill /T takes the tree.
The result
franky.test → 127.0.0.1:9001 PHP 8.4
libxastack.test → 127.0.0.1:9001 PHP 8.4
legacy-shop.test → 127.0.0.1:9002 PHP 8.1
phpmyadminlibxa.test → 127.0.0.1:9000 PHP 8.3
Four sites, three PHP versions, one nginx. The dropdown means something now.