Skip to content
LibxaFrame
Sign In
Tutorial 9 August 2026 6 min read

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.

Keep reading