# PHP 7.4 → 8.4 Upgrade Overview — pstat.dev

**Prepared:** 2026-08-13
**Scope:** `/home/btadmin/sites/pstat/pstat.dev` (Yii 1.1 application, ~1,994 application PHP files, 479 classes)
**Status:** Review document — no code changes have been made.

---

## 1. Verdict up front

**This is a low-code-risk, medium-ops-risk upgrade.** The application source is in far better shape than a typical 7.4-era Yii 1 codebase. A full `php8.4 -l` sweep over all 1,994 application files produced **only 2 syntax-level failures**, and both are one-line fixes.

The real work is not in rewriting application code. It is in:

1. **Server provisioning** — the installed PHP 8.4 is a bare CLI build missing ~15 extensions the app needs, and `php8.4-fpm` is not installed at all.
2. **Third-party dependencies** — the Composer tree is pinned to `platform: php 7.4.33` and contains several packages that predate PHP 8.x, including one with a hard parse error under 8.4.
3. **Runtime deprecations** — things static analysis cannot see (passing `null` to internal functions, dynamic property creation, string↔number comparison changes). These do not stop the app but will flood the error log and, via the always-on `EmailLogRoute`, your inbox.
4. **Framework support gap** — Yii 1.1.30 is officially tested to **PHP 8.3, not 8.4**. This is the one genuine unknown in the project.

Realistic estimate: **2–4 days of engineering** plus a soak period on staging.

---

## 2. Evidence base

Everything below is derived from direct inspection of this machine, not from assumptions:

| Check | Command | Result |
|---|---|---|
| Syntax sweep, app code | `php8.4 -l` over 1,994 files | 2 failures |
| Syntax sweep, Yii 1.1.27 framework | `php8.4 -l` over 1,665 files | 0 failures |
| Syntax sweep, Yii 1.1.30 framework | `php8.4 -l` over 1,666 files | 0 failures |
| Syntax sweep, Composer vendor | `php8.4 -l` over 1,312 files | 1 failure |
| Removed-function scan | `grep` for `each()`, `create_function`, `ereg`, `split`, `mysql_*`, `money_format`, `get_magic_quotes`, `utf8_encode` | 0 live hits |
| Extension delta | `ls /etc/php/*/fpm/conf.d` vs `/etc/php/8.4/*/conf.d` | 15 modules missing |

---

## 3. Current state

### Runtime topology

```
Apache 2.4.58
  └─ /etc/apache2/sites-enabled/008-pstat.conf:13       ← SetHandler proxy:unix:/run/php/php7.4-fpm.sock
  └─ /etc/apache2/sites-enabled/008-pstat-ssl.conf:18   ← SetHandler proxy:unix:/run/php/php7.4-fpm.sock
       └─ www/index.php
            └─ const YII_FRAMEWORK_VERSION = 'yii-1.1.27.8f9404'
                 └─ /home/btadmin/sites/pstat/yii-1.1.27.8f9404/framework/yii.php
                      └─ config/main.php
```

**The cutover point is exactly two lines** — the `SetHandler` directive in each vhost. That is a genuinely clean rollback story.

### Versions installed on this host

| Component | Version |
|---|---|
| PHP (serving pstat.dev) | 7.4 via `php7.4-fpm.sock` |
| PHP CLI default | 8.3.32 |
| PHP versions present | 7.4, 8.1, 8.3, **8.4.23** (CLI only) |
| PHP-FPM binaries present | 7.4, 8.3 — **no 8.4** |
| Yii framework in use | 1.1.27 |
| Yii framework already staged | **1.1.30** at `../yii-1.1.30.5f760e` |

### A useful property of the framework layout

Each site selects its framework by directory name in its own `www/index.php`. `plive.dev`, `psty2.dev` and `pstat.dev` all live alongside each other but each points at its own version constant. **Bumping pstat.dev to 1.1.30 does not touch the other sites** — no shared-library coordination is needed. Note however that `protected/config/bootstrap.php` (the PHPStan bootstrap) hardcodes the 1.1.30 path already, so tooling and runtime are currently pointed at *different* framework versions.

---

## 4. Blockers — concrete, with locations

### 4.1 Hard failures (app will fatal)

