PHP RFC: Typed Array Declarations
- Version: 0.1
- Date: 2026-07-16
- Author: Wendell Adriel, wendelladriel.ti@gmail.com
- Status: Under Discussion
- Implementation: TBD
- Discussion thread: https://news-web.php.net/php.internals/131961
- Voting thread: TBD
Introduction
PHP allows developers to declare that a value must be an array, but it cannot declare what the array contains.
final class ProductCatalog { /** @var array<string, Product> */ public array $products = []; }
The runtime guarantees that $products is an array. The key and value types exist only in a docblock and depend on an IDE or static analysis tool to be useful. Frameworks and libraries that need this information at runtime must parse docblocks, introduce attributes, or build another metadata layer.
This RFC proposes native key and value types for array declarations:
final class ProductCatalog { public array<string, Product> $products = []; }
The goal is intentionally smaller than full language generics. This proposal does not add generic classes, generic functions, typed array literals, or array shapes. It extends the existing array declaration with optional key and value type information.
Plain array declarations remain valid and keep their current behavior.
Proposal
This RFC introduces two parameterized forms of the existing array type:
array<TValue> array<TKey, TValue>
The one-argument form declares only the value type. Its key type is implicitly int. String keys must always be declared explicitly with the two-argument form.
These declarations are therefore equivalent:
array<string> array<int, string>
The one-argument form does not require contiguous or zero-based keys. It constrains the key type to int, but it is not a native list type.
The two-argument form declares both key and value types:
array<string, Product> array<int, Product|null> array<string, array<int, Product>>
Bare array remains an unconstrained array:
function process(array $items): array { return $items; }
No existing declaration needs to add type arguments.
Supported declaration positions
Parameterized array types may be used anywhere array is currently accepted as a declaration type:
final class ProductCatalog { public const array<string, string> LABELS = [ 'featured' => 'Featured', ]; public array<string, Product> $products = []; public function replace(array<string, Product> $products): void { $this->products = $products; } public function all(): array<string, Product> { return $this->products; } }
This includes parameters, return types, properties, promoted properties, and typed class constants. Parameterized arrays remain invalid as enum backing types because enum backing types continue to support only int and string.
Key types
PHP normalizes array keys to int or string. The explicit key type must therefore be one of:
int string int|string
Other key declarations are compile errors:
array<float, Product> // Invalid array<object, Product> // Invalid array<?string, Product> // Invalid
Keys are checked after PHP's existing key normalization. A numeric string key that PHP converts to an integer is an int key for the purpose of this declaration.
Appending to an array<string, TValue> creates an integer key and must fail under an implementation level that enforces mutations:
$catalog->products[] = new Product(); // TypeError: the declared key type is string, but append creates an int key
Value types
The value position accepts types that are valid in the surrounding declaration position, including nullable and union types:
array<Product> array<Product|null> array<Product|Service> array<string, array<int, Product|null>>
void, never, and callable are not valid value types. void and never cannot describe a stored value. callable is excluded initially because its validation is scope-sensitive and may trigger lookup or autoload behavior while the engine is traversing an array. The treatment of self, parent, static, intersection types, and DNF types must follow the rules of the surrounding declaration and be finalized before voting.
An empty array satisfies every parameterized array declaration because it contains no incompatible key or value.
Nested arrays
Parameterized array types may be nested:
function groupProducts( array<string, array<int, Product|null>> $groups, ): array<string, array<int, Product|null>> { return $groups; }
Validation is recursive for every implementation level that performs runtime validation.
The parser must recognize the closing >> as two generic type delimiters in this context without changing the meaning of the right-shift operator in expressions.
Nullable and union declarations
Parameterized arrays may participate in nullable and union declarations:
?array<string> array<string>|null array<string>|Traversable
The initial proposal should reject multiple parameterized array members in the same union:
array<A>|array<B>
This form is not equivalent to array<A|B>. The first describes an array containing only A values or only B values, while the second permits both in the same array. Rejecting the first form avoids introducing normalization rules that are not required for the core feature. Supporting it may be considered in a future RFC.
array<TValue> and array<int, TValue> are semantically equal for inheritance and redundancy checks. The source spelling may be preserved for Reflection through hasExplicitKeyType(). array<int|string, mixed> accepts the same values as plain array, but preserves its parameterized metadata for Reflection and static analysis. It is semantically equal to plain array for inheritance and redundancy checks. array<mixed> permits only integer keys and is therefore more restrictive than plain array. A union containing both plain array and any parameterized array is redundant and is rejected at compile time.
Validation and coercion
Key and value validation is strict and does not recursively coerce elements.
function acceptIds(array<int> $ids): void {} acceptIds([1, 2, 3]); // Valid acceptIds(['1', '2', '3']); // TypeError
Deep coercion would require copying or mutating the input array, resolving conflicting constraints from references, and defining rollback behavior after a partial failure. This proposal validates an existing structure and does not transform it.
This is stricter than weak scalar parameter coercion, but it is predictable and avoids hidden O(n) writes. The strict_types declaration does not change parameterized array element validation.
Error behavior
Runtime mismatches throw TypeError. Errors should identify the declaration and the first failing key path when available:
ProductCatalog::$products value at key "featured" must be of type Product, stdClass given
Nested failures should include enough path information to locate the invalid value without attempting to print an entire array.
Error timing follows the boundary being checked:
| Failure | Error timing |
|---|---|
| Invalid declaration or key type | Compile error while compiling the declaration. |
| Invalid directly evaluable parameter, property, or class constant default | Compile error, matching existing typed default behavior. |
| Invalid deferred constant expression | TypeError when the value is resolved. |
| Invalid argument, return value, or whole-property assignment | TypeError at that boundary. |
| Invalid Level 3 element mutation | TypeError before the individual write commits. |
Each individual write is validated before it commits. Deterministic native bulk operations should validate all inserted values and resulting keys before mutation or build a replacement transactionally. Callback-driven operations cannot roll back earlier successful writes or arbitrary callback side effects when a later callback fails.
Implementation Levels
This feature can provide three different levels of runtime support. They share the same syntax and Reflection model, but they do not provide the same language guarantee.
Level 1: Syntax and reflection metadata
Level 1 parses and preserves the complete array declaration but does not inspect its elements at runtime.
function publish(array<Article> $articles): void {} publish([new stdClass()]); // Accepted at Level 1 because the value is an array.
At this level:
- PHP validates that the declaration itself is well-formed.
- Reflection exposes the key and value types.
- Static analyzers and IDEs can consume native syntax instead of docblocks.
- Runtime parameter, return, and property checks still validate only the outer
arraytype.
This is the smallest implementation, but it introduces an important inconsistency: a native type declaration appears to promise more than the runtime enforces. The declined Bound-Erased Generic Types RFC and the PHP Foundation's generics research show that this tradeoff must be explained directly rather than treated as an implementation detail.
Level 1 is useful as an internal development milestone. It is not the recommended standalone language feature.
Level 2: Runtime validation at declaration boundaries
Level 2 includes Level 1 and recursively validates arrays when they cross a declared boundary:
- A function or method receives an argument.
- A function or method returns a value.
- A complete value is assigned to a typed property.
- A parameter or property default is validated.
- A typed class constant is initialized.
function publish(array<Article> $articles): void {} publish([new Article()]); // Valid publish([new stdClass()]); // TypeError
The array remains an ordinary PHP array after validation. The constraint is not attached to the value or to later dimension writes.
This approach works naturally for parameters and returns because PHP already checks parameter types on entry and return types on exit. It is incomplete for mutable properties:
final class Catalog { public array<string, Article> $articles = []; } $catalog = new Catalog(); $catalog->articles['first'] = new stdClass(); // Not rejected at Level 2.
The property passed its initial assignment check but no longer satisfies its declaration. For that reason, Level 2 should either exclude properties or be treated as a stepping stone toward Level 3, not as a complete property type guarantee.
Boundary checks are O(n) in the number of entries and recursive for nested arrays. Compiler, optimizer, and JIT fast paths must not remove these checks based only on the outer array type.
Level 3: Persistent mutation enforcement
Level 3 includes Levels 1 and 2 and preserves a parameterized constraint for the lifetime of a typed location.
Every operation that can mutate a typed array property must validate the affected key and value before committing:
$catalog->articles['first'] = new Article(); // Valid $catalog->articles['second'] = new stdClass(); // TypeError array_push($catalog->articles, new Article()); // TypeError: creates an int key
The same applies to:
- Nested dimension writes.
- Appends and compound assignments.
- References to the property or one of its elements.
- By-reference function calls and
foreach. - Standard array functions that mutate an argument.
- Reflection writes and supported internal extension APIs.
Constraints are attached to typed locations rather than permanently changing ordinary array values:
$copy = $catalog->articles; $copy['invalid'] = new stdClass(); // Valid: $copy is an untyped local array. $reference =& $catalog->articles; $reference['invalid'] = new stdClass(); // TypeError: the reference targets the typed property.
The persistent guarantee applies to typed storage locations, not to every variable that crosses a typed boundary:
| Declaration position | Level 3 behavior |
|---|---|
| By-value parameter | Validated on entry; the local parameter variable is not persistently typed. |
| By-reference parameter | Validated on entry; an existing typed-property source remains active through the reference. |
| By-value return | Validated on return; the caller receives an ordinary array value. |
| By-reference return | Validated on return; an existing typed-property source remains active through the reference. |
| Backed instance, static, or promoted property | Validated on assignment and constrained for later indirect mutations. |
| Typed class constant | Validated when resolved; no later mutation is possible. |
Property hooks keep their existing read and write model. A set hook validates its argument against its settable type, a get hook validates its result against the declared readable type, and backed storage keeps its own declaration constraint. Indirect mutation through a by-value virtual getter remains prohibited by the existing hook rules. A by-reference getter must return a reference carrying the declared constraint or reference creation fails.
This preserves PHP's current model: declarations constrain properties and function boundaries, while arrays remain arrays and continue to use copy-on-write value semantics.
The copied HashTable in the example is untyped. If it contains zend_reference elements shared with a constrained property, those referenced elements remain constrained until the property source is detached.
Level 3 provides the behavior developers are most likely to expect from a typed property. It is also the largest implementation because the engine currently loses property type context before many dimension writes reach a HashTable bucket. References, copy-on-write separation, internal array functions, raw extension writes, and JIT paths all need a common enforcement model.
If properties are part of this RFC, Level 3 is the recommended semantic target.
Selecting a level
The implementation level is user-visible behavior and cannot be changed silently after release. In particular, upgrading Level 1 or Level 2 to Level 3 would make previously successful mutations throw TypeError.
For that reason, the RFC must select one level before voting. The levels should not be presented as interchangeable implementation details.
A practical development process may implement the levels in order, but PHP should only release the guarantee approved by the final RFC.
Type Relationships and Inheritance
Every parameterized array is a subtype of plain array:
array<string, Product> <: array
For by-value boundaries, one parameterized array is a subtype of another when both its key and value types are subtypes:
array<K1, V1> <: array<K2, V2> when K1 <: K2 and V1 <: V2
Normal PHP declaration variance then applies:
- Method return types are covariant.
- Method parameter types are contravariant.
- Ordinary property types are invariant.
- Typed class constants are covariant.
- Property hook types follow their existing read and write variance rules.
By-reference parameters and returns keep PHP's existing declaration variance rules. At Level 3, references derived from typed properties retain the property's source constraint, which rejects writes that would violate the original property even when a broader parameter declaration accepts the reference.
Class names nested inside array types participate in the same delayed inheritance and dependency resolution used by existing declarations.
References and Recursive Arrays
At Level 2, current referenced values are validated at a boundary, but a later write through an external reference can invalidate the array. This limitation must be documented if Level 2 is selected.
At Level 3, a reference that can mutate a constrained element value must retain the relevant constraint for as long as the source location exists. Array keys cannot be references and are validated during insertion or structural mutation. If the engine cannot safely attach and remove an element source, creating the reference must be rejected.
Assigning an array that already contains references to a constrained property registers a constraint source on every reachable referenced element value governed by the declaration. Sources are detached when an element is replaced or removed, the whole property is replaced, or the owning property is destroyed. Clone and shared-reference cases retain the correct source multiplicity.
Recursive arrays must not cause infinite validation. Implementations should track visited array and declared-type pairs during one validation operation. Exact cycle behavior and error reporting must be covered by tests before voting.
Reflection
Bare array continues to be represented by ReflectionNamedType with the name array.
Parameterized arrays are represented structurally by a new ReflectionArrayType:
class ReflectionArrayType extends ReflectionType { public function getKeyType(): ReflectionType {} public function getValueType(): ReflectionType {} public function hasExplicitKeyType(): bool {} }
For array<string>, getKeyType() returns int, getValueType() returns string, and hasExplicitKeyType() returns false.
For array<string, Product>, getKeyType() returns string, getValueType() returns Product, and hasExplicitKeyType() returns true.
ReflectionUnionType::getTypes() may contain ReflectionArrayType instances when a parameterized array is part of a union. Reflection consumers that process declarations using this new syntax must account for the new subtype of ReflectionType.
ReflectionArrayType::__toString() returns the source-equivalent canonical declaration. allowsNull() follows the existing outer declaration rules. Canonical key union ordering is int|string, while hasExplicitKeyType() preserves whether the source used one or two arguments.
Existing Array Behavior
Parameterized arrays are still PHP arrays:
is_array($value); // true gettype($value); // "array"
Iteration, array access, equality, copy-on-write behavior, and array operators keep their current semantics except that an enforced mutation may throw TypeError when it would violate a declared constraint.
This RFC does not add typed array literals:
array<Product>(new Product()); // Not proposed
It also does not add parameterized casts or instanceof support:
(array<Product>) $value // Not proposed $value instanceof array<Product> // Not proposed
Ordinary array literals and arrays returned by standard library functions remain unconstrained values until they cross a declaration boundary.
Standard Library and Extensions
This RFC does not change standard library signatures to use parameterized arrays.
Under Level 3, existing functions that mutate an array by reference must preserve an active location constraint. A function such as array_push() cannot bypass a typed property declaration merely because its implementation writes through the HashTable API.
The Zend API will need a supported way for internal functions and extensions to perform checked writes to constrained user arrays. Generic HashTable operations cannot unconditionally perform these checks because HashTables are also used for engine data structures that are not PHP array values.
Out-of-tree extensions that directly inspect zend_type or mutate user arrays through raw HashTable pointers may need updates, depending on the selected implementation level and final internal representation.
Level 3 cannot be enforced through a new optional helper alone. The implementation must either centralize constrained user-array writes or define raw mutation of a constrained user array as unsupported and provide a checked replacement API for extensions.
Serialization and JSON
This proposal treats constraints as declaration metadata, not as a new serialized array value kind.
serialize() and json_encode() therefore keep their current array formats. When unserialize() or another hydration path writes an array into a parameterized property, the same property validation required by the selected implementation level applies.
The implementation must apply the same rule to raw Reflection property writes, lazy-object initialization, clone initialization, static property APIs, and custom property handlers. Hook-aware writes continue to use the property's settable type.
Performance
Plain array declarations and code that does not use parameterized arrays should not receive additional element checks.
Level 1 adds declaration metadata but no HashTable traversal.
Level 2 makes parameter, return, property assignment, default, and class constant checks O(n), recursively for nested arrays.
At Level 3, scalar leaf writes can usually be checked in O(1). Assigning a nested array requires O(n) validation of the inserted subtree, plus any reference-source bookkeeping. References and bulk array operations may require additional preflight work. The implementation must include benchmarks with OPcache and JIT both enabled and disabled.
Optimizations may be added when the engine can prove compatibility, but the initial implementation must prefer a shared correct slow path over duplicated checks that can be bypassed.
Backward Incompatible Changes
Existing plain array declarations keep their syntax, Reflection representation, and runtime behavior.
array<...> is currently invalid in declaration positions, so the new syntax does not change the meaning of an existing valid declaration.
The lexer must preserve >> as the right-shift operator in expression contexts. It may only treat it as two closing type delimiters while parsing nested parameterized types.
Reflection consumers inspecting code that adopts the new syntax may receive a ReflectionArrayType where that code previously could not compile at all.
Introducing the global internal class name ReflectionArrayType may conflict with userland code that already declares that name and changes global class enumeration even when no parameterized declaration is used. An ecosystem collision search is required before voting, and the final class name may change based on that result.
If a lower implementation level were released and later strengthened, that change would be behaviorally incompatible. This is why the runtime level must be selected before the initial release.
Proposed PHP Version
This RFC targets PHP 8.7 or PHP 9.0 version after acceptance and implementation.
RFC Impact
To the ecosystem
IDEs, language servers, static analyzers, formatters, parsers, and code transformation tools must add the grammar and a structural representation for key and value types.
Static analyzers already understand similar PHPDoc forms, including array<TValue> and array<TKey, TValue>. Native syntax removes duplicated docblocks but does not require analyzers to abandon richer types such as array shapes, non-empty arrays, and lists.
To existing extensions
Levels 1 and 2 should remain source-compatible with extensions using public declaration and array APIs. Level 3 requires a checked mutation path for constrained user arrays. Extensions that inspect zend_type internals, generate arginfo, or directly mutate constrained user arrays may require changes.
To SAPIs
No SAPI-specific behavior is proposed.
Open Issues
- Select Level 1, Level 2, or Level 3 as the normative runtime behavior.
- Decide whether properties are excluded if Level 2 is selected.
- Finalize support for
self,parent,static, intersections, and DNF in nested positions. - Define exact behavior for arrays that already contain references.
- Define recursion detection and diagnostics.
- Confirm subtype behavior for arrays containing shared references.
- Finalize the
ReflectionArrayTypeAPI and naming. - Finalize parameterized property hook behavior and Reflection settable types.
- Measure parser, runtime, memory, OPcache, and JIT impact with a proof-of-concept implementation.
Future Scope
The following features are intentionally outside this RFC:
- Generic classes, interfaces, traits, functions, and methods.
- Parameterized
iterableandTraversabledeclarations. - Native
list<T>declarations. - Array shapes and non-empty array types.
- Typed array literals and parameterized array casts.
instanceofchecks against parameterized arrays.- Local variable type declarations.
- Type aliases.
- Multiple parameterized array alternatives in one union.
- Retrofitting parameterized declarations onto standard library functions.
Voting Choices
Primary Vote requiring a 2/3 majority to accept the RFC:
Patches and Tests
No implementation is currently attached to this draft.
A proof of concept should first cover parsing, recursive type storage, Reflection, inheritance, OPcache persistence, and PHPT tests for bare array compatibility. Runtime work should then proceed according to the selected implementation level.
Implementation
TBD.
References
Rejected Features
None yet.
Changelog
- 0.1 (2026-07-16): Initial draft.