PHP 9.0 doesn't exist. This chapter tours draft proposals that may change — or die — in review: eventually wrong, possibly before it ships. About this series →

Structs: Values with Names

PHP has always had value semantics — in arrays. Assign an array and you get your own copy; pass one to a function and the callee can't mutate yours. Twenty years of copy-on-write engineering make those copies effectively free. But arrays are shapeless: no declared fields, no types, no name to put in a parameter list.

Objects are the mirror image. Shape, types, a name for instanceof — and always a shared handle. Every holder of a Point sees every other holder's mutations, which is why value-ish code fills up with defensive clone and readonly and with-er methods.

PHP 9.0 adds the quadrant that was missing: structs, declared like classes, behaving like arrays.

struct Point {
    public function __construct(
        public float $x,
        public float $y,
    ) {}
}

$a = new Point(1.0, 2.0);
$b = $a;            // $b is a copy
$b->x = 5.0;
var_dump($a->x);    // float(1.0) — $a unaffected

The rule to internalize is one sentence: a struct variable behaves like an array variable. Assignment copies. Passing to a function copies. Returning copies. Storing into an array element or a property copies. No mutation of a struct is ever visible through another variable:

function widen(Span $s): Span {
    $s->length += 10;   // mutates the local copy only
    return $s;
}
$a = new Span(0, 5);
$b = widen($a);         // $a->length is still 5

Under the hood the copies are copy-on-write, like arrays — a struct that's shared but never written is never physically duplicated. You don't manage this; you just stop thinking about aliasing.

What goes in one

Almost everything a class has. A struct body takes typed properties, class constants, a constructor, and methods — including, as of the current draft, methods marked mutating, which we'll get to. It can implements interfaces and use traits. Properties may carry hooks — a computed get, and optionally a set — so a struct's shape isn't limited to raw storage:

struct Span {
    public function __construct(
        public int $start,
        public int $length,
    ) {}

    public int $end {
        get => $this->start + $this->length;
        set => $this->length = $value - $this->start;
    }
}

$s = new Span(10, 5);
var_dump($s->end);   // int(15)
$s->end = 20;        // adjusts $s->length to 10 — still just a value, still only in $s

What's not in there is a short list, and it's worth being precise about why, because none of it is v1 caution. A struct is a class-like construct with value semantics, and every exclusion is a consequence of the "value" half:

  • No extends. A hierarchy is a relationship between handles, so structs are implicitly final and root. Behavior is composed with traits and contracts declared with interfaces — here, composition over inheritance isn't a preference, it's the entire story.
  • No identity. "Same instance" is meaningless when any assignment may copy.
  • No references into the interior — a slot inside a value has no stable name to point at.
  • No state-simulating magic. __get, __set, __isset, and __unset pretend to state a fixed, total shape doesn't have, and __clone and __destruct would let user code observe when copy-on-write separation happens — an engine-scheduled event. The pure-read magic methods survive, because they bind $this by value and can't leak anything: __toString (so structs can be Stringable), __invoke, __debugInfo, __call, and __callStatic are all permitted. The serialization hooks are deferred, pending a wire-format decision.

Every property must carry a declared type (mixed counts). A struct is a shape; an untyped slot is a compile-time error.

$this: one rule

Five contexts run your code with $this bound to a struct: the constructor, set hooks, mutating methods, ordinary (uncolored) methods, and get hooks. You could learn five sets of behavior. You don't have to, because one rule generates all of them:

Writes to a shared $this separate it into the frame and die with it. Writes to an exclusive $this land in place and persist.

Everything else falls out of asking, per context, whether $this is exclusive:

  • The constructor holds $this exclusively — the instance exists nowhere else yet. Writes persist. The constructor is really the built-in mutating member; even an explicit $s->__construct(...) re-invocation is a legal mutating call.
  • set hooks are implicitly mutating. A write separates the struct before the hook dispatches, so by the time your hook runs, $this is already exclusively held. Writes through it — the backing store, sibling properties — persist. That's how the $end hook above adjusts $length.
  • mutating methods hold $this exclusively because the call site makes it so — more on that mechanism next.
  • Uncolored methods and get hooks hold one reference among several. Their first write quietly separates a private copy, and the changes are discarded at return. These are the contexts that read and derive.

