Single source of truth for "where the compiler is and what's next."
Last updated: 2026-07-30 · branch main · HEAD dea8a63.
Pure-PHP, self-hosting PHP→native AOT compiler. bin/manticore compiles its own src/ to a
byte-identical fixpoint, and the runtime is emitted as LLVM IR from PHP.
Self-hosting is done. That was the north star; it is now the floor. What replaced it is
real-world applications — the quality corpus is no longer the compiler alone but
third-party PHP compiled through it (symfony/console is the current driver), and the oracle is
still Zend: if php runs it, tools/difftest.sh must agree byte-for-byte.
Gates: tests/aot/run.sh · tools/difftest.sh (parity vs PHP 8.5) ·
tools/selfhost_fixpoint.sh (fixpoint byte-identical · self-host suite · rebuild stability
5×2) · tools/docker/run_tests.sh --gate (Linux). Counts move every session — run them
rather than trusting a number written here.
Build: bin/build (self-host — the normal loop), bin/build --seed (cold Zend
bootstrap), bin/build --verify (+ the gate). bin/compile is the cold-bootstrap fallback
only.
⚠ bin/build green says nothing about tools/selfhost.sh. The manifest build compiles
src/Runtime as a LIBRARY with a flattened namespace; the self-host path takes everything as
ONE module. They diverge on emitted symbol names, and only the stability gate covers the
second. Corollary: never ship a compiler fix together with tree code that needs that fix —
the previous generation then cannot build the tree at all, and only a cold seed recovers.
⚠ A new codegen builtin used by the stdlib needs bin/build --seed. The previous
generation does not know the symbol, so the stdlib .o build dies on an undefined symbol.
ext/pdo+pdo_sqlite— a database layer (docs/pdo.md).PDO/PDOStatementare a thin facade over an internal driver seam, so a future mysql/pgsql driver (a socket with pack/unpack, no FFI) drops in without touching them — SQLite is not a server, which is why this one is FFI. Demand-gated on aPDOmention, so a binary links-lsqlite3only if it uses it. No trampolines: driving prepare/step directly avoidssqlite3_exec's callback entirely. No SQL scanner either —sqlite3_bind_parameter_index()already resolves:namewith sqlite's own quoting and comment rules. SQLITE_BUSY is waited out through\Runtime\AsyncHook(neversqlite3_busy_timeout, which sleeps in a C frame and stalls the netpoller). Divergences, each documented:getCode()is the driver's int becauseThrowable::getCode(): intis a contract here;bindParam()binds by value at bind time;bindColumn()/FETCH_BOUNDthrow — all three for the same missing zval reference. ⛔FETCH_OBJ/FETCH_CLASS/FETCH_INTO/FETCH_LAZY, andfetch()with no argument under the defaultFETCH_BOTH, are blocked on the ERASED-VALUE work, not on the driver: a value returned through amixedchannel whose arms have different shapes is not self-describing.docs/pdo.mdcarries three minimal repros that never mention PDO.ext/curl— an HTTP client (docs/curl.md). The easy API,curl_multi_*andcurl_share_*, bound to libcurl through FFI and demand-gated, so a binary links-lcurlonly if it calls one of them.CURLOPT_WRITEFUNCTIONand its three siblings take real Closures:fn_to_ptrneeds a string literal, so libcurl holds one of four fixed trampolines and carries the handle id in thevoid*it hands back. One#[Variadic(2)]binding serves everycurl_setoptoption class, because libcurl encodes the C type in the option NUMBER. Not implemented, each with a named throw:CURLFile/CURLOPT_MIMEPOSTmultipart, the*_BLOBoptions,CURLMOPT_PUSHFUNCTION, and a realCURLINFO_CERTINFO. Every floatCURLINFOis read through its_Tsibling — this build cannot read a Cdoubleout of memory (nopeek_f64, no bitcast builtin, nounpack('d')), which is the one gap worth closing if a caller ever needs a genuine double from C.Http\— an HTTP/1.1 server (docs/http.md). A handler iscallable(Request): Response; one process serves many requests at once, and php'sheader()/setcookie()/http_response_code()/headers_sent()/echowork inside it per request — as do$_GET/$_POST/$_COOKIE/$_SERVER/$_SESSIONundercompat(true). Streamed request and response bodies, chunked framing,Expect: 100-continue, keep-alive with pipelining, and every limit answered by a precomputed refusal.Buffer\ByteBuffer/Reader/Writerunderneath.serialize/unserialize+ magic methods —__serialize/__unserialize,allowed_classes,__PHP_Incomplete_Class,__debugInfo,var_exportof objects, and__get/__set/__isset/__unset/__callfiring on an erased receiver.- One ownership contract for conditionals —
?:,??, ternary andmatchshareCompile\Mir\CondOwn, so the arms and their consumer agree on who owns the result. - Generators: a resumed
tryowns its landing pad —Generator::throw()no longer lands in the caller's catch. - The full
array_*surface — all 59 functions, includingarray_multisort,array_merge_recursiveand the internal pointer. - The async remainder — 1 MiB fiber stacks with
MANTICORE_FIBER_STACK, non-quadratic channel waiter queues, checkedmmap/calloc,posix_getrlimit/setrlimit, and a per-case deadline in the harness so a liveness bug fails the suite instead of hanging it. - Dynamic resolution — dynamic function names,
new $cls,$cls::method(),$o->$m(),$o->$p,$obj instanceof $cls, and Reflection through Tier 3.
| Gap | Repro | Today | Want |
|---|---|---|---|
| Integer overflow wraps | PHP_INT_MAX + 1 |
PHP_INT_MIN (two's complement) |
promote to float, as php does. Needs value-range analysis to know which statically-int locals can overflow |
/ exact-int on variables |
$a/$b, both int, divisible |
float |
int. Literal 6/2 already folds to int(3); the variable case cascades through a numeric cell — low value |
echo / concat of INF/NAN |
— | renders lowercase | uppercase, as php does. var_dump is already correct. No repro exists — write one first |
['a'] === ['a'] compares pointers rather than contents, and extract() is unimplemented
(dynamic symbol-table writes the typed frame does not model). compact() works.
serialize/unserialize, __debugInfo, var_export of objects, and erased-receiver
dispatch for __get/__set/__isset/__unset/__call are done. What is left, and why:
- None of the hooks fire for an INACCESSIBLE DECLARED member. All of them gate on "the
class has no slot for this name". php also fires when the slot EXISTS but is
private/protected out of the accessing scope. Manticore enforces no visibility at all, so
such an access simply succeeds. Closing it needs a scope model in the emitter (compare the
frame's class prefix against
PropertyMeta::$visibility/$declaringClass) — separate work, not a dispatch problem. __callon an erased receiver is rerouted only when NO class declares the method. The mixed case — some classes declare it, others answer through__call— needs two different argument lists in one switch, and the call's arg emission is built against the resolved callee's signature.is_callable()on an erased value answers true for ANY object. Narrowing it to classes declaring__invokeneeds a class_id probe, and a Closure has NO class descriptor (slot 0 is its function pointer), so the probe would start answering false for the common case. Needs the closure header, not a switch.__sleep/__wakeupare ignored. php calls them from serialize/unserialize when__serialize/__unserializeare absent.unset($o->declaredProp)is a no-op, so the "unset it so__getfires again" idiom does not work.&__get(return by reference) is unsupported — the magic call yields an i64 cell.Stringableis not auto-added to a class declaring__toString, soclass_implements()/getInterfaceNames()do not report it as php 8 does.- Uninitialized typed properties serialize as their zero value. Manticore zero-fills
every slot, so
class P { public int $x; }writes1:{s:1:"x";i:0;}where php writes0:{}. Needs an init bitmap in the object header — an object-ABI change. R:is never emitted. It marks a php REFERENCE, and a Manticore array carries no is_ref bit, so there is no runtime fact to emit it from. It is accepted on input as a value copy.(array)$objyields the dynamic-property BAG only, where php returns the declared properties too.var_dump/serialize/var_exportcompose the two themselves.- A bare
arrayproperty hint erases its element, so the elements ofpublic array $aread raw —var_dumpandvar_exportprint ints as denormal floats. A@var int[]on the same property is correct. This is the parked element-repr work. - php 8.5 clone-with does NOT run the readonly guard here — and should not: the RFC's
purpose is to let a clone reinitialize a readonly property. Noted because it looks like a
missing check. There is no
phporacle for it (8.5.8 does not parse the syntax), so it is a superset feature.
gotointo a loop body is unsupported. Plain forward and backwardgotowork.ReflectionEnumdoes not exist — it was built and reverted. Every other Reflection class ships (prelude/reflection.php).- Static properties are external-linkage globals only, so two compilation units cannot disagree about one.
- Element representation is half done. The array flags word carries an element-repr
nibble that release / retain / COW read, but the erased element channel is not yet a cell,
so a concrete
string[]parameter fed a cell-element array still misreads.
.sigschema 2 ships classes, interfaces, enums and constants (tests/libs/classes+tools/libclass_smoke.sh). What remains:traits and generic (@template) classes still do not cross. Both need method BODIES on the far side — a trait because it is copy-paste into the using class, a generic because each binding is reified from source. They are recorded in the.sigas"unsupported"so the diagnostic can say why.instanceofMatchIds/descendantClassIdsremain closed-world andcatchAcceptsAllstill fails open — now over the UNION of local and imported classes, which is the whole program for a library-first build order, but not for a plugin loaded later.- A class descriptor is
linkonce_odrand the two modules can emit different bytes for it (the library's rmeta field is null unless the library itself reflects). The application object is linked first, so its richer copy wins — deterministic given how the link line is built, but an invariant rather than a guarantee. The fix is the descriptor extension below.
- EPIC: per-class function pointers in the class descriptor.
__mc_json_enclives inmanticore_stdlib.o, whose class table is empty, so(array)$objinside it yields[]andjson_encodeof ANY object — imported or local — answers{}. Same shape for__manticore_tagged_to_str. Extend{ i64 class_id, ptr drop_fn, ptr rmeta }withprops_fn/tostr_fn/debug_fn, generated once by whichever module OWNS the class, so the definition travels with the class and a generic walker instdlib.oreaches an application class without knowing its name. BumpsMemoryAbi::VERSION(⇒ onebin/build --seed) and lets four of the fiveLowerPrelude::*ObjectSrc()generators go. - No dependency resolution, no build cache, no packaging bootstrap.
MANTICORE_HOME,~/.manticore/cacheand acompiler_abifield appear indesign/module-system.mdbut nowhere insrc/. Manifest targets and Composer source discovery work; transitive dependency fetch does not. - The ABI version is not surfaced.
MemoryAbi::VERSIONis 7 andmanticore versionprints onlymanticore 0.6.0, so a vendored.ocannot detect a mismatch. dump-mir --after=<pass>is described indesign/mir.mdbut not implemented.- Cycle collector: manual trigger only, and it does not scan static or global roots. A threshold heartbeat and a safe-point trigger are unbuilt.
- Monomorphize has no
$cellfallback.Monomorphize.phpcalls it "future, Phase 3"; the "every monomorphized function keeps exactly one name-addressable$cellentry" invariant indesign/monomorphization.mdis aspirational, not upheld. - No CI, no prebuilt binaries. Every install compiles from source.
Everything already beats Zend (2–50× on compute; assoc arrays beat php outright). The remaining levers:
- IR volume is the build-time lever —
clang -O2is ~66% ofbin/build. Emitting less IR beats optimising the front end. - SSO / interning for dynamic small strings; property-bag literal hashes.
- Harden the gated
TypeCheckpass (MANTICORE_TYPECHECK=1) toward on-by-default — it would have caught thestr_replace-array misuse at compile time instead of as a runtime SIGBUS. - Array / JSON / sort helpers sit at roughly 2× php and are competing with hand-tuned C — that is close to the ceiling, not a bug.
- Probe-matrix first. One minimal repro per feature, diffed against
php, before committing to a design. - Find the convergent root. Monomorphization was the root behind a dozen erasure symptoms. Ask "is there one fix that collapses several rows?" before building.
- Phase and gate hard, every phase: suite + difftest + fixpoint + stability +
--seed. Never batch risky changes. A "random transient" can be a real latent bug — chase it. - Dual-validate the Zend seed AND the native build — they diverge on strings, floats and by-ref. Some bugs only surface in the native self-build, and an emitter fix needs TWO generations before its effect is real.
- php-faithful signatures; root cause over workaround. No reverts, no workarounds.
- Self-host is the gate; real programs are the probes.
Living reference:
../README.md— what it is, what it needs, how to use it.superset.md— everything with no Zend oracle, and what that costs.install.md— host dependencies and platform support.async.md— structured concurrency and transparent I/O.memory.md— the memory model as a user sees it (--memory, env knobs).modules.md— the manifest,.siginterfaces, Composer projects.generics.md— docblock@template, bounds, reified@var C<T>.ffi.md,attributes.md— native binding and the attribute set.http.md,curl.md— the HTTP server and the HTTP client.
Design notes:
design/mir.md— the IR, the type lattice, the pass pipeline.design/memory-abi.md— the stone tablet: layout, refcount encoding, destructor order, cycle-collector ABI.design/refcount-cow.md— why refcount + CoW, not a GC.design/type-system-v2.md,design/unknown-cell-soundness.md— the cell / union / NaN-box type system and the erased-representation soundness work.design/monomorphization.md— the generics / erasure engine.design/generators-and-pointers.md— generators.design/module-system.md— the module design behindmodules.md.design/build-and-packaging.md— packaging.design/late-static-binding.md— LSB lowering.design/async-attribute.md— a designed, deliberately unbuilt#[Async].