Quick Links

PHP doesn't let you define typed arrays. Any array can contain any value, which makes it tricky to enforce consistency in your codebase. Here are a few workarounds to help you create typed collections of objects using existing PHP features.

Identifying the Problem

PHP arrays are a very flexible data structure. You can add whatever you like to an array, ranging from scalar values to complex objects:

$arr = [

"foobar",

123,

new DateTimeImmutable()

];

In practice, it's rare you'd actually want an array with such a varied range of values. It's more likely that your arrays will contain multiple instances of the same kind of value.

$times = [

new DateTimeImmutable(),

new DateTimeImmutable(),

new DateTimeImmutable()

];

You might then create a method which acts on all the values within your array:

final class Stopwatch {

protected array $laps = [];

public function recordLaps(array $times) : void {

foreach ($times as $time) {

$this -> laps[] = $time -> getTimestamp();

}

}

}

This code iterates over the

        DateTimeInterface
    

instances in

        $times
    

. The Unix timestamp representation of the time (seconds measured as an integer) is then stored into

        $laps
    

.

The trouble with this code is it makes an assumption that

        $times
    

is comprised wholly of

        DateTimeInterface
    

instances. There's nothing to guarantee this is the case so a caller could still pass an array of mixed values. If one of the values didn't implement

        DateTimeInterface
    

, the call to

        getTimestamp()
    

would be illegal and a runtime error would occur.

$stopwatch = new Stopwatch();

// OK

$stopwatch -> recordLaps([

new DateTimeImmutable(),

new DateTimeImmutable()

]);

// Crash!

$stopwatch -> recordLaps([

new DateTimeImmutable(),

123 // can't call `getTimestamp()` on an integer!

]);

Adding Type Consistency with Variadic Arguments

Ideally the issue would be resolved by specifying that the

        $times
    

array can only contain

        DateTimeInterface
    

instances. As PHP lacks support for typed arrays, we must look to alternative language features instead.

The first option is to use variadic arguments and unpack the

        $times
    

array before it's passed to

        recordLaps()
    

. Variadic arguments allow a function to accept an unknown number of arguments which are then made available as a single array. Importantly for our use case, you may typehint variadic arguments as normal. Each argument passed in must then be of the given type.

Variadic arguments are commonly used for mathematical functions. Here's a simple example that sums every argument it's given:

function sumAll(int ...$numbers) {

return array_sum($numbers);

}

echo sumAll(1, 2, 3, 4, 5); // emits 15

        sumAll()
    

is not passed an array. Instead, it receives multiple arguments which PHP combines into the

        $numbers
    

array. The

        int
    

typehint means each value must be an integer. This acts as a guarantee that

        $numbers
    

will only consist of integers. We can now apply this to the stopwatch example:

final class Stopwatch {

protected array $laps = [];

public function recordLaps(DateTimeInterface ...$times) : void {

foreach ($times as $time) {

$this -> laps[] = $time -> getTimestamp();

}

}

}

$stopwatch = new Stopwatch();

$stopwatch -> recordLaps(

new DateTimeImmutable(),

new DateTimeImmutable()

);

It's no longer possible to pass unsupported types into

        recordLaps()
    

. Attempts to do so will be surfaced much earlier, before the

        getTimestamp()
    

call is attempted.

If you've already got an array of times to pass to

        recordLaps()
    

, you'll need to unpack it with the splat operator (

        ...
    

) when you call the method. Trying to pass it directly will fail - it'd be treated as one of the variadic times, which are required to be an

        int
    

and not an

        array
    

.

$times = [

new DateTimeImmutable(),

new DateTimeImmutable()

];

$stopwatch -> recordLaps(...$times);

Limitations of Variadic Arguments

Variadic arguments can be a great help when you need to pass an array of items to a function. However, there are some restrictions on how they can be used.

The most significant limitation is that you can only use one set of variadic arguments per function. This means each function can accept only one "typed" array. In addition, the variadic argument must be defined last, after any regular arguments.

function variadic(string $something, DateTimeInterface ...$times);

