jms-serializer/serializer-bundle
JMS Serializer Bundle integrates JMS Serializer with Symfony, adding service configuration, annotation/attribute support, and flexible serialization/deserialization of PHP objects to JSON, XML, and more. It simplifies building and consuming APIs with configurable metadata, handlers, and visitors.
Read the framing first: this package is a Symfony bundle, not a Laravel package. jms-serializer/serializer-bundle exists to wire jms/serializer into Symfony's DI container and kernel. In Laravel you get no auto-configured serializer service, no config tree, and no metadata driver auto-registration. The correct day-to-day adoption is to install the library (jms/serializer) and write a small service provider yourself. Treat the bundle name as a signal that the docs you'll find online (Symfony service config, jms_serializer.yaml) will not apply.
Minimal Laravel setup:
composer require jms/serializer
# do NOT install jms-serializer/serializer-bundle in a Laravel-only app
// app/Providers/SerializerServiceProvider.php
use JMS\Serializer\SerializerBuilder;
use JMS\Serializer\SerializerInterface;
public function register(): void
{
$this->app->singleton(SerializerInterface::class, function () {
return SerializerBuilder::create()
->addMetadataDir(app_path('Serialization/Metadata'), 'App\Serialization')
->setCacheDir(storage_path('framework/serializer'))
->setDebug(app()->environment('local'))
->build();
});
}
Where to look first (in priority order):
jms/serializer library docs (Read the Docs / GitHub README), not the bundle.SerializerBuilder API — that's your actual integration surface.JMS\Serializer\Annotation\ (@Groups, @Type, @Since, @Until, @SerializedName, @Exclude, @Accessor, @VirtualProperty).First use case — pin down one outbound shape and get it byte-identical to an existing JsonResource before touching anything else:
#[Groups(['api.v1'])]
class OrderDto
{
#[SerializedName('order_id')]
public string $id;
#[Type('array<App\Serialization\LineItemDto>')]
public array $lines = [];
}
$json = app(SerializerInterface::class)->serialize($dto, 'json', SerializationContext::create()->setGroups(['api.v1']));
Pick the smallest nontrivial payload with an existing golden-output test as your oracle. That first comparison is where you confirm the whole approach is worth the setup.
Wrap the vendor class behind your own interface. Bind App\Support\Serialization\Serializer (your interface) and have one adapter class delegate to JMS\Serializer\SerializerInterface. Every call site depends on the interface. Swapping to symfony/serializer or spatie/laravel-data later becomes a one-file change instead of a grep-and-replace across controllers.
Do not annotate Eloquent models. Serializing Eloquent directly pulls lazy relationships, observers, and circular parent references into the transport layer, and the metadata cache flaps every time a migration changes the model. Introduce explicit DTOs at the boundary and hydrate them from models in the controller or a dedicated transformer. This keeps persistence and transport decoupled and keeps your metadata cache stable.
Use serialization groups as your API contract. Version surfaces with @Groups(['api.v1']), @Groups(['api.v2']) rather than conditionals in controllers. Combine with @Since('2.0') / @Until('1.5') and SerializationContext::setVersion('2.0') to ship deprecations without touching the DTO class:
#[Groups(['api.v1', 'api.v2'])]
#[Since('1.0')]
#[Until('2.0')]
public ?string $legacyField = null;
Multi-format is where JMS earns its keep. One mapping, three output formats — the same DTO produces JSON for the API and XML for the partner B2B feed:
$xml = $serializer->serialize($dto, 'xml');
For XML partners you control the shape via @XmlRoot, @XmlList, @XmlAttribute, @SerializedName. This is the single feature no Laravel-native resource or spatie/laravel-data covers, so it's usually the real justification.
Bidirectional hydration is a first-class capability. Inbound request → DTO via deserialize is a genuine alternative to FormRequest, but you must add validation after hydration (JMS is a type mapper, not a validator):
$dto = $serializer->deserialize(
$request->getContent(),
OrderDto::class,
'json',
DeserializationContext::create()->setGroups(['api.v1.write'])
);
$validated = validator($dto->toArray(), $rules)->validate();
Cache metadata explicitly in the build pipeline. In production, setCacheDir() is mandatory — it's not a perf nicety, it avoids per-request reflection over every class in the graph. Warm it during deploy (php artisan serializer:warm — write a small command that instantiate-resolves your DTO classes) so the first request after a rolling deploy isn't a cold-start tax on the whole container.
Keep handler/visitor extension points narrow. Custom SubscribingHandlerInterface implementations earn their keep when you have a value object with no sensible default representation (Money, Uuid, phone numbers). Don't reach for visitors unless you actually need graph-wide behavior — groups usually solve the same problem more locally and more readably.
The bundle will not install cleanly against laravel/framework on major versions. It requires symfony/framework-bundle, symfony/http-kernel, and symfony/config, all of which pull their own version ranges. composer why symfony/framework-bundle after an accidental install — or worse, composer.lock diffs showing framework-bundle/http-kernel — is a defect to fix before it becomes a version-constraint headache on your next Laravel upgrade. Add the bundle to a composer.json conflict section if you have contributors who might reach for it by name.
Metadata cache is not optional in production. Without setCacheDir(), every request re-parses every attribute on every class it touches, and profiling will make it look like your DB is slow. If you're not sure it's working, run with setDebug(true) locally to get a warmup-able cache and confirm the prod container's /storage/framework/serializer directory is populated after a deploy.
Do not trust deserialization input. @Type will happily instantiate classes based on client-supplied payload data in polymorphic structures. Lock down discriminated types with an explicit allowlist before any user-controlled request hits deserialize(). Also ensure the metadata cache is not writable at runtime in production (read-only cache after warm) — a writable cache with attacker-controlled classes is a template for object-injection bugs.
Expression language and event dispatcher are optional dependencies — skip them unless you need them. symfony/expression-language is only needed for @Exclude(expression="...") and dynamic groups; symfony/event-dispatcher only if you need serializer events. Each adds dependency surface for no benefit if unused. Prefer plain group logic to expression-based groups until you have a demonstrated requirement.
Attributes, not Doctrine annotations. PHP 8 attributes (#[Groups(...)]) are the current, supported metadata format. Annotations are legacy, cost you a parser dependency, and won't survive the next PHP major. If you inherit annotation-based metadata from a Symfony service, budget a migration before it becomes a compliance problem.
Debugging: use the built-in profiler shape via your own middleware. JMS ships a Symfony data collector; in Laravel, write a small log middleware that records the format, groups, version, and payload size for outbound serialization on non-production environments. This is more useful than step-debugging through visitors, and it catches the common failure — a group typo silently excluding a field — before it reaches staging.
Watch @Groups typos — they fail silently. A property excluded by a group it doesn't match simply vanishes from the payload with no error. Characterization tests (byte-pinned golden JSON) catch this; reading your DTO at 9pm on a Friday does not. Consider a small test helper that asserts a DTO under a given context emits exactly the expected key set, and run it in CI.
@Accessor and @VirtualProperty are useful but easy to overuse. They let you derive JSON fields from existing properties, which is tempting when the DTO would otherwise need a real transformer. Reach for them for trivial derivations (getFullName()), not for business logic — anything non-trivial belongs in the controller-level transformer where it's testable in isolation.
XML output has non-obvious defaults. Empty arrays render as <lines/>, single-element arrays lose the list wrapper unless you set @XmlList, and attribute vs. element placement requires explicit annotation. If XML is your justification for the package, pin down the exact partner contract with fixture-based round-trip tests before you build any downstream integration on it — this is the highest-value test you will write on this project.
How can I help you explore Laravel packages today?