std-out / simple-data-objects
Lightweight typed Data Transfer Object library for PHP 8.4+ with attribute-driven hydration
Requires
- php: ^8.4
- illuminate/contracts: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
- illuminate/validation: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- illuminate/database: ^10.0|^11.0|^12.0|^13.0
- illuminate/http: ^10.0|^11.0|^12.0|^13.0
- laravel/pint: ^1.0
- phpunit/phpunit: ^11.0
Suggests
- ext-sodium: Required to use EncryptedCast (bundled with PHP by default)
- illuminate/database: Required to use HasLaravelIntegration::fromModel()
- illuminate/http: Required to use HasLaravelIntegration::fromRequest() and toResponse()
README
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 |
| Peak memory — streaming 50,000 rows | 0.26 MB with lazyCollection() |
~13 MB | ~50× less memory |
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.
Quick Look
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 override
Killer Features
DataPipe — input preprocessing middleware
Transform 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); } }
Zero-reflection in production
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/Data
Every worker then starts with opcache-compiled metadata and hydration/serialization code — zero reflection, zero compilation at runtime.
Streaming large datasets
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); // 50k rows, ~0.26 MB peak instead of ~13 MB }
Immutable copies with with()
$updated = $user->with(email: 'new@example.com'); // original unchanged $updated->equals($user); // false $user->diff($updated); // ['email' => ['old@...', 'new@...']]
Typed collections with IDE generics
#[DataCollection(UserData::class)] public readonly TypedDataCollection $members, // IDE infers type throughout the chain: $team->members->filter(fn (UserData $u) => $u->active)->first()->name;
Validation anywhere
// In Laravel — fromRequest() auto-validates $data = CreateOrderData::fromRequest($request); // Standalone — no Laravel app needed CreateOrderData::validate($rawArray); // throws ValidationException
All Attributes
| Attribute | Where | Effect |
|---|---|---|
#[Cast(new DateTimeCast('Y-m-d'))] |
property | type conversion on hydration + serialization |
#[Rules(['required', 'email'])] |
property | Laravel validation rules |
#[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] |
property | exclude from toArray() / JSON |
#[IgnoreIfNull] |
property | omit from output when null |
#[MapPropertyName('input_key')] |
property | map different input key → property |
#[TransformKeys(TransformKeys::SNAKE_CASE)] |
class | transform all keys at class level |
#[DataCollection(ItemData::class)] |
property | typed collection of DTOs |
Built-in Casts
DateTimeCast · DateTimeImmutableCast · EnumCast · BooleanCast · IntegerCast · FloatCast · TrimCast · JsonCast · EncryptedCast (XSalsa20-Poly1305)
Contributing
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.
License
MIT — see LICENSE.