These three will stop execution under PHP 8.4. All are fixable in minutes.

**A. `www/archiveupdate.php:297` — curly-brace string offset (removed in PHP 8.0)**

```php
$func_retVal .= chr(hexdec($func_string{$func_index} . $func_string{++$func_index}));
```

Fix — replace `{}` with `[]`:

```php
$func_retVal .= chr(hexdec($func_string[$func_index] . $func_string[++$func_index]));
```

**B. `protected/views/livestream/multimatch2.php:35` — unparenthesised nested ternary (fatal since PHP 8.0)**

```php
$hpos = $situ == 0 ? 'draw' : $situ > 0 ? "lead" : "trail";
```

Fix — add explicit parentheses. Note line 36 (`$apos`) is the same shape and must be done together:

```php
$hpos = $situ == 0 ? 'draw' : ($situ > 0 ? "lead" : "trail");
$apos = $hpos == 'draw' ? 'draw' : ($hpos == 'lead' ? 'trail' : 'lead');
```

**C. `vendor/bluerhinos/phpmqtt/phpMQTT.php:242` — curly-brace string offset in a dependency**

`bluerhinos/phpmqtt 1.0.0` does not parse under PHP 8.4. It is used in exactly one place: `www/publish.php:13`.

The project **already has a modern MQTT client installed** — `php-mqtt/client v1.8.1`, used by `protected/components/my/MyMQTT.php`. The right fix is to port `www/publish.php` onto `MyMQTT` / `php-mqtt/client` and **remove `bluerhinos/phpmqtt` from `composer.json` entirely**, rather than patching a vendor file that any `composer install` will overwrite.

### 4.2 Server provisioning gaps

The installed PHP 8.4 is a minimal build. Comparing `/etc/php/7.4/fpm/conf.d/` against what 8.4 currently has, these are **missing and required**:

| Missing extension | Why the app needs it |
|---|---|
| `php8.4-fpm` | **Not installed at all** — no FPM SAPI, no socket to point Apache at |
| `php8.4-mysql` | `pdo_mysql` + `mysqlnd` — three DB connections in `config/main.php:402,413,423` |
| `php8.4-gd` | Declared in `composer.json`; `CCaptcha` widget (`protected/components/widgets/views/LatestNews/subscribedlg.php:58`) |
| `php8.4-mbstring` | Declared in `composer.json`; used throughout mPDF |
| `php8.4-xml` | `dom`, `simplexml`, `xmlreader`, `xmlwriter` — `DOMDocument` used in 6 files |
| `php8.4-zip` | `ZipArchive` used in 3 files; mPDF |
| `php8.4-bcmath` | Present on 7.4; verify before dropping |
| `php8.4-xsl` | Present on 7.4; verify before dropping |
| `php8.4-memcached` | **`config/main.php:260` uses `CMemCache`** — the app will fail to boot without this |

All are available from the `ondrej/php` PPA already configured on this host. Provisioning command:

```bash
sudo apt install php8.4-fpm php8.4-mysql php8.4-gd php8.4-mbstring php8.4-xml \
                 php8.4-zip php8.4-bcmath php8.4-xsl php8.4-memcached \
                 php8.4-curl php8.4-intl php8.4-opcache
```

Then copy the pool config and PHP ini settings across from 7.4 — in particular `memory_limit`, `max_execution_time`, `post_max_size`, `upload_max_filesize` and `session.*`, and create `/run/php/php8.4-fpm.sock` with the same owner/group as the 7.4 socket.

**Note:** 7.4 FPM currently loads `igbinary`, `msgpack` and `memcached`. The 8.3 pool on this host loads `memcached` but *not* `igbinary`/`msgpack`. If your memcached serialiser is configured as igbinary, either install `php8.4-igbinary` too or accept a cache-format change — **stale cache entries written with a different serialiser will not deserialise**. Plan to flush memcached at cutover regardless.

### 4.3 IMAP — a non-issue that looks like one

19 `imap_*` calls exist in `protected/components/general/MailParser.php`, `IMAPConnector.php`, `TestController.php` and bundled PHPMailer copies. IMAP was **unbundled from PHP core in 8.4** (moved to PECL), which normally makes this a blocker.

