Welcome to this post where I will show you how to bring more type safety to your arrays in PHP.
Summary
What is the problem?
In my experience it is very rare to have list of multiple type of data within an array. Most of the time, we want to have a list of something.
use App\Services\User;
$cards = (new User())->getSavedCreditCards();
print_r($cards);
[
[
"id" => "YTZERT37RR3TR7",
"type" => "amex",
"lastFour" => "0826",
"expiry" => [
"month" => "04",
"year" => "2024",
],
],
[
"id" => "83E38Y38YF8FYF3",
"type" => "visa",
"lastFour" => "1162",
"expiry" => [
"month" => "02",
"year" => "2023"
],
],
]
Let us imagine the UserService
takes data from our configured payment service provider of choice, and this is actually the raw response that is returned on the UserService::getSavedCreditCards()
.
There is several problem having to deal with this kind of response:
- You are exposed to "undefined array key..." errors
- Your IDE cannot help you know what is the key name (if you will have to go on the source code to find the array shape, if you are lucky and it is not burried inside the PSP vendor code)
- It is not guaranteed the array will only contain a list of stored cards
Typing our items
In this example objects would help convey more information in the code and bring type safety.
Let us start with a basic object representing a credit card.
readonly class Card
{
public function __construct(
public string $id,
public CardType $type,
public string $lastFour,
public CardExpiry $expiry,
) {}
}
readonly class CardExpiry
{
public function __construct(
public string $month,
public string $year,
) {}
}
enum CardType: string
{
case AmericanExpress = "amex";
case Visa = "visa";
case MasterCard = "mastercard";
}
Now we could stop there and create an array of Card
.
$cards = [
new Card(
id: "YTZERT37RR3TR7",
type: CardType::AmericanExpress,
lastFour: "0826",
expiry: new CardExpiry(
month: "04",
year: "2024",
),
),
new Card(
id: "83E38Y38YF8FYF3",
type: CardType::Visa,
lastFour: "1162",
expiry: new CardExpiry(
month: "02",
year: "2023",
),
),
];
At this point, nothing prevents us from adding something else than a Card
in this array. It is time to introduce "array objects".
Using objects as type safe array
Let us start with a class that holds a list of Card
.
class Cards
{
private array $cards;
public function __construct()
{
$this->cards = [];
}
}
To be able to push values to an object as if it was an array, let us implement the ArrayAccess
interface.
class Cards implements ArrayAccess
{
private array $cards;
public function __construct()
{
$this->cards = [];
}
// Array access methods
public function offsetExists(mixed $offset): bool
{
return isset($this->cards[$offset]);
}
public function offsetGet($offset): ?Card
{
return $this->cards[$offset] ?? null;
}
public function offsetSet(mixed $offset, mixed $value): void
{
if (!$value instanceof Card) {
throw new InvalidArgumentException("Expected parameter 2 to be a Card.");
}
$this->cards[$offset] = $value;
}
public function offsetUnset($offset): void
{
if (isset($this->cards[$offset])) {
unset($this->cards[$offset]);
}
}
}
Now we can safely push Card
into the array.
$cards = new Cards();
$cards[] = new Card(
id: "YTZERT37RR3TR7",
type: CardType::AmericanExpress,
lastFour: "0826",
expiry: new CardExpiry(
month: "04",
year: "2024",
),
);
$cards[] = 1; // Uncaught InvalidArgumentException: Expected parameter 2 to be a Card.
To be able to loop through the array, we need to implement another built-in interface.
class Cards implements ArrayAccess, IteratorAggregate
{
private array $cards;
public function __construct()
{
$this->cards = [];
}
// Iterator aggregate methods
public function getIterator(): ArrayIterator
{
return new ArrayIterator($this->cards);
}
// Array access methods
public function offsetExists(mixed $offset): bool
{
return isset($this->cards[$offset]);
}
public function offsetGet($offset): ?Card
{
return $this->cards[$offset] ?? null;
}
public function offsetSet(mixed $offset, mixed $value): void
{
if (!$value instanceof Card) {
throw new InvalidArgumentException("Expected parameter 2 to be a Card.");
}
$this->cards[$offset] = $value;
}
public function offsetUnset($offset): void
{
if (isset($this->cards[$offset])) {
unset($this->cards[$offset]);
}
}
}
And now we can loop through our cards.
$cards = new Cards();
$cards[] = new Card(
id: "YTZERT37RR3TR7",
type: CardType::AmericanExpress,
lastFour: "0826",
expiry: new CardExpiry(
month: "04",
year: "2024",
),
);
foreach ($cards as $card) {
echo $card->expiry->month; // "04"
}
Conclusion
This "array object" is the equivalent of a "Set" in other language: it ensures the list have a consistent type across all its items.
One downside is that this check is done at runtime (unless you keep reading). The PHP interpreter will not natively understand your $cards
array is a list of Card
. Your IDE will still consider $card
as a mixed
. The last trick is to use the assert()
function to give an extra help to your IDE.
foreach ($cards as $card) {
assert($card instanceof Card);
echo $card->expiry->month; // "04", IDE autocompletion works
}
Static analysis tools would prevent you from assigning an int
to the lis of Card
, with some extra PHPDoc blocks.
/**
* @implements ArrayAccess<int, Card>
* @implements IteratorAggregate<int, Card>
*/
class Cards implements ArrayAccess, IteratorAggregate
{
// ...
}
$cards = new Cards();
$cards[] = 1; // PHPStan will prevent you from doing this now
You can try what PHPStan tells you online with this pre-filled example.
Now you can ensure type consistency across your lists, which gives an extra layer of safety, and can be a great ally when working in teams.
At some point I hope this article will be deprecated in favor of generics within PHP. A proposal already thrown the basis for something like this.
$cards = array<Card>();
$cards[] = new Card(...);
$cards[] = 1; // Both PHPstan and PHP would throw
In the mean time, I hope you will leave with some ideas to make your code base more robust. Thank you for reading!
Happy type hardening 🛡️
Cover image by Pixabay.
Top comments (9)
Nice article. I think this is a good use case for Laravel's new
ensure
collection method to make sure each item in a collection is a specific type. Though I wonder what the performance implications might be there checking the entire collection versus checking on add.laravel.com/docs/10.x/collections#...
Nice I forgot about this one 😅 I think the cost would be quite similar as ensure would stop at the first non coherent type VS throwing as soon as a bad type is pushed in the array, the only difference would be throwing at the assignation level VS throwing when calling
->ensure()
I guess 🤔I disagree with this statement. A set is defined like the following:
The "array object" does not store unique elements. An element could exist twice in the array.
Instead the "array object"resembles somewhat of a collection data structure:
You absolutely right!
In the current PhpStorm version you don't need the assert call anymore, you just use the phpstan annotation and it works. 👍
PS: I created an abstract collection library some time ago, so you don't have to implement the ArrayAccess methods every time: github.com/voku/Arrayy#collections
The Card class implements the
getIterator
method twice. So it will give the following error:Fatal error: Cannot redeclare Cards::getIterator()
. Just had to remove one of them. Thanks for the article.Fixed good catch Daniel!
If you want to improve your PHP skills I recommend to you this youtube channel . This gay is my friend and he is a professional PHP developer.
I see your friend just created his channel, I wish him good luck and keep up the good video release frequency!