Crell/Serde 1.5 released

Submitted by Larry on 15 July 2025 - 1:01pm

It's amazing what you can do when someone is willing to pay for the time!

There have been two new releases of Crell/Serde recently, leading to the latest, Serde 1.5. This is an important release, not because of how much is in it but what major things are in it.

That's right, Serde now has support for union, intersection, and compound types! And it includes "array serialized" objects, too.

mixed fields

A key design feature of Serde is that it is driven by the PHP type definitions of the class being serialized/deserialized. That works reasonably well most of the time, and is very efficient, but can be a problem when a type is mixed. When serializing, we can just ignore the type of the property and use the type of the value. Easy enough. When deserializing, though, what do you do? In order to support non-normalized formats, like streaming formats, the incoming data is opaque.

The solution is to allow Deformatters to declare, via an interface, that the can derive the type of the value for you. Not all Deformatters can do that, depending on the format, but all of the array-oriented Deformatters (json, yaml, toml, array) are able to, and that's the lion's share of format targets. Then when deserializing, if we hit a mixed field, Serde delegates to the Deformatter to tell it what the type is. Nice.

Sometimes that's not enough, though. Especially if you're trying to deserialize into a typed object, just knowing that the incoming data is array-ish doesn't help. Serde 1.4 therefore introduced a new type field for mixed values: #[MixedField]. MixedField takes one argument, $suggestedType, which is the object type that should be used for deserialization. If the Deserializer says the data is an array, then it will be upcast to the specified object type.

class Message
{
    public string $message;
    #[MixedField(Point::class)]
    public mixed $result;
}

When serializing, the $result field will serialize as whatever value it happens to be. When deserializing, scalars will be used as is while an array will get converted to a Point class.

Unions and compound types

PHP has supported union types since 8.0, and intersection types since 8.1, and mixing the two since 8.2. But they pose a similar challenge to serialization.

The way Serde 1.5 now handles that is to simply fold compound types down to mixed. As far as Serde is concerned, anything complex is just "mixed," and we just defined above how that should be handled. That's... remarkably easy. Neat.

If the type is a union, specifically, then there's a little more we can do.

First, if a union type doesn't specify a suggestedType but the value is array-ish, it will iterate through the listed types and pick the first class or interface listed. That won't always be correct, but since the most common union type will likely be something like string|array or string|SomeObject, it should be sufficient in most cases. If not, specifying the $suggestedType explicitly is recommended.

Second, a separate #[UnionField] attribute extends MixedField and adds the ability to specify a nested TypeField for each of the types in the list. The most common use for that would be for an array, like so:

class Record
{
    public function __construct(
        #[UnionField('array', [
            'array' => new DictionaryField(Point::class, KeyType::String)]
        )]
        public string|array $values,
    ) {}
}

In this case, if the deserialized value is a string, it gets read as a string. If it's an array, then it will be read as though it were an array field with the specified #[DictionaryField] on it instead. That allows upcasting the array to a list of Point objects (in this case), and validating that the keys are strings.

Improved flattening, now for the top level

Another unrelated but very cool fix is a long-standing bug when flattening array-of-object properties. Previously, their type was not respected. Now it is. What that means in practice is you can now do this:

[
	{x: 1, y: 2},
	{x: 3, y: 4},
]
class PointList
{
    public function __construct(
	    #[SequenceField(arrayType: Point::class)]
	    public array $points,
    ) {}
}

$json = $serde->serialize($pointList, format: 'json');
$serde->deserialize($json, from: 'json', to: PointList::class);

Boom. Instant top-level array. Previously, this behavior was only available when serializing to/from CSV, which had special handling for it. Now it's available to all formats.

New version requirements

Because compound types were only introduced in PHP 8.2, Serde 1.5 now requires PHP 8.2 to run. It will not run on 8.1 anymore. Technically it would have been possible to adjust it in a way that would still run on 8.1, but it was a hassle, and according to the Packagist stats for Crell/Serde the only PHP 8.1 user left is my own CI runner. So, yeah, this shouldn't hurt anyone. :-)

Special thanks

These improvements were sponsored by my employer, MakersHub. Quite simply, we needed them, so I added them. One of the advantages of eating your own dogfood: You have an incentive to make it better.

Is your company using an OSS library? Need improvements made? Sponsor them. Either submit a PR yourself or contract the maintainer to do so, or just hire the maintainer. All of this great free code costs time and money to make. Kudos to those companies that already do sponsor their Open Source tool chain.