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