An array does not explain its contents
A native array can represent a list, a map, or a mixture of keys and values. The variable name alone is not a contract.
A list uses consecutive integer keys starting at zero. A map associates keys with values. PHP uses the same array type for both, so this parameter declaration says very little:
<?php
declare(strict_types=1);
final class User
{
public function __construct(
public readonly string $email,
) {}
}
function sendWelcomeEmails(array $users): void
{
foreach ($users as $user) {
echo $user->email, PHP_EOL;
}
}
sendWelcomeEmails([new User('owner@example.com')]);A reader has to infer that $users should be a list of User objects. An IDE or static analyzer may infer some values from nearby code, but that information becomes less reliable as the array crosses methods, services, or external boundaries.
Choose the simplest tool first
A class is useful only when it adds a contract or behavior the code actually needs.
Annotate a local list
Use list<User> or array<int, User> for a short-lived array. IDEs and analyzers can understand the expected values without a new runtime object.
Describe reusable generics
PHPStan and Psalm understand template annotations such as @template T of object. This scales one collection implementation across several object types during analysis.
Enforce a domain boundary
Create UserCollection when values cross a boundary, wrong values must fail at runtime, or the list has useful behavior of its own.
PHP 8.1 does not provide native userland generics. Template annotations are contracts for development tools; PHP itself does not enforce them. The collection below adds the runtime check.
Wrap the array and keep the contract small
Use standard interfaces so the object supports foreach, indexed access, and count() without managing iterator position manually.
IteratorAggregateReturns a traversable view of the internal list. There is no mutable iterator cursor to maintain.
ArrayAccessSupports $users[0], replacement, append with $users[], and removal.
CountableLets count($users) return the number of stored objects.
list<User>The class validates every insertion and keeps consecutive integer keys after removal.
The method signatures must match PHP’s interfaces: offsets and inserted values arrive as mixed. The implementation checks them before use. Append happens only when $offset === null, so index 0 is never mistaken for an empty value.
Use the collection like a focused list
Construction, append, indexed access, iteration, and count remain familiar.
<?php
$users = new UserCollection([
new User('Ada', 'ada@example.com'),
new User('Linus', 'linus@example.com'),
]);
$users[] = new User('Grace', 'grace@example.com');
$users[0] = new User('Ada Lovelace', 'ada@example.com');
echo $users[0]->name, PHP_EOL;
echo count($users), PHP_EOL;
foreach ($users as $user) {
echo $user->email, PHP_EOL;
}Because offsetGet() returns User and getIterator() is documented as Traversable<int, User>, many IDEs can offer member completion for $users[0]->name and $user->email. Exact behavior depends on the IDE and its analysis settings.
Add static-analysis templates when reuse justifies them
A concrete UserCollection is easiest to read. A generic base becomes useful only when several collections share the same mechanics.
PHPStan and Psalm can model a base collection with annotations such as @template T of object, @implements IteratorAggregate<int, T>, and @implements ArrayAccess<int, T>. A subclass then binds T to User.
Those annotations do not create runtime generics. A reusable base still needs a reliable runtime validator—such as a class-string passed by the subclass—if values must be enforced while PHP executes. Keep the concrete implementation first for teams that do not run a static analyzer.
What type safety does—and does not—do
An element-type contract prevents one category of mistake. It is not a general security boundary.
A method accepting UserCollection communicates that it expects an ordered group of users.
An attempted insertion of another object fails immediately with InvalidArgumentException.
Return and iterator annotations give IDEs and static analyzers more information about each element.
A valid User object can still contain an unsafe name, email, or other value.
Knowing that an object is a User does not prove that the current requester may view or change it.
Wrapping an array adds method calls and validation. Benchmark your actual workload before making a performance claim.
Use a collection when the list has a job
Prefer a normal typed parameter plus PHPDoc when the array is local and temporary.
The values cross controllers, services, repositories, or API boundaries; runtime rejection matters; or the list owns behavior such as finding active users or enforcing uniqueness.
The list exists inside one small function, does not need behavior, and is already checked by a configured static analyzer.
Avoid making a collection imitate every array function. Add domain methods that make calling code clearer. If the object becomes a grab bag of sorting, filtering, persistence, and presentation behavior, split those responsibilities.
Complete PHP 8.1 example
This self-contained script tests construction, append, replacement at index zero, iteration, count, invalid type rejection, and out-of-range access.
<?php
declare(strict_types=1);
final class User
{
public function __construct(
public readonly string $name,
public readonly string $email,
) {}
}
/**
* @implements IteratorAggregate<int, User>
* @implements ArrayAccess<int, User>
*/
final class UserCollection implements IteratorAggregate, ArrayAccess, Countable
{
/** @var list<User> */
private array $items = [];
/** @param iterable<User> $users */
public function __construct(iterable $users = [])
{
foreach ($users as $user) {
$this[] = $user;
}
}
/** @return Traversable<int, User> */
public function getIterator(): Traversable
{
yield from $this->items;
}
public function count(): int
{
return count($this->items);
}
public function offsetExists(mixed $offset): bool
{
return is_int($offset)
&& array_key_exists($offset, $this->items);
}
public function offsetGet(mixed $offset): User
{
if (!$this->offsetExists($offset)) {
throw new OutOfBoundsException('Unknown user index.');
}
return $this->items[$offset];
}
public function offsetSet(mixed $offset, mixed $value): void
{
if (!$value instanceof User) {
throw new InvalidArgumentException(
'UserCollection accepts only User objects.'
);
}
if ($offset === null) {
$this->items[] = $value;
return;
}
if (!is_int($offset) || $offset < 0 || $offset > count($this->items)) {
throw new OutOfBoundsException('Invalid user index.');
}
$this->items[$offset] = $value;
}
public function offsetUnset(mixed $offset): void
{
if (!$this->offsetExists($offset)) {
throw new OutOfBoundsException('Unknown user index.');
}
array_splice($this->items, $offset, 1);
}
}
$check = static function (bool $condition, string $message): void {
if (!$condition) {
throw new RuntimeException($message);
}
};
$users = new UserCollection([
new User('Ada', 'ada@example.com'),
new User('Linus', 'linus@example.com'),
]);
$users[] = new User('Grace', 'grace@example.com');
$check(count($users) === 3, 'Append or count failed.');
$users[0] = new User('Ada Lovelace', 'ada@example.com');
$check($users[0]->name === 'Ada Lovelace', 'Index zero failed.');
$names = [];
foreach ($users as $user) {
$names[] = $user->name;
}
$check($names === ['Ada Lovelace', 'Linus', 'Grace'], 'Iteration failed.');
$wrongTypeRejected = false;
try {
$users[] = new stdClass();
} catch (InvalidArgumentException) {
$wrongTypeRejected = true;
}
$check($wrongTypeRejected, 'Invalid object type was accepted.');
$outOfRangeRejected = false;
try {
$users[99];
} catch (OutOfBoundsException) {
$outOfRangeRejected = true;
}
$check($outOfRangeRejected, 'Out-of-range access was accepted.');
echo "All UserCollection checks passed.", PHP_EOL;Save the decoded block as user-collection.php, then run php user-collection.php. It should print “All UserCollection checks passed.”
References
- PHP manual: ArrayAccess
Required signatures and array-style object access.
- PHP manual: IteratorAggregate
External iteration through
getIterator(). - PHPStan PHPDoc types and Psalm array types
Tool-level list, array-shape, and generic type syntax.