Direction set 2026-07-26: the compiler core must not include every extension by default. Extensions become separate, discoverable, side-loadable modules, and a third party must be able to add one without patching the core.
Why this is a release-blocking lever, not a nice-to-have
| measurement |
value |
ext/ vs lib/ |
4,715 files vs 1,853 — extensions are ~72% of the tree |
hardcoded loads in Runtime::loadCoreModules() |
75, all unconditional |
| gen-0 spine |
6,519 files ≈ lib + ext |
phpc build on <?php echo "hello"; |
14.8 MB binary |
Runtime::loadCoreModules() instantiates all 75 modules on every run regardless of what the script touches, and everything links into every binary.
The extension boundary is the gen-0 problem. The spine that takes ~4.6 h and OOMs (#22642) is essentially lib + ext; removing extensions from the default core takes it from ~6,500 files to ~1,850 before any other optimisation. It is also the same partition a split-TU build needs (#23018/#23033 measured the spine as ~96% partitionable — only 3–4% of intra-spine class references cross a directory boundary).
Direction
- Discovery over hardcoding — a per-extension manifest (name, provided symbols, dependencies, capability rows) enumerated at build time, replacing the 75 constructor calls.
- Opt-in linking — the extension set is a property of the build (
phpc.json / CLI) with a small default; --with-ext= / --without-ext=.
- Link what is reachable, with an explicit override for dynamic use (
$fn = 'curl_init').
- No core → ext imports — dependencies point ext → core, never the reverse; core-level hooks live in
lib/ with extensions implementing an interface.
- Per-extension translation units, following the helper-runtime cache model (
lib/AOT/HelperRuntimeCache.php): a crash resumes at the failing unit, one edit re-emits one unit.
Boundary hazard
An unbound cross-module call lowers to __value__writeNull with no diagnostic (#579) — a silent miscompile rather than a missing-symbol error. Any work moving module edges must run with PHP_COMPILER_REPORT_EXTERNAL_STUBS=1 (#22967) and treat stub reports as failures.
OPEN: which extensions form the default set? Proposal: those the language itself leans on — standard, spl, types, ctype, hash, random — with everything else opt-in.
Done when: a script that never calls into an extension pays nothing for it — not in link time, not in binary size, not in startup — demonstrated by before/after numbers for gen-0 file count, hello-world binary size and cold phpc build wall time; and a new extension can be added by creating ext/<name>/ alone, with no core edit.
Rule: .cursor/rules/extensions-sideloaded.mdc. Plan: #23474.
Direction set 2026-07-26: the compiler core must not include every extension by default. Extensions become separate, discoverable, side-loadable modules, and a third party must be able to add one without patching the core.
Why this is a release-blocking lever, not a nice-to-have
ext/vslib/Runtime::loadCoreModules()lib + extphpc buildon<?php echo "hello";Runtime::loadCoreModules()instantiates all 75 modules on every run regardless of what the script touches, and everything links into every binary.The extension boundary is the gen-0 problem. The spine that takes ~4.6 h and OOMs (#22642) is essentially
lib + ext; removing extensions from the default core takes it from ~6,500 files to ~1,850 before any other optimisation. It is also the same partition a split-TU build needs (#23018/#23033 measured the spine as ~96% partitionable — only 3–4% of intra-spine class references cross a directory boundary).Direction
phpc.json/ CLI) with a small default;--with-ext=/--without-ext=.$fn = 'curl_init').lib/with extensions implementing an interface.lib/AOT/HelperRuntimeCache.php): a crash resumes at the failing unit, one edit re-emits one unit.Boundary hazard
An unbound cross-module call lowers to
__value__writeNullwith no diagnostic (#579) — a silent miscompile rather than a missing-symbol error. Any work moving module edges must run withPHP_COMPILER_REPORT_EXTERNAL_STUBS=1(#22967) and treat stub reports as failures.OPEN: which extensions form the default set? Proposal: those the language itself leans on —
standard,spl,types,ctype,hash,random— with everything else opt-in.Done when: a script that never calls into an extension pays nothing for it — not in link time, not in binary size, not in startup — demonstrated by before/after numbers for gen-0 file count, hello-world binary size and cold
phpc buildwall time; and a new extension can be added by creatingext/<name>/alone, with no core edit.Rule:
.cursor/rules/extensions-sideloaded.mdc. Plan: #23474.