# PHP 8 readiness — verified against post-deploy production log

> **Tooling moved 2026-08-21.** The scanners now live in the repo at
> `evolution/cron/php8-migration/` — `php8-scan.php` (static, was
> `php8-scan-companies.php` in this folder) and `php8-log-scan.php` (log
> analysis, new). Both are CLI-only and PHP 7.4-compatible, so they ship to
> prod with a normal pull and run there in place. See that folder's README.

**2026-08-20.** Supersedes `PHP8_READINESS_2026-08-20_prelim.md`, which was
written against a log that stopped before the fixes shipped.

Source: `Production Logs/error_log_new` — 800,000 lines, 162 MB,
**06 Jul – 20 Aug 2026** (through today). Checked against `evolution` @
`staging` `0c68e1a91`.

---

## 1. The deploy is visible in the data, and it worked

Prod took the PHP 8 fix deploy on **10–11 August**. Daily counts of every
class that is a *hard fatal* under PHP 8:

| Day | bareword const | count() null | number_format | non-numeric | div/0 | each() |
|---|---|---|---|---|---|---|
| 08 Jul | 140,579 | 37 | 16 | 71 | 49 | 9 |
| 13 Jul | 135,184 | 1,196 | – | 3 | 9 | 13 |
| 31 Jul | 1,131 | 25,130 | 1 | 2 | 39 | 19 |
| 06 Aug | 9 | 1,927 | 66 | 288 | 2 | 4 |
| 10 Aug | 18 | 7 | 234 | 163 | 8 | 18 |
| **11 Aug →** | **0** | **~0** | **0** | **0** | **0** | **0** |
| 12–20 Aug | 0 | 15 total | 0 | 0 | 0 | 0 |

Across the ten days since (11–20 Aug), **41 PHP-8-fatal events over 8 sites**,
down from tens of thousands per day. `Use of undefined constant` — the class
that is an unconditional fatal in PHP 8 — went from 448,000 occurrences to
**zero**.

The prelim assessment was right about direction and right about the two sites
it named. This log adds six it could not have seen.

---

## 2. Everything still fatal under PHP 8 — the complete list

Ten days post-deploy, whole log, both prod roots:

| Hits | File:line | Problem | Predicted? |
|---|---|---|---|
| 12 | `invexportsave.php:93` | `unlink($fp)` on a `fopen()` resource | ✅ yes |
| 10 | `purchaseaddsave.php:226` | `count(($_REQUEST['receiptId'] ?? 0))` — **the guard is wrong**, `count(0)` is still a TypeError | ❌ new |
| 6 | `siteeditMerge.inc:12` | `array_search($v, $abbwho)` — `$abbwho` defined nowhere | ✅ yes |
| 5 | `quoteditsave.php:1291` | `unlink($fp)` on a resource — second instance of the same bug | ❌ new |
| 4 | `print.php:2976`, `:3009` | `in_array($x, $approved ?? [])` — **`??` doesn't catch `false`**, only null | ❌ new |
| 3 | `library/notifications.php:70` | `count($rows)` where `$rows = updateQuery(...)` returns a scalar | ❌ new |
| 3 | `functions.php:2936` | `in_array($parentID, $parentIDs)` — `$parentIDs` arrives null | ❌ new |
| 1 | `library/upload.php:136` | `count($files['source']['name'])` unguarded | ❌ new |
| 1 | `invoiceadd.inc:1442` | `count($payments)` — `selectQuery()` can return non-array | ❌ new |

Plus three found by static scan that the log never exercised in 45 days — low
traffic, but still unconditional fatals when reached:

| File:line | Problem |
|---|---|
| `calender.php:28` | `$dayArray[$count][time]` bareword key |
| `taxadd.inc:19` | `$_REQUEST[value]` bareword key |
| `taxedit.inc:31` | `$tax[value]` bareword key |

**12 sites total.** All are one- or two-line fixes.

### Status: all 12 fixed 2026-08-20 (uncommitted)

Applied in the working tree, every file `php -l` clean, no PHP-8-only syntax
introduced (still parses on 7.4):

