PHP RFC: Native Immutable Collections — "vec", "set" and "tuple"
- Version: 1.0
- Date: 2026-08-04
- Author: Michał Marcin Brzuchalski brzuchal@php.net
- Status: Draft
- Target version: PHP 9.0
- Implementation: php-src branch ''first-class-collections''
Introduction
PHP's array is a single, mutable type that serves as an ordered list, an
associative map, a record and a set at once. That versatility means an array
type declaration conveys almost nothing: not the element type, not whether the
value is a sequence or a map, not whether it is a fixed-arity record, and not
whether it may be mutated after construction.
This RFC proposes three native, immutable, value-typed collections:
vec[T]— an ordered, densely indexed sequence ofT;set[T]— a collection of distinctT;tuple[A, B, …]— a fixed-arity, positionally typed record.
$ids = vec[int]{1, 2, 3}; $perms = set[string]{"read", "write", "read"}; // {"read", "write"} $pair = tuple[int, string]{200, "OK"};
Each is a first-class runtime value with its own type tag — not an object and not
an array with conventions. Each is immutable (length, order and contents are
fixed at construction) and carries a declared element or member type that is
validated at construction and is part of the value's runtime identity, so
vec[int]{1} and vec[float]{1.0} are different types and a vec[int] is never
interchangeable with a vec[float].
Motivation
- An enforced element type.
vec[int]guarantees, at the language level, that every element is anint— checked by the engine, not by an external analyser. - Distinct sequence, set and record contracts. Order and density (
vec), distinctness (set) and fixed positional arity (tuple) are three different contracts anarraycannot distinguish. - Value semantics and immutability. A collection compares by contents (
===), cannot be mutated in place, and can be shared without defensive copies. - In the engine, because it must be. PHP has no language-level generics (the bound-erased generics RFC was declined in June 2026, and reified generics are considered too costly), so an enforced element type cannot be delegated to a userland
Collection<T>; it has to live in the engine. This is complementary to generics rather than a substitute: a future generics feature could sit alongside these types (a generic collection library might even build on them), and nothing here forecloses that design.
Proposal
This RFC adds three collection kinds — vec, set, tuple — as native
immutable value types, with:
- a literal syntax with an explicit element/member type;
- their use as type declarations for parameters, return types and properties, including nested and nullable forms and the bare
vec[]/set[]/tuple[]forms; - strict, coercion-free validation of every element at construction;
- shallow immutability and value semantics (
===, truthiness, conversions); - integer indexing (read-only) for
vecandtuple, by-valueforeach,iterableintegration, and the intrinsic read-only->count/->isEmpty; - immutable-update methods that return new collections;
- support in
serialize/unserialize,var_export,var_dump,print_r,debug_zval_dump,gettype,get_debug_type, and Reflection.
map, shape, functional/bulk methods, contextual literal inference, a
general update expression, and a global count() overload are not part of
this RFC; see Future scope.
Syntax
A collection type is written with a compound head token — the kind name
immediately followed by [, with no whitespace between them:
collection_type : head '[' type_list ']'
collection_literal : head '[' type_list ']' '{' element_list? '}'
head : 'vec' | 'set' | 'tuple'
type_list : type (',' type)* (no trailing comma)
element_list : expr (',' expr)* ','? (trailing comma allowed)
- The element/member type list is always explicit: there is no type-argument-less literal —
vec{1, 2, 3}is a syntax error. - The literal body is a plain comma-separated expression list, not an array-pair list: keys (
{0 => 1}), by-reference elements ({&$x}) and spread ({...$a}) are syntax errors. - An empty body is allowed for
vecandset(vec[int]{}); atuplehas at least one member. - The head must be adjacent to
[:vec [int](with a space) andvec/*c*/[int]are parse errors.
vec, set and tuple are not reserved words. Only the adjacent two-token
sequence name[ in type/expression position is special, so existing code keeps
working:
function vec(int $n): void {} // function named vec class set {} // class named set const tuple = 7; // constant named tuple $o->vec[0]; // property access then subscript Foo::vec[0]; // class-constant access then subscript
Type declarations
vec[T], set[T] and tuple[A, B, …] may be used anywhere an ordinary type may
be used — parameters, return types, and properties (including promoted and
readonly properties, and interface/abstract method signatures). Ordinary PHP
visibility and readonly rules apply unchanged.
function sum(vec[int] $values): int { /* … */ } function headers(): vec[string] { /* … */ } final class Response { public function __construct( public readonly tuple[int, string] $status, public readonly set[string] $varyHeaders, ) {} }
Concrete collection types
At the declaration level the member-type grammar is permissive: it accepts builtin types, class names and nested collections. Not every member type a declaration accepts can hold a value, however; that narrower question is covered under Construction and validation.
Collections nest without special syntax, because a member type is an ordinary type:
function matrix(): vec[vec[int]] { /* … */ } function pairs(): vec[tuple[int, string]] { /* … */ }
Bare collection types — ''vec[]'', ''set[]'', ''tuple[]''
Empty brackets give a bare collection type meaning *any element type of that kind* — the top of that kind's hierarchy:
function rows(vec[] $any): int { return $any->count; } // any vec accepted rows(vec[int]{1, 2}); // ok rows(vec[string]{"a"}); // ok rows(set[int]{1}); // TypeError: … must be of type vec[], set[int] given
- Usable in type positions only (parameters, returns, properties,
?vec[]); there is no bare literal —vec[]{}is a parse error. tuple[]also erases arity, accepting a tuple of any length.vec[]accepts a vec of any element type — it is the top of the kind, the one supertype (a concretevec[T]is invariant and accepts onlyvec[T]). It is not “a vec ofmixed”:mixedis not a constructible element type (Construction and validation), so there is novec[mixed]value to confuse it with.
Nullability, nesting and variance
Nullability uses the ordinary prefix ? form (?vec[Row]). A collection type
may not be a member of a union or intersection (bare forms included); this is
a compile-time error naming the fix:
function f(vec[int]|null $x) {} // Fatal error: Collection type cannot be part of a union type; // write ?vec[...] for a nullable collection
Concrete collection types are invariant in their members: vec[Dog] is not a
vec[Animal] (nor the reverse), and vec[int] is not vec[float]. The only
subtyping is the bare form: a subtype may narrow a vec[] return to a
concrete vec[int] and widen a vec[int] parameter to vec[].
Construction and validation
A literal evaluates every element left to right, then validates each against the declared member type; publishing the value is a single final step, so no partially built value is ever observable.
vec[int]{1, 2, 3}; tuple[int, string]{200, "OK"}; set[int]{1, 2, 2, 3}; // → {1, 2, 3}
Constructible element types. A type accepted as a declaration is not
automatically one a value may hold. A value may hold: the leaf scalars
int / float / string / bool / array, a single class/interface name, and a
nested constructible collection (all three kinds, including set[vec[int]]).
Every other accepted member type — ?int, int|string, A&B, mixed,
object, callable, null, false — parses and type-checks but cannot hold a
value, and constructing such a literal fails before any element is evaluated:
vec[?int]{1}; // TypeError: Cannot create a value of type vec[?int] vec[mixed]{1}; // TypeError: Cannot create a value of type vec[mixed]
vec[?int] is thus a legal type even though vec[?int]{…} is a runtime error.
Validation is strict and coercion-free, independent of strict_types — no
scalar coercion, and a class member accepts subtypes only. The error names the
position, the collection type, and the expected vs. offending type:
vec[int]{1, "2"}; // TypeError: Element 1 of vec[int] must be of type int, string given vec[float]{1}; // TypeError: Element 0 of vec[float] must be of type float, int given vec[Animal]{new Dog()}; // ok (Dog is an Animal); vec[Dog]{new Animal()} is a TypeError
Failure atomicity. All elements are evaluated before validation and publication; if an element expression throws, no value is produced and the assignment target is untouched. Side effects of already-evaluated elements are retained, because PHP cannot unwind them.
A collection literal is not permitted in a constant expression (const,
class constant, parameter/property default, enum case, attribute argument);
these fail at compile time with *“Collection literals are not allowed in constant
expressions.”* Ordinary runtime initializers (e.g. static $x = vec[int]{…})
are unaffected.
Common semantics
Immutability
A collection cannot be modified in place. $v[0] = …, $v[] = … and
unset($v[0]) are not operations on a collection. Immutability is shallow: a
mutable object stored as an element stays mutable, but the collection's length,
order and slots are fixed.
Identity and comparison
=== / !== is recursive structural value identity: two collections are
identical iff they have the same type (kind + member types) and strictly
identical elements — vec/tuple positionally, set order-insensitively —
recursing into nested collections. Elements compare with ordinary ===.
vec[int]{1, 2} === vec[int]{1, 2}; // true vec[int]{1, 2} === vec[int]{2, 1}; // false (vec/tuple are positional) set[int]{1, 2} === set[int]{2, 1}; // true (set identity is order-insensitive) vec[int]{1} === vec[float]{1.0}; // false (different type) vec[int]{1, 2} === [1, 2]; // false (never === a non-collection)
Loose and ordering comparisons — ==, !=, <, <=, >, >=, <=>, sorting,
and non-strict in_array — throw `TypeError: Cannot compare collection
values`. A collection has a well-defined equality but no natural ordering; use
===. (Strict !== null / !== false / !== true behave normally.)
Truthiness and conversions
- A collection is always truthy, empty or not.
(int)/(float)and arithmetic throw;(string)/echo/ concatenation throw.json_encode()returnsfalse(a collection has no JSON representation in this RFC).gettype()andget_debug_type()return“collection”; the parameterised name (vec[int]) is produced byvar_dump,var_exportand diagnostics.
References and contained objects
A collection is a value, not an object, so clone does not apply. A collection
cannot hold a PHP reference as an independently mutable slot: a reference element
is read by value. There is no implicit conversion between array and any
collection kind in either direction.
Collection kinds
''vec''
vec[T] is an ordered, densely indexed (from 0) sequence with a single element
type T. A vec element may itself be a constructible collection.
$v = vec[int]{10, 20, 30}; $nested = vec[vec[int]]{ vec[int]{1, 2}, vec[int]{3} };
''set''
set[T] is a collection of distinct T. Distinctness is decided by the same
structural ===. Duplicates are silently dropped (that is set semantics, not
an error) and the first occurrence is kept; iteration visits values in
first-occurrence order, but set identity is order-insensitive, so iteration
order is not part of the value.
set[int]{1, 2, 2, 3, 1}; // {1, 2, 3} set[vec[int]]{ vec[int]{1, 2}, vec[int]{1, 2}, vec[int]{3} }; // 2 distinct members
''tuple''
tuple[A, B, …] is a fixed-arity record with a declared type per position;
element i is validated against member i. The element count must equal the
declared arity exactly, and — because arity is statically known — a mismatch
is a compile-time error:
tuple[int, string]{200, "OK"}; // ok tuple[int, string]{200}; // Fatal error: Collection type tuple expects 2 elements, 1 given
Reading collections
Indexing
$v[$i] reads the element at integer position $i from a vec or tuple
(read-only):
(vec[int]{10, 20, 30})[1]; // 20 (tuple[int, string]{1, "a"})[1]; // "a"
intonly —$v[“1”],$v[1.5],$v[true],$v[null]throwTypeError; even$v[“0”]is a strict-int miss, not index 0.- Out-of-range or negative throws
ValueError(never a warning +null). - Read-only —
$v[0] = …,$v[] = …,++$v[0],unset($v[0]),&$v[0]all throw and never mutate. isset()/empty()/??andlist()destructuring are total (never throw) and strict-int.- A
setis not positionally indexable. Indexing is native (notArrayAccess).
Iteration and ''iterable''
foreach iterates by value: vec/tuple yield int keys 0..n−1 in order,
set yields keys over its first-occurrence order. A contained array is
copy-on-write, an object keeps its identity, a nested collection stays immutable.
Iterating by reference throws (Cannot iterate over vec by reference).
foreach (vec[int]{10, 20, 30} as $k => $v) { /* 0=>10 1=>20 2=>30 */ } foreach (set[int]{3, 1, 3, 2} as $v) { /* 3, 1, 2 */ }
Collections satisfy the iterable type: is_iterable($c) is true, an
iterable parameter/return accepts a collection, and
iterator_to_array() / iterator_count() work. A collection is not
Traversable and not an object — it is iterable as a value, like an array.
Cardinality
Cardinality is read through two read-only intrinsic properties, ->count and
->isEmpty. They are a closed, case-sensitive set (any other name throws) and
read-only (assignment / ++ / unset throw):
(vec[int]{1, 2, 3})->count; // 3 (vec[int]{})->isEmpty; // true (set[int]{1, 1, 2})->count; // 2 (after de-duplication)
The global count() function is not extended in this RFC (see
Future scope).
Immutable update methods
New collections are produced from existing ones through methods; each returns
a new collection and never mutates the receiver. Method dispatch does not turn
the value into an object (is_object($c) stays false); the methods are
chainable, first-class-callable ($c->append(...)) and reflectable.
$v = vec[int]{1, 2, 3}; $v->append(4); // vec[int]{1,2,3,4} ($v unchanged) $v->prepend(0); // vec[int]{0,1,2,3} $v->withAt(1, 99); // vec[int]{1,99,3} $v->withoutAt(0); // vec[int]{2,3} $s = set[int]{1, 2, 3}; $s->with(4)->without(2); $s->union(set[int]{3, 4, 5}); // {1,2,3,4,5} $s->intersect(set[int]{2, 3, 9}); // {2,3} $s->diff(set[int]{2}); // {1,3} $t = tuple[int, string]{1, "a"}; $t->withAt(1, "b"); // tuple[int,string]{1,"b"} (arity preserved)
| Receiver | Signature | Meaning |
|---|---|---|
vec[T] | append(T $value): vec[T] | $value added at the end |
vec[T] | prepend(T $value): vec[T] | $value added at the front |
vec[T] | withAt(int $i, T $value): vec[T] | index $i replaced |
vec[T] | withoutAt(int $i): vec[T] | index $i removed (the rest close up) |
tuple[…] | withAt(int $i, T $value): tuple[…] | position $i replaced (T = the type at $i; arity fixed) |
set[T] | with(T $value): set[T] | a set that also contains $value |
set[T] | without(T $value): set[T] | a set without $value |
set[T] | union(set[T] $other): set[T] | the union of the two sets |
set[T] | intersect(set[T] $other): set[T] | the intersection of the two sets |
set[T] | diff(set[T] $other): set[T] | the elements in this set but not $other |
Here T is notation, not PHP syntax — PHP has no generics. T denotes the
receiver's element type (for tuple::withAt, the type declared at position
$i), and vec[T] / set[T] / tuple[…] denotes the receiver's exact type.
- At the Reflection level everything is erased: each
Tparameter is declaredmixed(theunion/intersect/diffoperands are declaredset[]), and every return type reflects as the erased bare kind (e.g.vec[],set[]). The element/member type is enforced at runtime — a mismatching value throwsTypeError, and an out-of-range index (withAt/withoutAt) throwsValueError. - The returned value still carries the receiver's exact type — a
vec[int]->append(2)is avec[int]— even though its reflected return type is erased. A tuple's arity is preserved, so it has onlywithAt. - Set membership, de-duplication and the set operations all compare with
===.
tuple has no append/remove because changing arity would produce a different
type. Functional/bulk methods (map, filter, …) are not part of this RFC
(see Future scope).
Serialization and display
serialize() and unserialize() round-trip every kind, using one new,
versionless serialize token. unserialize() rebuilds the type and revalidates
every element through the same path a literal uses, so it grants no new trust:
malformed input, an unknown or unconstructible kind, a bad element, or a bad
count/arity all fail cleanly (unserialize() returns false). Existing
serialized data is unaffected.
var_export() emits re-parseable collection-literal syntax; var_dump(),
print_r() and debug_zval_dump() render the parameterised type and elements.
var_export(vec[int]{1, 2, 3}); // vec[int]{ 1, 2, 3, } (re-parseable)
Reflection
A collection type is reflected through a dedicated ReflectionCollectionType
(extending ReflectionType):
$t = (new ReflectionFunction(fn(vec[vec[string]] $x) => null))->getParameters()[0]->getType(); $t instanceof ReflectionCollectionType; // true $t->getCollectionName(); // "vec" (string) $t; // "vec[vec[string]]" $t->allowsNull(); // false $t->getTypes(); // [ ReflectionCollectionType for vec[string] ]
getCollectionName() returns the kind; getTypes() returns the member types (and
the empty array [] for a bare type). getTypes() generalises to multi-member
kinds.
Error behavior
| Condition | Error | When |
|---|---|---|
| Element does not satisfy the member type | TypeError (Element N of T must be of type …, … given) | construction (runtime) |
Member type has no value representation (?int, mixed, union, …) | TypeError (Cannot create a value of type …) | before elements (runtime) |
tuple element count ≠ arity | Fatal error (… expects N elements, M given) | compile time |
Wrong type-argument count (vec[int, string]) | Fatal error (… expects 1 parameter, 2 given) | compile time |
| Collection type in a union/intersection | Fatal error | compile time |
| Collection literal in a constant expression | Fatal error | compile time |
| Index of wrong type / out of range | TypeError / ValueError | runtime |
| Loose or ordering comparison of collections | TypeError (Cannot compare collection values) | runtime |
foreach by reference | Error (Cannot iterate over … by reference) | runtime |
| Numeric/string conversion | TypeError | runtime |
| Update method: bad element / out-of-range index | TypeError / ValueError | runtime |
Backward compatibility
- No identifiers are reserved.
vec,set,tuple(and the futuremap,shape) remain usable as function, class, constant, method and property names and in member access ($o->vec,Foo::vec). Only the adjacent two-token sequencename[in type/expression position is reserved. - The single BC break is a bare constant dereference where the constant is named after a kind and is immediately followed by
[—vec[0]withvecaconst. Both workarounds continue to work:vec [0](with a space) and(vec)[0]. A corpus scan found no such occurrences; a broader ecosystem sweep is a stated prerequisite before the vote. - Serialization adds one token and is additive; existing serialized data is unaffected.
- Extensions and debuggers that switch exhaustively on zval types need one new arm for the collection tag.
- No implicit
array↔collection conversion exists in either direction.
Rejected alternatives
- A userland
Collection<T>library. PHP has no generics, so an enforced element type and a distinct runtime type cannot be expressed in userland. readonlyclasses. They give shallow immutability for a nominal object, but not a structural type, an enforced element type, or set/tuple semantics; every shape would be a hand-written class. Complementary, not a substitute.- Overloading
array. Makingarrayelement-typed at runtime is infeasible for the language's most-used type; a distinct value type keepsarrayuntouched. - Operators for set algebra (
+,|,&,-) and comparison (==,<=>).+on arrays already means key-union;|/&/-are bitwise and would require an operator-overloading model PHP does not have and merely duplicate the namedunion/intersect/diff;==/<=>on collections throw by design. The named methods and===are the surface. - A general
$value with { … }update expression. Set aside in favour of methods for this RFC; it is a broader, cross-type feature (see Future scope).
Future scope
The following are not proposed here and are independently decidable later. Detailed design analysis for these items is kept in separate design notes, not in this RFC.
mapandshape— separate, independently votable future RFCs.mapneeds a keyed representation and akey => valuegrammar;shapeis blocked on a record-vs-ordered decision.- Functional / bulk methods —
map,filter,slice,reverse,flatMap,groupBy,sorted,reduce,any,all,find,contains. The type-changing ones (notablymap) need a mechanism to determine and preserve the result element type that PHP does not yet have; the analysis is in the architecture document. - Contextual literal inference (
vec{1, 2, 3}, inferring the element type from context). Prototyped and set aside: the argument position needs a VM change to be sound under opcache. (Architecture §10.) - A general
$value with { … }update expression — a future cross-type, non-destructive-update RFC. - Global
count()/is_countable()support — the->countproperty covers cardinality for now. - Union / intersection / nullable member types in constructible values, and collection literals in constant expressions — both forward-compatible extensions.
- Integration with a future generics feature, should one arrive — these types are designed to coexist with, not pre-empt, such a design.
Proposed PHP version
PHP 9.0 (8.6 is already at feature freeze).
Implementation
Implemented on the ''first-class-collections'' branch of the author's php-src fork; a pull request to php-src will follow. The engine-level design (runtime representation, descriptors and canonicalization, the construction pipeline, set de-duplication, garbage collection, the serialization wire format, and opcache/JIT integration), the current build and platform status, and the test suite are documented separately and accompany the implementation.
The implementation includes coverage for parsing, type declarations, construction, value semantics, iteration, indexing, the update methods, serialization, Reflection, and opcache/JIT.
Vote
The vote requires a two-thirds majority.
Add native immutable collection value types —vec,setandtuple— as
described in this RFC?
Secondary votes may be offered for independently removable parts (for example initial Reflection support, or the target version) if reviewers request them.
References
- Implementation — php-src branch ''first-class-collections'' (author's fork; a php-src pull request will follow)
mapandshape— separate future RFCs, not yet proposed- Engine design, implementation status and the test inventory are maintained separately and accompany the implementation