DEV Community

Cover image for Compiling PHP DTOs: from reflection to 4.5M hydrations per second
Yurii Zub
Yurii Zub

Posted on

Compiling PHP DTOs: from reflection to 4.5M hydrations per second

TL;DR: attribute-driven DTOs are pleasant to write but usually pay a reflection-and-dispatch tax on every call. Simple Data Objects compiles a specialized closure per data class instead — plain properties become inline array reads, the generated code is persisted into an opcache-served cache, and PHP 8.4 lazy ghosts defer hydration until first property access. Here's the architecture, step by step, with numbers at each stage.

Every modern PHP codebase eventually grows a layer of data objects: typed, immutable classes that sit between raw input (a request, a DB row, an API payload) and your domain logic. Attribute-driven DTO libraries made this pleasant to write:

class CreateOrderData extends BaseData
{
    public function __construct(
        #[Rules(['required', 'string', 'max:200'])]
        public readonly string $title,

        #[Cast(new DateTimeCast('Y-m-d'))]
        public readonly \DateTime $deliveryDate,

        public readonly ?string $notes = null,
    ) {}
}

$order = CreateOrderData::from($request->all());
Enter fullscreen mode Exit fullscreen mode

But pleasant to write usually means expensive to run. The typical implementation reflects over the constructor, reads the attributes, and walks a generic hydration pipeline for every property of every object — on every call. That's fine for one DTO per request. It stops being fine when you hydrate a few thousand rows from a report query, or when your API serializes nested object graphs on every response.

I wanted the ergonomics without the tax. The result is a PHP 8.4+ package where the hot path contains no reflection, no attribute reads, and no generic dispatch loop — because by the time production traffic hits it, hydration is a compiled function.

Step 1: reflect once, not per call

The obvious first move: do all reflection up front. On the first from() call for a class, the package builds a ClassMeta — an immutable description of the constructor: parameter names, types, defaults, nullability, and everything the attributes declare (casts, input-key mappings, pipes, validation rules).

Every subsequent call reuses that metadata. This is table stakes — most libraries do some version of it — and it still leaves you with an interpreted loop: for each parameter, check "does it have a cast? a mapped key? a default?" and branch accordingly. Thousands of objects × dozens of properties = millions of predictable, redundant branch decisions per request.

Step 2: compile the loop away

The metadata is static per class. So instead of interpreting it on every call, the package generates a specialized closure per class and lets PHP execute that.

For the CreateOrderData above, the generated hydrator is essentially:

static function (array $d) use ($p, $pipes): \App\Data\CreateOrderData {
    return new \App\Data\CreateOrderData(
        \array_key_exists('title', $d)
            ? $d['title']
            : throw DataHydrationException::missingField(CreateOrderData::class, 'title'),
        \array_key_exists('deliveryDate', $d)
            ? ValueCaster::cast($p[1], $d['deliveryDate'])
            : throw DataHydrationException::missingField(CreateOrderData::class, 'deliveryDate'),
        $d['notes'] ?? null,
    );
}
Enter fullscreen mode Exit fullscreen mode

Note what happened to each kind of parameter:

  • Plain properties (title, notes) became direct array reads. No pipeline, no dispatch — the nullable-with-null-default case collapses all the way down to $d['notes'] ?? null.
  • Properties with behavior (deliveryDate has a cast) delegate to the same runtime ValueCaster as before, with the metadata captured in use. Semantics are identical to the interpreted path; only the per-parameter "what kind of property is this?" decision is gone, because it was made once at compile time.
  • Missing-field errors are inlined as throw expressions, so the happy path carries no error-handling scaffolding.

The serializer side (toArray()) gets the same treatment: plain properties become inline property reads into an array literal.

