Documentation menu
On this page
Components · v1.x
Serializer
The Serializer component turns objects into JSON, XML, CSV or YAML and back: DTOs, dates, enums and ORM entities (lazy relations, proxies, circular references).
It maps request bodies and query strings to typed controller arguments (#[MapRequestPayload], #[MapQueryString]) with validation, and powers json() in controllers.
No external library is used; the XML format requires ext-dom.
Quick start
use NeoPHP\Component\Serializer\Attribute\Groups;
use NeoPHP\Component\Serializer\Attribute\SerializedName;
class ProductOutput
{
public function __construct(
#[Groups(['read'])] public int $id,
#[Groups(['read', 'write'])] #[SerializedName('product_name')] public string $name,
#[Groups(['read'])] public ?DateTimeImmutable $createdAt = null,
) {
}
}
use NeoPHP\Component\Serializer\Contract\SerializerInterface;
class ProductExporter
{
public function __construct(protected SerializerInterface $serializer)
{
}
public function export(array $products): string
{
return $this->serializer->serialize($products, 'json', ['groups' => ['read']]);
}
public function import(string $json): array
{
return $this->serializer->deserialize($json, ProductOutput::class . '[]', 'json');
}
}
The service is SerializerInterface (aliases: SerializerManager, serializer). Outside of the kernel, SerializerManager::create($config) builds a serializer with the default normalizers and encoders.
Configuration
config/framework/serializer.yaml (key framework.serializer, every option is optional):
default_format: json
datetime_format: 'Y-m-d\TH:i:sP'
circular_reference_limit: 1
max_depth: 10
enable_max_depth: true
skip_null_values: false
allow_extra_attributes: true
name_converter: null
normalizers: []
| Option | Default | Description |
|---|---|---|
default_format |
json |
format returned by getDefaultFormat() |
datetime_format |
Y-m-d\TH:i:sP |
format of dates (RFC 3339) |
circular_reference_limit |
1 |
how many times an object can appear in its own branch before the circular reference handler is called |
max_depth |
10 |
maximum object nesting (safety limit, 0 disables it) |
enable_max_depth |
true |
reads the #[MaxDepth] attributes |
skip_null_values |
false |
omits null values when normalizing |
allow_extra_attributes |
true |
false throws an ExtraAttributesException on unknown input keys |
name_converter |
null |
snake_case, camel_case, null or a class implementing NameConverterInterface |
normalizers |
[] |
extra normalizers: [App\Serializer\MoneyNormalizer] or [{ class: App\Serializer\MoneyNormalizer, priority: 10 }] |
default_context |
[] |
any context option applied to every call |
Serializing
serialize() normalizes the data (objects to arrays and scalars) then encodes it:
$json = $serializer->serialize($order, 'json');
$xml = $serializer->serialize($order, 'xml', ['xml_root_node_name' => 'order']);
$array = $serializer->normalize($order, 'json', ['groups' => ['read']]);
An object is normalized from:
- its public properties;
- its getters
getX(),isX()andhasX()without required argument (the attribute isx), also for private properties; - the attributes of the properties and getters (
#[Groups],#[SerializedName],#[Ignore]...).
Arrays are normalized recursively (keys kept), Traversable objects (collections) become arrays (lists when the keys are integers), stdClass and ArrayObject become arrays, JsonSerializable objects are normalized from jsonSerialize(), dates become strings and enums their value.
Deserializing
deserialize() decodes the string then denormalizes it into the given type:
$order = $serializer->deserialize($json, Order::class, 'json');
$items = $serializer->deserialize($json, 'App\Dto\Item[]', 'json');
$count = $serializer->denormalize('42', 'int', 'xml');
| Type | Result |
|---|---|
App\Dto\Order |
an object |
App\Dto\Item[] |
a list of objects (keys kept) |
int, float, string, bool, array, mixed, ?int... |
a scalar, checked |
DateTimeImmutable, DateTime, DateTimeInterface |
a date |
| an enum | a case |
An object is built:
- with its constructor: each parameter is read from the input by name (serialized name), else its default value, else
nullwhen nullable; missing required parameters throw aMissingConstructorArgumentsException; - then the other input keys are written with the setters
setX()or the public properties; - unknown keys are ignored (or rejected with
allow_extra_attributes: false).
The types of the constructor parameters, setters and properties drive the recursive denormalization: nested objects, nullable types, union types (a value that already matches a member is kept, else each member is tried in order), and arrays of objects with #[Type('App\Dto\Item[]')]. An array without #[Type] is kept as is.
class Order
{
public function __construct(
public int $id,
public Customer $customer,
#[Type('App\Dto\Item[]')] public array $items = [],
public ?DateTimeImmutable $paidAt = null,
public Status $status = Status::Pending,
) {
}
}
To update an existing object instead of creating one:
$serializer->deserialize($json, Order::class, 'json', ['object_to_populate' => $order]);
Type errors throw a NotNormalizableValueException (status 422) with the path of the value (items[1].qty). JSON is strict ("3" is not an int); XML, CSV, form and query string data is read leniently ("3" becomes 3, "1"/"true" become true, an empty string becomes null for a nullable non-string type). Set disable_type_enforcement: true in the context to be lenient with other formats.
Formats
| Format | Encoder | Notes |
|---|---|---|
json |
JsonEncoder |
json_encode_options (default JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION), json_decode_options (default JSON_BIGINT_AS_STRING) |
xml |
XmlEncoder |
requires ext-dom; xml_root_node_name (default response), xml_encoding (default UTF-8), xml_format_output |
csv |
CsvEncoder |
csv_delimiter (,), csv_enclosure ("), csv_headers (forced order), csv_key_separator (.) |
yaml, yml |
YamlEncoder |
yaml_inline (level from which arrays are written inline, default 4), yaml_indent (default 2); decoded with the Yaml package |
JSON:
$serializer->serialize($data, 'json', ['json_encode_options' => JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES]);
XML:
- lists repeat the element:
['items' => [a, b]]gives<items>a</items><items>b</items>, a top-level list gives<item key="0">...</item>; - keys starting with
@are attributes, the key#is the text of the element; true/falseare written1/0,nullis an empty element;- on decoding, the root element is removed, every value is a string (read leniently), a single repeated element is not a list:
X[]accepts it and wraps it; DOCTYPEdeclarations are rejected and the network is disabled (no XXE).
$serializer->serialize(['@id' => 5, 'name' => 'Neo'], 'xml', ['xml_root_node_name' => 'user']);
<?xml version="1.0" encoding="UTF-8"?>
<user id="5"><name>Neo</name></user>
CSV:
- a list of rows (a single object is one row), nested arrays are flattened (
author.name,items.0.qty); - on decoding, the first line is the header, keys are unflattened and a list of rows is returned;
deserialize()into a class (notX[]) takes the single row; - every value is a string (read leniently).
YAML:
$serializer->serialize($config, 'yaml', ['yaml_inline' => 2]);
Strings are quoted when needed (JSON-style double quotes), null, booleans, integers and floats (1.0) are written as YAML scalars.
Context options
The keys are available as constants of NeoPHP\Component\Serializer\Contract\AbstractSerializer.
| Key | Constant | Description |
|---|---|---|
groups |
GROUPS |
serialize / deserialize only the attributes of these groups ('*': all) |
attributes |
ATTRIBUTES |
whitelist, nested: ['id', 'author' => ['name']] |
ignored_attributes |
IGNORED_ATTRIBUTES |
property names to skip |
enable_max_depth |
ENABLE_MAX_DEPTH |
reads #[MaxDepth] |
max_depth |
MAX_DEPTH |
maximum nesting |
datetime_format |
DATETIME_FORMAT |
date format |
datetime_timezone |
DATETIME_TIMEZONE |
converts dates to this time zone when normalizing, and parses dates in it |
skip_null_values |
SKIP_NULL_VALUES |
omits null values |
object_to_populate |
OBJECT_TO_POPULATE |
existing object to update (top level) |
circular_reference_handler |
CIRCULAR_REFERENCE_HANDLER |
fn (object $object, ?string $format, array $context): mixed |
circular_reference_limit |
CIRCULAR_REFERENCE_LIMIT |
see Circular references |
allow_extra_attributes |
ALLOW_EXTRA_ATTRIBUTES |
false: unknown keys throw |
disable_type_enforcement |
DISABLE_TYPE_ENFORCEMENT |
lenient scalar casts for every format |
json_encode_options / json_decode_options |
JSON_ENCODE_OPTIONS / JSON_DECODE_OPTIONS |
flags |
xml_root_node_name, xml_encoding, xml_format_output |
XML_* |
XML options |
csv_delimiter, csv_enclosure, csv_headers, csv_key_separator |
CSV_* |
CSV options |
yaml_inline, yaml_indent |
YAML_INLINE, YAML_INDENT |
YAML options |
The context given to a call is merged over the configuration (default_context and the options of serializer.yaml).
Attributes
NeoPHP\Component\Serializer\Attribute\*, on properties, getters and constructor parameters:
| Attribute | Description |
|---|---|
#[Groups(['read', 'write'])] |
groups of the attribute; on the class: default groups of the attributes without #[Groups] |
#[SerializedName('user_name')] |
name in the serialized data (wins over the name converter) |
#[Ignore] |
never serialized nor deserialized |
#[MaxDepth(1)] |
how many times the attribute is followed in a branch (with enable_max_depth) |
#[Context(normalization: [...], denormalization: [...], context: [...], groups: [...])] |
context options for this attribute only, optionally for some groups; repeatable |
#[Type('App\Dto\Item[]')] |
type used to deserialize the attribute (element type of arrays) |
class Event
{
#[Context(normalization: ['datetime_format' => 'Y-m-d'], denormalization: ['datetime_format' => 'Y-m-d'])]
public ?DateTimeImmutable $day = null;
#[Ignore]
public string $internalNote = '';
#[Type('App\Dto\Speaker[]')]
public array $speakers = [];
}
Groups
#[Groups(['read'])]
class Article
{
public int $id = 0;
#[Groups(['read', 'write'])]
public string $title = '';
#[Groups(['admin'])]
public ?string $notes = null;
}
$serializer->normalize($article, 'json', ['groups' => ['read']]);
$serializer->denormalize($data, Article::class, 'json', ['groups' => ['write']]);
- Without
groupsin the context, every attribute is used. - With
groups, only the attributes of at least one of the groups are used; the attributes without group are skipped. - On deserialization, the keys of the other attributes are ignored (or rejected with
allow_extra_attributes: false).
Name conversion
name_converter: snake_case converts createdAt into created_at (and back when deserializing). camel_case does the opposite (PHP properties in snake case, data in camel case). #[SerializedName] always wins. A custom converter implements NeoPHP\Component\Serializer\Contract\NameConverterInterface (normalize(string $propertyName): string, denormalize(string $propertyName): string).
Dates and enums
DateTimeInterfacevalues are formatted withdatetime_format; on deserialization the format is tried first (time fields reset:Y-m-dgives midnight), then any format understood bynew DateTimeImmutable(); an integer is a timestamp.DateTimeInterfacetypes give aDateTimeImmutable.- A backed enum is written with its value (
tryFrom()on the way back, the allowed values are listed in the error), a pure enum with its case name.
Circular references and max depth
An object that appears again in its own branch (a post, its comments, their post...) is a circular reference. With the default circular_reference_limit: 1:
- for an entity, its identifier is written;
- for another object, the
circular_reference_handlerof the context is called, or aCircularReferenceExceptionis thrown.
$serializer->serialize($category, 'json', [
'circular_reference_handler' => fn (object $object): string => $object->getName(),
]);
#[MaxDepth(n)] limits how many times an attribute is followed in a branch (Category::$parent with #[MaxDepth(1)]: the parent is written, not the parent of the parent). Beyond the limit, the attribute is skipped (for an entity relation: its identifier).
max_depth (default 10) is a safety limit on the object nesting: beyond it, an entity is written as its identifier and another object throws a NotNormalizableValueException.
Entities (ORM)
Entities (#[ORM\Entity]) are handled by EntityNormalizer when the ORM package is available:
- proxies are initialized and read with the metadata of the real class (
ClassResolver::getRealClass(), never$entity::class); - a to-one relation is written as a nested object; it is written as its identifier when the related entity has no attribute in the requested
groups, when a circular reference is found or when the max depth is reached; - a to-many collection is written as a list (of objects, or of identifiers in the same cases);
- a circular reference is written as the identifier.
#[ORM\Entity]
class Post
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
#[Groups(['read'])]
private ?int $id = null;
#[ORM\Column]
#[Groups(['read', 'write'])]
private ?string $title = null;
#[ORM\ManyToOne(User::class)]
#[Groups(['read', 'write'])]
private ?User $author = null;
#[ORM\ManyToMany(Tag::class)]
#[Groups(['read', 'write'])]
private CollectionInterface $tags;
}
$this->json($post, 200, [], ['groups' => ['read']]);
{"id": 1, "title": "Hello", "author": {"id": 3, "displayName": "Ada"}, "tags": [1, 4]}
(User has attributes in the read group, Tag has none.)
Deserializing into an entity:
object_to_populateupdates a managed entity (callflush()afterwards);- a relation given as an identifier (
"author": 3) or as{"id": 3}is loaded withfind()of the ORM; an array without identifier creates a new entity; - a to-many relation takes a list of identifiers or objects: the collection is updated in place (elements removed, added);
- an unknown identifier throws a
NotNormalizableValueException(422):The Category "999" at "category" does not exist.
$post = $this->deserialize($request->getContent(), Post::class, 'json', [
'groups' => ['write'],
'object_to_populate' => $post,
]);
$this->getOrm()->flush();
Request payloads
#[MapRequestPayload] maps the request body to a typed controller argument, #[MapQueryString] maps the query string:
use NeoPHP\Component\Serializer\Attribute\MapQueryString;
use NeoPHP\Component\Serializer\Attribute\MapRequestPayload;
use NeoPHP\Component\Serializer\Attribute\Type;
#[Route('/api/products', methods: ['POST'])]
public function create(#[MapRequestPayload] ProductInput $input): JsonResponse
{
}
#[Route('/api/products/import', methods: ['POST'])]
public function import(#[MapRequestPayload(acceptFormats: ['json'])] #[Type('App\Dto\ProductInput[]')] array $inputs): JsonResponse
{
}
#[Route('/api/products', methods: ['GET'])]
public function search(#[MapQueryString] ?ProductSearch $search): JsonResponse
{
}
| Option | MapRequestPayload |
MapQueryString |
Description |
|---|---|---|---|
groups |
yes | yes | serialization groups used to deserialize |
validationGroups |
yes | yes | validation groups (default ['Default']) |
format |
yes | forces the format instead of reading Content-Type |
|
acceptFormats |
yes | allowed formats, e.g. ['json'] |
|
context |
yes | yes | extra context options |
validate |
yes | yes | false skips the validation |
- The format comes from
Content-Type:application/jsonand*+json(json),application/xml,text/xmland*+xml(xml),text/csv(csv),application/yaml,application/x-yaml,text/yaml(yaml),application/x-www-form-urlencodedandmultipart/form-data(form: the request fields and files). - The body is decoded and denormalized into the type of the argument (a class, or an array with
#[Type('X[]')]). - The object is validated with the Validator component (
#[Assert\...]constraints).
| Case | Response |
|---|---|
| valid data | the argument is the object |
| empty body / empty query string | null when the argument is nullable, else its default value, else the object is built from [] (usually a 422) |
unsupported or missing Content-Type, format not in acceptFormats |
415 HttpException |
| malformed body (invalid JSON, XML...) | 400 BadRequestHttpException |
type error, missing constructor argument, extra attribute (allow_extra_attributes: false), unknown entity id |
422 ValidationFailedException with the path of the value |
| validation errors | 422 ValidationFailedException |
A ValidationFailedException is rendered as JSON by the Validator component when the request is JSON or asks for JSON (Accept: application/json):
{"error": {"status": 422, "message": "The data is not valid: 2 violation(s).", "violations": {"title": ["This value is too short. It should have 3 character(s) or more."], "quantity": ["This value should be positive."]}}}
Lists are validated item by item (paths [1].title). The resolution is done by NeoPHP\Component\Serializer\ArgumentResolver\RequestPayloadResolver, an argument resolver of the Controller component (see the Controller documentation).
Controllers
The trait SerializerController of AbstractController provides:
$yaml = $this->serialize($report, 'yaml');
$input = $this->deserialize($request->getContent(), ProductInput::class, 'json');
json() (Http component) normalizes the data with the Serializer when it contains objects or when a context is given:
return $this->json($post, 200, [], ['groups' => ['read']]);
return $this->json(['items' => $products, 'total' => $total]);
Custom normalizers
A normalizer implements NormalizerInterface (normalize(), supportsNormalization()) and / or DenormalizerInterface (denormalize(), supportsDenormalization()). To call the serializer for nested values, implement SerializerAwareInterface (the trait NeoPHP\Component\Serializer\Normalizer\SerializerAwareTrait provides setSerializer() and serializer()).
<?php
declare(strict_types=1);
namespace App\Serializer;
use App\Model\Money;
use NeoPHP\Component\Serializer\Attribute\AsNormalizer;
use NeoPHP\Component\Serializer\Contract\DenormalizerInterface;
use NeoPHP\Component\Serializer\Contract\NormalizerInterface;
#[AsNormalizer(priority: 10)]
class MoneyNormalizer implements NormalizerInterface, DenormalizerInterface
{
public function normalize(mixed $data, ?string $format = null, array $context = []): mixed
{
return sprintf('%.2f %s', $data->cents / 100, $data->currency);
}
public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
{
return $data instanceof Money;
}
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed
{
[$amount, $currency] = explode(' ', (string) $data);
return new Money((int) round((float) $amount * 100), $currency);
}
public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
{
return $type === Money::class && is_string($data);
}
}
#[AsNormalizer(priority: 0)]classes ofsrc/are discovered (cached invar/cache/serializer/, refreshed in debug), like#[AsListener]listeners.- Or list them in
config/framework/serializer.yaml(normalizers: [App\Serializer\MoneyNormalizer], or{ class: ..., priority: 10 }). - They are built by the container (constructor autowiring). The highest priority is asked first; the built-in normalizers have negative priorities:
| Normalizer | Priority | Handles |
|---|---|---|
DateTimeNormalizer |
-800 | DateTimeInterface |
BackedEnumNormalizer |
-800 | backed and pure enums |
JsonSerializableNormalizer |
-900 | JsonSerializable (normalization only) |
ArrayDenormalizer |
-900 | X[] types (denormalization only) |
EntityNormalizer |
-950 | ORM entities |
ObjectNormalizer |
-1000 | any other object |
In code: $serializer->addNormalizer($normalizer, $priority) and $serializer->addEncoder($encoder) (an EncoderInterface / DecoderInterface; the last added encoder wins for its format).
Console
php bin/neo serializer:debug "App\Entity\Post"
Displays, for each attribute of the class: name, serialized name, groups, type, access (r readable, w writable), ignored, max depth and context.
SerializerInterface reference
NeoPHP\Component\Serializer\Contract\SerializerInterface:
| Method | Description |
|---|---|
serialize(mixed $data, string $format = 'json', array $context = []): string |
normalize and encode |
deserialize(string $data, string $type, string $format = 'json', array $context = []): mixed |
decode and denormalize |
normalize(mixed $data, ?string $format = null, array $context = []): mixed |
to arrays / scalars / null |
denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed |
from arrays / scalars |
encode(mixed $data, string $format, array $context = []): string |
encode normalized data |
decode(string $data, string $format, array $context = []): mixed |
decode a string |
supportsFormat(string $format): bool |
an encoder or decoder handles the format |
supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool |
a normalizer handles the value |
supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool |
a denormalizer handles the type |
getDefaultContext(): array |
the configured context |
SerializerManager also provides create(array $config = [], ?Closure $orm = null, ?YamlInterface $yaml = null), addNormalizer(), addEncoder(), getNormalizers(), getEncoders(), getFormats(), getDefaultFormat(), getMetadataFactory() and getNameConverter().
Exceptions (NeoPHP\Component\Serializer\Exception\*, all extend SerializerException, a FrameworkException):
| Exception | Status | Thrown when |
|---|---|---|
UnsupportedFormatException |
500 | no encoder / decoder for the format |
NotEncodableValueException |
500 | the data cannot be encoded |
UnexpectedValueException |
400 | the input cannot be decoded (malformed) |
NotNormalizableValueException |
422 | a value has a wrong type, an enum value or a date is invalid, an entity does not exist (getPath()) |
MissingConstructorArgumentsException |
422 | required constructor parameters are missing (getMissingArguments()) |
ExtraAttributesException |
422 | unknown keys with allow_extra_attributes: false (getExtraAttributes()) |
CircularReferenceException |
500 | circular reference without handler (non-entity objects) |
Changelog
- v1.23.0 — Serializer component: JSON / XML / CSV / YAML encoders, object, date, enum,
JsonSerializable, array and ORM entity normalizers, attributes#[Groups],#[SerializedName],#[Ignore],#[MaxDepth],#[Context],#[Type], name converters, circular reference and max depth handling,#[MapRequestPayload]/#[MapQueryString]controller arguments with validation (400 / 415 / 422), custom normalizers (#[AsNormalizer]or configuration),serialize()/deserialize()in controllers,json()with a serializer context,serializer:debug.