01
Flexible but ambiguous

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:

Ambiguous array parameterPHP 8.1+
<?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.

02
Avoid unnecessary abstraction

Choose the simplest tool first

A class is useful only when it adds a contract or behavior the code actually needs.

01 · PHPDOC

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.

02 · STATIC ANALYSIS

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.

03 · COLLECTION

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.

03
A minimal modern design

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.

IteratorAggregate

Returns a traversable view of the internal list. There is no mutable iterator cursor to maintain.

ArrayAccess

Supports $users[0], replacement, append with $users[], and removal.

Countable

Lets count($users) return the number of stored objects.

One internal 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.

04
Familiar syntax

Use the collection like a focused list

Construction, append, indexed access, iteration, and count remain familiar.

Usage after loading the complete classes belowPHP 8.1+
<?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.

05
Optional tool-level generics

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.

06
Use precise claims

What type safety does—and does not—do

An element-type contract prevents one category of mistake. It is not a general security boundary.

It documents intent

A method accepting UserCollection communicates that it expects an ordered group of users.

It rejects wrong object types

An attempted insertion of another object fails immediately with InvalidArgumentException.

It improves tool feedback

Return and iterator annotations give IDEs and static analyzers more information about each element.

It does not sanitize input

A valid User object can still contain an unsafe name, email, or other value.

It does not authorize a request

Knowing that an object is a User does not prove that the current requester may view or change it.

It is not a performance shortcut

Wrapping an array adds method calls and validation. Benchmark your actual workload before making a performance claim.

07
A practical rule

Use a collection when the list has a job

Prefer a normal typed parameter plus PHPDoc when the array is local and temporary.

Choose a collection

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.

Choose a documented array

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.

08
Copy, run, and adapt

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.

Complete PHP 8.1 UserCollection exampleSelf-testing script
<?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

About the author

Cory Marsh

Cory has more than 20 years of internet security experience and is a lead developer on the BitFire project.

Read more BitFire research →
Keep the contract visible

Use a collection when the list has a job.

Start with a documented array. Add runtime enforcement only when values cross boundaries or the collection owns useful domain behavior.

Protect my site free →