2

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.

  • My 2 internal systems communicate via internal Redis streams.
  • One is a legacy code (php56-based), and the other one is php81-based
  • Since the communication is internal, I totally ignored the validation aspect
  • During the testing, the legacy system mistakenly sent a malformed object (did not contain any properties)
  • The deserializer on the other side did the job but got the totally uninitialized object and my Redis consumer started spinning in circles attempting to process it
  • After reaching the failed number of attempts it tried to push it to the failed queue, but it could not due to the serialization of uninitialized properties.
  • My Redis consumer got "stuck" at that very message forever.
5
  • 2
    So is the field required or not? Your question implies that you want to have it both ways, which is not going to happen. If it's optional, then Person::getDateOfBirth() should either return a date, or throw an exception. Commented Oct 7, 2022 at 23:06
  • All of the properties should be required. But deserialization would leave some properties to uninitialized state and when some code does invokes getDateOfBirth(), my code would crash with TypeError Commented Oct 7, 2022 at 23:10
  • 1
    Ok then, in what way would you prefer your code to crash? Trying to catch your deserialization errors this late in the execution is not particularly productive, IMHO. My first suggestion would be "fix the deserialization", second would be "check object integrity after serialization, and throw an appropriate exception", and third is a repeat of "make your getter throw an exception if its value is not valid". What deserializer even allows this? Are you abusing the built-in PHP serialization functions? Commented Oct 7, 2022 at 23:19
  • Thanks for the ideas @Sammitch. Nope, I am deserializing the data via Symfony/serializer. I like the idea "check object integrity after serialization", after all, that makes a lot of sense in general, but, AFAIK, no existing validator covers the isset($property). I guess I would have to write it myself. Commented Oct 8, 2022 at 9:26
  • Updated the question with the problem explained a bit further. @Sammitch I guess, your ideas are well worth an accepted answer. Can you please post them? Commented Oct 8, 2022 at 9:37

1 Answer 1

2

(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.

Sign up to request clarification or add additional context in comments.

3 Comments

I actually wasn't aware that I could use Serializer with objects that had a constructor. I guess I misread the docs section symfony.com/doc/current/components/…. This looks very interesting, let me try it and get back :)
@JovanPerovic Yes I also read that section, and it confused me too. For some reason, I still tried it and it just... worked
I concurred that the promoted property way was indeed what I needed. In addition, if somebody really doesn't want to use them, then applying #[NotNull] (or @NotNull) to all attributes (event is not nullable) will detect missing property and give a violation BEFORE you attempt to access the property itself. Either way, strict validation is a must. Thanks Pieter! :)

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.