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());
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,
);
}
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 (
deliveryDatehas a cast) delegate to the same runtimeValueCasteras before, with the metadata captured inuse. 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
throwexpressions, 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'));
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
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;
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
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
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
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 |
- Docs: https://std-out.github.io/simple-data-objects/
- Good first issues (new casts, pipes, cookbook recipes): https://github.com/std-out/simple-data-objects/issues
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)