# How-To: Put a Concurrency Cap on PHP (cPanel / EA4)

**Status:** proposal + runbook — nothing below has been applied.
**Written:** 2026-08-27
**Context:** [MARIADB_OOM_INVESTIGATION.md](../../evolution/audits/MARIADB_OOM_INVESTIGATION.md) §Round 5 — **ten** kills of
`mariadbd` in 14 days across four outage episodes, every one with the same cPanel
account inflating its PHP worker pool until the box ran out of memory. MariaDB is
the victim, not the cause.

---

## 1. The problem in one table

Peak PHP worker footprint per account, measured across the 231 incident dumps in
`erp-forensics/` (2026-08-12 → 2026-08-27). Box total RAM: **11,729 MB**.

| Account | Peak procs | Peak RSS | vs box RAM |
|---|---:|---:|---:|
| madalice | 107 | 7,347 MB | 63 % |
| alison | 44 | 5,459 MB | 47 % |
| speedlin | 62 | 5,210 MB | 44 % |
| tubesupplies | 53 | 5,134 MB | 44 % |
| olympic | 42 | 3,591 MB | 31 % |
| evolution (ERP) | 34 | 2,579 MB | 22 % |
| corpkicks | 20 | 2,456 MB | 21 % |
| customc | 24 | 2,328 MB | 20 % |
| infinium | 23 | 1,915 MB | 16 % |
| vinesit | 23 | 1,908 MB | 16 % |
| otterfencing | 16 | 1,846 MB | 16 % |

(The ERP's own worst moment isn't in this table — it hit **137 procs / 5,860 MB** on
08-12 11:55 AEST, in a window whose dumps had already aged out of the 14-day
retention. Same disease, different tenant.)

**Six separate accounts have each individually exceeded 5 GB.** There is no
per-account limit of any kind on this box, so any one of them can take the whole
server — and MariaDB — down on its own. That is exactly what happened ten times,
and `madalice` was the top consumer in every one of them.

Per-worker RSS across 3,619 sampled `php-cgi` processes:

| p50 | p90 | p99 | max |
|---:|---:|---:|---:|
| 104 MB | 216 MB | 263 MB | 308 MB |

---

## 2. Why there is nothing to tune right now

The accounts that blow up run under EA4's **`cgi` handler** (`suexec` + `mod_cgi`).
Evidence from `incident-2026-08-15_075051.txt`: 39 concurrent
`/opt/cpanel/ea-php82/root/usr/bin/php-cgi` processes with **39 distinct PPIDs** —
one interpreter forked per request, parented by its own Apache child, never reused,
13–20 s lifetimes.

Under the `cgi` handler:

- there is **no process manager**, so no pool, no `max_children`, no `FcgidMaxProcesses`;
- the *only* ceiling is Apache's `MaxRequestWorkers` (currently **150**, `ServerLimit 256`,
  MPM **prefork**) — which is **shared across every account on the box**;
- so one account is free to occupy all 150 slots. 150 × 216 MB (p90) = **32 GB** of
  demand against 11.7 GB of RAM. The OOM killer is not a bug here, it is arithmetic.

`FcgidMaxProcesses` is a **`mod_fcgid` directive**. It does not exist under the `cgi`
handler. Setting it does nothing until the handler changes — that is the
"PHP reconfig" this document is about.

### 2.1 Three different things called "PHP-FPM" — which one you have

This trips everyone up, so, explicitly:

| Thing | Where it's set | What it covers | Cap knob |
|---|---|---|---|
| **PHP-FPM Service for cPanel Daemons** | WHM → Tweak Settings | cPanel's *own* UI: cpsrvd, webmail, phpMyAdmin. Pool `cpanelphpmyadmin`. | irrelevant to customer sites |
| **Per-account PHP-FPM** | WHM → MultiPHP Manager, per domain | One customer site. Pool `user_<account>`. | `pm.max_children` |
| **mod_fcgid handler** | WHM → MultiPHP Manager → PHP Handlers, per PHP *version* | every account on that version that is *not* on FPM | `FcgidMaxProcesses`, `FcgidMaxProcessesPerClass` |

On this box the dumps show three live FPM pools:

```
php-fpm: pool cpanelphpmyadmin     <- cPanel daemons (row 1 above)
php-fpm: pool user_corpkicks       <- per-account FPM (row 2)
php-fpm: pool user_infinium        <- per-account FPM (row 2)
```

So: **cPanel-daemon FPM is on, and two customer accounts are on per-account FPM.**
Everyone else — including `madalice`, `alison`, `speedlin`, `tubesupplies` and the
ERP — is on the version-level `cgi` handler with no cap at all. The daemon setting
does *not* give the customer sites a pool, and it is not the knob that needs `fcgi`.

Five PHP versions are in play across the dumps: **ea-php82** (2,496 samples),
**ea-php74** (776), **ea-php81** (253), **ea-php83** (182), **ea-php84** (48).
Handler choice is per version, so a handler change has to be repeated for each.

---

## 3. Confirm the live state before changing anything

Run on the server as root. Record the output in this file before proceeding.

```bash
# Which handler is each PHP version actually using right now?
/usr/local/cpanel/bin/rebuild_phpconf --current

# Which accounts have per-account FPM turned on?
whmapi1 php_get_domain_handler_settings | grep -A3 -i fpm
ls -1 /opt/cpanel/ea-php*/root/etc/php-fpm.d/ 2>/dev/null

# Is mod_fcgid even installed?
rpm -q ea-apache24-mod_fcgid
httpd -M 2>/dev/null | grep -E 'fcgid|cgi|proxy_fcgi'

# Current MPM ceiling
grep -rE 'MaxRequestWorkers|ServerLimit|^\s*Server MPM' /etc/apache2/conf.d/ /etc/apache2/conf/ 2>/dev/null
```

### 3.1 Confirmed from WHM, 2026-08-27

Read directly off the WHM screens, so this is no longer inferred:

| Fact | Value | Where seen |
|---|---|---|
| Handler, **every** PHP version 7.2 - 8.4 | `cgi` | MultiPHP Manager -> PHP Handlers |
| `madalice.com.au` PHP version | **PHP 8.2 (ea-php82)** | MultiPHP Manager -> domain row |
| `madalice.com.au` PHP-FPM | **Unavailable** - *"You must install the PHP-FPM package in order to use PHP-FPM"* - **this message is a lie, see 4.0** | same row |
| `ea-php82-php-fpm` | **installed** (8.2.30-1.1.1) - as are all 8 versions | `rpm -qa` |
| `mod_proxy` / `mod_proxy_fcgi` | both **loaded** | `httpd -M` |
| Actual cause of the 🚫 | **a broken `netdata` yum repo breaks cPanel's package check** | `whmapi1` error output, see 4.0 |
| Accounts *seen* running FPM pools | `corpkicks`, `infinium` - but 0 FPM units loaded as of 08-27 | forensic `ps` dumps vs `systemctl` |

Two things follow, and the second one is the important one.

**(a) The "Unavailable" message is not accurate**, and 4.0 has the reason: a
broken third-party yum repo makes cPanel's package-existence check fail, and
cPanel renders that failure as "you must install the package". The FPM package is
present for all eight PHP versions.

**(b) The handler being `cgi` does NOT prevent per-account FPM.** This is the
part that looks contradictory and isn't. In EA4 the version-level handler is the
*fallback* for domains with no FPM pool. When you switch a domain to FPM, cPanel
writes a `SetHandler proxy:unix:/opt/cpanel/ea-phpXX/root/usr/var/run/php-fpm/<domain>.sock|fcgi://localhost`
into that domain's vhost, which overrides the handler for that domain only.

The proof is already on this box: the handler is `cgi` for all eight PHP
versions, and `corpkicks` and `infinium` are nonetheless running FPM pools right
now. So:

* **Do not** change the PHP Handler dropdowns.
* **Do not** do Route B (section 5). It is documented for completeness and is now
  clearly the wrong tool - it would convert all ~20 accounts at once.
* Route A works exactly as written, with one extra step first.

---

## 4. Route A — per-account PHP-FPM (recommended)

This is the smallest, safest change, it is **already proven on this box** for two
accounts, and it caps the offenders individually without touching anyone else.

