DEV Community

Cover image for Simple Data Objects v2: from a fast PHP DTO hydrator to a full Typed Data Boundary
Yurii Zub
Yurii Zub

Posted on

Simple Data Objects v2: from a fast PHP DTO hydrator to a full Typed Data Boundary

Simple Data Objects v2: from a fast PHP DTO hydrator to a full Typed Data Boundary

In a previous article I wrote about an experiment compiling Reflection metadata in PHP to reach extremely fast DTO hydration. That experiment proved one thing: DTOs in PHP can be incredibly fast without sacrificing typing.

But speed in a vacuum is only part of the puzzle. While preparing the Simple Data Objects v2 (SDO) release, I asked a deeper question:

What does a developer actually need from a DTO library in a real, large PHP or Laravel application, beyond a blazing-fast from(array) method?

The answer turned out to be broad: convenient validation, input normalization, support for nested collections, safe error accumulation, flexible serialization, frontend contracts, and framework integration. And, crucially, all of it has to work without losing the legendary performance.

Version 2 grew from a "fast hydrator" into a full, production-ready Typed Data Boundary. Let's look at how it's built, and why performance here isn't just a number in a table but an architectural philosophy.


1. Why plain arrays aren't enough anymore

Arrays in PHP are a great, flexible tool — right up until the moment they start crossing the boundaries between different layers of a system:

HTTP Request ➔ Controller ➔ Domain Service ➔ Queue Job ➔ Database ➔ API Response
Enter fullscreen mode Exit fullscreen mode

At every one of these steps, we're forced to make dozens of micro-assumptions:

$email = trim((string) $payload['email']);
$amount = (int) $payload['amount'];
$date = Carbon::parse($payload['created_at']);
Enter fullscreen mode Exit fullscreen mode

These assumptions gradually get duplicated across controllers, jobs, webhooks and tests. If the array's shape changes on the frontend, the chain breaks in a completely unpredictable place.

A DTO solves this by creating a clear, named contract. All the conversion and validation magic happens once — at the boundary where data enters the system. From then on, your code works with clean, typed objects.


2. Anatomy of a DTO in Simple Data Objects v2

Installation is standard, via Composer:

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

A basic DTO stays a maximally simple, readable PHP class:

namespace App\Data;

use StdOut\SimpleDataObjects\BaseData;

