# PHP 8 readiness assessment — 2026-08-20

Source: `Production Logs/error_log_recent` (100,000 lines) re-scanned against
`evolution` @ `staging` `0c68e1a91`, plus a fresh token-level scan of the whole
repo with `php8-scan-companies.php`.

---

## 0. Read this first — the log is NOT post-fix

| | |
|---|---|
| Log window | **27 Jul – 05 Aug 2026** (ends 05-Aug 12:22) |
| PHP 8 fix commits | **05 Aug – 10 Aug 2026** (`be9dadeea` … `e6d7fa478`) |

The log tail stops *before* most of the migration work landed. It is the input
that drove the fixes, not evidence about what is left. Everything below is
therefore "log site → verified against current staging code", not "still
failing in production".

Two production roots appear in the log:

| Root | Lines |
|---|---|
| `/home/evolution/my.evolutionerp.com.au` | 77,690 |
| `/home/evolution/public_html/evolution_production` | 3,909 |

Volume breakdown: 81,285 warnings · 116 fatals · 65 deprecated · 5 parse errors.
None of the 116 fatals are PHP-version related — they are PDO issues (null
column 50, missing table 27, connection refused 16, unknown column 8) plus
`inventoryUpdate::bomCheckParents()` undefined (3) and two `max_execution_time`.

---

## 1. Verdict

**Tracked code is close to ready. The untracked per-tenant tree is not started.**

`php8-migration` is fully merged into `staging` (0 commits ahead, merged flag
set). Of the nine PHP-8-fatal classes the production log actually exercised,
seven are fully closed and two have residue. Separately, a whole-repo rescan
found three live bareword-key fatals the earlier pass missed.

The blocker is not the ERP source. It is `companies/` — gitignored, therefore
invisible to every scan and every fix so far.

---

## 2. Fatal classes seen in production, and where they stand

