Menu

Manticore Attribute Reference

PHP 8 attributes consumed by the Manticore compiler at AOT time. All attributes are read during compilation; none allocate at runtime. Unknown attributes are ignored.

Two namespaces:

⚠ Attribute names are matched textually, in three spellings each: bare (#[Struct]), Attr\Struct, and Manticore\Attr\Struct. Two consequences. First, a use statement is optional — the bare form is what the test suite and the compiler's own source use. Second, Manticore\Attr\TypeDef is accepted by name even though no such class file exists (src/Manticore/Attr/ holds Struct, RefOut, CellArg, Overload, CompareKey, CompareNone and Uncomparable), so write #[TypeDef] bare.


Memory Layout#

By default every class instance carries a 16-byte header — a pointer to the class descriptor at offset 0, and the packed refcount word at offset 8 — used for polymorphic dispatch, instanceof, refcounting and the cycle collector. The class id itself lives one indirection away, at descriptor offset 0. Exact layout: design/memory-abi.md.

Two attributes opt out, by degree:

Manticore\Attr\Struct#

Mark a class as a value-type record: no header at all, fields start at offset 0, all method calls resolve statically, and no retain / release / cycle-collector hook ever touches it. A 3-field class drops from 40 bytes to 24 — a 40% saving, and the bigger win is the refcount traffic that disappears with it.

use Manticore\Attr\Struct;

#[Struct]
final class Span
{
    public function __construct(
        public int $line,
        public int $col,
    ) {}
}

Rules you must keep — the compiler does not check them. Unlike #[TypeDef], which has a dedicated soundness gate, #[Struct] is read once and only sets ClassDef::$isStruct; nothing diagnoses a misuse, so breaking these produces wrong code rather than an error:

Use for AST nodes, span markers, coordinate pairs, parser tokens — anywhere the type is monomorphic and short-lived.

TypeDef#

Where #[Struct] removes the object header, #[TypeDef] removes the object.

A #[TypeDef] class is erased to the single readonly value it wraps. The class costs nothing at runtime: no allocation, no refcount, no class id, no header, no indirection. U8 is an i64; Email is a string pointer. $byte->value is the value itself (no load, no offset), and $byte->add($x) is a direct call taking the raw scalar.

You still get everything the object model was for: a named type, its own methods, and type-checking at every boundary. You just stop paying for it.

Two shapes#

A machine type — nothing to compute, so the property is promoted and that is the whole class:

#[TypeDef(repr: 'u8')]
final class U8
{
    public function __construct(public readonly int $value) {}

    public function add(U8 $other): U8
    {
        return new U8(($this->value + $other->value) & 0xFF);
    }
}

$sum = (new U8(200))->add(new U8(100));   // 44

new U8(200) allocates nothing. U8::add compiles to two i64 arguments and integer arithmetic — byte for byte what a hand-written add_u8(int, int): int would emit.

A refinement type — the value must be validated or sanitised first, so the class declares a __invoke normaliser:

#[TypeDef]
final class Email
{
    public readonly string $address;

    // Zend needs this to build a real object when `php` runs the same source.
    // The compiler never lowers it — see "Why the PHP body is real" below.
    public function __construct(string $raw)
    {
        $this->address = $this($raw);
    }

    public function __invoke(string $raw): string
    {
        $raw = strtolower(trim($raw));
        if (strpos($raw, '@') === false) {
            throw new InvalidArgumentException('not an email: ' . $raw);
        }
        return $raw;
    }

    public function domain(): string
    {
        return substr($this->address, strpos($this->address, '@') + 1);
    }
}

function deliver(Email $to): string { ... }   // re-validates NOTHING

new Email($raw) lowers to a direct call to the normaliser. The validation runs once, at construction; from then on the type carries the proof. deliver() takes a raw string pointer, not a wrapper — a refinement type that costs nothing.

The constructor is not where the meaning lives, and it may not be: PHP constructors do not return values. __invoke is an ordinary function with an ordinary return, and its carrier → carrier signature is checked by the compiler.

The carrier#

The carrier is the type of the one property: int, float or string. It is what the value really is. Reading the property is where the TypeDef ends and a plain scalar begins — $byte->value + 1 is ordinary integer arithmetic and is always allowed.

repr names the machine form (i8/i16/i32/i64, u8/u16/u32/u64, f32/f64), and it costs what it says: a property declared as one occupies exactly its repr — 1 byte for i8/u8, 2 for i16/u16, 4 for i32/u32/f32 — aligned to its own width.

final class Pixel {
    public function __construct(
        public U8 $r, public U8 $g, public U8 $b, public U8 $a,
    ) {}
}
// 24 bytes, not 48.

A property slot is the only place the promise can be kept: in a register a narrow type buys nothing, because a register is 64 bits either way. Loads widen back to the carrier (sign-extending for the signed reprs, zero-extending for the unsigned, fpext for an f32), so the value your program sees is unchanged — only the bytes on the heap differ.

That holds because the range is enforced when the value is created: every new I8(…) checks the value its constructor (or normaliser) produced, and one outside the repr is a ValueError — never a silent wrap:

new I8(300);                         // ValueError: Value 300 is out of range for I8 (-128..127)
new I8($a->value + $b->value);       // throws when the sum leaves -128..127
new I8((($x + 128) & 0xFF) - 128);   // wrap-around, written out

An f32 value is rounded to binary32 at creation (so a register and a slot hold the same number); a finite value beyond binary32's range is the ValueError, infinities and NaN pass. i64, u64 and f64 narrow nothing and check nothing.

Omit repr for a plain newtype: then the property is a full word, and the type is about meaning, not layout.

What the compiler refuses, and why#

An erased value is invisible — and therefore correct — everywhere the program treats it as a value. It becomes visible, and would disagree with php, at exactly the places where it is treated as an object. Each of these is a hard error naming the class, the site and the fix:

you wrotewhy it is refused
$a === $bPHP compares object identity; two new U8(5) are not identical. Compare $a->value === $b->value. (== is fine — PHP's loose object compare is field-wise, so it agrees.)
$a + $bPHP has no operator overloading: php raises TypeError. The erased form would quietly compute a number instead. Operate on the carrier. (A regular class can opt in with Manticore\Overloadable, see superset §5.1.)
$x instanceof U8there is no class id left to test.
var_dump($x)php prints object(U8)#1 { … }; the erased form is a number. Same for print_r / var_export / get_class / is_object / json_encode / serialize / gettype.
a mixed slotboxing into a tagged cell drops the type, and everything downstream sees a bare number. Declare the parameter / property / return as the TypeDef.

A typed container is fine and stays unboxed: /** @var U8[] $bytes */ is a vector of raw scalars, not of boxed cells.

Refusal, not a quiet fallback to a heap object — a TypeDef that silently re-boxed would silently give back the allocation it exists to remove.

Why the PHP body is real#

The class body is ordinary PHP, and php executes it as a genuine object — the honest arithmetic, the honest validation. That is deliberate: the same source must still run under stock php (the tools/difftest.sh oracle, portability), so the language may only be extended in ways Zend ignores. An attribute is inert to Zend; the body is not.

So there is exactly one implementation. Native runs the very __invoke and the very methods the programmer wrote — only unboxed. The two paths cannot drift, because there is nothing to drift from.

Rules#


Parameter semantics#

Manticore\Attr\RefOut#

Mark a by-reference (&$x) parameter as pure output — the callee assigns it fresh and never reads the incoming value. Unlike a plain by-ref param (IN-OUT, e.g. sort()), a #[RefOut] arg is safe for the caller to auto-vivify: an undefined variable passed to it is defined with the parameter's element type before the call, and the read-back carries that static type instead of erasing to unknown. This is what lets preg_match($p, $s, $m) work with no prior $m = [].

#[\Attribute(\Attribute::TARGET_PARAMETER | \Attribute::TARGET_FUNCTION | \Attribute::TARGET_METHOD)]

// on the parameter (natural form)
function preg_match(string $p, string $s, #[RefOut] array &$matches = []): int;

// on the function, by name (handy for generated / multi-out signatures)
#[Manticore\Attr\RefOut('matches')]
function preg_match(string $p, string $s, array &$matches = []): int;

Core compiler semantics (not FFI-specific); used by the preg_* family.


Manticore\Attr\CellArg#

Mark an array parameter as element-consuming: the callee reads element values (casts, concatenates, stringifies them) and therefore needs a self-describing cell-tagged array, not the raw element representation a concrete vec[string] / vec[int] caller would hand it.

function vsprintf(string $format, #[\Manticore\Attr\CellArg] array $args): string

Why it is needed: a bundled-stdlib function is compiled once, so its array parameter carries a fixed element repr in the .sig (mixed[]). A caller with a concrete-element array does not match, and the raw slots then decode as tagged cells — garbage. #[CellArg] tells the call site to box each element before the call, so the callee always sees cells.

Do not put it on an element-preserving passthrough (array_merge, array_combine move slots raw) — those must keep the raw repr.

The PHP signature stays identical (array $args); this is compiler metadata carried in the .sig exactly like byref / refout. Used across src/Runtime/Stdlib/{Format,Csv,Encoding,Sockets}.php.


Overloads#

Manticore\Attr\Overload#

#[Overload('name')] declares a function as a more precise variant of another one. A call to name whose arguments statically fit the overload's parameters is compiled as a call to the overload instead.

use Manticore\Attr\Overload;

function shout(array|string $v): array|string { /* php's union signature */ }

#[Overload('shout')]
function shout_str(string $v): string { /* same answer, string only */ }

echo strlen(shout('abc'));   // statically a string: calls shout_str, result typed string

The canonical function stays php-faithful: an erased (mixed) argument and a call through a function-name string still reach it. An overload only buys precision — a return type the union signature cannot state, and a body that skips the run-time dispatch — so it must answer exactly what the canonical function answers for the same arguments. The stdlib uses it for hot functions such as preg_replace. Function targets only. Superset feature: php has no oracle for it (tests/aot/cases/overload_attr.php).

Object comparison#

php's ==, !=, <, <=> on two objects run the class's compare handler: by default every property in declaration order, but many internal classes bring their own (DateTime by instant, CurlHandle never equal, HashContext always equal). A Manticore class that re-implements such a class keeps different private state than Zend's C struct, so its compare behaviour is declared instead of derived. Without any of these attributes a class compares like a php user class: two objects of one class property by property, objects of different classes never equal.

Manticore\Attr\CompareKey#

On a property: only the marked properties are the object's value for == / <=>, compared in declaration order. Objects of different classes that both mark keys compare too, which is how a handler that compares across classes is written (DateTime against DateTimeImmutable, by instant).

final class DateTime
{
    #[\Manticore\Attr\CompareKey] private int $ts = 0;
    #[\Manticore\Attr\CompareKey] private int $us = 0;
    private string $zname = 'UTC';   // not part of the value
}

Manticore\Attr\CompareNone#

On a class: the php class has no properties, so two instances are == and <=> answers 0, whatever hidden state this implementation keeps. != / < follow from that.

Manticore\Attr\Uncomparable#

On a class: the php class declares its objects uncomparable. == is identity, and <=> of two distinct instances answers 1 (so < and > are both false).

An enum case compares by identity with no attribute. How each choice is laid out at run time (the compare view and the compare group in the class descriptor): design/memory-abi.md §3 (the class descriptor).


FFI#

The Ffi\* attributes — #[Library], #[Symbol], #[CType], #[Weak], #[Variadic], and the Ownership family (checked, never lowered) — are documented in ffi.md, together with the type vocabulary, linking and the raw-buffer memory rule.

Edit this page on GitHub