| File | Fix |
|---|---|
| `invexportsave.php` | dropped `unlink($fp)`; `fopen(...,'w')` already truncates |
| `quoteditsave.php:1291` | same |
| `purchaseaddsave.php:226` | `count((array)($_REQUEST['receiptId'] ?? []))` |
| `siteeditMerge.inc:12` | `$abbwho` → `$abb` (see behaviour note below) |
| `library/notifications.php:70` | `count($rows)` → `(int)$rows > 0` |
| `print.php:2976`, `:3009` | `is_array()` guard + `foreach ((array)$vars ...)` |
| `functions.php:2936` | `is_array($parentIDs) &&` guard |
| `library/upload.php:136` | `$sourceNames` normalised to an array before `count()` |
| `invoiceadd.inc:86` | `$payments` normalised after `selectQuery()` — covers the `foreach` at :1427 and the `count()` at :1442 |
| `calender.php:28` | `[time]` → `['time']` |
| `taxadd.inc:19` | `$_REQUEST[value]` → `$_REQUEST['value']` |
| `taxedit.inc:31` | `$tax[value]` → `$tax['value']` |

**Behaviour change — `siteeditMerge.inc`.** This was not just a PHP 8 fatal, it
was a live 7.4 bug. `array_search($v, $abbwho)` searched an undefined variable,
returned `false`, and `$who[false]` is `$who[0]` — so *every* street
abbreviation expanded to "AVENUE". St, Rd, Cres, all of them. With `$abb` as
the haystack the parallel arrays line up and abbreviations expand correctly.
Any site addresses merged through this path before today may carry a wrong
street type.

### Two guard patterns that were applied wrongly

Worth calling out, because they will recur:

1. **`?? 0` instead of `?? []`** — `count($x ?? 0)` reads like a guard and is
   not one. One instance (`purchaseaddsave.php:226`), and it is the second
   most frequent remaining fatal.
2. **`?? []` where the value can be `false`** — `??` tests null/unset only. A
   function returning `false` sails straight through. Two instances
   (`print.php:2976`, `:3009`), both on `$param=null` defaults where callers
   pass `false`. `(array)` casting is the safe form.

A pass over the fix commits looking for both shapes is worth doing before the
cutover.

---

## 3. Something is already executing this code on PHP 8

170 warnings in the log use **PHP 8.0+ wording that PHP 7.4 cannot produce**:

- `Undefined array key "support"` — 7.4 says `Undefined index: support`
- `Undefined variable $bomSelect` — 7.4 says `Undefined variable: bomSelect`

Zero PHP 7 forms of either appear anywhere in 800,000 lines. The paths are
production paths (`/home/evolution/my.evolutionerp.com.au/app/templates/menu.php`,
`print.php`, `quoteditsave.php`), dated **07 Jul – 24 Jul** and again **20 Aug**.

Meanwhile 448,000 `Use of undefined constant … will throw an Error in a future
version of PHP` warnings prove the main web SAPI is 7.4 — that message does not
exist in PHP 8.

So two runtimes are writing to one log. Most likely a PHP 8 CLI (cron, or a
manual run) against the prod document root, or a canary vhost. **Find out which
before the cutover** — it is either a useful canary nobody documented, or an
unnoticed inconsistency between the CLI and web PHP versions.

Sample of what PHP 8 hit while it ran (all warning-level, none fatal):
`history.inc:16` `"support"` (126), `jobeditmf.inc:191` `$bomSelect` (12),
`quoteditsave.php:1436/1655/1789/1827/1853/1892`, `print.php:605/1740/1921-1924/
1784/1809/2249/2256`, `activityadd.inc:38`, `activityedit.inc:18`,
`siteeditMerge.inc:54`, `mfAdd.inc:34`, `jobeditrcti.inc:339/373`,
`quotedit.inc:1247/2587`, and both tenant `plugins/controller.php` files
(`$path` undefined — see the portal/library `$path` note).

---

## 4. Not blockers, but still there

**`foreach()` on a non-array — 27,329 hits in ten days.** Stays a *warning* in
PHP 8, so it does not block the move. But 22,814 of them are a single line,
`invcatedit.inc:6`, and it is the largest single source of log noise in the
system. Others: `quotedit.inc:310/1521/1547/1584` (~2,000),
`quotedit_copymodal.inc:78` (714), `library/quote.php:196` (141).

**Broken includes, post-deploy:**

