MEP-55: Mochi-to-PHP 8.4 transpiler
Status
Final. All 18 phases landed via umbrella PR #22481, merged to main on 2026-05-29 at 07:35 UTC (merge commit 7c72ebaae9).
Fourteen post-merge audit follow-ups across rounds 1 through 6 landed afterwards. See implementation tracking for the full per-phase commit list.
Motivation
PHP runs WordPress (~43% of the web as of 2026 Q1), Laravel, Symfony, Drupal, MediaWiki, and Magento. Packagist hosts over 400000 Composer packages. PHP 8.4 (released Nov 2024) ships readonly inheritance, \\Random\\Randomizer, asymmetric visibility, and #[\\Override]; it is the first PHP version where Mochi's sum-type-with-readonly-variant story lowers cleanly without workarounds. Reaching the PHP ecosystem unlocks deployment to every shared-hosting tier and every WordPress plugin author.
Existing transpiler targets (MEP-45 C, MEP-46 BEAM, MEP-47 JVM, MEP-48 .NET, MEP-49 Swift, MEP-50 Kotlin, MEP-51 Python, MEP-52 TypeScript, MEP-53 Rust, MEP-54 Erlang/OTP) cover compiled, managed, mobile, scientific, edge, and supervised-server tiers but skip the largest server-side web tier by CMS deployment. MEP-55 closes that gap.
Design
Pipeline
.mochi -> parser -> types.Check -> clower.Lower (aotir) -> colour.Compute -> php/lower.Lower (ptree) -> php/emit.Emit -> main.php
Reuses MEP-45's aotir IR. New code is the PHP ptree, the PHP-specific lowerer, and the emitter.
transpiler3/php/ptree--- PHP-specific IR (declare, namespace, class, method, raw stmt)transpiler3/php/colour--- pass annotating which calls need sync wrappers (Phase 11)transpiler3/php/lower--- aotir to ptree, with runtime-helper injection gated by aruntimeFlagsstructtranspiler3/php/emit--- ptree to PHP 8.4 sourcetranspiler3/php/build--- Driver, phase tests, packaging (Phar, FrankenPHP, RoadRunner)transpiler3/php/runtime--- Composer package staged into the build sandbox
Lowering rules
| Mochi | PHP 8.4 |
|---|---|
int | int (PHP 64-bit on all supported platforms) |
float | float with === for comparison (strict equality, not loose ==) |
bool | bool with echo $value ? "true\\n" : "false\\n" |
string | string (byte sequence) |
list<T> | array (0-indexed, packed) |
map<K, V> | array (string-keyed) |
set<T> | array of value => true pairs |
| record | final readonly class with constructor promotion |
| sum type | abstract readonly class base + final readonly class variants |
| function type | Closure(...) with PHPDoc @param / @return |
| closure | first-class callable syntax $obj->method(...) |
match | match (true) { ... } chain |
| agent | final class wrapping a userland Channel |
| stream | final class implements IteratorAggregate with subscribe(int $limit) |
Result<T, E> | final readonly class Ok / final readonly class Err union |
panic | throw new \\RuntimeException |
division / | intdiv($a, $b) for int operands; / for float |
equality == | === (strict). Float compare uses === not ==. |
str_contains | helper short-circuits on empty needle, then str_contains(...) |
| infinity literals | fdiv(1, 0) for +Inf, fdiv(-1, 0) for -Inf, fdiv(0, 0) NaN |
The lowerer mangles user function names with a mochi__ prefix so they cannot collide with the inline runtime helpers (mochi_print_i64, mochi_str_contains, mochi_llm_generate, ...) in the same global namespace.
Agents and streams
PHP has no preemptive scheduler. Phase 9 agents lower to a final class wrapping a userland Channel: an array FIFO plus an integer counter. send(msg) appends, recv() shifts. The receive loop runs synchronously; spawn AgentType() constructs and returns the agent without forking a thread (the spec note at lower.go pins this).
Phase 10 streams lower to a final class implements IteratorAggregate with subscribe(int $limit). Backpressure is explicit: when the bounded buffer hits $limit, subsequent publish calls drop. A regression test fixture (stream_backpressure) pins both the subscribe-limit and the drop branch.
Async coloring
PHP has no native async runtime in the standard library. Phase 11 colour analysis identifies async functions, and the PHP target lowers them to synchronous wrappers (no Amp/Revolt dependency). The runtime composer.json originally drafted amphp/revolt as a dependency; audit round 1 dropped it after Phase 11 was confirmed sync-only.
LLM (Phase 13)
Phase 13 ships cassette-only dispatch. The user calls generate openai { model: "...", prompt: "..." }; the lowerer emits a mochi_llm_generate(string $provider, string $model, string $prompt): string call. The inline runtime computes a DJB2 hash of provider \\0 model \\0 prompt in GMP (uint64 wraparound exceeds PHP_INT_MAX for some real keys) and reads $MOCHI_LLM_CASSETTE_DIR/<key>.txt. If the env var is unset or the cassette is missing, the helper writes a stderr diagnostic and returns "". Live providers (OpenAI, Anthropic, Google, llama.cpp) are deferred; the C runtime's behaviour without libcurl is mirrored.
Reproducibility (Phase 16)
Builds are byte-equal by construction. The lower + emit pipeline has no time-, random-, or PATH-derived sources of non-determinism, so SHA-256 of the emitted main.php matches across runs without a SOURCE_DATE_EPOCH setting. Driver.Deterministic is reserved for a future Phar sorted-entries flag; today it is a no-op gated by TestPhase16NonDeterministicBuildsAlsoMatch.
Packaging (Phase 17)
Three deployment targets ship in parallel:
- Phar archive --- PHP's built-in
Pharclass (nohumbug/boxdependency). A stager script runs underphp -d phar.readonly=0to produceout.phar;php out.pharruns the program. - FrankenPHP --- emit a Caddyfile + Dockerfile. Pinned to
dunglas/frankenphp:php8.4, 4 workers,php_server,:8080. The worker line uses the modernworker /app/main.php 4form. - RoadRunner --- emit a
.rr.yaml+worker.php. 4 workers, 64 max-jobs, 60s allocate/destroy timeouts,:8080.
TestPhase17AllTargetsTogether runs all three for every Phase 17 fixture so a regression in any one target fails fast.
Signed releases (Phase 18)
The release workflow at .github/workflows/transpiler3-php-publish.yml layers three independent trust mechanisms documented in transpiler3/php/runtime/TRUST.md:
- GPG-signed Git tag (primary) ---
git tag -v v<version>against the release key fingerprint inSECURITY.md. - GitHub Actions OIDC attestation (secondary) ---
actions/attest-build-provenance@v1attaches a Sigstore-backed provenance statement. Verify withgh attestation verify mochi-runtime-v<version>.tar.gz --repo mochilang/mochi. - php-signify Ed25519 (optional tertiary) --- Drupal's port of OpenBSD
signify(1). Pinned todrupal/php-signify:^1.0.
Composer audit runs against the extracted staged tarball (not the in-tree source), and the defensive verify-gate job re-stages the tarball from a fresh checkout and audits the result.
Phases
| Phase | Title | Status |
|---|---|---|
| 0 | Skeleton + scaffold + matrix CI | LANDED |
| 1 | Hello world (print int/float/bool/string + newline shape) | LANDED |
| 2 | Scalars (int, float, bool, string, compare, arith, casts, control) | LANDED |
| 3 | Collections (lists, maps, sets, list-of-records) | LANDED |
| 4 | Records (final readonly class) | LANDED |
| 5 | Sum types (abstract readonly base + final readonly variants) | LANDED |
| 6 | Closures and higher-order functions | LANDED |
| 7 | Query DSL | LANDED |
| 8 | Datalog (compile-time semi-naive evaluation) | LANDED |
| 9 | Agents (userland Channel + final class) | LANDED |
| 10 | Streams (IteratorAggregate + subscribe_limit + drop) | LANDED |
| 11 | Async coloring (sync wrappers, no Amp/Revolt) | LANDED |
| 12 | FFI (FFI extension + sodium dispatch) | LANDED |
| 13 | LLM (cassette dispatch with DJB2/GMP hash) | LANDED |
| 14 | fetch (curl, byte-equal against vm3) | LANDED |
| 15 | Composer + PHPStan level 9 + Psalm 6 level 1 + php-cs-fixer | LANDED |
| 16 | Reproducibility (byte-equal by construction) | LANDED |
| 17 | Packaging (Phar + FrankenPHP + RoadRunner) | LANDED |
| 18 | Signed releases (GPG tag + Sigstore attestation + optional php-signify) | LANDED |
Out of scope
- Real preemptive concurrency (PHP fibers are cooperative). Agents and streams ship as synchronous receive loops; a future MEP can replace with
amphp/revoltif a use case justifies the dep. - Live LLM providers in Phase 13. Cassette-only dispatch is the only mode; live mode is deferred to a follow-up MEP gated on a libcurl-equivalent abstraction.
- Symfony / Laravel framework adapters. The runtime is framework-agnostic and works in any PHP project that meets the version requirement.
See also
- Implementation tracking
- Runtime trust documentation:
transpiler3/php/runtime/TRUST.md(in-repo) - Umbrella PR: #22481