Handling array parameters in PHP can be kind of a pain, since you can't be sure about the shape of the array. Gladly there a ways to make handling such parameters way easier.
If you've ever worked with array parameters in PHP you might feel familiar with something like this:
public function showUser(array $user)
{
if (empty($user['id'])) {
throw new UserNotFoundException();
}
$fullName = join(' ', [
(isset($user['firstName']) ? $user['firstName'] : ''),
(isset($user['lastName']) ? $user['lastName'] : ''),
]);
return $this->render('userDetails', [
'fullName' => $fullName,
// ... all the other view relevant user fields
]);
}
That's really ugly for many reasons:
If you're dealing with large arrays (and a user containing information about a user can get pretty big) your controllers are more like data handlers than controllers. Additionally having data validation in your controllers is just really ugly and violates the separation of concerns principle.
By just looking at the this function you (and your IDE) have no idea what the $user
array contains. Is there an attribute dateOfBirth
? Maybe there is, but it's name birthday
. You don't know. To find out you either print_r
your entire $user
(which just gives you the information, no one else), you need to jump to the call of this function and analyze how $user
is assembled or start debugging. You then write a comment which is double the length of the method just describing one single param - and get fired because you are obviously insane. You (probably) don't want that.
If the definition of fullName
changes - e.g. a middle name comes into play - you need to change every fullName
assembly in your entire application.
At this point you might say "but dude, I'm never doing anything like this! I'm not insane!. No, you're (probably) not insane, but a lot of other people are - especially when working with third party tools like frameworks, CMS', etc.. Let's take a look at some systems out there:
WordPress
Regardless of how much I dislike WordPress it's a excellent example of showing how to do it wrong:
$posts_array = get_posts( $args );
Do you know what $args
is all about? Is the key for getting the amount of how many posts to show on a page posts_per_page
, postsperpage
or items_per_page
? Do you know all posibilities you're having here without having to look at the docs every now and then?
Shopware
Taking a brief look into the Shopware documentation about how to implement payment methods shows up the following:
$user = $this->getUser();
$billing = $user['billingaddress'];
$parameter = [
'amount' => $this->getAmount(),
'currency' => $this->getCurrencyShortName(),
'firstName' => $billing['firstname'],
'lastName' => $billing['lastname'],
'returnUrl' => $router->assemble(['action' => 'return', 'forceSecure' => true]),
'cancelUrl' => $router->assemble(['action' => 'cancel', 'forceSecure' => true]),
'token' => $service->createPaymentToken($this->getAmount(), $billing['customernumber'])
];
So, uhm, okay, it seems like $this->getUser()
returns an array of things, which contains a billingaddress
which again contains things like firstname
. We knew that, didnt we?!
Joomla
I've never developed anything for Joomla, but looking into their documentation it's relatively easy to find what we're looking for:
/**
* Retrieves the hello message
*
* @param array $params An object containing the module parameters
*
* @access public
*/
public static function getHello($params)
{
return 'Hello, World!';
}
Ahh, the good old $params
param. Not just that this is great naming, I have absolutely no idea what's inside $params
.
As you can see many well distributed systems do shenanigans like this. They don't help developers and indicate lack of design.
But, as we don't want some blogging dude to take our source code and write a post about it how much we've fucked up, a simple yet very effective way of becoming a better developer.
Abstraction to the rescue: map arrays into models
Abstraction is a great great way of making your (and everyone else who's doomed to work with you) life easier: we could simply map our arrays to objects. So instead of this:
$user = [
'firstname' => 'John',
'lastname' => 'Doe',
];
We'd have something like:
$user = new User();
$user->setFirstName('John');
$user->setLastName('John');
So getting the full name from our user turns from
$fullName = join([
(isset($user['firstName']) ? $user['firstName'] : ''),
(isset($user['lastName']) ? $user['lastName'] : ''),
]);
Into
$fullName = $user->getFullName();
Your IDE likes it, your brain likes it and everyone's brain who's going to work with it will like it. Great!
This concept is applicable to almost any kind of data. In case of our user the User
model would look something like this:
// src/Models/User.php
class User
{
protected $firstName;
protected $lastName;
public function setFirstName($firstName)
{
$this->firstName = $name;
return $this;
}
public function getFirstName()
{
return $this->firstName;
}
public function setLastName($lastName)
{
$this->lastName = $lastName;
return $this;
}
public function getLastName()
{
return $this->lastName;
}
public function getFullName()
{
return join(' ', [$this->firstName, $this->lastName]);
}
}
Pretty easy thing as you can see. In case you want first and last name to be required for every user just put them into the constructor of the user class:
public function __construct($firstName, $lastName)
{
$this->firstName = $firstName;
$this->lastName = $lastName;
}
Now you can't initialize a new User
class without giving it a first and last name.
Validation
An additional benefit by using this approach is that we can easyily separate validation. Let's say we want our $lastName
property to be required and fulfill some requirements to be valid.
public function setLastName($lastName)
{
if (! is_string($lastName) || strlen($lastName) < 3) {
throw new \InvalidArgumentException("Last name ($lastName) is invalid");
}
$this->lastName = $lastName;
return $this;
}
Since $lastName
is not nullable it's required for us to pass any other kind of type to our method. Validation is happening right before setting our last name to prevent having invalid data in our model.
Note: In case you're using PHP >=7.0 you can additionally typehint the
$lastName
param withstring
to prevent getting an invalid type and getting rid of the (pretty ugly)is_string
check.
Handling third party vendors with deserialization
Even if you follow this concept as good as you can: others will not. As shown above existing systems often lack design and give you poorly named arrays to work with. But we can kinda fix what they messed up.
We just take a look at existing array keys (in the documentation for example) to wrap them into a model, so we never need to worry about this again.
Let's take a look at Shopwares getUser()
method an wrap the returned array into a user object. Since there's a lack of documentation we need to dig a bit in the source code of Shopware to find out what we're looking for. After some "Wtf?" moments we finally find out that the data we want lives in getUser()['additional']['user']['<the data we want>']
- that was easy... kinda.
To never look at this brainfuck again we will map the shopware user to our very own model:
class UserMapper
{
protected $shopwareUser;
/**
* @param array $shopwareUser The user array we get from Shopwares `getUser`
*/
public function __construct(array $shopwareUser)
{
$this->shopwareUser = $shopwareUser;
}
public function getId()
{
return $this->getUserData('id');
}
public function getFirstName()
{
return $this->getUserData('firstname');
}
public function getLastName()
{
return $this->getUserData('lastname');
}
protected function getUserData($key)
{
if (!isset($this->shopwareUser['additional']['user'][$key]) {
throw new \InvalidArgumentException("Invalid key ($key) requested from user");
}
return $this->shopwareUser['additional']['user'][$key];
}
protected function setShopwareUser(array $shopwareUser)
{
if (!isset($this->shopwareUser['additional']['user'])) {
throw new \InvalidArgumentException("Invalid user");
}
$this->shopwareUser = $shopwareUser;
}
}
Such classes can get pretty long, but that's no problem since they're holding third party data which is not really influenceable by us.
Now when dealing with users inside Shopware we can now simply initialize our UserMapper
with the array we get from Shopware and the life of being a Shopware dev has become a lot better:
$user = new UserMapper($this->getUser());
// Now we do
$id = $user->getId();
// instead of
$id = $this->getUser()['additional']['user']['id'];
You just have to deal with the array data once when implementing the mapper class. Afterwards you're likely to never look at it again. Additional benefits are obvious:
- easy and fast to implement
- easy to test
- auto completion will help you now
- when not working on this project for some time (or another dev starts working with you) it's really easy to understand the data without having to think about the real data beneath
To summarize this so far:
TL;DR: For better array parameter handling, use objects instead.
An alternative approach: Symfonys OptionsResolver component
In case you're still need to handle raw arrays you might want to take a look at Symfonys OptionsResolver component.
You might know this component if you've already worked with Symfony Forms, where the OptionsResolver
is used quite often. But, like all other Symfony components, the OptionsResolver
can be used standalone in any application.
This component eases the handling by giving you the possibility of specifying required keys in your arrays and add additional features like validation, normalization, values dependent on other values and more.
Basically it turns things like:
public function showUser(array $user)
{
$firstName = (! empty($user['firstName']) ? $user['firstName'] : 'John');
$lastName = (! empty($user['firstName']) ? $user['firstName'] : 'Doe');
if (! is_string($firstName) || ! is_string($lastName)) {
throw new \InvalidArgumentException("First or last name is invalid");
}
return $this->render('userDetails', [
'firstname' => $firstName,
'lastname' => $lastName
]);
}
To something which is easier to follow and maintain:
public function showUser(array $user)
{
$resolver = new OptionsResolver();
$resolver->setDefaults(array(
'firstName' => 'John',
'lastName' => 'Doe'
));
$resolver->setAllowedTypes('firstName', 'string');
$resolver->setAllowedTypes('lastName', 'string');
$options = $resolver->resolve($user);
return $this->render('userDetails', [
'firstname' => $options['firstName'],
'lastname' => $options['firstName']
]);
}