| Hits | Site | Missing |
|---|---|---|
| 1,604 | `PROD2:companies/85-1516155235/plugins/testandtag/functions.php:2` | `../../../../.env` — resolves nowhere, still unfixed |
| 842 | `quoteditsave.php:1117` | `include($pluginFile[0]['file'])` with an unvalidated DB-sourced path |
| 158 | `report.inc:46` | `reports/.inc` — empty report name reaching the include |
| 6 | `index.php:455` | |

**`allow_url_fopen=0`** blocks Google geocoding — 1,533 + 1,502 hits. Version
independent; the geocode feature has simply been dead. Several of the addresses
being geocoded are free-text delivery notes, not addresses.

**Only one fatal in ten days**: `templates/invreorder.php:56` memory exhausted.

---

## 5. `companies/` — unchanged, still the largest unknown

Still gitignored, still unscanned, still unmigrated. Post-deploy it is
generating 1,604 `.env` include failures, 1,208 `foreach` warnings from
`testandtag/siteedit.php`, and it holds the bareword-key print templates
(`Pricebook_No_RRP.html`, `invoicedefault.html`) that are **unconditional
fatals in PHP 8**. The 41-file partial pull scans at 95 class-A fatals.

The tenant enumeration from `PROD_FILES_TO_PULL.md` §2 has still not been run,
so the true scope is unknown. This is now the only large item left.

---

## 5b. Dev box (PHP 8.3.6) — 36 days of real PHP 8 execution

Target confirmed as **8.3**. The dev code-server box already runs **PHP 8.3.6**,
and `/config/workspace/error.log` is a live Apache feed of it — **15 Jul to
20 Aug 2026, 65,687 PHP entries, 788 fatals**. This is the PHP 8 canary the
earlier assessment said was missing; it just already existed.

### Live `evolution` web-root code is clean

Every PHP-8-class fatal in 36 days, and where it stands now:

| Hits | Site (log line no.) | Class | Now |
|---|---|---|---|
| 9 | `reports/salesByRep.inc:108` | `in_array(null)` TypeError | fixed |
| 4 | `reports/invitemhist.inc:126` | Undefined constant `id` | fixed |
| 3 | `reports/salesByRepCustomers.inc:108` | `in_array(null)` TypeError | fixed |
| 3 | `quoteditsave.php:1500` | `string * string` TypeError | fixed (float casts) |
| 2 | `plugins/fencecosting/plugin.php:427` | assign property on null | fixed |
| 2 | `messageTemplateSave.php:8` | Undefined constant `userid` | fixed |
| 2 | `clienthistory.inc:95` | Undefined constant `string` | fixed |
| 1 ea. | `staffreport.inc:88`, `sessions.inc:29`, `reports/invstckmovement.inc:46`, `reports/invitemdetail.inc:59`, `reportpl.inc:53` | Undefined constant | fixed (`reportpl.inc` deleted) |
| 1 | `quotedit.inc:1042` | `round(string)` TypeError | fixed |
| 1 | `jobeditsave.php:1149` | `count(null)` TypeError | fixed |
| 1 | `emailAlertSave.php:53` | assign property on null | fixed |
| 1 | `billaddsave.php:295` | `string * string` TypeError | fixed (float casts) |
| 1 | `invoiceadd.inc:1418` (14 Aug) | `count($payments)` TypeError | **fixed today** |

The last entry is the same defect the prod log flagged at `:1442`. Two
independent logs, two line numbers, one bug — confirmed closed.

**Since 14 Aug there has been no PHP-8-class fatal anywhere in `evolution/`,
across 8,561 log entries in the last ten days.** The only fatals left in that
window are `PDOException`s out of `library/db.php` (missing tables/columns on
the dev tenant — schema drift, not a language issue).

A fresh static scan of the current tree agrees: **zero class-A bareword array
keys in live code.** All 1,195 that remain sit in `depreciated/` (1,091) and
`cron/depreciated/` (104).

### `companies/` — the dev log confirms the blocker

Ten of the 12 Aug fatals came from the 41-file partial tenant pull in this
folder being opened in a browser: `Call to undefined function selectQuery()`,
`Class "DB" not found`, and — new — **`Non-static method lecsafe\DB::select()
cannot be called statically`**. That last class was an `E_DEPRECATED` in 7.x
and is a hard `Error` in 8.0. It is not in the scanner's rule set and should be
added before the tenant tree is scanned properly.