### 4.0 SOLVED — the "Unavailable" message was a broken yum repo

**Root cause found 2026-08-27. Nothing to do with PHP.**

Symptom: WHM *and* cPanel-side MultiPHP Manager both showed 🚫 for
`madalice.com.au` and `auction.madalice.com.au` with *"You must install the
PHP-FPM package in order to use PHP-FPM"* — while `rpm -qa` showed the FPM
package installed for **all eight** PHP versions, and `httpd -M` showed
`proxy_module` and `proxy_fcgi_module` both loaded.

Chasing it through the API produced the real error:

```
# whmapi1 php_set_vhost_versions version=ea-php82 vhost=madalice.com.au php_fpm=1
  reason: "API failure: /usr/bin/python3 reported error code 1 ...
    Errors during downloading metadata for repository 'netdata':
      - Status code: 404 for
        https://repository.netdata.cloud/repos/edge/el/$releasever_major/x86_64/repodata/repomd.xml
    dnf.exceptions.RepoError: Failed to download metadata for repo 'netdata'
    w/ /usr/local/cpanel/bin/packman_get_info_json ea-php82-php-fpm"
  result: 0
```

**The chain:** cPanel decides whether an FPM package is installed by calling
`/usr/local/cpanel/bin/packman_get_info_json ea-php82-php-fpm` → that runs dnf →
dnf aborts because a third-party `netdata` repo 404s → packman exits 1 → cPanel
catches a failure and renders its generic fallback string. The package was
installed the whole time; cPanel could not ask.

**The bug in the repo file:** `$releasever_major` is being sent to the server
**unexpanded**. That variable only exists in newer dnf; this box runs python3.6-era
dnf (EL8), so the repo file was written for a dnf newer than the one installed.

**This is bigger than the FPM toggle.** A dnf failure blocks *every* cPanel package
operation — EasyApache 4 updates, PHP version installs/updates, cPanel's own
package checks — and it has been doing so behind a misleading error message. Check
whether EA4/PHP security updates have actually been applying.

#### Fix

```bash
cat /etc/yum.repos.d/netdata.repo
cat /etc/redhat-release; dnf --version | head -1     # confirm the real major
```

Recommended — substitute the real major, keeps netdata updatable:

```bash
cp /etc/yum.repos.d/netdata.repo /root/netdata.repo.bak
sed -i 's/\$releasever_major/8/g' /etc/yum.repos.d/netdata.repo   # use the real major
```

Or, if netdata updates are not wanted, just stop it breaking everything else:

```bash
sed -i '/^\[netdata/,/^\[/ s/^enabled=1/enabled=0/' /etc/yum.repos.d/netdata.repo
```

Netdata itself keeps running either way — this only affects package metadata.

Verify:

```bash
dnf clean all && dnf makecache
/usr/local/cpanel/bin/packman_get_info_json ea-php82-php-fpm   # JSON, not a traceback
```

Then reload MultiPHP Manager; the 🚫 should be gone and 4.1 proceeds normally.

**Confirmed fixed 2026-08-27.** Two repo files carried the bug —
`netdata-edge.repo` *and* `netdata-repoconfig.repo` — so disable them together
(`sed -i 's/^enabled=1/enabled=0/' /etc/yum.repos.d/netdata*.repo`), and run
`grep -ln 'releasever_major' /etc/yum.repos.d/*.repo` to be sure no other repo
carries the same unexpanded variable. Afterwards `packman_get_info_json` returned
JSON.

#### Collateral damage this revealed

That JSON also said:

```
"_installed": "8.2.30-1.1.1.cpanel"   "_latest": "8.2.33-1.1.2.cpanel"   "_state": "updatable"
```

**PHP 8.2 is three patch releases behind**, and not by choice — cPanel's nightly
update has been unable to apply EA4 packages for as long as the repo has been
broken. Assume every PHP package on the box is in the same state.

```bash
dnf check-update 2>/dev/null | grep -c cpanel          # size of the backlog
```

Do **not** `dnf update` a cPanel box directly; cPanel owns those packages. Run
`/usr/local/cpanel/scripts/upcp --force` in a window (it restarts services), or
let the nightly run catch up and confirm via `/var/cpanel/updatelogs/`.

