speakeasy/serializer
Speakeasy Serializer is a Laravel/PHP package for converting models and data into consistent serialized output. It provides a straightforward way to define reusable serialization logic, making it easier to shape API responses and keep data transformations organized.
This package is a temporary fork of jms/serializer published under the speakeasy vendor. The namespace is still JMS\Serializer\..., so every example in the upstream JMS Serializer docs applies verbatim. Treat it as a drop-in replacement you switch to only when you need a specific fix that has not landed upstream yet.
Install and pin. The fork now publishes versioned releases (currently 3.40.0) that track upstream minor versions, so a caret constraint is viable if you accept the fork's release cadence. Pin tightly anyway while you validate:
composer require speakeasy/serializer:"^3.40"
# or, if you need a specific unreleased fix:
composer require speakeasy/serializer:"dev-main#<commit-sha>"
Check the fork's composer.json for a replace/provide entry against jms/serializer; if it is missing, you must ensure nothing else in the dependency graph pulls upstream jms/serializer, or you will get duplicate class definitions for JMS\Serializer\Serializer.
First step: wire a singleton. There is no Laravel service provider in the box (the official integration is jms/serializer-bundle, which is Symfony-only). Register the serializer yourself in AppServiceProvider:
use JMS\Serializer\Serializer;
use JMS\Serializer\SerializerBuilder;
$this->app->singleton(Serializer::class, fn () => SerializerBuilder::create()
->setCacheDir(storage_path('framework/serializer'))
->setDebug(config('app.debug'))
->build());
Attributes are read from your classes by default in 3.x, so the builder above is enough to start.
First use case: deserialize a JSON request body into a typed DTO instead of hand-reading $request->input().
namespace App\Data;
use JMS\Serializer\Annotation as Serializer;
class CreateOrder
{
#[Serializer\Type('string')]
#[Serializer\Required]
public string $sku;
#[Serializer\Type('int')]
public int $quantity;
#[Serializer\Type("DateTimeImmutable<'Y-m-d'>")]
#[Serializer\SkipIfNull]
public ?\DateTimeImmutable $deliverBy = null;
}
$dto = app(Serializer::class)->deserialize(
$request->getContent(),
CreateOrder::class,
'json',
);
You now have a validated-shape object (types enforced by reflection) that you can hand to a form request or action, rather than an untyped array. #[Serializer\Required] (new in 3.40) makes presence explicit even when a property is nullable or has a default; #[Serializer\SkipIfNull] keeps it out of the serialized output when it was not supplied.
DTO-first, not Eloquent-first. The day-to-day sweet spot is a thin DTO layer between HTTP and your domain. Keep Eloquent models out of the serializer; map DTOs to models in an action or a toModel() method. This avoids lazy-loading surprises, circular relations, and accidental exposure of $hidden/$appends semantics that differ from Laravel's own toArray().
Mark required fields explicitly with #[Serializer\Required]. Previously the only way to enforce presence was a non-nullable typed property with no default, plus PostDeserialize for cross-field checks. With 3.40 you can declare intent per property:
class UpdateProfile
{
#[Serializer\Type('string')]
#[Serializer\Required]
public ?string $email = null; // present but may be null? still required as a key
#[Serializer\Type('string')]
public ?string $nickname = null; // optional; absent is fine
}
This is the attribute to reach for when the payload contract requires a key but the value itself is nullable — otherwise you are stuck choosing between a strict non-nullable type and a loose nullable one.
Per-property null handling with #[Serializer\SkipIfNull]. The global setSerializeNull(true) in your service provider makes every null appear in output; #[Serializer\SkipIfNull] on a property opts that specific field out. Use it for optional fields where "absence" is semantically different from null:
class UserDto
{
#[Serializer\Groups(['detail'])]
public string $email;
#[Serializer\SkipIfNull]
#[Serializer\Groups(['detail'])]
public ?string $lastLoginIp = null; // omitted when the user has never logged in
}
Combine with setSerializeNull(true) globally so the common case emits keys, and mark the genuinely optional ones with the attribute.
Serialization groups for response shaping. Use groups instead of separate resource classes when the same object has list vs. detail shapes:
class UserDto
{
#[Serializer\Groups(['list', 'detail'])]
public int $id;
#[Serializer\Groups(['detail'])]
public string $email;
}
$json = $serializer->serialize(
$dto,
'json',
SerializationContext::create()->setGroups(['list']),
);
Naming strategy to match your API contract. If your API is camelCase but PHP is too, this is a no-op; if you emit snake_case, configure it once on the builder:
->setPropertyNamingStrategy(
new SerializedNameAnnotationStrategy(new CamelCaseNamingStrategy())
)
#[Serializer\SerializedName('user_name')] then overrides per-field.
Custom handlers for value objects. Anything that is not a scalar, array, or DateTimeInterface needs a handler. This is the standard way to serialize Money, Uuid, PostalCode, etc.
class MoneyHandler implements SubscribingHandlerInterface
{
public static function getSubscribingMethods(): array
{
return [[
'type' => Money::class,
'format' => 'json',
'direction' => GraphNavigatorInterface::DIRECTION_SERIALIZATION,
'method' => 'serialize',
], [
'type' => Money::class,
'format' => 'json',
'direction' => GraphNavigatorInterface::DIRECTION_DESERIALIZATION,
'method' => 'deserialize',
]];
}
}
Register with ->configureHandlers(fn (HandlerRegistry $r) => $r->registerSubscribingHandler(new MoneyHandler())).
Virtual properties. Computed fields that should appear in output but are not stored:
#[Serializer\VirtualProperty]
#[Serializer\SerializedName('full_name')]
#[Serializer\Groups(['detail'])]
public function fullName(): string
{
return "{$this->first} {$this->last}";
}
Lifecycle hooks instead of constructors. JMS sets properties by reflection after instantiation, so constructors are not a reliable place for invariant checks. Use #[Serializer\PostDeserialize] to validate and normalize:
#[Serializer\PostDeserialize]
public function validate(): void
{
if ($this->quantity < 1) {
throw new \InvalidArgumentException('quantity must be positive');
}
}
Wrap the deserialize call in a try/catch and convert JMS\Serializer\Exception\* into a 422 response in your exception handler.
Versioning for APIs. ->setVersion('2.0') plus #[Serializer\Since('2.0')] / #[Serializer\Until('1.5')] lets you keep one DTO across API versions. Useful if you are shipping a versioned public API; overkill for internal apps.
Polymorphic payloads. #[Serializer\DiscriminatorMap('type', ['card' => CardPayment::class, ...])] plus #[Serializer\Type('...')] on the parent property handles tagged unions (webhooks, event streams) without match-on-type boilerplate.
Warm metadata cache on deploy. Add a deploy step that builds the serializer and touches a known class so the metadata cache in storage/framework/serializer is primed before traffic hits. Otherwise the first request after deploy pays the reflection cost for every class.
It is still a fork. Low stars, low dependents. The only defensible reason to use it over upstream is a specific change you cannot wait for. Pin the constraint, add a calendar reminder to re-check jms/serializer releases, and be ready to swap the constraint back. The new tagged releases make the exit cheaper than a commit-pinned install, but they do not change the dependency-ownership calculus.
Namespace collision is the main install hazard. Because it keeps JMS\Serializer\ and (depending on the fork state) may not declare replace, Composer can happily install both this and jms/serializer, producing "Cannot declare class" fatals. Verify after composer require with composer show | grep serializer and composer why jms/serializer.
Metadata cache goes stale silently. In production (setDebug(false)) the serializer caches class metadata aggressively. Changing an attribute without clearing storage/framework/serializer results in the old mapping being used and no error. This bites hard when you roll out the new #[Serializer\Required] or #[Serializer\SkipIfNull] attributes: the old cached metadata will simply ignore them and your fields will neither be enforced nor skipped. Put the cache dir under storage/framework so php artisan optimize:clear or a deploy rm -rf cleans it, and keep setDebug(true) locally so changes are picked up.
Typed properties with no default are strict — #[Serializer\Required] now expresses this intent explicitly. A missing JSON key on public string $sku; throws during deserialization; it does not silently leave it null. Before 3.40 the only way to enforce presence was to skip a default and rely on that exception. Use #[Serializer\Required] on nullable or defaulted properties when the API contract demands the key; combine with a ->setObjectConstructor(...) or PostDeserialize to produce a clean 422. This is a feature, but it surprises people coming from $request->input().
serializeNull and skipIfNull interact. The global SerializationContext::create()->setSerializeNull(true) flag makes null fields appear in output. #[Serializer\SkipIfNull] on a property takes precedence for that field. If you set the global flag, audit every null-able DTO field and mark the genuinely-optional ones — otherwise you will start emitting "last_login_ip": null where the previous contract omitted the key entirely, and downstream consumers will notice.
Readonly and constructor-promoted DTOs are awkward. Reflection can populate readonly properties in current PHP, but invariants in a constructor will not run the way you expect, and PostDeserialize cannot reassign readonly fields. For immutable DTOs, use plain public properties plus a toDomain() factory, or a custom object constructor (->setObjectConstructor(...)) that builds via named arguments.
Enums need an explicit type. Backed enums are supported in recent JMS 3.x, but only if the property has #[Serializer\Type(Status::class)] (or an @var docblock). Without it, deserialization fails with an "expected argument of type" error.
DateTime format strings are embedded in the type, not the property. #[Serializer\Type("DateTimeImmutable<'Y-m-d H:i:s'>")] — note the escaped angle brackets. If the incoming string does not match exactly, you get a RuntimeException from the date handler, not a validation error. Timezones default to the server's default timezone; if clients send offsets, use DateTimeImmutable without a format and normalize after.
serializeNull defaults to false. Null fields disappear from output. If your API contract requires keys to be present, pass SerializationContext::create()->setSerializeNull(true) on every serialize call (or set it globally in your service provider wrapper) and use #[Serializer\SkipIfNull] for the exceptions.
Do not deserialize untrusted class names. JMS uses reflection rather than PHP's unserialize(), so it is not vulnerable to object-injection the way unserialize() is, but any handler or discriminator that resolves a class from input must validate against an allowlist. Discriminator maps are safe by construction; #[Serializer\Type($someUserString)] is not.
Debugging failures. Exceptions carry the path ($.items[3].price) in the message, which is the fastest way to find the offending field. A missing #[Serializer\Required] failure reports the property path in the same shape as any other deserialization error — check that the attribute survived the metadata cache flush before diving into handlers. For stubborn cases, dump SerializationContext and inspect the graph navigator's stack trace; the GraphNavigator wraps handler exceptions rather than rethrowing the inner type, so the root cause is usually one level down in getPrevious().
Extension points to know. Naming strategies (setPropertyNamingStrategy), handlers (configureHandlers), visitors (addDefaultHandlers plus a custom SerializationVisitorInterface for new formats), metadata drivers (AnnotationDriver with FileLocator for attributes outside app/), and object constructors (setObjectConstructor) — this is the full surface you would otherwise reach for in Laravel via custom casts and resource classes. Handlers are resolved through Laravel's container if you register them with app()->make(...), so constructor injection in a handler works.
Laravel validation still belongs in FormRequests. Do not try to encode validation rules in JMS attributes, including #[Serializer\Required] — it enforces key presence, not value correctness. The idiomatic flow is: deserialize() for shape and types, then Validator::make($dto->toArray(), $rules) or a FormRequest, then PostDeserialize for cross-field invariants. That keeps error messages consistent with the rest of the app.
How can I help you explore Laravel packages today?