It isn't here: **`php7.4-imap` is not installed and no `imap.ini` exists in the 7.4 FPM conf.d**. These code paths already fatal on the current production runtime — they are dead code. `IMAPConnector` has no live callers (its only reference, `TestController.php:3670`, is commented out).

Options: leave as-is (no regression), delete the dead classes, or install `php8.4-imap` (available as PECL build `3:1.0.3`) if you intend to revive the feature. No action required for the upgrade itself.

### 4.4 `mcrypt`

7 hits in `protected/components/general/Defender.php:258-285` and `TestController.php:6752` — **all commented out**. mcrypt was removed in PHP 7.2, so this is already dead. No action.

---

## 5. Composer dependencies

`protected/extensions/composer.json` pins:

```json
"platform": { "php": "7.4.33" }
```

This must become `8.4.x` — until it does, Composer will resolve every package as if PHP 7.4 were the target and refuse the correct versions.

| Package | Installed | Assessment |
|---|---|---|
| `bluerhinos/phpmqtt` | 1.0.0 | **Parse error on 8.4.** Remove — `php-mqtt/client` already covers this |
| `mpdf/mpdf` | v8.0.4 | Predates PHP 8.1. Upgrade to **8.2.x** for 8.3/8.4 support. Low API churn but PDF output is worth visually diffing |
| `endroid/qr-code` | 3.9.7 | Released 2020, PHP 7.1-era. Needs **^5.x**, which is a **breaking API change** (`QrCode::create()` → builder API). Budget real time here |
| `symfony/http-client` + 5 other Symfony components | v5.4.x | **Symfony 5.4 reached EOL in Nov 2025.** Move to 6.4 LTS (PHP ≥8.1) or 7.x (PHP ≥8.2) |
| `psr/log` | 1.1.4 | Symfony 6.4+ will pull this to ^3 |
| `phpstan/phpstan` | 1.12.29 | Upgrade to **2.x** — needed for the `phpVersion: 80400` analysis described below |
| `mailgun/mailgun-php` | v4.3.5 | Fine on 8.4 |
| `nyholm/psr7`, `php-http/*` | current | Fine |
| `setasign/fpdi` | v2.6.4 | Fine |

Also note `protected/extensions/` contains **vendored, non-Composer libraries** — `yiibooster401`, `x-editable`, `MobileDetect`, `YiiMailer` (with its own bundled PHPMailer), `Xero`, `EMpdf.php`, `helpSystem`, `groupgridview`, `ecalendarview`. These have no upgrade path and no version tracking. They all lint clean under 8.4, but they are the most likely source of *runtime* deprecations and must be exercised in staging.

---

## 6. The framework question

Yii 1.1.30's CHANGELOG (Oct 2024) reads:

> `Enh #4552: Added support for PHP 8.3`

There is **no released Yii 1.1 version that claims PHP 8.4 support.** 1.1.30 is the end of the line as of this review.

What this means in practice:

- Both 1.1.27 and 1.1.30 **parse cleanly** under 8.4 — there is no syntax blocker.
- Yii 1.1 barely uses parameter type declarations, so the headline PHP 8.4 deprecation (*implicit nullable parameter types*, `function f(Foo $x = null)`) has **near-zero surface area** in the framework. A scan found 0 occurrences.
- The residual risk is **runtime deprecation noise** and edge-case behaviour, not fatals.

**Recommendation: upgrade to 1.1.30 first, as an independent step on PHP 7.4.** 1.1.30 carries three releases' worth of PHP 8.x compatibility fixes (CLocale, MarkdownParser, CCaptcha/Imagick, null-to-string-param fixes) plus two CVE fixes — **CVE-2022-41922 and CVE-2023-47130, both RCE-on-deserialisation**. Since you are on 1.1.27, you are currently exposed to CVE-2023-47130. That alone justifies the bump regardless of the PHP work.

Doing it separately means one line changes in `www/index.php:38` and it is independently revertible.

---

## 7. What static analysis cannot tell you

The clean lint result is genuinely good news, but be clear about its limits. `php -l` catches syntax only. These categories are invisible to it and **will** exist in a codebase this size:

1. **Passing `null` to non-nullable internal parameters** (deprecated in 8.1) — `trim(null)`, `strlen(null)`, `explode(',', null)`, `htmlspecialchars(null)`. In legacy code fed from DB nullable columns this is typically the single largest source of deprecation volume.
2. **Dynamic property creation** (deprecated in 8.2). Yii's `CComponent` uses `__get`/`__set`, which shields most model/controller code. Plain classes — the vendored extensions, `EmailLogRoute`, helper classes — are exposed.
3. **String↔number comparison semantics** (changed in 8.0). `0 == "foo"` was `true`, now `false`. This changes *behaviour silently* with no error. In a scoring/results application, comparisons against DB values are worth targeted review.
4. **`PDO::ERRMODE_EXCEPTION` is the default from PHP 8.0.** Yii sets its own error mode, so this should be contained, but any raw PDO use outside the framework may now throw where it previously returned `false`.
5. **`count()` on non-countables** — `TypeError` since 8.0.
6. **`@` no longer suppresses fatal errors** (8.0).

The tool for categories 1, 2 and 5 is PHPStan. The project already has it configured at `protected/extensions/phpstan.neon` (level 3, with a large Yii-specific ignore list) and a wrapper at `phpstan.sh` — **which currently hardcodes `/usr/bin/php7.4`**. Upgrade to PHPStan 2.x, point the wrapper at 8.4, and add:

```yaml
parameters:
    phpVersion: 80400
```

That turns PHPStan into a PHP 8.4 compatibility checker across `controllers`, `models`, `components`, `commands` and `modules`. Note the current config **excludes `../extensions/*`** — for this exercise, temporarily include it, since the vendored libraries are the highest-risk code.

Category 3 has no tool. It needs functional testing.

---

## 8. Recommended plan

### Phase 0 — Baseline (½ day)

- Confirm what cron/console jobs invoke `protected/yiic`. This review could not read the crontabs (permissions); **they run on the CLI PHP, not the FPM PHP, and are an easy thing to forget at cutover.**
- Record current behaviour: capture a known-good PDF export, a scoresheet save, a live-scoring session, and an outbound email.
- Confirm `runtime/app.log` is quiet at baseline so new noise is attributable.

### Phase 1 — Framework bump, still on PHP 7.4 (½ day)

- `www/index.php:38` → `const YII_FRAMEWORK_VERSION = 'yii-1.1.30.5f760e';`
- Also update `protected/config/console.php` consumers and confirm `protected/yiic.php` resolves the same path.
- Regression test. Deploy. **Let it soak.** This is a security fix in its own right and should not wait behind the PHP work.

### Phase 2 — Code fixes, still on PHP 7.4 (½ day)

All three hard failures in §4.1 are valid PHP 7.4 as well, so they can ship ahead of the runtime change with zero risk:

- `www/archiveupdate.php:297` — `{}` → `[]`
- `protected/views/livestream/multimatch2.php:35-36` — parenthesise ternaries
- `www/publish.php` — port to `php-mqtt/client`; drop `bluerhinos/phpmqtt`

### Phase 3 — Dependencies (1 day)

- Set `platform.php` to `8.4.x` in `composer.json`
- Bump mPDF → 8.2.x, PHPStan → 2.x, Symfony → 6.4 LTS
- Migrate `endroid/qr-code` 3 → 5 (the one genuine API rewrite)
- Run `composer update` **on PHP 8.4 CLI**, then re-run the vendor lint sweep

### Phase 4 — Analysis pass (½ day)

- PHPStan 2.x with `phpVersion: 80400`, extensions included, level 3 initially
- Fix what it reports; regenerate the baseline for what you defer

### Phase 5 — Staging cutover (1 day + soak)

- Provision `php8.4-fpm` and the extension set from §4.2
- **Set `EmailLogRoute` to `'enabled' => false` in `config/main.php:494` before first boot.** It is currently unconditional at `error` level and pointed at live webhooks (`analyser.poolstat.net.au`, `mantis.poolstat.net.au`). A first boot on a new PHP major with that enabled will generate an email and webhook storm.
- Temporarily add a `CFileLogRoute` at `levels => 'error, warning'` writing to a dedicated `php84-deprecations.log`, and set `error_reporting = E_ALL` in the 8.4 pool
- Point staging's `SetHandler` at the 8.4 socket
- Exercise: login/session (uses `CDbHttpSession` against `_tmpsessiondata`), scoresheet save, live scoring, PDF/mPDF export, QR generation, Mailgun send, MQTT publish, Xero integration, memcached-backed pages
- **Soak for at least one full competition cycle** and drive the deprecation log to zero