#### Still open after the fix

`systemctl list-units 'ea-php*-php-fpm*'` returned **0 loaded units**, yet the
forensic dumps captured `php-fpm: pool user_corpkicks` and `pool user_infinium`
processes between 08-12 and 08-25 on this same host. Either those services have
since been stopped, or the pools were not cPanel-managed. Re-check after the repo
fix:

```bash
systemctl list-unit-files | grep -i php-fpm
whmapi1 php_get_vhost_versions | grep -E 'account:|php_fpm:|vhost:' | grep -B2 -A1 -E 'corpkicks|infinium'
```

If those two accounts show `php_fpm: 0`, then per-account FPM has **never** run
here and the "already proven on this box" justification for Route A does not hold.
Route A remains the right shape of fix, but treat it as untried rather than proven,
and weight Route C (CloudLinux) higher.

### 4.0b Pre-flight EVERY account before flipping it — `.htaccess` will 500 the site

**Learned the hard way on `madalice`, 2026-08-27.** Minutes after the FPM toggle
went on, `https://madalice.com.au/wp-admin` returned:

```
Internal Server Error ...
Additionally, a 500 Internal Server Error error was encountered while trying to
use an ErrorDocument to handle the request.
```

Cause: a `php_value` line in `.htaccess`. Those directives (`php_value`,
`php_flag`, `php_admin_value`, `php_admin_flag`) are understood **only by
mod_php**. Under FPM Apache treats them as unknown directives and 500s the entire
directory tree — before PHP runs at all, which is why even the ErrorDocument
failed.

**This will recur on every account carrying such a line.** With ~20 accounts to
migrate, sweep for it once rather than discovering it one outage at a time:

```bash
find /home -maxdepth 6 -name .htaccess -type f 2>/dev/null \
  | xargs grep -lE '^[[:space:]]*php_(value|flag|admin_value|admin_flag)' 2>/dev/null
```

Every listed account must have its directives moved to `.user.ini` **before** its
FPM toggle is flipped. Not all translate: `.user.ini` honours only `PHP_INI_USER`
/ `PHP_INI_PERDIR` settings, so `memory_limit`, `upload_max_filesize` and
`max_execution_time` carry across, while `engine off` and similar do not and need
another approach.

Two related traps worth knowing while debugging these:

* **`.user.ini` is cached for 300 s** (`user_ini.cache_ttl`). A correct change can
  look like it failed. Wait it out or `systemctl reload ea-phpXX-php-fpm`.
* **PHP's `memory_limit` parser reads only the last character as the unit**, and
  accepts `K`/`M`/`G` — not `B`. `500MB` therefore parses as **500 bytes**, not
  500 megabytes, and produces this same 500-with-failed-ErrorDocument signature.
  Always write `500M`.

### 4.1 Enable FPM for one account

WHM → **MultiPHP Manager** → select the domain → **PHP-FPM: On**
(the toggle becomes selectable once 4.0 is done).

Or by API:

```bash
whmapi1 php_set_vhost_versions version=ea-php82 vhost=madalice.com.au php_fpm=1
```

### 4.2 Set the cap

cPanel's defaults are far too generous for an 11.7 GB box shared 20 ways. Set them
explicitly per account in:

```
/var/cpanel/ApachePHPFPM/system_pool_defaults.yaml     # box-wide defaults (absent on this box)
/var/cpanel/userdata/<user>/<domain>.php-fpm.yaml      # per-account override
```

On this box the per-account file arrives containing only `_is_present: 1` — i.e.
**no overrides**, pool running on cPanel's compiled-in defaults. Read the
*generated* pool file for what is actually in force; the yaml is input, the
`.conf` is truth:

```bash
grep -E '^(user|group|listen|pm|pm\.|php_admin_value|request_terminate)' \
  /opt/cpanel/ea-php82/root/etc/php-fpm.d/<domain>.conf
```

Per-account file, for a heavy WordPress account:

```yaml
---
_is_present: 1
pm: ondemand
pm_max_children: 6
pm_process_idle_timeout: 20
pm_max_requests: 200
request_terminate_timeout: 300
```