"You're eval()-ing generated code?" Yes, once per class per process — and the generated source is assembled only from reflection metadata. Class and property names are valid PHP identifiers by definition; the single free-form string that can appear (an input key from #[MapPropertyName]) is embedded via var_export(). There is no path from runtime input into the generated code.

Result of this step alone, measured on the same DTO shapes: hydration ~2.6× faster, serialization ~2.2× faster than the interpreted pipeline it replaced.

Step 3: don't even compile at runtime

A compiled closure still costs reflection + code generation + eval() on the first call of each process. With PHP-FPM that's every worker after every deploy; with short-lived CLI jobs it's every run.

So the metadata cache persists the compiled code. Point the registry at a directory:

MetadataRegistry::setStoragePath(storage_path('framework/data-objects'));
Enter fullscreen mode Exit fullscreen mode

and each class gets a .meta.php file containing the exported metadata plus the generated hydrator and serializer source. Loading it is a PHP include — which means opcache serves it as compiled opcodes, shared across all FPM workers. A warmed worker pays neither reflection nor eval(). The cache write is atomic (write-to-temp + rename), and a guard refuses to persist classes whose metadata isn't safely exportable.

Pre-warm on deploy so even the first request is hot:

vendor/bin/sdo-warm storage/framework/data-objects app/Data
Enter fullscreen mode Exit fullscreen mode

The warmer scans your PSR-4 sources for concrete data classes, compiles everything, and fails the deploy fast if any DTO definition is invalid — which is exactly when you want to find out.

Step 4: don't hydrate what nobody reads

PHP 8.4 shipped lazy objects, and they're a perfect fit for DTOs. fromLazy() returns an uninitialized lazy ghost; the compiled argument resolver runs on first property access:

$orders = array_map(OrderData::fromLazy(...), $rows);

// casts, nested DTOs, pipes — none of it has run yet.
// Touch one property and that object (only) hydrates:
$orders[0]->title;
Enter fullscreen mode Exit fullscreen mode

Both the eager hydrator and the lazy argument resolver are generated from the same code builder, so hydration semantics can't drift between the two paths.

When only ~10% of created objects are ever read — think "hydrate the page, render the visible rows" — this measures ~3× faster on cast-heavy DTOs and ~6× with nested collections. Honest footnote: for trivial flat DTOs the ghost bookkeeping costs about as much as it saves, so the docs say exactly that instead of pretending it's free speed.

And for the "million rows through a pipeline" case there's lazyCollection(), which hydrates one item at a time as the collection is consumed: streaming 50,000 rows peaks at ~0.26 MB instead of ~13 MB materialized.

The numbers

Benchmarked against the most popular full-featured data-object library in the PHP/Laravel ecosystem — identical DTO shapes, 20,000 iterations per scenario, PHP 8.4:

Scenario Simple Data Objects Popular alternative Advantage
Hydration — flat DTO ~4,500,000 ops/s ~130,000 ops/s ~35×
Hydration — nested DTO ~2,200,000 ops/s ~74,000 ops/s ~30×
Hydration — collection of 20 ~270,000 ops/s ~7,500 ops/s ~36×
Serialization — flat DTO ~7,400,000 ops/s ~200,000 ops/s ~37×
Serialization — nested DTO ~4,000,000 ops/s ~117,000 ops/s ~34×
Peak memory — 50k rows 0.26 MB (lazyCollection()) ~13 MB ~50× less

Absolute numbers vary with hardware; the ratios stay stable across runs. To be fair to the alternative: it does more — it's a framework-integrated toolkit with TypeScript transformers, wire formats, and a large extension surface. If you use that surface, use it; it's excellent. This package targets the case where DTOs are on your hot path and you mostly need hydrate/validate/serialize — the 80% — as fast as PHP can go.

What you give up

Design honesty section. The speed comes from constraints:

  • PHP 8.4 only. Lazy ghosts, property hooks-era engine. No polyfills, no legacy branches.
  • Constructor-promoted, readonly properties are the model. No setters, no mutable state — which is also just correct DTO design.
  • No runtime magic. Everything the DTO does is declared in attributes and compiled ahead of time. If you need to rewire behavior per instance at runtime, this is the wrong tool.

What you don't give up: validation (Laravel rules, working standalone without a Laravel app), casts (dates, enums, JSON, encrypted fields via libsodium), input pipelines, key transforms, nested collections, immutable with()/diff()/equals(). The test suite covers 100% of lines, enforced in CI — the coverage gate fails below 100, and there are no @codeCoverageIgnore escapes.

Try it

composer require std-out/simple-data-objects
Enter fullscreen mode Exit fullscreen mode

GitHub logo std-out / simple-data-objects

Blazing-fast attribute-driven DTOs for PHP 8.4+ — compiled hydrators, zero reflection in production, works standalone or with Laravel

Simple Data Objects

Tests Security Coverage Latest Version Total Downloads PHP License

Lightweight, attribute-driven DTOs for PHP 8.4+.
Works standalone or inside Laravel 10–13. No reflection in production.

composer require std-out/simple-data-objects
Enter fullscreen mode Exit fullscreen mode

Full documentation


Why

Simple Data Objects
Hot path Compiled per-class closures — zero reflection, zero dispatch overhead
Boilerplate None — constructor props + attributes
Roundtrip from(toArray()) always works, mapped keys included
Standalone Validation works without a Laravel app
Pipelines Middleware-style input preprocessing, class or property level

Performance

Benchmarked against the most popular full-featured data-object library in the PHP/Laravel ecosystem — identical DTO shapes, 20,000 iterations per scenario, PHP 8.4:

Scenario Simple Data Objects Popular alternative Advantage
Hydration — flat DTO ~4,500,000 ops/s ~130,000 ops/s ~35× faster
Hydration — nested DTO ~2,200,000 ops/s ~74,000 ops/s ~30× faster
Hydration — collection of 20 ~270,000 ops/s ~7,500 ops/s ~36× faster
Serialization — flat DTO ~7,400,000 ops/s ~200,000 ops/s ~37× faster
Serialization — nested DTO ~4,000,000 ops/s ~117,000 ops/s ~34× faster

If you try it on a real workload, I'd genuinely like to hear the numbers — especially where it doesn't win. Benchmarks that survive contact with other people's production are the only ones that matter.

Top comments (0)