One honest consequence, stated plainly: a lazy-caching get hook doesn't persist its cache on a struct. A read never mutates a value. The discard is silent by design — telling a literal $this-> write apart from mutation through an alias is impossible at compile time — so catching it is a linter's job, not the engine's.

Escape is more relaxed than you might expect. Stash $this in a registry from almost anywhere and it's well-defined: the escapee becomes an ordinary shared value, severed from the original at the next write. The single exception is a new-invoked constructor, which must return its instance unshared or throw. Construction is the one transactional context — if it throws, the instance is discarded, and a half-initialized value must never be observable from anywhere.

Mutating methods: the scoped borrow

Earlier drafts had no way for a method to update its receiver; every caller-visible update went through property writes or by-ref parameters. As of v0.19 that changed. A method can declare itself mutating, and the marker sits in the one position where no identifier can otherwise appear — between the parameter list and the return type — so it reserves no name:

struct IntSequence {
    private array $items = [];

    public function push(int $v) mutating: void {
        $this->items[] = $v;
    }
}

$nums = new IntSequence();
$nums->push(3);         // $nums holds [3] — the write persisted

The mechanism is a scoped borrow, and it's all caller-side. When you call a mutating method, the call site first separates the receiver in your slot if it's shared — the same separation a property write would do — then lends the now-exclusive value to the method's frame. Writes land in place; when the method returns, the borrow ends and your variable holds the updated value. No other holder of the pre-call value sees a thing, because separation already gave you your own copy.

That "in your slot" phrasing is load-bearing, because it defines which receivers can lend:

  • A variable — always; it's separated first if shared.
  • $this inside another mutating context — already exclusive, so mutating calls chain.
  • A property — when the slot is writable, non-readonly, and anchored, via a dedicated receiver-position opcode, so $order->lines->append($line) updates in place.
  • Anything else — a temporary, a call result, a callable, a first-class callable, a dynamic call, reflection on a shared receiver — throws an Error telling you to assign it to a variable first. There is no slot to write back into, so the engine refuses rather than silently discard.

There's deliberately no call-site marker — no $list->&append(3) — because the receiver's declaration already carries the information and a marker would just defer the decision to every caller instead of settling it once.

Uncolored methods and the wither idiom

An unmarked method binds $this by value, like any other parameter. Every mutation is local and discarded, and intentional updates use the wither idiom: mutate your copy, return it.

struct Point {
    public function __construct(public float $x, public float $y) {}

    public function length(): float {
        return sqrt($this->x ** 2 + $this->y ** 2);
    }

    public function moved(float $dx, float $dy): Point {
        $this->x += $dx;      // mutates the method's own copy
        $this->y += $dy;
        return $this;         // returns that copy
    }
}

$q = $p->moved(1.0, 0.0);     // $p untouched

The two colors are a genuine division of labor: uncolored methods read and derive, mutating methods update. First-class callables capture their receiver by value and operate on a copy per invocation; explicit by-ref parameters (function move(Point &$p, float $dx)) still work exactly as they do for arrays, referencing the storage slot rather than the interior. And extension methods, if they land, apply to structs with no special rules — their receiver is already a by-value local, which is uncolored-struct-method semantics.

Contracts and reuse

Structs implement interfaces, and v0.20 gives interfaces something PHP never had before: declarable mutation intent. An interface method may itself be marked mutating, and one variance rule, enforced at link time on every subtyping edge, governs the whole system:

mutating may be removed along an edge, never added.

A mutating requirement is permission, not obligation — it can be satisfied by a mutating struct method, an uncolored struct method, or any class method (class methods are always uncolored; their reference receivers are always writable anyway). An uncolored requirement is a promise not to mutate, so only uncolored implementations satisfy it.

The payoff is that the standard library's mutation contracts can finally say what they mean. Iterator::next() and Iterator::rewind() are colored as mutating requirements — a retrofit that is backward compatible by construction, since every existing class implementation is uncolored and uncolored satisfies mutating. And with that, this works:

struct Range implements Iterator, Countable {
    private int $position;

    public function __construct(
        public readonly int $start,
        public readonly int $end,
        public readonly int $step = 1,
    ) {
        $this->position = $start;
    }

    public function current(): int { return $this->position; }
    public function key(): int { return $this->position; }
    public function next() mutating: void { $this->position += $this->step; }
    public function rewind() mutating: void { $this->position = $this->start; }
    public function valid(): bool { return $this->position < $this->end; }
}

