Lightweight, attribute-driven DTOs for PHP 8.4+ — up to 60× faster hydration and serialization than the most popular alternative, zero reflection at runtime.
Works standalone or inside Laravel 12–13.
composer require std-out/simple-data-objects| 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 |
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, inside a fully booted Laravel app (not a synthetic standalone script). Medians of 5 runs:
| Scenario | Simple Data Objects | Popular alternative | Advantage |
|---|---|---|---|
| Hydration — flat DTO | ~6,550,000 ops/s | ~132,000 ops/s | ~50× faster |
| Hydration — nested DTO | ~3,390,000 ops/s | ~95,000 ops/s | ~36× faster |
| Hydration — collection of 20 | ~209,000 ops/s | ~10,200 ops/s | ~21× faster |
| Serialization — flat DTO | ~14,900,000 ops/s | ~249,000 ops/s | ~60× faster |
| Serialization — nested DTO | ~7,500,000 ops/s | ~166,000 ops/s | ~47× faster |
| Streaming — 100k-row CSV import | ~67,200 rows/s | ~35,800 rows/s | ~87% faster, same flat ~12 KB memory footprint |
Absolute numbers vary with hardware; the ratios stay stable across runs. CPU time per operation follows the same ratios — less CPU burned per request means more headroom per server. Streaming a large import with lazyCollection() keeps memory flat regardless of row count — the win there is architectural (no full materialization), not a per-row memory difference from the alternative, which also streams comparably once both sides are measured on equal footing.
Don't take the numbers on faith — run the benchmarks yourself: clone the companion repo, make bench, or swap in your own payload shapes.
use StdOut\SimpleDataObjects\BaseData;
use StdOut\SimpleDataObjects\Attributes\{Cast, Rules, Pipe};
use StdOut\SimpleDataObjects\Casts\DateTimeCast;
use StdOut\SimpleDataObjects\Pipes\TrimValuePipe;
class CreateOrderData extends BaseData
{
public function __construct(
#[Rules(['required', 'string', 'max:200'])]
#[Pipe(TrimValuePipe::class)]
public readonly string $title,
#[Rules(['required', 'email'])]
public readonly string $customerEmail,
#[Cast(new DateTimeCast('Y-m-d'))]
public readonly \DateTime $deliveryDate,
public readonly ?string $notes = null,
) {}
}
// validate → pipe → cast → hydrate
$order = CreateOrderData::fromValidated($request->all());
$order->title; // trimmed string
$order->deliveryDate; // \DateTime object
$order->toArray(); // ['title' => ..., 'customerEmail' => ..., 'deliveryDate' => '2025-01-15']
$order->toJson(); // JSON string
$order->with(notes: 'x'); // immutable copy with overrideTransform input before hydration, at class or property level:
use StdOut\SimpleDataObjects\Pipes\{TrimStringsPipe, NullifyEmptyStringsPipe};
use StdOut\SimpleDataObjects\Pipes\{TrimValuePipe, NullifyEmptyStringValuePipe};
// Class-level: runs on the entire input array
#[Pipe(TrimStringsPipe::class, NullifyEmptyStringsPipe::class)]
class ContactData extends BaseData { ... }
// Property-level: runs only on that field's value
class ProfileData extends BaseData
{
public function __construct(
#[Pipe(TrimValuePipe::class)]
public readonly string $name,
#[Pipe(TrimValuePipe::class, NullifyEmptyStringValuePipe::class)]
public readonly ?string $bio = null,
) {}
}Custom pipe in 3 lines:
final class UpperCasePipe implements ValuePipe
{
public function handle(mixed $value, string $paramName, callable $next): mixed
{
return $next(is_string($value) ? strtoupper($value) : $value);
}
}from() and toArray() compile a specialized closure per class — plain properties become direct array reads. Enable the file cache and the compiled code persists between requests:
// bootstrap / AppServiceProvider — run once
MetadataRegistry::setStoragePath(storage_path('framework/data-objects'));Pre-warm it on deploy so even the first request is hot:
vendor/bin/sdo-warm storage/framework/data-objects app/DataEvery worker then starts with opcache-compiled metadata and hydration/serialization code — zero reflection, zero compilation at runtime.
lazyCollection() hydrates one item at a time as the collection is consumed — peak memory stays flat no matter how many rows flow through:
foreach (UserData::lazyCollection($csvRows) as $user) {
$importer->process($user); // ~12 KB peak at 100k rows — flat regardless of row count
}$updated = $user->with(email: 'new@example.com'); // original unchanged
$updated->equals($user); // false
$user->diff($updated); // ['email' => ['old@...', 'new@...']]#[DataCollection(UserData::class)]
public readonly TypedDataCollection $members,
// IDE infers type throughout the chain:
$team->members->filter(fn (UserData $u) => $u->active)->first()->name;// In Laravel — call fromRequest() yourself, auto-validates
$data = CreateOrderData::fromRequest($request);
// Or register SimpleDataObjectsServiceProvider (opt-in, not auto-discovered)
// and skip FormRequest entirely:
public function store(CreateOrderData $data) { /* already validated */ }
// Standalone — no Laravel app needed
CreateOrderData::validate($rawArray); // throws ValidationExceptionfromResult() tries every field instead of stopping at the first one, with dot-paths for nested DTOs and collections:
$result = CreateOrderData::fromResult($request->all());
$result->ok(); // bool
$result->errors(); // ['deliveryDate' => 'Invalid date format', 'items.2.price' => '...']
$result->value(); // CreateOrderData — throws if !ok()
// fromValidatedResult() merges in #[Rules] failures the same wayOptional distinguishes "key not sent" from "key sent as null" — an omitted field means leave it untouched, not clear it:
class UpdateUserData extends BaseData
{
public function __construct(
public readonly string|Optional $name,
public readonly string|Optional|null $bio, // null = clear it, Optional = don't touch it
) {}
}
$data = UpdateUserData::from(['bio' => null]);
$data->definedOnly(); // ['bio' => null] — 'name' stays absent
$model->update($data->definedOnly());jsonSchema() walks the same metadata as from()/toArray() — no extra reflection — into a JSON Schema (draft 2020-12). TypeScriptGenerator/bin/sdo-typescript build on top of it for .d.ts output:
OrderData::jsonSchema();
// ['type' => 'object', 'properties' => [...], 'required' => [...], '$defs' => [...]]vendor/bin/sdo-typescript resources/js/types/data-objects.d.ts app/Dataexport interface OrderData {
id: number;
shippingAddress: AddressData;
status: 'pending' | 'shipped' | 'cancelled';
}| Attribute | Where | Effect |
|---|---|---|
#[Cast(new DateTimeCast('Y-m-d'))] |
property | type conversion on hydration + serialization |
#[Rules(['required', 'email'])] |
property | Laravel validation rules |
#[InferRules] |
class | auto-infer validation rules from property types |
#[Pipe(TrimValuePipe::class)] |
property | value-level preprocessing pipeline |
#[Pipe(TrimStringsPipe::class)] |
class | array-level preprocessing pipeline |
#[Flatten] |
property | inline nested DTO fields into parent |
#[Hidden(except: ['admin'])] |
property | exclude from toArray() / JSON, optionally per toArray(context:) |
#[IgnoreIfNull] |
property | omit from output when null |
#[Computed] |
method | add a derived, method-backed field to toArray() |
#[MapPropertyName('input_key', ...)] |
property | map input key(s) (aliases) → property, same name on output |
#[MapInputName] / #[MapOutputName] |
property | map hydration and serialization keys independently |
#[TransformKeys(TransformKeys::SNAKE_CASE)] |
class | transform all keys at class level |
#[DataCollection(ItemData::class)] |
property | typed collection of DTOs |
#[Discriminator('type', ['card' => CardData::class])] |
abstract class | polymorphic hydration — from() picks the subclass by field value |
#[WrapIn('data')] |
class | wrap toResponse()'s payload under a key |
DateTimeCast · DateTimeImmutableCast · EnumCast · BooleanCast · IntegerCast · FloatCast · TrimCast · JsonCast · EncryptedCast (XSalsa20-Poly1305)
Bug reports, feature ideas, and PRs are welcome — see CONTRIBUTING.md
for the dev setup (one make build away) and the quality bar (100% coverage, enforced in CI).
Release history lives in the CHANGELOG.
MIT — see LICENSE.