jms-serializer/serializer
JMS Serializer is a mature PHP library for converting objects to and from JSON, XML, YAML, and other formats. It offers configurable metadata, annotations or attributes, handlers, and visitors for complex serialization needs.
JMS Serializer is framework-agnostic and ships no Laravel service provider, so the first hour of work is wiring, not learning the API. Do that once, behind a binding, and never construct a Serializer in application code again.
1. Install the canonical package. Confirm the vendor/name before anything else — the scanned identifier jms-serializer/serializer does not match the upstream Composer package, which is jms/serializer:
composer require jms/serializer
Pin a caret constraint, commit the lockfile, and verify the PHP support matrix against your runtime.
2. Bind a singleton. One provider, one factory, one cache path:
// app/Providers/SerializerServiceProvider.php
public function register(): void
{
$this->app->singleton(SerializerInterface::class, function () {
return SerializerBuilder::create()
->setCacheDir(storage_path('framework/serializer'))
->setDebug(config('app.debug'))
->addDefaultHandlers() // dates, arrays, Doctrine collections
->setPropertyNamingStrategy(new CamelCaseNamingStrategy())
->build();
});
$this->app->alias(SerializerInterface::class, 'serializer');
}
3. Where to look first. Four files carry almost everything you need:
SerializerBuilder — the only wiring surface; cache dir, debug, handlers, naming strategy, visitor registration.Context/SerializationContext and DeserializationContext — groups, version, depth limits, circular-reference callbacks, custom per-call handlers.Metadata/Driver/AttributeDriver — attribute-based mapping on DTOs, the modern default. Avoid the docblock-annotation path; it drags in doctrine/annotations, which is on a deprecation track.Handler/SubscribingHandlerInterface — where custom types (money, UUIDs, enums) plug in.4. First use case. A single DTO at a boundary, not an Eloquent model:
#[Groups(['public', 'internal'])]
final class CustomerDTO
{
public int $id;
#[Groups(['public', 'internal'])]
public string $name;
#[Groups(['internal'])]
public string $email;
#[Type(DateTimeImmutable::class)]
#[SerializedName('created_at')]
public DateTimeImmutable $createdAt;
}
Then both directions through the bound service:
$json = $serializer->serialize($dto, 'json', SerializationContext::create()->setGroups(['public']));
$dto = $serializer->deserialize($payload, CustomerDTO::class, 'json',
DeserializationContext::create()->setGroups(['internal']));
If the requirement is only "fluent JSON for a CRUD endpoint," stop here and use Laravel API Resources or spatie/laravel-data instead. JMS earns its place when the same DTO must also emit XML/YAML, when versioned contracts diverge, or when the object graph is deep.
DTOs at the boundary, never Eloquent models. Keep JMS metadata on dedicated DTOs and map to/from Eloquent explicitly. Serializing a model directly invites lazy-loading during graph traversal and produces N+1 queries that only surface under production load. The DTO also becomes the anti-corruption layer between your domain and external partners, which is the architectural payoff.
Groups drive versioning and visibility. This is the feature that pays for the package. One DTO, one mapping, multiple contracts:
$v1 = SerializationContext::create()->setGroups(['v1']);
$v2 = SerializationContext::create()->setGroups(['v2']);
$admin = $v1->setGroups(array_merge(['v1'], ['admin']));
@Since/@Until with setVersion() handle time-based contracts; for API versions, groups applied against a documented set of group names are easier to reason about than version numbers.
Attribute-based metadata, colocated. Map fields on the DTO (@Groups, @SerializedName, @Type, @ReadOnly, @Accessor, @PostDeserialize) rather than in external YAML/XML config. Config-driven mapping is a legitimate escape hatch for third-party classes you cannot annotate, but as the default it separates mapping from the class and makes code review harder.
Custom handlers for value types. Money, enums, and UUIDs rarely serialize cleanly out of the box:
class MoneyHandler implements SubscribingHandlerInterface
{
public static function getSubscribingMethods(): array
{
return [[
'direction' => GraphNavigatorInterface::DIRECTION_SERIALIZATION,
'format' => 'json',
'type' => 'Money',
'method' => 'serializeMoney',
]];
}
public function serializeMoney(JsonSerializationVisitor $visitor, Money $money, array $type, Context $context): array
{
return ['amount' => $money->getAmount(), 'currency' => $money->getCurrency()->getCode()];
}
}
Register handlers centrally in the service provider. Keep a registry array so onboarding engineers can find them without reading the whole DI graph.
Symmetric contracts. Write the DTO so serialization and deserialization round-trip. Then prove it with golden-file tests — serialize a fixture, assert against committed JSON/XML, and deserialize that same fixture back into the DTO. This is the only cheap protection against a renamed property silently breaking a partner's parser.
Deserialize untrusted input behind validation. JMS hydrates typed objects from arbitrary shape; it does not validate. Pair it with a Laravel FormRequest or a validator on the raw payload first, or you will surface TypeError and constructor failures as 500s instead of 422s. Consider @PostDeserialize for normalizing inbound values once the fields are populated.
Keep one serialization convention per boundary. Document the rule (JMS owns external/partner contracts; Resources own internal HTTP; the queue owns its own payload shape) and enforce it. Two competing conventions across a large codebase is the real long-term cost.
Confirm the package identity before adoption. The scanned jms-serializer/serializer, zero stars, zero dependents, does not match upstream jms/serializer. A near-name variant is a textbook dependency-confusion vector. Verify Packagist, the repository URL, and maintainer identity against the upstream project before adding anything to composer.json.
The metadata cache is a deployment artifact. Without a writable cache directory, first request after deploy fails hard — common in read-only or ephemeral container filesystems. Pre-warm in CI:
php artisan serializer:warm # your own command wrapping metadata extraction
Or set setCacheDir() to storage/framework/cache for local/dev and a baked-in image directory for production. setDebug(false) in production; debug mode disables metadata caching and reflection cost climbs sharply under load.
Circular references recurse until memory runs out. Object graphs with back-references (parent→child, user→orders→user) need an explicit strategy: a circular-reference handler that emits an identifier, or a depth limit per call via setCircularReferenceLimit() / setMaxDepthHandler(). Test this with a real graph, not a flat fixture.
XML input is a security boundary. Do not enable libxml default entities. Recent JMS versions default to safe behavior; if anyone adds enableDefaultEntities(), that is a review blocker. Run a security pass on deserialization of externally supplied XML specifically — XXE and entity expansion are the failure modes.
Constructor invariants and typed properties. A DTO with non-nullable typed properties that aren't present in the payload will hydrate in an inconsistent state and explode later, far from the deserialization site. Either initialize defaults, use nullables, or validate the payload shape before handing it to JMS. Prefer readonly DTOs constructed via @PostDeserialize where invariants matter.
Group behavior is not "all by default" once groups are set. Passing a non-empty group list restricts output to matching properties. A field with no @Groups disappears. This is the single most common "why is my field missing" bug; make sure every property carries at least the groups it belongs to, and cover it with the golden-file test.
Dates drift. DateTimeInterface handling respects per-property @Type and format options, but timezone defaults differ depending on how the handler is configured. Pin format and timezone explicitly on any date field that leaves the application, and assert on it in tests.
Debugging a metadata exception is a distinct skill. Errors read like reflection failures but usually mean: cache stale after a property rename, missing @Type for a collection, or a handler not registered for the format. Your runbook should list, in order: clear the metadata cache, confirm the attribute driver is loaded, confirm the handler registry includes the type, re-run the golden-file test.
Extension points are the escape hatch, not the default. Visitors, custom naming strategies, and expression-language conditions are all available; resist using them to solve problems groups and handlers already solve. Every custom visitor is a piece of the package you now maintain, and it narrows the upgrade path.
Budget for the major-version upgrade. Upstream is mature and maintained, but majors have shipped breaking metadata changes. Confining usage to DTOs at the boundary bounds the blast radius to one layer — which is also the strongest argument for keeping Eloquent models out of the mapping entirely. Assign a named owner and plan roughly one upgrade cycle per year.
How can I help you explore Laravel packages today?