### Phase 6 — Production cutover (½ day)

- Provision 8.4 FPM on prod, leave 7.4 pool running
- Flip both `SetHandler` lines, `apache2ctl configtest && systemctl reload apache2`
- **Flush memcached** (serialiser/format change risk)
- **Update cron jobs to the 8.4 CLI binary**
- Rollback = revert two lines + reload Apache. 7.4-FPM stays installed for at least two weeks.

---

## 9. Risk register

| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| `EmailLogRoute` storm on first 8.4 boot | **High** | High — mail reputation, webhook flood | Disable before cutover (§ Phase 5) |
| Deprecation noise fills `runtime/app.log`, 2MB×10 rotation loses real errors | High | Medium | Dedicated deprecation log route during soak |
| `endroid/qr-code` v3→v5 API break | High | Medium | Isolated, testable — do it early in Phase 3 |
| Silent behaviour change from string↔number comparison | Medium | **High** — wrong scores, not errors | Functional testing of results/scoring paths |
| Yii 1.1 unsupported on 8.4 | Medium | Medium | Soak period; 1.1.30 first; fork-and-patch if needed |
| Vendored extensions (yiibooster, x-editable, YiiMailer, Xero) misbehave | Medium | Medium | Include in PHPStan scope; exercise in staging |
| memcached serialiser mismatch | Medium | Low | Flush cache at cutover |
| Cron/console jobs left on 7.4 | **High** (easy to miss) | Medium | Explicit Phase 0 inventory |

---

## 10. Housekeeping worth doing along the way

Not blockers, but this upgrade is the natural moment:

- **`www/php.ini`** is a stale cPanel artifact — `session.save_path` points at `/var/cpanel/php/sessions/ea-php71`, a path that does not exist on this Ubuntu/FPM host. It is not read under FPM. Delete it.
- **`www/.htaccess:25-36`** — cPanel `php_flag` directives wrapped in `<IfModule php7_module>`. mod_php is not loaded, so they are inert. The wrapper name would also stop matching under PHP 8. Remove the block.
- **`display_errors = On`** appears in `protected/extensions/Xero/Xero.php:3`, `protected/extensions/authorizedResource.php:2` and four files under `www/test-xero/`. `www/test-xero/` is a publicly-reachable directory with error display forced on — worth reviewing for exposure independent of this upgrade.
- **`protected/config/bootstrap.php`** hardcodes an absolute Samba path (`/mnt/samba/share/sites/pstat/...`) as its first framework candidate. Make it relative, or at minimum keep it in step with `www/index.php`.
- **`phpstan.sh`** hardcodes `/usr/bin/php7.4`. Parameterise it.
- `protected/controllers/TestController.php` is 8,762 lines and holds most of the dead IMAP/mcrypt references. Not in scope, but it is where compatibility problems will hide.

---

## Appendix — reproducing the sweeps

```bash
cd /home/btadmin/sites/pstat/pstat.dev

# Full app-code syntax check under PHP 8.4
find protected www config modules -name "*.php" \
     -not -path "*/vendor/*" -not -path "*/assets/*" \
  | xargs -P 8 -n 1 sh -c '/usr/bin/php8.4 -l "$0" >/dev/null 2>&1 || echo "$0"'

# Vendor tree
find protected/extensions/vendor -name "*.php" \
  | xargs -P 8 -n 1 sh -c '/usr/bin/php8.4 -l "$0" >/dev/null 2>&1 || echo "$0"'

# Framework
find ../yii-1.1.30.5f760e/framework -name "*.php" \
  | xargs -P 8 -n 1 sh -c '/usr/bin/php8.4 -l "$0" >/dev/null 2>&1 || echo "$0"'

# Extension delta, 7.4 vs 8.4
diff <(ls /etc/php/7.4/fpm/conf.d/) <(ls /etc/php/8.4/cli/conf.d/)
```