By nature, variadic arguments can only be used with functions. This means they can't help you out when you need to store an array as a property, or return it from a function. We can see this in the stopwatch code - the

        Stopwatch
    

class has a

        laps
    

array which is meant to store only integer timestamps. There's currently no way we can enforce this is the case.

Collection Classes

In these circumstances a different approach must be selected. One way to create something close to a "typed array" in userland PHP is to write a dedicated collection class:

final class User {

protected string $Email;

public function getEmail() : string {

return $this -> Email;

}

}

final class UserCollection implements IteratorAggregate {

private array $Users;

public function __construct(User ...$Users) {

$this -> Users = $Users;

}

public function getIterator() : ArrayIterator {

return new ArrayIterator($this -> Users);

}

}

The

        UserCollection
    

class can now be used anywhere you'd normally expect an array of

        User
    

instances.

        UserCollection
    

uses variadic arguments to accept a series of

        User
    

instances in its constructor. Although the

        $Users
    

property has to be typehinted as the generic

        array
    

, it's guaranteed to consist wholly of user instances as it's only written to in the constructor.

It may seem tempting to provide a

        get() : array
    

method which exposes all the collection's items. This should be avoided as it brings us back to the vague

        array
    

typehint problem. Instead, the collection is made iterable so consumers can use it in a

        foreach
    

loop. In this way, we've managed to create a typehint-able "array" which our code can safely assume contains only users.

function sendMailToUsers(UserCollection $Users) : void {

foreach ($Users as $User) {

mail($user -> getEmail(), "Test Email", "Hello World!");

}

}

$users = new UserCollection(new User(), new User());

sendMailToUsers($users);

Making Collections More Array-Like

Collection classes solve the typehinting problem but do mean you lose some of the useful functionality of arrays. Built-in PHP functions like

        count()
    

and

        isset()
    

won't work with your custom collection class.

Support for these functions can be added by implementing additional built-in interfaces. If you implement

        Countable
    

, your class will be usable with

        count()
    

:

final class UserCollection implements Countable, IteratorAggregate {

private array $Users;

public function __construct(User ...$Users) {

$this -> Users = $Users;

}

public function count() : int {

return count($this -> Users);

}

public function getIterator() : ArrayIterator {

return new ArrayIterator($this -> Users);

}

}

$users = new UserCollection(new User(), new User());

echo count($users); // 2

Implementing

        ArrayAccess
    

lets you access items in your collection using array syntax. It also enables the

        isset()
    

and

        unset()
    

functions. You need to implement four methods so PHP can interact with your items.

final class UserCollection implements ArrayAccess, IteratorAggregate {

private array $Users;

public function __construct(User ...$Users) {

$this -> Users = $Users;

}

public function offsetExists(mixed $offset) : bool {

return isset($this -> Users[$offset]);

}

public function offsetGet(mixed $offset) : User {

return $this -> Users[$offset];

}

public function offsetSet(mixed $offset, mixed $value) : void {

if ($value instanceof User) {

$this -> Users[$offset] = $value;

}

else throw new TypeError("Not a user!");

}

public function offsetUnset(mixed $offset) : void {

unset($this -> Users[$offset]);

}

public function getIterator() : ArrayIterator {

return new ArrayIterator($this -> Users);

}

}

$users = new UserCollection(

new User("example@example.com"),

new User("hello@world.com")

);

echo $users[1] -> getEmail(); // hello@world.com

var_dump(isset($users[2])); // false

You now have a class which can only contain

        User
    

instances and which also looks and feels like an array. One point to note about

        ArrayAccess
    

is the

        offsetSet
    

implementation - as

        $value
    

must be

        mixed
    

, this could allow incompatible values to be added to your collection. We explicitly check the type of the passed

        $value
    

to prevent this.

Conclusion

Recent PHP releases have evolved the language towards stronger typing and greater consistency. This doesn't yet extend to array elements though. Typehinting against

        array
    

is often too relaxed but you can circumvent the limitations by building your own collection classes.

When combined with variadic arguments, the collection pattern is a viable way to enforce the types of aggregate values in your code. You can typehint your collections and iterate over them knowing only one type of value will be present.