wikimedia/json-codec
A PHP library from Wikimedia for converting objects to and from JSON using schemas, with validation, custom codecs, and typed deserialization to keep JSON handling consistent and safe.
composer require wikimedia/json-codec. The GitHub repo is only a Gerrit mirror; Composer resolves through Packagist, so don't use a vcs repository entry pointed at GitHub. It requires PHP 8.1+ and ext-json.README.md → src/JsonCodec.php (the entry point and registry) → src/JsonCodecable.php / src/JsonCodecableTrait.php (how your DTOs opt in) → src/JsonClassCodec.php (how to serialize classes you don't own).$dto = $codec->deserialize($response->body(), OrderPayload::class);use Wikimedia\JsonCodec\JsonCodec;
use Wikimedia\JsonCodec\JsonCodecable;
use Wikimedia\JsonCodec\JsonCodecableTrait;
final class Money implements JsonCodecable {
use JsonCodecableTrait;
public function __construct(
public int $amount,
public string $currency,
) {}
}
$codec = new JsonCodec();
$json = $codec->serialize( new Money( 1250, 'EUR' ) );
$money = $codec->deserialize( $json, Money::class );
If that round-trip works, you understand the library. If you find yourself fighting it to make it fit Eloquent models, stop — it isn't the right tool.
DTO-first, not model-first. Keep the codec at the edges: HTTP clients, queue payloads, cache values, webhook bodies. Map explicitly to and from Eloquent models. Do not add JsonCodecable to models or let it replace JsonResource.
Bind a singleton in a service provider.
$this->app->singleton( JsonCodec::class, function () {
$codec = new JsonCodec();
// Stable wire names decoupled from PHP class names.
$codec->addClassAlias( 'OrderPayload', OrderPayload::class );
// Custom codecs for vendor value objects.
$codec->addCodec( Money::class, new MoneyCodec() );
return $codec;
});
Then inject JsonCodec into the few services that need it. Reflection is not free; don't construct a codec per request or per row.
Custom codecs for classes you can't modify. Implement JsonClassCodec and register it with JsonCodec::addCodec(). This is the pattern for DateTimeImmutable, money libraries, enums, and any vendor value object. It's also where you should centralize casts and scalar coercion.
Polymorphism via aliases, not FQCNs. Store a stable alias per class so renaming or moving a class in your codebase doesn't invalidate cached or queued JSON. Treat the alias map like a database schema and version it alongside your code.
Lists and generics. The trait needs type information for array members; express it with typed properties and docblocks/attributes so it knows what to instantiate. For heterogeneous lists, emit a per-element type hint and deserialize into a common interface backed by aliases.
Laravel integration points:
Http::withBody($codec->serialize($dto), 'application/json')->post(...). Inbound: $codec->deserialize($response->body(), Foo::class).handle(), so the queue never sees PHP objects.redis-cli, and free of unserialize risk.Testing. Write round-trip tests (deserialize(serialize($x)) == $x) and snapshot tests on the JSON string. Snapshot the alias names too — that's your wire contract, and it's the thing that will silently break.
When not to use it. Simple toArray() responses (native JsonSerializable is enough), Eloquent resources (JsonResource), or anything already covered by spatie/laravel-data or Symfony Serializer. The value here is explicit, safe, round-trippable type tags — if you don't need that, you're paying complexity for nothing.
unserialize() object injection. Allowlist only the aliases you control; never let arbitrary input select the class. Validate and reject unknown aliases before deserializing.toJsonArray() / newFromJsonArray() overrides are your migration tool; plan for it.toJsonArray(); nullable properties need explicit handling. Give every property a default or set it in the constructor, and keep property visibility consistent.json_encode flags aren't yours to set. You get the library's output. If you need JSON_PRETTY_PRINT, JSON_UNESCAPED_SLASHES, or key ordering, you're post-processing or decode/re-encoding — which defeats part of the point. Don't rely on the codec's string being byte-stable for signing or hashing; canonicalize explicitly.1250 vs "1250" and int/float ambiguity bite in custom codecs. Cast defensively and test both shapes.dd($dto->toJsonArray($codec)) before serializing (the trait makes this easy); wrap deserialize() in try/catch and convert failures to a domain InvalidPayloadException so 500s don't leak internals; log the raw JSON plus the expected class on failure.JsonCodecableTrait for defaults (override for custom keys), JsonClassCodec for external classes, class aliases for stable wire names, and subclass registration for polymorphic hierarchies.How can I help you explore Laravel packages today?