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| 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:
| 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.
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); // 50k rows, ~0.26 MB peak instead of ~13 MB
}$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 — fromRequest() auto-validates
$data = CreateOrderData::fromRequest($request);
// Standalone — no Laravel app needed
CreateOrderData::validate($rawArray); // throws ValidationException| 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 |
DateTimeCast · DateTimeImmutableCast · EnumCast · BooleanCast · IntegerCast · FloatCast · TrimCast · JsonCast · EncryptedCast (XSalsa20-Poly1305)
MIT — see LICENSE.