**`request_terminate_timeout` matters more than it looks.** Under the `cgi`
handler, Apache's `FcgidBusyTimeout` / `Timeout` eventually reaped a stuck
request. FPM has no equivalent unless set — without it a request hung on an
outbound call holds its child, and its memory, indefinitely. 300 s is generous
enough for legitimate WordPress admin imports.

> ### ⚠️ Do NOT set `php_admin_value_memory_limit` here
>
> An earlier draft of this runbook included `php_admin_value_memory_limit: 192M`.
> **That is a defect — it would break WordPress admin.**
>
> `php_admin_value` is a hard ceiling that PHP code **cannot** raise. WordPress
> raises its own limit at runtime with `ini_set()` (that is what `WP_MEMORY_LIMIT`
> and `WP_MAX_MEMORY_LIMIT` in `wp-config.php` do). Under a `php_admin_value`
> ceiling those calls silently fail and wp-admin returns to "Allowed memory size
> exhausted" on plugin/theme updates, image regeneration and WooCommerce reports —
> the exact operations the limit was raised for on this box. `php_value` (without
> `admin`) is the overridable form; `php_admin_value` is not.
>
> Omitting it entirely lets the pool inherit whatever is already set via MultiPHP
> INI Editor / `.user.ini`, preserving that fix.
>
> **`max_children` is the cap; `memory_limit` is a per-request ceiling.** They do
> different jobs. `memory_limit` is not an allocation — a request using 90 MB uses
> 90 MB whether the limit is 192M or 1000M. Bounding *concurrency* is what turns
> "89 processes, box dead" into "6 processes, queue forms".

#### Sizing against this box's actual `memory_limit`

`madalice` currently runs **`memory_limit = 1000M`**, raised at some point so
WordPress admin would complete. Keep it — but size `max_children` against it,
because the two multiply:

| `max_children` | Theoretical worst (× 1000M) | Realistic (× 216 MB p90 RSS) |
|---|---|---|
| 8 | 8.0 GB | 1.7 GB |
| **6 (use this)** | **6.0 GB** | **1.3 GB** |
| 5 | 5.0 GB | 1.1 GB |

On an 11.7 GB box also holding MariaDB (~2.4 GB), Apache (~1.3 GB) and ~19 other
accounts, an 8 GB theoretical tail for one account is too much headroom. Use **6**
while the limit stays at 1000M.

**Separately, and not in the same change window:** 1000M is roughly 4× what
WordPress admin normally needs (typical admin 256M, heavy WooCommerce 512M). It
was raised to make an error stop, which works but does not identify *what* needed
it — usually image regeneration over large originals, a WooCommerce import/report,
or one badly-behaved plugin. Reversible test: drop to 512M, exercise the admin
operations that used to fail. If they still fail you have learned something
specific; if they do not, the tail exposure halves and `max_children: 8` becomes
comfortable. One change at a time.

Then rebuild and reload:

```bash
/usr/local/cpanel/scripts/php_fpm_config --rebuild
/usr/local/cpanel/scripts/restartsrv_apache_php_fpm
grep -E '^pm' /opt/cpanel/ea-php82/root/etc/php-fpm.d/<domain>.conf   # confirm it took
```

Equivalent through the API:

```bash
whmapi1 php_set_vhost_versions version=ea-php82 vhost=<domain> php_fpm=1 \
  php_fpm_pool_parms='{"pm_max_children":8}'
```

**Why `ondemand`:** `pm: dynamic` (the cPanel default) keeps
`pm.start_servers` children resident per account forever. Twenty accounts × 2 idle
children × 104 MB = 4.2 GB of RAM burnt doing nothing. `ondemand` starts at zero and
reaps after `pm_process_idle_timeout`, which preserves the low floor the `cgi`
handler gives us today while adding the ceiling it lacks.

### 4.3 Sizing the caps

Memory budget on an 11,729 MB box:

