DEV Community

Timo Schinkel
Timo Schinkel

Posted on

Constructor promotion in serializable objects

I am not one to easily get excited about new syntactical sugar in languages. As such I was also sceptical about the introduction of constructor promotion in PHP 8.0. But I have grown to really like it. It is a great way to shorten the definition of you objects, and I actually miss it now when I'm working in C# or TypeScript. I use constructor promotion in both services and in value objects, but recently we suffered an outage caused by applying constructor on an object that was serialized for caching.

NB Serialization does not necessarily have to happen explicitly. Session handlers and cache handlers often serialize and deserialize objects implicitly.

Our object

In order to demonstrate the issue let's assume an object:

final class Client
{
    public function __construct(
        private string $id,
    ) {
    }

    public function getId(): string
    {
        return $this->id;
    }
}
Enter fullscreen mode Exit fullscreen mode

No, what happens when we serialize this object?

file_put_contents('client.serialized', serialize(new Client('abc')));
Enter fullscreen mode Exit fullscreen mode

This will output the following string:

O:6:"Client":1:{s:10:"Clientid";s:3:"abc";}
Enter fullscreen mode Exit fullscreen mode

NB Clientid is not a string with a length of 10. Because Client::$id is a private property the property in the string representation is surrounded by special byte characters.

A new property appears

As time goes by we find that we want to add another property to our object, so we update the definition:

final class Client
{
    public function __construct(
        private string $id,
        private string $name = '',
    ) {
    }

    public function getId(): string
    {
        return $this->id;
    }

    public function getName(): string
    {
        return $this->name;
    }
}
Enter fullscreen mode Exit fullscreen mode

After deployment the codebase tries to deserialize the previous data, and we

$client = unserialize('O:6:"Client":1:{s:10:"Clientid";s:3:"abc";}');
$client->getName();
Enter fullscreen mode Exit fullscreen mode

This will break on the line where we call Client::getName():

Fatal error: Uncaught Error: Typed property Client::$name must not be accessed before initialization in {filename}
Enter fullscreen mode Exit fullscreen mode

What's happening

When unserializing PHP interprets the serialized string and tries to rebuild the object. The serialized string contains the name of the class and all the properties. This includes private and protected properties. The rebuilding of the object is not done via the constructor, but the values are set directly on the object instance. Because the constructor is not called, the new property name is not defined. This is the cause of the error.

How to prevent

There are a number of ways to prevent this.

Don't use constructor promotion

Easiest solution is to not use constructor promotion for objects that can be serialized. That will add some code, but it will prevent these errors:

final class Client
{
    private string $id;

    private string $name = '';

    public function __construct(
        string $id,
        string $name = '',
    ) {
        $this->id = $id;
        $this->name = $name;
    }

    public function getId(): string
    {
        return $this->id;
    }

    public function getName(): string
    {
        return $this->name;
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example I opted to declare all properties for consistency reasons. Keep in mind that this can potentially still cause issues if you add properties without a default value. Those will result in the same error that the property must not be accessed before initialization.

Use the magic methods __serialize() and __unserialize()

By using the __serialize() and __unserialize() you can steer how your object is serialized and unserialized:

class Client 
{
    public function __serialize(): array
    {
        return get_object_vars($this);
    }

    public function __unserialize(array $data): void
    {
        // instantiate new properties:
        $this->name = '';

        foreach ($data as $key => $value) {
            $this->{$key} = $value;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This adds more difficulties as you still need to update your __unserialize() method body with every new property you add.

NB PHP has the Serializable interface, but that has been deprecated in favor of __serialize() and __unserialize(), so I will not mention that as a solution.

Run your own serialization

You can of course build your own serialization mechanism. You can opt to export to JSON, but you will have to handle your unserialization yourself as well. In which case this whole article might not be relevant to you.

tl/dr;

Object serialization, although often used, has some pitfalls that you as a developer should be aware of, but that are often overlooked. One of those pitfalls is constructor promotion. I found that the easiest way around that is to not use constructor promotion for properties that you add while you may still have string representations that were created before the property was added.

Top comments (1)

Collapse
 
sammousa profile image
Sam

A bit late to the party; I just ran into this issue as well.
The biggest concern for me is the silent failure part... the unserialization will happily work only to crash my code 30 function calls later.

This trait tries its best to figure out proper values for the uninitialized properties and throws an error if it cannot resolve all uninitialized values.
gist.github.com/SamMousa/4df8735a5...