| # | Class (PHP 8 behaviour) | Prod hits | Sites | Status |
|---|---|---|---|---|
| 1 | `count()`/`sizeof()` on null → **TypeError** | 42,214 | 23 | ✅ all 23 verified guarded (`?? []`, `(array)`, `is_countable`) |
| 2 | Bareword array key → **Error: Undefined constant** | 3,100 | ~40 | ⚠️ repo sites fixed; **3 live sites still open** + tenant templates untouched |
| 3 | `number_format(string)` → **TypeError** | 966 | 1 | ✅ `clienthistory.inc:352` now `is_numeric(...) ? ... : 0` |
| 4 | Non-numeric string in arithmetic → **TypeError** | 350 | 13 | ✅ all cast (`(float)`) in `jobeditsave`, `inveditsave`, `quoteditsave`, `billaddsave`, `inveditsubsave` |
| 5 | `in_array`/`array_push`/`array_search` on null → **TypeError** | 472 | 4 | ⚠️ 3 fixed, **1 open** |
| 6 | Division by zero → **DivisionByZeroError** | 97 | 3 | ✅ all 3 guarded |
| 7 | `each()` removed → **Error** | 65 | 1 | ✅ rewritten as `foreach` |
| 8 | `unlink(resource)` → **TypeError** | 9 | 1 | ❌ **open** |
| 9 | Parse error (`.docx` include'd as PHP) | 5 | 1 | ❌ open — tenant tree |

Class 1 detail — the two hottest sites, `library/testTag.php:442` and `:493`
(41,799 of the 42,214 hits between them), are now `count($Assetkey ?? [])` /
`count($descKey ?? [])`.

Class 6 detail — `mfshoporderdetail.inc:260` is behind `if($cost > 0 && ...)`,
`opportunityCashflow.inc:155` uses `max(1, ...)`, `salesQuoteProfitByRep.inc:119`
is behind `if($tmp->total>0)`.

### Warning-only in PHP 8 — no migration action required

`Invalid argument supplied for foreach()` — 27,574 hits over 49 sites — stays a
warning in PHP 8. 23,978 of those are one line, `invcatedit.inc:6`. Worth fixing
for log hygiene, not for the version move.

Likewise version-independent (real bugs, but not migration blockers):
`include(plugins/pipe/pipetopost.php)` missing (838 — the file genuinely does
not exist in `plugins/pipe/`), `include(reports/.inc)` (150),
`allow_url_fopen=0` blocking Google geocoding (1,268 + ~600),
`include_once(../../../../.env)` failing (1,215, tenant plugin).

---

## 3. Open items in tracked code — 5 fixes, all small

| File:line | Problem | PHP 8 result |
|---|---|---|
| `calender.php:28` | `$dayArray[$count][time]` | `Error: Undefined constant "time"` |
| `taxadd.inc:19` | `$_REQUEST[value],` | `Error: Undefined constant "value"` |
| `taxedit.inc:31` | `$tax[value]` | `Error: Undefined constant "value"` |
| `siteeditMerge.inc:12` | `array_search($v, $abbwho)` — **`$abbwho` is not defined anywhere in the codebase** (the arrays are `$abb` / `$who`) | `TypeError: array_search(): Argument #2 must be of type array, null given` |
| `invexportsave.php:93` | `unlink($fp)` where `$fp = fopen(...)` | `TypeError: unlink(): Argument #1 must be of type string, resource given` |

`siteeditMerge.inc:12` and `invexportsave.php:93` are also plain bugs today:
the first silently mis-expands street abbreviations, the second unlinks by
resource (a no-op on 7.4) so `$target` is never actually truncated.

Whole-repo scan totals for context: **1,198 class-A bareword keys across 19
files — but 1,195 of them are in `depreciated/` and `cron/depreciated/`.**
Only the three above are in live code.

---

## 4. The real blocker — `companies/` is gitignored

`.gitignore` lines 1–3 exclude `/companies`, `companies/`, `companies2/`. The
repo tracks **zero** files there. Everything the per-tenant tree contains —
print/PDF templates that are `include()`d as PHP, and legacy tenant plugins —
has never been scanned or fixed.

Production evidence from the log:

| Tenant | Log hits | What is failing |
|---|---|---|
| `85-1516155235` | 3,693 | legacy `testandtag` plugin: `array_push()` on null (197), `foreach` on non-array (887), `.env` include failure (1,215) |
| `108-1481181613` | 205 | `Pricebook_No_RRP.html` + `invoicedefault.html`: bareword array keys — **unconditional fatal in 8** |
| `85-1559715131` | 15 | a `.docx` being `include()`d as PHP (5 parse errors) — also an LFI smell |

Re-scanning the partial pull in `companies/` (41 files) today still reports
**95 class-A hard fatals, 14 class-B, 56 class-C, 1 class-D**. Nothing has been
applied to it.

`plugins/legacy/ara` *was* brought into git (`ea79a46b2`) and fixed — but that
is one tenant's plugin, and prod still runs its own copy under `companies/`
unless that copy was replaced.

**Step 2 of `PROD_FILES_TO_PULL.md` — enumerate every tenant on prod that ships
executable PHP or HTML templates — has still not been run.** Until it is, the
size of this problem is unknown.

---

## 5. Risks a PHP 7.4 log can never show you

These produce no 7.4 diagnostic at all, so the log's silence means nothing.

| Risk | Exposure | Severity |
|---|---|---|
| **mysqli default error mode changed in 8.1** — failed queries throw `mysqli_sql_exception` instead of returning `false` | **19 live files** use `new mysqli`/`mysqli_connect`; `mysqli_report()` is called **nowhere**. Includes `login.php`, `invoiceaddsave.php`, `quoteditsave.php`, `staffeditsave.php`, `chainlinkWizard.php`, `functions.php` | **High** if targeting 8.1+ |
| String↔number comparison semantics (8.0): `0 == "abc"` is now `false`, `"" == 0` is now `false` | Codebase compares against string literals constantly (`> "0"`, `== "0"`, `>= "1"`) | Medium — silent behaviour change, no error |
| Dynamic property creation deprecated (8.2) | Ad-hoc property assignment on `job`, `inventoryUpdate`, `testTag` etc. | Low (deprecation) — heavy log noise |
| `${var}` string interpolation deprecated (8.2) | 47 PHP-context occurrences | Low |
| `utf8_encode` / `utf8_decode` deprecated (8.2) | 6 | Low |
| `strftime` deprecated (8.1) | 2 | Low |
| Passing null to non-nullable internal params deprecated (8.1) | Pervasive | Low, but will flood the error log |

Two things that came out **clean**:

- `library/db.php` already sets `PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION`
  explicitly, so PHP 8.0's PDO default change is a no-op here.
- **No `composer.json`, no `vendor/`.** There is no third-party dependency
  matrix to resolve. Bundled PHPMailer 6.6.4 is PHP 8 compatible.

### Already dead on 7.4 — not new breakage, but worth knowing

The scanner flags 68 "removed function" hits (`ereg`, `eregi`, `split`,
`mysql_query`). All of these were removed in **PHP 7.0**, so these code paths
are *already* fatal on production today:

- `plugins/abn/lib/*` (nusoap) — 24 `ereg`/`eregi`/`split`
- `cron/min_functions.php` — 16 `mysql_query`
- `depreciated/prleave*.inc`, `depreciated/reportbas.inc` — 23
- `plugins/testandtag/uploadadd.inc` — 3
- `qualaddsave.php:107`, `qualcataddsave.php:89` — 2 ← **these two are live web-root filenames**

Delete or repair regardless of the migration. The ABN lookup plugin in
particular is non-functional as shipped.

---

## 6. Recommended plan

**Phase 0 — pick the target version.** 8.1 is EOL (Dec 2025). Recommend **8.2
or 8.3**. This decision changes the scope of §5: on 8.0 the mysqli issue does
not exist; on 8.1+ it is the single largest untested risk.

**Phase 1 — close the 5 tracked fixes in §3.** Half a day.

**Phase 2 — mysqli error-mode decision.** Either add
`mysqli_report(MYSQLI_REPORT_OFF)` at bootstrap to preserve 7.4 semantics
(fast, safe, reversible), or audit all 19 files for `if(!$result)` patterns
(correct, slower). Recommend the former for the cutover, the latter as follow-up.

**Phase 3 — the `companies/` tree.** Run the enumeration from
`PROD_FILES_TO_PULL.md` §2 on prod, pull the full tree, run
`php8-scan-companies.php --apply` for class A (mechanical, backed up,
lint-checked), hand-fix B/C/D. This is the long pole and it has not started.

**Phase 4 — get a genuinely fresh log.** Pull `error_log` from *after*
10 Aug 2026 on both prod roots and re-run this analysis. That is the only way
to confirm the 05–10 Aug fixes actually landed on production and to catch sites
the 27 Jul – 05 Aug window never exercised.

**Phase 5 — a PHP 8 canary.** Stand up staging on the target 8.x with a copy of
prod data, drive the top ~30 pages plus the save handlers, and read the 8.x
error log. Static scanning cannot prove readiness for a codebase this dynamic —
the 2,744 class-C `count()` candidates are mostly false positives, and only
execution distinguishes them from the 23 that genuinely fire.

**Do not cut over on the strength of this log alone.** It predates the fixes and
covers 10 days, which is not enough to have exercised month-end, EOFY, or
low-frequency reports.
