Jovan Perovic
Jovan Perovic

Reputation: 20201

PHP uninitialized object properties

I seem to have a bit of a misunderstanding of PHP's Typed properties and uninitialized state.

Imagine that a REST-like service gets the following JSON object:  

{
    "firstName": "some name",
    "lastName": "some last name",
    "groupId": 0,
    "dateOfBirth": "2000-01-01"
}

I would much prefer to have a DTO look something like this:

class Person {
    private string $firstName;
    private string $lastName;
    private int $groupId;
    private \DateTime $dateOfBirth;

    // All the getters/setters, cannot have __construct due to serializer limitation
}

However, since any of these message properties may be omitted (by mistake, not a valid case), my deserialization would leave some of the fields in an unintialized state.

So, this sucks. I guess I have a couple of options:

  1. Declare all of them nullable and initialize to null (yuck)
  2. Initialize scalar properties to their respective defaults (0, '', etc)), and object properties to null (declare them nullable for that purpose).

Let's say I went for option #2:

class Person {
    private string $firstName = '';
    private string $lastName = '';
    private int $groupId = 0;
    private ?\DateTime $dateOfBirth = null;

    // The rest
}

In a different part of the code, I have something like this:

function doSomethingWithDate(\DateTime $dateTime): string{
    return ...; // does not really matter
}

...

doSomethingWithDate($person->getDateOfBirth());
...

My IDE screams with the warning:

Expected parameter of type '\DateTime', '\DateTime|null' provided 

It is obvious why - getter says "hey, I can be nullable", but the method says "no, no" to that.

But what should I do to "convince" it that this is a valid scenario?

Should I have a separate set of DTOs - one for an unsafe state and another for a safe? Seems unlikely...

How would you approach this "problem"?

Update

As much as my question sounded vague and weird (I know it did :D), I'd like to elaborate on it a bit.

Upvotes: 2

Views: 1779

Answers (1)

Pieter van den Ham
Pieter van den Ham

Reputation: 4494

(This answer is specific to the Symfony Serializer component and PHP 8.1+.)

Ensuring that incoming data adheres to a certain contract is certainly a good thing to enforce. I also like my property types to be as strict as possible, and I also hate it when PhpStorm yells at me.

The problem

Imagine we'd have this DTO:

class Dto1 {
    private string $foo;

    public function setFoo(string $foo): void { $this->foo = $foo; }
    public function getFoo(): string { return $this->foo; }
}

You'd probably deserialize it like so:

$dto = $serializer->deserialize($json, Dto1::class, 'json', [
    AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false,
]);

(Setting ALLOW_EXTRA_ATTRIBUTES to false ensures that the legacy system cannot sneak in extra properties)

As you noticed, we will now have an issue when the JSON is missing the $foo property:

$json = '{}';
$dto = $serializer->deserialize($json, Dto1::class, 'json', [
    AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false,
]);
var_dump($dto->foo); // Oops! Uninitialized property access

Unfortunately, there does not seem to be a way to have the Symfony Serializer check for uninitialized properties after deserialization. That leaves us with two other options to tackle this problem.

Solution 1

Ensure that all properties are initialized after deserialization. This probably requires writing a function that looks somewhat like this:

function ensureInitialized(object $o): void {
    // There are probably more robust ways to do this, 
    // this is just an example.
    $reflectionClass = new ReflectionClass($o);

    foreach ($reflectionClass->getProperties() as $reflectionProperty) {
        if (!$reflectionProperty->isInitialized($o)) {
            throw new RuntimeException('Uninitialized properties!');
        }
    }
}

We can use this function to ensure that the deserialized DTO is valid:

$json = '{}';
$dto = $serializer->deserialize($json, Dto1::class, 'json', [
    AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false,
]);

ensureInitialized($dto); // <-- throws exception

However, I would much rather avoid having to check every deserialized DTO. I prefer the next solution.

Solution 2

Since you mentioned you were using PHP 8.1, we can use constructor property promotion and readonly properties for our DTOs.

class Dto2 {
    public function __construct(
        public readonly string $foo,
    ) {}
}

We can still deserialize our JSON like normal:

$json = '{"foo": "bar"}';
$dto = $serializer->deserialize($json, Dto2::class, 'json', [
    AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false,
]);

var_dump($dto->foo); // string(3) "bar"

But if the legacy system attempts to trick us again:

$json = '{}';
$dto = $serializer->deserialize($json, Dto2::class, 'json', [
    AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false,
]);
// ^ Will throw: Uncaught Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException: Cannot create an instance of "B" from serialized data because its constructor requires parameter "foo" to be present

You can now simply catch this exception and return a 4XX error as appropriate.

Upvotes: 1

Related Questions