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