| Consumer | Reserve |
|---|---:|
| MariaDB (peaked 2,351 MB; normally ~500 MB) | 2,400 MB |
| Apache `nobody` (peak 1,216 MB) | 1,300 MB |
| root + agents (imunify, netdata, cpanel; peak ~976 MB) | 1,000 MB |
| OS page cache / headroom (do not skip — this is what stopped the swap-thrash death spiral) | 2,000 MB |
| **Available for PHP** | **≈ 5,000 MB** |

At the p90 worker size of 216 MB, 5,000 MB buys roughly **23 concurrent PHP workers
box-wide**. Suggested starting allocation:

| Account | `pm_max_children` | Worst case |
|---|---:|---:|
| madalice | 6 | 1,296 MB |
| alison | 4 | 864 MB |
| speedlin | 4 | 864 MB |
| tubesupplies | 4 | 864 MB |
| olympic | 3 | 648 MB |
| evolution (ERP) | 8 | 1,728 MB |
| every other account | 3 | 648 MB each |

That is deliberately *oversubscribed* — not every account peaks at once, and
`ondemand` means the floor stays near zero. What it guarantees is that **no single
account can ever again reach 5 GB**, which is the only property we actually need.

The ERP gets a larger allowance because its workers are smaller and its traffic is
staff, not bots. Revisit after a week of watcher data.

### 4.4 What the user sees when the cap is hit

Requests beyond `pm_max_children` queue in the FPM listen backlog. If they wait
longer than the backlog allows, Apache returns **503**. That is the trade being
made, and it should be stated out loud: *one account's own site gets slow or errors
under load, instead of the whole server plus the database falling over.* That is
strictly the better failure — today a `madalice` bot storm takes the ERP down.

---

## 5. Route B — switch the handler to `fcgi` (mod_fcgid)

> **Superseded 2026-08-27 — background only, do not do this.** §3.1(b) showed
> that per-account FPM already runs on this box *under* the `cgi` handler
> (`corpkicks`, `infinium`), so there is no reason to touch the handler at all.
> Route B changes every account on a version at once; Route A changes one.
> Kept because it documents the `FcgidMinProcessesPerClass` trap, which would
> have made things worse, and because it is the answer to "can we set
> `FcgidMaxProcesses`?" — you can, but you should not.

Use this if you would rather cap globally in one move than enable FPM per account.
It is a bigger blast radius: it changes how PHP runs for **every account on that
PHP version at once**.

### 5.1 Change the handler

WHM → **MultiPHP Manager** → **PHP Handlers** → set `fcgi` for each version in use
(`ea-php74`, `ea-php81`, `ea-php82`, `ea-php83`, `ea-php84`).

Or:

```bash
/usr/local/cpanel/bin/rebuild_phpconf --current                       # record first
/usr/local/cpanel/bin/rebuild_phpconf ea-php82=fcgi ea-php81=fcgi \
    ea-php74=fcgi ea-php83=fcgi ea-php84=fcgi
```

### 5.2 Set the limits

Include file, so it survives an EA4 rebuild:

```
/etc/apache2/conf.d/includes/pre_main_global.conf
```

```apache
<IfModule mod_fcgid.c>
    # Box-wide ceiling. 30 x 216MB (p90) = ~6.5GB worst case.
    FcgidMaxProcesses            30

    # Per account (mod_fcgid "class" == one suexec'd interpreter binary+user).
    FcgidMaxProcessesPerClass     6

    # CRITICAL: default is 3. Leaving it there pins 3 resident children per
    # account forever -- ~20 accounts x 3 x 104MB = 6.2GB of idle RAM, which
    # makes this change WORSE than the cgi handler it replaces.
    FcgidMinProcessesPerClass     0

    # Reap aggressively -- keep the memory floor near the cgi baseline.
    FcgidIdleTimeout             30
    FcgidIdleScanInterval        20
    FcgidProcessLifeTime        600
    FcgidMaxRequestsPerProcess  200

    # Queue rather than 500 when the cap is hit.
    FcgidBusyTimeout             90
    FcgidConnectTimeout          20
    FcgidIOTimeout              120
</IfModule>
```

```bash
apachectl configtest && /usr/local/cpanel/scripts/restartsrv_httpd
```

### 5.3 The trap in Route B

