Skip to content

Serialization

The serialization system converts PHP objects to JSON and back, driven entirely by PHP attributes on properties. The same #[Json] attribute controls both directions: when serializing (object → JSON) it determines which properties are included and how they are named; when deserializing (JSON → object) it maps incoming keys back to the right properties and handles type coercion automatically.


Making a class serializable

Implement JsonSerializableInterface and use JsonSerializeTrait. The trait provides all the serialization and deserialization methods; the interface enforces the contract.

use Amarant\Framework\Contract\Data\JsonSerializableInterface;
use Amarant\Framework\Serialization\JsonSerializeTrait;
use Amarant\Framework\Attribute\Serialization\Json;

final class ProductModel implements JsonSerializableInterface
{
    use JsonSerializeTrait;

    #[Json]
    public readonly int $id;

    #[Json]
    public readonly string $name;

    #[Json('price_incl_tax')]
    public readonly float $price;
}

Only properties annotated with #[Json] are included in serialization/deserialization. Properties without the attribute are ignored in both directions.

Serialization

$model->toJson();       // → JSON string
$model->toJsonData();   // → PHP array ready for json_encode
$model->toArray();      // alias for toJsonData()

// Static helpers for collections
ProductModel::toJsonList($models);      // → JSON array string
ProductModel::toJsonListData($models);  // → array of PHP arrays

Deserialization

$model = ProductModel::fromJsonString($jsonString);
$model = ProductModel::fromJsonData($assocArray);

// Nested path: extract from $data['response']['product']
$model = ProductModel::fromJsonData($data, ['response', 'product']);

// Collections
$models = ProductModel::listFromJsonString($jsonString);
$models = ProductModel::listFromJsonData($assocArray, 'items');

#[Json] attribute reference

#[Json(
    path: '',               // string or string[] — JSON key(s). Defaults to the property name.
    type: '',               // class-string — element type for arrays or typed collections.
    skipEmpty: false,       // omit the key from output when value is null, '' or [].
    factoryMethod: '',      // method name used to construct a typed collection from an array.
    required: false,        // throw SerializationException if the key is absent during deserialization.
    emptyArrayAsObject: false, // serialize an empty array as {} instead of [].
    leaveUninitializedIfEmpty: false, // leave the property uninitialized (rather than null) when the key is absent.
    jsonString: false,      // the JSON value is itself a JSON-encoded string; decode it before processing.
)]

Custom key name

#[Json('price_incl_tax')]
public readonly float $price;
// serializes as {"price_incl_tax": 9.99}
// deserializes from {"price_incl_tax": 9.99}

Nested path

#[Json(['address', 'city'])]
public readonly string $city;
// deserializes from {"address": {"city": "Zagreb"}}
// serializes as {"address": {"city": "Zagreb"}}

Arrays of objects

Set type to the element class. Elements must also implement JsonSerializableInterface (or be BackedEnum).

#[Json(type: OrderItemModel::class)]
public readonly array $items;

Nullable backed enums

#[Json]
public readonly ?StatusEnum $status;
// uses StatusEnum::tryFrom() when nullable, StatusEnum::from() otherwise

Collections (Traversable)

When the property type is a Traversable subclass, pass type for the element class and optionally factoryMethod for construction:

#[Json(type: TagModel::class, factoryMethod: 'fromArray')]
public readonly TagCollection $tags;

If factoryMethod is a static method, it is called as TagCollection::fromArray($mapped). If it is an instance method, an instance is created without constructor and the method is called on it.


Type coercion rules

During deserialization, the #[Json] attribute resolves the value type automatically:

PHP type Resolution
Scalar (int, string, float, bool) Passed through as-is.
Class implementing JsonSerializableInterface ClassName::fromJsonData($value)
BackedEnum ClassName::from($value) (non-nullable) / ClassName::tryFrom($value) (nullable)
Pure UnitEnum Serialized using ->name; not automatically deserialized.
array with type set to a class array_map(fn($d) => Type::fromJsonData($d), $value)
array with type set to a BackedEnum array_map(fn($d) => Type::from($d), $value)
Traversable subclass with type set Elements mapped then passed to constructor / factoryMethod.

