The Serializer component relies on the TypeInfo component to know the type of each property it denormalizes. Symfony 8.2 makes both components understand more of the types you already write in your PHPDoc, such as generics and arrays of union types, and makes them stricter about the data they accept.

Generic Types in Denormalization

Yonel Ceruto
Contributed by Yonel Ceruto in #66005

Generic classes are a common way to reuse the same structure for different contents, like a paginated result of products or users. Normalization already worked with them, but when denormalizing, the Serializer ignored the template types. A @var T property was denormalized as its bound, or kept as a plain array when the template had no bound.

In Symfony 8.2, the Serializer resolves template types from the generic type declared on the property that holds the object:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Product
{
    public string $name = '';
}

/**
 * @template T
 */
class Page
{
    /** @var list<T> */
    public array $items = [];
    public int $total = 0;
}

class ProductCatalog
{
    /** @var Page<Product> */
    public Page $products;
}

$json = '{"products":{"items":[{"name":"Keyboard"}],"total":1}}';

$catalog = $serializer->deserialize($json, ProductCatalog::class, 'json');
// $catalog->products is a Page
// $catalog->products->items[0] is a Product

The Serializer reads Page<Product> from ProductCatalog::$products, resolves T to Product, and uses that type when denormalizing Page::$items. This also works with nullable and nested generics, several templates, union types, constructor arguments and collections of generics like list<Page<Product>>.

Declare the concrete template types on the property that holds the generic object. The class name passed to denormalize() doesn't carry that information.

This requires PropertyInfo's PhpStanExtractor and the phpstan/phpdoc-parser package.

Arrays of Union Types

Pascal CESCON
Contributed by Pascal CESCON in #63811

An order can contain both card payments and bank transfers. Previously, the Serializer could denormalize a single value typed as CardPayment|BankTransfer, but the same union inside a collection left its elements as raw arrays.

Symfony 8.2 also denormalizes the elements of list<CardPayment|BankTransfer>:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class CardPayment
{
    public function __construct(public readonly string $cardNumber)
    {
    }
}

class BankTransfer
{
    public function __construct(public readonly string $iban)
    {
    }
}

class Order
{
    /** @param list<CardPayment|BankTransfer> $payments */
    public function __construct(public readonly array $payments = [])
    {
    }
}

$order = $serializer->denormalize([
    'payments' => [['cardNumber' => '4242...'], ['iban' => 'FR76...']],
], Order::class);
// $order->payments[0] is a CardPayment
// $order->payments[1] is a BankTransfer

This is a behavior change: an element that matches no member of the union, or a key that doesn't match the key type, now throws an exception instead of being returned as is.

List Properties Must Contain Lists

Hubert Lenoir
Contributed by Hubert Lenoir in #63971

A list<string> has consecutive integer keys starting at zero but an array<int, string> doesn't make that promise. Until now, the Serializer treated both types the same way, so it accepted a JSON object in a list property:

1
2
3
4
5
6
7
8
class Article
{
    /** @var list<string> */
    public array $tags = [];
}

$article = $serializer->deserialize('{"tags":{"5":"foo","1":"bar"}}', Article::class, 'json');
// $article->tags = [5 => 'foo', 1 => 'bar']

In Symfony 8.2, this still works but triggers a deprecation. In Symfony 9.0, it will throw a NotNormalizableValueException. Valid lists and properties typed with other array types are not affected.

If the keys carry meaning, declare an array type that allows them. If the property represents a list, send a JSON array:

1
{"tags": ["foo", "bar"]}

Better Template Support in TypeInfo

George Steel Mathias Arlaud
Contributed by George Steel and Mathias Arlaud in #64130 and #66361

TypeInfo now supports @template-covariant and its @phpstan- and @psalm- prefixed variants, so classes that declare covariant templates are resolved like any other generic class.

Resolving Page<Product> means replacing template types inside a larger type: list<T> must become list<Product>. The new Type::map() method provides that traversal. Its callback visits inner types before the types that contain them:

1
2
3
4
5
6
7
8
use Symfony\Component\TypeInfo\Type;
use Symfony\Component\TypeInfo\Type\TemplateType;

// $templates = ['T' => Type::object(Product::class)]
$type = $type->map(static fn (Type $t): Type => $t instanceof TemplateType
    ? ($templates[$t->getName()] ?? $t)
    : $t
);

Only the parts that change are rebuilt, so a callback that returns every type unchanged gives you back the same instance.

Finding Out Why a Value Doesn't Match a Type

Nicolas Grekas
Contributed by Nicolas Grekas in #66671

Type::accepts() answers one question: does this value match the type? When the answer is false, you still need to find the offending value. Symfony 8.2 adds Type::getMismatches() to report those failures with their paths and expected types:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use Symfony\Component\TypeInfo\TypeResolver\TypeResolver;

$type = TypeResolver::create()->resolve('list<array{id: string}>');

$payload = [
    ['id' => 123],
    [],
];

foreach ($type->getMismatches($payload) as $mismatch) {
    $logger->warning(sprintf('%s: expected "%s", got "%s"', $mismatch->path, $mismatch->expectedType, $mismatch->actualType ?? 'missing'));
    // [0][id]: expected "string", got "int"
    // [1][id]: expected "string", got "missing"
}

Each TypeMismatch object contains the property path of the value, the expected type and the actual type of the value (null when a key or property is missing). Unexpected keys or properties are reported with never as their expected type. Since this method does more work than accepts(), call it only after accepts() returned false.

Published in #Living on the edge