$r = new Range(0, 10, 2);
foreach ($r as $i) { /* 0 2 4 6 8 */ }
foreach ($r as $i) { /* 0 2 4 6 8 again — $r was never consumed */ }

foreach copies the struct iterator into exclusively-held loop state and the mutating next() advances it in place there — your original value is never consumed, which is arguably more predictable than the reference-based behavior classes get. Notice the second loop: with a class-based iterator, it would yield nothing at all. Earlier drafts had to shrug here ("a struct Iterator will type-check and never advance; that's a lint"); coloring closes the gap in the type system, where it belongs. ArrayAccess is next in line, once dimension-writes learn separation.

Traits work too, and with no extends they're a struct's only implementation-reuse mechanism. Flattening happens at link time, before any instance exists, and a flattened trait method is indistinguishable from one written in the body — same $this rule, and the mutating marker travels with the member. A class can use that same trait: since a class receiver is always writable, the mutating marker is simply discarded as the method flattens in, and move() becomes an ordinary class method whose writes land on the shared instance. It's the variance rule again — mutating only ever grants permission a class already has, so dropping it is safe. The same discard covers the other route a mutating method can arrive from outside the body: an extension declared on an interface, which adds its method to everything that implements the interface. Mark that extension method mutating and it does the right thing on both kinds of receiver — a struct implementer gets the borrowing version that writes back, a class implementer gets the marker dropped and an ordinary method. One declaration, correct for both.

trait Movable {
    public function move(float $dx, float $dy) mutating: void {
        $this->x += $dx;
        $this->y += $dy;
    }
}

struct Point {
    use Movable;
    public function __construct(public float $x, public float $y) {}
}

There's a pleasant twist here: structs make traits safer than classes do. A trait method that touches a property the composing struct never declared is a loud Error, where a class would warn or quietly fabricate a dynamic property.

A few more consequences

All of them point the same direction:

  • Copies are shallow, exactly like arrays: scalar and struct properties copy by value, object properties copy by handle. readonly freezes the struct's own slots but doesn't reach into held objects. Want a value all the way down? Compose structs, scalars, and readonly.
  • No identity. == and === both compare contents — two separately built Point(1,2) are identical. There's no "same instance" to ask about, so spl_object_id() and WeakMap refuse structs outright rather than leak the engine's sharing decisions.
  • The shape is total. Writing an undeclared property is an Error, and so is reading one — a typo'd read on a declared shape has no meaning. (isset() and ?? keep their gentle probing behavior.)
  • clone is a legal no-op, since the value already is its copy; it's there so generic code needn't special-case structs. __clone isn't merely banned but impossible in principle: copy-time user code would have to run at copy-on-write separation, an engine-scheduled event, which would make sharing decisions observable. (C# forbids struct copy constructors on identical grounds.)

For readonly structs, the update idiom is the clone-with syntax you already know — the updates are ordinary writes to a fresh, exclusively-held instance, so set hooks fire and persist, per the one rule:

$moved = clone($span, ['start' => $span->start + 1]);

Where they'll show up

Money, spans, coordinates, ranges, RGB triples, cursor positions — the small shapes you currently model as either an anxious little readonly class or an undocumented array. Structs make the honest version the easy version: typed enough for your analyzer, named enough for your signatures, and value-semantic enough that a function receiving one simply cannot mutate yours behind your back — and, when you want an update, honest enough to put mutating in the signature where everyone can see it.

The vote is structured to match. Two questions, each needing two-thirds: the primary accepts structs as described; the secondary accepts mutating methods, the scoped borrow, and interface coloring — and it's severable. If the secondary falls, every struct method is uncolored and updates go through the wither idiom and by-ref parameters; nothing else in the proposal changes. The draft reports the whole thing implemented and green across the interpreter and both JIT variants, which for a proposal this deep in the engine is worth something.

One open question worth watching: the keyword itself. There's a competing draft that spells a similar idea record with immutable semantics; this proposal argues for mutable-with-copy-on-write struct. Where the community lands on that will say a lot about which tradition PHP wants to borrow from. Beyond that, the open list is mostly edges — relational comparison and sort(), the serialization wire format, var_export() round-tripping — and the far scope is where it gets interesting: lending array elements, anonymous tuples like (int, string) as a step toward generics, and destructuring over shapes.

Sources