ivory/serializer
ivory/serializer is a lightweight Laravel and PHP package for transforming models and data structures into consistent arrays or JSON. Define serialization rules to control output, simplify API responses, and keep data formatting predictable across your application.
ivory/serializer is a standalone wrapper around the Symfony Serializer's encoder/decoder + normalizer stack, with one encoder class per format (JSON, XML, YAML, CSV) and a Format constants class. There is no Laravel package, service provider, facade, or config file — it is a library you wire yourself. Also note the docs are ambiguous about direction: the description advertises serialization (object → array/JSON), while most ecosystem discussion frames it as a deserializer. It is actually both — the Serializer exposes serialize() and deserialize() and delegates to the registered encoders/normalizers. Verify what your pinned tag exposes before designing around it.
Order of reading, before writing any code:
composer.json — confirm the PHP constraint (7.0+) and the exact Symfony components pulled in (serializer, property-access, options-resolver, yaml). You pay for all of them even if you only use JSON.src/Serializer.php — the entire public surface. Expect serialize($data, $format, array $context = []) and deserialize($data, $type, $format, array $context = []).src/Format.php — the format constants you'll pass around.src/Encoder/ and src/Decoder/ — one class per format; this is where every format-specific quirk lives.src/Normalizer/ — how objects become arrays and back (property access, name conversion, date handling).Freeze it before you depend on it. The repo is archived, has 3 stars and 0 dependents. Nothing will ever be patched, no PHP 8.2/8.3/8.4 fixes will land, and a transitive dependency break will be yours to fix. Use an exact constraint in composer.json, commit composer.lock, and record the decision in an ADR with an explicit exit criterion. If you are greenfield, stop and bind symfony/serializer directly — this library is convenience, not capability.
Minimal working setup:
use Ivory\Serializer\Serializer;
use Ivory\Serializer\Format;
$serializer = new Serializer(); // registers the default encoders/decoders
// object -> array/JSON
$payload = $serializer->serialize($user, Format::JSON);
// raw string -> hydrated DTO
$user = $serializer->deserialize($json, App\Dto\User::class, Format::JSON);
Bind it once in the container so you never new it in a controller:
// app/Providers/AppServiceProvider.php
$this->app->singleton(Serializer::class, fn () => new Serializer());
Better still, bind your own interface and let the package live behind it:
interface PayloadSerializer
{
public function toArray(object $value, string $format): array|string;
public function fromString(string $raw, string $type, string $format): object;
}
Your first use case should be the boring one: a webhook/API payload landing in a flat DTO, or an admin "export to CSV/JSON" button. If either works end-to-end on your target PHP version with representative production payloads, the rest of the library behaves predictably. If the spike fails on PHP 8.x, you have lost hours, not a sprint.
Let the DTO shape do the work. Hydration goes through public properties and setters via Symfony's property access. A class whose constructor requires arguments with no no-arg path fails deep inside the normalizer with an unhelpful message. Keep import DTOs plain and mutable:
final class ContactRow
{
public ?string $email = null;
public ?string $name = null;
public ?string $company = null;
public ?string $createdAt = null; // string; cast downstream
}
Register only the formats you actually consume. new Serializer() wires everything, which means a malformed CSV can only fail after the YAML parser has been autoloaded. If your pinned version allows explicit encoder construction, register JSON only, and the failure surface shrinks accordingly.
Normalize before hydration, not during. CSV is the acute case: every cell is a string, headers are the only key source, nesting is unrepresentable. Keep CSV DTOs flat and scalar, then run a single explicit post-hydration cast step ((int), filter_var(..., FILTER_VALIDATE_BOOLEAN), Carbon::parse). Doing type coercion inside setters scatters the rules across every DTO.
Use it as an export tool where it earns its keep. Laravel's API Resources are the right answer for JSON API responses — they're explicit and testable. The serializer earns its place when one model needs the same shape in four formats (a report download offered as CSV/JSON/XML, a data-room export). Define the projection once, emit it per format:
Route::get('/reports/{report}/export.{format}', function (Report $report, string $format) {
$rows = $report->rows()->get()->map->toExportArray()->all();
return response(
app(Serializer::class)->serialize($rows, $format),
200,
['Content-Type' => match ($format) {
Format::CSV => 'text/csv',
Format::XML => 'application/xml',
default => 'application/json',
}]
);
});
Snake_case vs camelCase. Wire a NameConverter (camelCase ↔ snake_case) into the normalizer once, in the container binding, and never again per-DTO. Doing it per-call via context arrays is how you end up with three different JSON key conventions in one app.
Validation belongs in a Form Request, not the DTO. Deserialize inside a try/catch, translate parser exceptions into a 422 with row/column context, and never let a raw Symfony exception reach the client:
try {
$rows = $serializer->deserialize($file->getContent(), ContactRow::class, Format::CSV);
} catch (\Throwable $e) {
throw ValidationException::withMessages(['file' => 'Unreadable CSV: '.$e->getMessage()]);
}
Push bulk work to a job. Request-time deserialization is fine to a few hundred KB. For real imports: store the upload, dispatch a job, stream the file, deserialize in chunks, and report per-row errors into a table the UI can poll. Do not pass the raw payload into the job — pass a storage path.
Contract-test each format you enable. One fixture file per format, in tests/Fixtures, asserting round-trip equality for a known DTO. When you eventually swap the package for symfony/serializer, those tests are the migration plan.
app/Support/Ivory (MIT permits it) or fork it internally rather than depending on an unreachable upstream. Either way, keep the package behind your own interface so the exit is a one-file change.;-delimited files on some locales.=, +, -, @, tab, or CR is executable in Excel/Sheets. Prefix with ' or wrap in quotes on export. The library will not do this for you.libxml_disable_entity_loader/LIBXML_NONET at the decoder, or you have a file-disclosure and DoS vector on any upload endpoint. This is exactly the class of hardening an archived package will never receive.!php/object and unmarshalled custom tags can construct arbitrary classes from user input. Use Yaml::PARSE_EXCEPTION_ON_INVALID_TYPE | Yaml::PARSE_CUSTOM_TAGS off-by-default, and never parse YAML from an untrusted source with object support on."" becomes null, false, or stays "". Make the intent explicit in setters or the post-cast step; otherwise CSV and JSON paths silently diverge on the same field.DateTimeImmutable conversion out of the box. Either keep them as strings in the DTO and parse downstream, or register a custom normalizer. Mixed approaches produce Carbon vs string inconsistencies across importers.circular_reference_limit only as a debugging aid, not a fix.$hidden/$visible are not honored by the serializer; you will leak password, remember_token, or internal IDs. Always project through an explicit array/DTO first.json_decode the input), then attempt normalization on that array. This tells you instantly whether the problem is the format or the class shape — the exception messages usually don't.Normalizer implementations for value objects, a NameConverter for key casing, and the context array for per-call behaviour. These are Symfony contracts, so anything you write is portable to symfony/serializer later — write them against Symfony's interfaces, not Ivory's, wherever possible.symfony/serializer + league/csv once the import feature stabilises, or on the first PHP upgrade that breaks the package." Without that written down, an archived dependency becomes permanent by default.How can I help you explore Laravel packages today?