`mod_fcgid` **keeps interpreters alive between requests**. That is its performance
win and its memory risk. Under `cgi` today, a worker exits the instant the request
ends, so the *floor* is near zero and only the *ceiling* is unbounded. Route B
inverts that: it fixes the ceiling but raises the floor.

On a box that is already OOM-killing its database, an unconsidered handler switch
can make things worse on day one. `FcgidMinProcessesPerClass 0` plus a short
`FcgidIdleTimeout` is what keeps the floor down. Do not skip them.

---

## 6. Route C — CloudLinux (the actual fix)

Everything above is a workaround for a missing feature. CloudLinux LVE gives real
per-account limits enforced by the kernel — memory, CPU, IO, **entry processes** —
so a runaway account throttles itself and nothing else on the box notices. It is
purpose-built for exactly this failure and it is what a shared host with 20
untrusted WordPress installs should be running.

Cost: licence per server, plus a kernel swap and reboot. Worth pricing given this
box has taken the ERP down ten times in a fortnight.

---

## 7. Recommended order

1. **Confirm state** (§3) — do not act on inference, including mine.
   Handler and version are already confirmed (§3.1); still run
   `rebuild_phpconf --current` so the record is from config, not a screenshot.
2. **Fix the broken `netdata` yum repo** (§4.0). It is why the FPM toggle reads
   *Unavailable* — dnf aborts, cPanel's package check fails, and cPanel reports
   that failure as a misleading "install the package" message. Nothing about PHP
   needs installing. This also unblocks EA4/PHP updates generally, which have been
   failing the same way. Then reload MultiPHP Manager.
3. **Route A on `madalice` alone.** It is the top PHP consumer in all eleven
   samples across the ten OOM kills. One account, instantly reversible,
   immediate signal.
4. Watch for 48 h. `erp-forensics/` samples will show whether `madalice` peak procs
   is now bounded at `pm_max_children` and whether its 503 rate is acceptable.
   (Deploy watcher **v2.2** first — it names the URLs behind the burst, which is
   what tells you whether the 503s are hitting real visitors or scanners.)
5. If good, roll Route A to `alison`, `speedlin`, `tubesupplies`, `olympic`, then
   the rest. No package work needed for any of them — every PHP version on the box
   already has its FPM package.
6. **Skip Route B.** §3.1(b) proves it is unnecessary — per-account FPM works
   under the `cgi` handler. It is retained below only as background.
7. Price Route C regardless.

Do **not** do Route A and Route B in the same change window. If it breaks you will
not know which one did it. (With §3.1(b) settled, there is no reason to do Route B
at all.)

---

## 8. Rollback

| Route | Undo |
|---|---|
| A | MultiPHP Manager → PHP-FPM: Off for the domain, or `whmapi1 php_set_vhost_versions version=ea-php82 vhost=<domain> php_fpm=0`; then `/usr/local/cpanel/scripts/php_fpm_config --rebuild` |
| A (limits only) | delete `/var/cpanel/userdata/<user>/<domain>.php-fpm.yaml`, rebuild |
| B | `/usr/local/cpanel/bin/rebuild_phpconf ea-php82=cgi ...` (the values recorded in §3), then `restartsrv_httpd` |

Both routes are config-only. No data is at risk, and no ERP code changes.

---

## 9. What this does *not* fix

A concurrency cap bounds the blast radius; it does not stop the thing lighting the
fuse. Still open, tracked in the main audit:

- **Why** `madalice` generates 100+ concurrent PHP requests — the probe-block work
  (`apache-probe-block.conf`) showed scanners hitting unlisted paths that render a
  full 30 KB WordPress 404 per hit. A blocklist cannot win this; the cap can.
- **The 08-12 ERP pile-up** — 137 ERP workers blocked *before* the database
  (~20 DB connections held). Not a report-lock issue. Needs the watcher v2.2
  `/proc/<pid>/environ` capture to name the URL.
- **Origin exposure** — locking Apache to Cloudflare ranges + `mod_remoteip` would
  cut the scanner traffic at the door.
- **`OOMScoreAdjust=-500` on mariadbd** — stops MariaDB being chosen as the victim.
  Treats the symptom, but a good symptom to treat.