final class CustomerData extends BaseData
{
    public function __construct(
        public readonly int $id,
        public readonly string $email,
        public readonly string $name,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

Hydration is direct and safe:

$customer = CustomerData::from([
    'id' => '42', // The string is automatically cast to int
    'email' => 'ada@example.com',    'name' => 'Ada Lovelace',
]);

$customer->id; // int(42)
Enter fullscreen mode Exit fullscreen mode

Your class stays clean, declarative code. The hydration compiler and metadata cache are SDO's under-the-hood work — invisible to the developer, but critical for the CPU.

Important note: while most examples in this article target Laravel (since it's one of the most popular frameworks in the ecosystem), the Simple Data Objects library itself is completely framework-independent! It works great on any other framework (Symfony, Slim, for example) or even on plain (vanilla) PHP.

That said, the Laravel ecosystem gets powerful, opt-in support for a lot of native features! For example, you can automatically inject a DTO directly into a controller method instead of a standard Request object, use a DTO as an Eloquent attribute cast, plug in Livewire integration (WireableData), and use ready-made Artisan commands to warm the metadata cache (sdo:warm) or generate TypeScript. All of these extras are opt-in and never weigh down the core package.

One caveat: the core package still pulls in illuminate/contracts, illuminate/support and illuminate/validation as hard dependencies (without a full Laravel application) — so on Symfony or Slim these packages will show up in your project's composer.json, even if you never touch an Illuminate\* class directly.


3. Data boundaries and flexible usage scenarios

In real life, data arrives with varying levels of trust. SDO v2 offers a distinct approach, splitting the hydration API by scenario:

A. HTTP requests: fast fail-fast validation

When a user submits a form, we need to check the rules quickly and, on failure, return a response:

use StdOut\SimpleDataObjects\Attributes\Rules;
use StdOut\SimpleDataObjects\Attributes\InferRules;

#[InferRules] // Automatically infers baseline rules from PHP types
final class RegisterData extends BaseData
{
    public function __construct(
        public readonly string $name,
        #[Rules(['required', 'email', 'unique:users,email'])]
        public readonly string $email,
    ) {}
}

// Runs Laravel validation and throws ValidationException on failure
$data = RegisterData::fromValidated($request->all());
Enter fullscreen mode Exit fullscreen mode

B. Importing large files (CSV/JSON): error accumulation

If you're importing 100,000 rows from a partner's CSV, failing on the first invalid row is a bad idea. You need to collect a detailed report of every error instead:

foreach (CsvUsers::rows($path) as $line => $row) {
    // Returns a HydrationResult object instead of throwing
    $result = UserImportData::fromValidatedResult($row);

    if (!$result->ok()) {
        $errors[$line + 2] = $result->errors(); // Store the error for the report
        continue;
    }

    ImportUserJob::dispatch($result->value()); // Process the successful DTO
}
Enter fullscreen mode Exit fullscreen mode

C. Lazy typed collections

So that huge imports don't eat up all the available memory, SDO v2 supports lazy hydration through generators:

// Each item is only hydrated at the moment it's iterated over
$lazyCollection = UserImportData::lazyCollection(CsvUsers::rows($path));

foreach ($lazyCollection as $user) {
    // Process one DTO at a time, keeping memory usage flat
}
Enter fullscreen mode Exit fullscreen mode

4. The SDO v2 feature map: everything in one place

SDO v2 uses PHP attributes as the single source of truth for the IDE, static analysis, validation and serialization.

Pipes for input cleanup

Pipes run before the constructor and casts are invoked. They're ideal for mechanical normalization (trimming stray whitespace, lowercasing, and the like):

use StdOut\SimpleDataObjects\Attributes\Pipe;
use StdOut\SimpleDataObjects\Pipes\TrimStringsPipe;
use StdOut\SimpleDataObjects\Pipes\NullifyEmptyStringsPipe;

#[Pipe(TrimStringsPipe::class, NullifyEmptyStringsPipe::class)]
final class ContactData extends BaseData
{
    public function __construct(
        public readonly string $email,
        public readonly ?string $note = null, // An empty string becomes null
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

Built-in and custom casts

SDO ships with a large set of ready-made casts: BooleanCast, IntegerCast, DateTimeImmutableCast, MoneyCast, JsonCast, EncryptedCast (backed by Sodium), and others. You can just as easily write your own by implementing the CastsValue contract.

PATCH API: working with the Optional type

The eternal REST API problem: how do you tell the difference between a client sending 'email' => null (they want to clear the field) and them not sending the 'email' key at all (they don't want to touch it)? SDO solves this elegantly:

use StdOut\SimpleDataObjects\Optional;

final class UpdateProfileData extends BaseData
{
    public function __construct(
        public readonly string|Optional $name,
        public readonly ?string|Optional $bio,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

If a key is missing from the request, the property receives an Optional object instead. During subsequent serialization, such fields are automatically skipped.

Contextual serialization: #[Hidden(except: [...])]

A classic problem: one DTO needs to serve both a public API and an admin panel, but not every field should be visible everywhere. Instead of duplicating classes or manually calling except() in every controller:

final class UserData extends BaseData
{
    public function __construct(
        public readonly string $name,
        #[Hidden(except: ['admin'])]
        public readonly string $email,
    ) {}
}

$data->toArray();                  // no email
$data->toArray(context: 'admin');  // includes email
Enter fullscreen mode Exit fullscreen mode

Each context compiles into its own specialized serializer and is cached separately, the same way the default one is — no runtime branching per field.

One contract for both backend and frontend (TypeScript)

No more manually keeping types in sync between PHP and JS/TS. SDO can generate JSON Schemas and TypeScript types directly from your DTOs:

php artisan sdo:typescript app/Data --output=resources/js/types/dto.d.ts
Enter fullscreen mode Exit fullscreen mode

5. Performance: numbers you can verify

SDO's performance is built on the principle of metadata compilation. Instead of parsing classes through the heavy Reflection API on every request at runtime (as most libraries do), SDO analyzes classes once, compiles optimized PHP files for hydration, which get cached by OPcache and run at native-code speed.

Comparative benchmark: SDO vs spatie/laravel-data

For a fair comparison, we built identical fixtures (the same field types, nested objects, and 20-item collections) and ran them in a clean Docker environment (php:8.4-cli-alpine, PHP 8.4.23, Laravel 12.65, Spatie 4.23, OPcache + JIT (tracing) enabled for the CLI SAPI — the base image ships with both off). We measured the pure library overhead (no I/O, DB, or network).

Here are the median throughput results (operations per second, higher is better):

Scenario Simple Data Objects (SDO) spatie/laravel-data Speedup
Flat hydration (simple DTO) 1,162,551 ops/s 83,789 ops/s 13.9×
Nested hydration 3,070,989 ops/s 94,773 ops/s 32.4×
Collection hydration (20 items) 183,041 ops/s 10,570 ops/s 17.3×
Date cast hydration 1,347,300 ops/s 119,791 ops/s 11.2×
Flat serialization (to array) 2,892,779 ops/s 171,100 ops/s 16.9×
Nested serialization 4,674,271 ops/s 164,027 ops/s 28.5×
Collection serialization (20 items) 312,904 ops/s 27,263 ops/s 11.5×
CSV streaming (100k rows)* 71,147 rows/s 38,866 rows/s 1.83×

*Note: the CSV-streaming gap is smaller because a large share of the time is spent parsing the file through native fgetcsv.

CPU load and memory usage

Speed also means smaller server bills. Let's look at pure CPU time (CPU microseconds per operation, lower is better):

Scenario SDO CPU us/op laravel-data CPU us/op
Flat hydration 0.84 us 11.86 us
Nested hydration 0.34 us 10.63 us
Collection hydration 5.45 us 96.64 us

And memory usage (retained memory per kept result, lower is better)?

  • Flat hydration: SDO uses 517 bytes/op vs 533 bytes/op for Spatie.

6. Smart trade-offs

An engineering approach demands honesty. Our benchmark compares against spatie/laravel-data, a very popular and mature library. That package deserves enormous respect in the PHP community. In fact, while designing and building Simple Data Objects, I was partly inspired by Spatie's own decisions — their attention to detail and excellent developer experience (DX).

spatie/laravel-data is an extremely powerful tool with deep integration into the Laravel ecosystem. If you need highly complex dynamic transformations, on-the-fly magic type resolution, or ready-made solutions for many Laravel-specific components out of the box — Spatie remains a great choice.

Simple Data Objects, on the other hand, was designed from day one under a completely different philosophy — it's tuned for uncompromising performance and low runtime overhead. If your hot paths (high-throughput APIs, queue workers, parsing large files, or data imports) suffer from noticeable CPU load and high memory usage — SDO offers a unique alternative. You get a strict, safe, and blazing-fast contract at a minimal execution cost.


Try it yourself!

All code examples, Docker configs, and the full test tooling live in a dedicated companion repository:

👉 simple-data-objects-benchmark on GitHub

You can fork it, run the benchmarks on your own hardware, or swap in your own payload shapes to see the difference in practice:

git clone https://github.com/std-out/simple-data-objects-benchmark.git
cd simple-data-objects-benchmark
make bench
Enter fullscreen mode Exit fullscreen mode

We'd love to hear your feedback, pull requests, or a star on GitHub!


What do you think of this approach to compiling DTOs in PHP? Share your experience in the comments!

Top comments (0)