Skip to main content

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 a runtimeFlags struct
  • transpiler3/php/emit --- ptree to PHP 8.4 source
  • transpiler3/php/build --- Driver, phase tests, packaging (Phar, FrankenPHP, RoadRunner)
  • transpiler3/php/runtime --- Composer package staged into the build sandbox

Lowering rules​

MochiPHP 8.4
intint (PHP 64-bit on all supported platforms)
floatfloat with === for comparison (strict equality, not loose ==)
boolbool with echo $value ? "true\\n" : "false\\n"
stringstring (byte sequence)
list<T>array (0-indexed, packed)
map<K, V>array (string-keyed)
set<T>array of value => true pairs
recordfinal readonly class with constructor promotion
sum typeabstract readonly class base + final readonly class variants
function typeClosure(...) with PHPDoc @param / @return
closurefirst-class callable syntax $obj->method(...)
matchmatch (true) { ... } chain
agentfinal class wrapping a userland Channel
streamfinal class implements IteratorAggregate with subscribe(int $limit)
Result<T, E>final readonly class Ok / final readonly class Err union
panicthrow new \\RuntimeException
division /intdiv($a, $b) for int operands; / for float
equality ===== (strict). Float compare uses === not ==.
str_containshelper short-circuits on empty needle, then str_contains(...)
infinity literalsfdiv(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:

  1. Phar archive --- PHP's built-in Phar class (no humbug/box dependency). A stager script runs under php -d phar.readonly=0 to produce out.phar; php out.phar runs the program.
  2. FrankenPHP --- emit a Caddyfile + Dockerfile. Pinned to dunglas/frankenphp:php8.4, 4 workers, php_server, :8080. The worker line uses the modern worker /app/main.php 4 form.
  3. 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:

  1. GPG-signed Git tag (primary) --- git tag -v v<version> against the release key fingerprint in SECURITY.md.
  2. GitHub Actions OIDC attestation (secondary) --- actions/attest-build-provenance@v1 attaches a Sigstore-backed provenance statement. Verify with gh attestation verify mochi-runtime-v<version>.tar.gz --repo mochilang/mochi.
  3. php-signify Ed25519 (optional tertiary) --- Drupal's port of OpenBSD signify(1). Pinned to drupal/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​

PhaseTitleStatus
0Skeleton + scaffold + matrix CILANDED
1Hello world (print int/float/bool/string + newline shape)LANDED
2Scalars (int, float, bool, string, compare, arith, casts, control)LANDED
3Collections (lists, maps, sets, list-of-records)LANDED
4Records (final readonly class)LANDED
5Sum types (abstract readonly base + final readonly variants)LANDED
6Closures and higher-order functionsLANDED
7Query DSLLANDED
8Datalog (compile-time semi-naive evaluation)LANDED
9Agents (userland Channel + final class)LANDED
10Streams (IteratorAggregate + subscribe_limit + drop)LANDED
11Async coloring (sync wrappers, no Amp/Revolt)LANDED
12FFI (FFI extension + sodium dispatch)LANDED
13LLM (cassette dispatch with DJB2/GMP hash)LANDED
14fetch (curl, byte-equal against vm3)LANDED
15Composer + PHPStan level 9 + Psalm 6 level 1 + php-cs-fixerLANDED
16Reproducibility (byte-equal by construction)LANDED
17Packaging (Phar + FrankenPHP + RoadRunner)LANDED
18Signed 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/revolt if 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​