During serialization, nested objects call toJsonData() on themselves if they implement JsonDataWriteInterface, or use their ->name if they are a pure enum.


Serialization groups (#[Context])

Use #[Context] on a property to restrict it to specific normalization or denormalization groups. Properties with no #[Context] are always included.

use Amarant\Framework\Attribute\Serialization\Context;

#[Json]
#[Context(groups: ['detail'], denormalizationGroups: ['write'])]
public readonly string $internalNote;

Pass group names when calling the Serializer service to filter properties:

$serializer->serialize($model, normalizationContext: ['detail']);
$serializer->deserialize($json, MyModel::class, denormalizationContext: ['write']);

Properties whose groups have no intersection with the active context are skipped entirely.


Polymorphic deserialization (#[Discriminator])

Use #[Discriminator] on a base class to select a concrete subclass during deserialization based on a type-discriminator field in the JSON.

use Amarant\Framework\Attribute\Data\Discriminator;

#[Discriminator(
    propertyName: 'type',
    mapping: [
        'physical' => PhysicalProductModel::class,
        'digital'  => DigitalProductModel::class,
    ]
)]
abstract class ProductModel implements JsonSerializableInterface
{
    use JsonSerializeTrait;
}

When deserializing, the value of the type key is looked up in mapping and fromJsonData is delegated to the matched subclass. Throws SerializationException if the discriminator value is absent from the mapping.


Using the Serializer service

Inject SerializerInterface when you need to serialize through the framework's event pipeline (which allows subscribers to modify context and inject callbacks).

use Amarant\Framework\Contract\Serialization\SerializerInterface;

$json = $this->serializer->serialize($model);

$json = $this->serializer->serialize(
    $model,
    normalizationContext: ['detail'],
);

$model = $this->serializer->deserialize(
    $jsonString,
    ProductModel::class,
    denormalizationContext: ['write'],
);

Calling toJson() / fromJsonData() directly on the object bypasses the event pipeline. Use the Serializer service when event subscribers need to participate (e.g. to append extra fields or validate input).


Events

Three events fire during serialization/deserialization, all using EventEnum constants:

Event EventEnum case Fired when
Serialize before EventEnum::SERIALIZATION_SERIALIZE_BEFORE Before json_encode is called. Normalization context and extra context can be modified.
Serialize after EventEnum::SERIALIZATION_SERIALIZE_AFTER After the JSON string is produced. Read-only access to the result.
Deserialize before EventEnum::SERIALIZATION_DESERIALIZE_BEFORE Before fromJsonData is called. Raw input data and denormalization context can be modified.

Adding per-request callbacks from a subscriber

SerializerSerializeBeforeEvent exposes convenience methods to attach inline callbacks that run during toJsonData() on every object in the current serialization call:

use Amarant\Framework\Serialization\Event\SerializerSerializeBeforeEvent;

$event->addJsonSerializationBeforeCallback(function (object $subject): void {
    // called before each object's properties are iterated
});

$event->addJsonSerializationAfterCallback(function (object $subject, array &$result): void {
    // called after each object's toJsonData() returns; $result can be modified
    $result['_extra'] = 'injected';
});

$event->addJsonSerializationNormalizeValueCallback(function (mixed $value): mixed {
    // called for every nested object value that doesn't implement JsonDataWriteInterface
    return $value;
});

SerializerDeserializeBeforeEvent provides the equivalent for deserialization:

use Amarant\Framework\Serialization\Event\SerializerDeserializeBeforeEvent;

$event->addJsonDeserializationBeforeCallback(function (array &$data): void {
    // called before each object's properties are populated
});