Constructor promotion in serializable objects
Tags: php
Published: 2023-06-16 11:42:27

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](https://www.php.net/manual/en/language.oop5.decon.php#language.oop5.decon.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:

```php
final class Client
{
    public function __construct(
        private string $id,
    ) {
    }
    
    public function getId(): string
    {
        return $this->id;
    }
}
```

No, what happens when we serialize this object?

```php
file_put_contents('client.serialized', serialize(new Client('abc')));
```

This will output the following string:

```
O:6:"Client":1:{s:10:"Clientid";s:3:"abc";}
```

**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:

```php
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;
    }
}
```

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

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

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}
```

## 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:

```php
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;
    }
}
```

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:

```php
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;
        }
    }
}
```

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`](https://www.php.net/manual/en/class.serializable.php) 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.