### `portal` needs its own pass

Portal is still fataling every day, right up to 20 Aug:

| Hits | Site | Class |
|---|---|---|
| 38 | `portal/functions.php:10` | `Class "authObject" not found`, 06–20 Aug, ongoing |
| 53 | `portal/library/helper/ModalHelper.php:16` | `ValueError: Path cannot be empty` (PHP 8 only) |
| 2 | `portal/api/api-roles.php:17`, `api-form-answers.php:77` | `implode()` TypeError |
| 7 | `portal/functions.php:767`, `:774` | PDOException |

The `authObject` fatal is a chain, not a missing class: `functions.php:10` does
`include_once(".env")` then `include_once($path . "/library/authenticate.php")`,
and both fail 39 times each with "Failed to open stream" because `$path` is
empty. Dev deployment config, not PHP 8 — but the `ValueError` and the two
`implode()` TypeErrors are genuine 8.x breaks and portal has never had a fix
pass. See [[evolution_portal_duplicated_libraries]].

### Division by zero: quieter than the scanner suggests

Two `DivisionByZeroError`s in 36 days — `reports/opportunityCashflow.inc:155`
(06 Aug, now guarded with `max(1, ...)`) and `chainlinkWizard.php:4097` (10 Aug,
fixed by `e6d7fa478`). The static scan still lists 164 candidates in live code,
led by `chainlinkWizard.php` (20), `staffsales.inc` (10), `staffreport.inc` (9),
`library/pipeWire.php` (6), `jobeditinvoice.inc` (6). Two real hits out of 164
candidates in 36 days is the usual static-scan signal-to-noise; treat the list
as a review queue, not a defect list.

### The gap this log cannot close: deprecations are switched off

Dev runs `error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT`. There are
**zero** `Deprecated:` lines in 36 days — not because there are none, but
because they are suppressed. That hides the entire 8.1/8.2 deprecation surface,
and one item on it matters: **passing null to a non-nullable internal parameter**
(`strlen(null)`, `trim(null)`, `htmlspecialchars(null)`, `explode(',', null)`)
was deprecated in 8.1 and becomes a `TypeError` in 9.0. With 26,061 "Undefined
property" and 14,077 "Undefined array key" warnings in this log, nulls are
flowing into function calls constantly. Also hidden: dynamic property creation
(deprecated 8.2, `Error` in 9.0) and `${var}` string interpolation.

None of this blocks 8.3. All of it is the bill for 9.0.

**Action:** set `error_reporting = E_ALL` on the dev box for a week and re-read
this log. It costs nothing and is the only way to size that surface.

### Warning noise, for reference

8,561 warnings in `evolution/` over the last ten days, no blockers among them.
Concentrated in a handful of lines: `invedit.inc` accounts for ~4,000 across ten
line numbers (`:1002` 1,296, `:1023` 1,024, `:713` 486), and `invcatedit.inc:6`
contributes 605 `foreach() argument must be of type array|object, int given` —
the same single line that dominates the prod log.

---

## 6. Where this leaves the migration

| Area | State |
|---|---|
| Tracked ERP source | **12 known fatal sites, all small.** Down from ~450,000 events/45 days to 41. |
| `companies/` tenant tree | **Not started.** Scope unknown. |
| mysqli 8.1+ error mode | **Undecided.** 19 live files, `mysqli_report()` called nowhere. Invisible to any 7.4 log. |
| Target version | **Undecided.** Gates the mysqli question. |
| PDO / dependencies | Clean. `ERRMODE_EXCEPTION` already explicit; no composer/vendor. |

**Recommended order:**

1. Fix the 12 sites in §2, plus sweep the fix commits for the two bad guard
   shapes in §2.
2. Identify what is already running PHP 8 (§3) — that may be a canary you can
   use immediately, or a config drift to correct.
3. Pick the target version (8.2 or 8.3; 8.1 is EOL). Then settle mysqli.
4. Run the `companies/` enumeration and pull. Scanner `--apply` for class A,
   hand-fix the rest.
5. Canary: staging on the target 8.x against a prod data copy, drive the top
   pages *and* the save handlers, read the 8.x log.

The tracked codebase is in good shape and the deploy demonstrably worked. What
is left is a short defect list, one undecided config question, and an untracked
tree nobody has measured.
