std-out/simple-data-objects
Lightweight, attribute-driven DTOs for PHP 8.4+ with zero runtime reflection and compiled per-class closures for fast hydration and serialization. Works standalone or in Laravel 12–13.
Install and check your runtime first. The package requires PHP 8.4+ and Laravel 12–13.
composer require std-out/simple-data-objects
php -v # must be 8.4+
That PHP floor is the one hard gate. Before you write a single DTO, confirm your CI image, your local Docker, and your production runtime all run 8.4 — nothing else in this package matters if one of them is on 8.3.
Where to look first, in order:
#[Rules] validates, #[Pipe] preprocesses, #[Cast] converts, from() hydrates.opcache.preload).from() became universal in 1.7.2).First use case: stop hand-writing your first DTO. Since 2.8.0 you can bootstrap from a payload you already have. Capture a real request body (or a row from the legacy table) and run:
# straight from a captured JSON response / request body
php vendor/bin/sdo-generate order-sample.json app/Data/Order
# or via the artisan wrapper
php artisan make:data --from-json=order-sample.json --path=app/Data/Order
You get one class per object shape, with property types, nested DTOs, #[DataCollection] for lists, and #[TransformKeys] / #[MapPropertyName] inferred from the keys. Add --optional if some keys are missing from some objects in your sample and you want it to round-trip exactly.
The Eloquent shortcut is the one to try on day one. If you already have a model, generate the DTO from a real record with its relations loaded:
php artisan make:data --from-model=Order --id=1 --with=items,shippingAddress
Relations become nested classes automatically. Open the output, delete the properties you don't want to expose, and you have a service-layer contract in one edit instead of one afternoon.
Then swap one FormRequest + array-mapping endpoint. Pick a POST controller that currently does $request->validate(), then hand-builds an array for a service class. That's the highest-value swap and it's reversible in one commit.
// app/Data/CreateOrderData.php
namespace App\Data;
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,
) {}
}
public function store(Request $request, CreateOrderAction $action)
{
$data = CreateOrderData::fromValidated($request->all());
$action->execute($data);
return response()->json($data->toArray(), 201);
}
Then wire up the cache — this is the step people skip and regret. Zero reflection in production only holds if you set a storage path and warm it:
// AppServiceProvider::boot()
use StdOut\SimpleDataObjects\MetadataRegistry;
MetadataRegistry::setStoragePath(storage_path('framework/data-objects'));
And in your deploy script, after composer install, before workers restart:
php vendor/bin/sdo-warm storage/framework/data-objects app/Data
Upgrading note: 2.8.0 is fully backward compatible with 2.7.x, so this is a safe minor bump — no migration, no behavior change. Take it for the generator alone.
Optional but worth trying early: SimpleDataObjectsServiceProvider is not auto-discovered. Register it explicitly and you can drop FormRequests entirely for DTO-typed controller arguments:
public function store(CreateOrderData $data) { /* already validated */ }
Try this on one endpoint before committing to it broadly — it changes error-handling semantics (see Gotchas).
The 2.8.0 generator is a scaffolding tool, and treating it as one is the difference between a fast adoption and a mess. The workflow that works:
app/Data.pivot, accessor-derived fields.#[Rules] beyond types, #[Hidden], #[Computed], casts for dates/money.sdo-warm, write one round-trip test.php vendor/bin/sdo-generate order-sample.json /tmp/gen/order
diff -ru /tmp/gen/order app/Data/Order # see what you're adopting
Generation is a one-shot bootstrap, not a build step. Once a class has hand edits, it is yours — regenerate into a temp dir and diff if you need to, but don't point the tool at a directory you've curated.
This is where the feature earns its keep beyond the first five minutes. Old SOAP/XML endpoints and hand-rolled APIs with inconsistent naming get a DTO with the mapping already inferred:
# XML attributes / text nodes / repeated elements all mapped
php vendor/bin/sdo-generate legacy-order.xml app/Data/Legacy
You end up with #[TransformKeys], #[MapPropertyName] aliases, and XML-shape handling generated from the actual document rather than reasoned about from a spec. That kills the tedious, error-prone half of an integration.
For the same result programmatically — useful in an importer, a one-off artisan command, or a test fixture builder:
use StdOut\SimpleDataObjects\Generation\DataClassGenerator;
$generator = DataClassGenerator::fromValue($payload); // array or object
$generator->write(app_path('Data/Order'));
Same rules apply: generate to a scratch path, review, then adopt.
The pattern that pays off daily: controller validates and hydrates into a DTO, action/service classes accept only DTOs, never arrays or Request. Downstream code becomes typed, IDE-navigable, and trivially testable without HTTP.
final class CreateOrderAction
{
public function execute(CreateOrderData $data): Order { /* no $request here */ }
}
Migrate one endpoint at a time. A DTO is cheap to add and cheap to delete — that reversibility is the reason to adopt this incrementally rather than refactoring the whole app. The generator just makes "cheap to add" cheaper.
fromResult() over fromValidated() for API endpointsfromValidated() throws on the first failure. fromResult() evaluates every field and returns a dot-path keyed error map — which is what your frontend wants for a 422.
$result = CreateOrderData::fromResult($request->all());
if (! $result->ok()) {
return response()->json(['errors' => $result->errors()], 422);
}
// ['deliveryDate' => 'Invalid date format', 'items.2.price' => '...']
fromValidatedResult() merges #[Rules] failures into the same structure, so validation and hydration errors arrive in one consistent shape.
Frontend note: keys are dot-paths with numeric indexes (items.2.price). If you're rendering field-level errors, make sure your JS error-map library handles dotted paths, or flatten them server-side.
Keep them mentally separate. Pipes transform strings on the way in; casts transform types.
#[Pipe(TrimStringsPipe::class, NullifyEmptyStringsPipe::class)] // class level: whole array
class ContactData extends BaseData
{
public function __construct(
#[Pipe(TrimValuePipe::class, LowercaseValuePipe::class)] // property level
#[Rules(['required', 'email'])]
public readonly string $email,
) {}
}
LowercaseValuePipe / UppercaseValuePipe (added in 1.9.0) are the correct place for currency codes, country codes, and email normalization — cheaper and more reliable than prepareForValidation(). Non-string values pass through untouched, so they're safe on ?string and union-typed properties.
Custom pipes are three lines and are a good place to put domain normalization you'd otherwise sprinkle across controllers:
final class StripCurrencySymbolPipe implements ValuePipe
{
public function handle(mixed $value, string $paramName, callable $next): mixed
{
return $next(is_string($value) ? preg_replace('/[^\d.,-]/', '', $value) : $value);
}
}
Flat DTOs are fast, but the ~36× nested / ~21× collection ratios are what show up in a real order-with-line-items endpoint.
class OrderData extends BaseData
{
public function __construct(
public readonly int $id,
public readonly AddressData $shippingAddress,
#[DataCollection(OrderLineData::class)]
public readonly TypedDataCollection $lines,
) {}
}
The #[DataCollection] attribute is also what makes ->filter(fn (OrderLineData $l) => ...)->first()->sku type-safe in your IDE. Use it instead of array whenever the elements are DTOs — you get both the generics and the hoisted-hydrator optimization from 1.7.2. The generator emits #[DataCollection] for you when it sees a consistent list shape, which is a good reason to feed it a sample with several list entries rather than one.
lazyCollection() for imports, not for eager workforeach (UserData::lazyCollection($csvRows) as $user) {
$importer->process($user);
}
Flat memory at 100k rows (~12 KB in their benchmark). Two rules of thumb:
lazyCollection() when memory is the binding constraint — large CSV imports, queued exports, sitemap generation.Also consider fromLazy() for cast-heavy DTOs where only a fraction of objects get read (PHP 8.4 lazy ghosts; hydration deferred to first property access). The README cites ~10% read rate for the ~3× figure — measure your own ratio before adopting it, because if you read most fields you pay the deferral for nothing.
This is the feature that makes adoption practical on an existing API with legacy field names.
// Accept 'email_address', expose 'customerEmail'
#[MapInputName('email_address')]
public readonly string $customerEmail;
// Accept multiple aliases
#[MapPropertyName('email_address', 'email')]
public readonly string $customerEmail;
// Whole class: snake_case in, snake_case out
#[TransformKeys(TransformKeys::SNAKE_CASE)]
class OrderData extends BaseData { ... }
Roundtrip is guaranteed: from(toArray()) always works, including mapped keys. So you can hydrate from a legacy payload, serialize back to it, and not lose data on the way through. Since 2.8.0 the generator infers these attributes from your sample, so the roundtrip property holds by construction on day one — verify it with a test rather than assuming it.
Use #[TransformKeys] at class level for outbound JSON when your frontend expects snake_case — this replaces a whole API Resource layer for straightforward DTOs.
Hidden, IgnoreIfNull, Computed, WrapInThese four attributes let you delete a lot of hand-written toArray() / Resource code:
#[Hidden(except: ['admin'])] // excluded from toArray()/JSON, context-aware
public readonly string $internalCost;
#[IgnoreIfNull]
public readonly ?string $notes;
#[Computed]
public function displayName(): string { return "{$this->first} {$this->last}"; }
#[WrapIn('data')]
class OrderData extends BaseData { ... }
#[Hidden(except: [...])] is the interesting one — call $order->toArray(context: 'admin') and hidden fields reappear. That's a clean way to serve one payload shape to two audiences without duplicating serializers. Caveat: computed fields execute on every toArray() call, so keep them cheap — no N+1 queries from inside a #[Computed] method.
None of these are generated. A generated class exposes everything in the sample, so the pruning pass is where you decide what leaves the server.
Optional + definedOnly()class UpdateUserData extends BaseData
{
public function __construct(
public readonly string|Optional $name, // absent = don't touch
public readonly string|Optional|null $bio, // null = clear it
) {}
}
$data = UpdateUserData::from($request->all());
$model->update($data->definedOnly()); // only keys actually sent
This is the most under-appreciated feature in the package. It cleanly distinguishes "not sent" from "sent as null," which is exactly the distinction Eloquent's update() cannot express and which every hand-rolled PATCH endpoint gets wrong at least once.
--optional will produce exactly this shape for you — which is a great head start, and also the fastest way to acquire the union-type bug described in Gotchas. Read the generated unions line by line.
$updated = $user->with(email: '[email protected]'); // original unchanged
$user->diff($updated); // ['email' => ['old@…', 'new@…']]
$updated->equals($user); // false
diff() is a ready-made audit-log payload — feed it straight into an activity_log entry or a domain event. For queue payloads, serialize with toJson() and rehydrate with fromJson() (an alias of from() since 1.7.2) rather than serializing the DTO itself.
#[Discriminator('type', ['card' => CardData::class, 'sepay' => SepayData::class])]
abstract class PaymentMethodData extends BaseData { ... }
PaymentMethodData::from($webhookPayload); // resolves to the right subclass
Use this for inbound third-party webhooks where the payload shape is determined by a type field. It keeps one integration boundary instead of a match statement in every consumer. The generator does not infer discriminators — generate one class per variant from one sample per variant, then wire the discriminator by hand.
php vendor/bin/sdo-typescript resources/js/types/data-objects.d.ts app/Data
Because jsonSchema() walks the same metadata as from() / toArray(), the generated .d.ts cannot drift from what the server actually hydrates — until someone forgets to regenerate. Add it to your build script or a CI check that fails on diff:
php vendor/bin/sdo-typescript /tmp/sdo.d.ts app/Data
diff -q /tmp/sdo.d.ts resources/js/types/data-objects.d.ts
The two generators compose well: sdo-generate on the way in from a legacy payload, sdo-typescript on the way out to the frontend.
Mirror your domain, not your HTTP layer:
app/Data/Order/CreateOrderData.php
app/Data/Order/OrderData.php
app/Data/Order/OrderLineData.php
app/Data/Shared/AddressData.php
Name them after the operation (CreateOrderData, UpdateUserData) rather than the entity when input shapes differ from output shapes. Both sdo-warm and sdo-typescript scan PSR-4 directories for concrete BaseData subclasses, so a clean app/Data tree pays off directly in warm-up time, in generated .d.ts quality, and in failing fast on invalid DTO definitions.
The generator does not know your domain names. It names classes from the key that held them, so an anonymous object inside items[] becomes something like ItemsData or ItemData. Rename in the pruning pass — before the name is referenced from five places.
Generated code is a snapshot of one payload, not a contract. The generator infers types from the values it sees. "1" and 1 are different samples; a key that happens to be null in your sample gives the tool nothing to work with; a list with one element gives no evidence about element shape consistency. Practical mitigations:
null, either include a second sample with it populated, or accept the loose type and tighten it by hand.--optional is the escape hatch for sparse samples, but it over-applies: keys the API always sends become T|Optional too, which weakens the type everywhere downstream. Prefer a complete sample over --optional when you can get one.--from-model round-trips through toArray(), so $hidden and $visible decide what gets generated. If a column is hidden on the model, it simply won't appear as a DTO property. That's usually the behavior you want and always the behavior you should verify — compare the generated property list against the model's $fillable / schema before assuming nothing is missing. Appended accessors and mutated casts also come through as their serialized form, so a datetime cast that serializes as "2026-09-04T14:42:57.000000Z" will generate a string property, not a DateTime one. Fix those with an explicit cast in the pruning pass.
--with loads real relations — watch the cost. --from-model=Order --id=1 --with=items,shippingAddress executes the query. Point it at a modest record on a dev or read replica, not at order #1 in production with 4,000 line items. And nested-of-nested relations are not loaded for you; add them to --with explicitly or accept a missing nested class.
The validation→pipe→cast order bites you. The README comments the pipeline explicitly as validate → pipe → cast → hydrate. That means #[Rules] see raw input values, not cast ones. A #[Rules(['date_format:Y-m-d'])] rule on a property cast with DateTimeCast must validate the incoming string, not the resulting \DateTime. Write rules against the wire format, always. If you find yourself wanting to validate the cast output, that's a signal to do it in the constructor or a domain method.
The service provider is opt-in, and that has a sharp edge. It is deliberately not auto-discovered. If you register it, controller injection of a DTO validates automatically — which means a validation failure throws a ValidationException before your controller body runs. That's fine for HTML form flows but produces inconsistent JSON error shapes if some endpoints use fromResult() and others rely on injection. Pick one strategy per surface: injection for form posts, fromResult() for JSON APIs.
Warm the cache on every deploy — including after generating classes. MetadataRegistry::setStoragePath() is a static — call it once in AppServiceProvider::boot() and nowhere else. Two operational traps:
sdo-warm will fail rather than create your parent structure silently.sdo-warm, you can serve stale compiled hydrators. Make sdo-warm a mandatory deploy step and run it after the code is in place, before FPM/queue workers restart. Newly generated classes are no exception — a class that isn't warmed pays reflection on first hit per worker.The good news: cache format v2 stores the compiled hydrator and serializer inside the .meta.php file, so a warmed worker pays neither reflection nor eval — opcache serves the whole thing. Legacy v1 cache files still load, so a rolling deploy won't hard-fail mid-flight, and 2.8.0 is backward compatible with 2.7.x so the generator introduces no new cache-format risk.
"Zero reflection" is a warm-path claim, not a cold-path one. The first hit on each class in each process compiles the closure. A cold worker is not zero-reflection. If your performance argument depends on this, verify you actually warmed — don't assume the composer install did it.
Lazy DTOs and queues. fromLazy() uses PHP 8.4 native lazy ghosts, initializing on first property access. If you push a lazily-hydrated DTO onto a queue, serialization will force initialization — you gain nothing and pay the deferral. Hydrate eagerly for anything crossing a process boundary. The generator never emits fromLazy() calls, so this only comes up if you add them by hand.
from() accepts Eloquent models — and inherits their $hidden. Universal from() (1.7.2) accepts Arrayable, stdClass, JsonSerializable, JSON strings, Traversable, and same-class instances. When you pass a model, it goes through toArray(), which respects the model's $hidden / $visible. A DTO property named for a hidden column will silently hydrate as absent. Be explicit with $model->only([...]) when the mapping matters.
Don't try to use DTOs as Eloquent models. Keep the boundary explicit: Order::create($data->toArray()). toArray() applies your #[Hidden], #[IgnoreIfNull], #[Computed], and key transforms — so mass-assignment from a DTO's output array can quietly write computed or transformed keys. Map explicitly for writes, or use definedOnly() for PATCH. This is sharper right after generating a class, because a generated DTO's toArray() is close to a passthrough of the row you sampled — it looks safe and isn't.
#[Hidden] hides from output, not from memory. Hidden properties still hydrate, still cast, still cost. If you're hydrating a huge payload only to strip most of it, use fromResult() on a narrow DTO instead, or fromLazy().
Optional requires care in union types — and --optional hands you a batch of them. string|Optional and string|Optional|null are different contracts, and the difference is exactly "absent means leave alone" vs. "null means clear." Get the union wrong and PATCH silently resets fields — this is the highest-consequence typo in the whole package, and generated code multiplies the number of places it can happen. Review every generated |Optional union by hand, and write a test per PATCH field asserting definedOnly() output.
XML inference has real ambiguity. Attributes, text nodes, and child elements can all carry the same value in different documents, and namespaces get flattened. Generate from XML, then open the sample and the class side by side and confirm the #[MapPropertyName] / #[TransformKeys] choices match what your consumer actually sends. Repeated elements become #[DataCollection] only when the shape is structurally consistent across the sample — one repeated element is not evidence.
Date serialization format leaks into your API contract. DateTimeCast('Y-m-d') controls both directions, so your API's date format is defined in a DTO attribute, not in a controller. That's good — but it means changing a cast format is a breaking API change. DateTimeImmutableCast is usually the better default for DTO readability; pick one per codebase and be consistent, or you'll get mixed Z-suffixed and non-Z dates across endpoints. Generated classes will default to string for date-looking values; converting them to casts is a deliberate step, so do it in the same commit as the generation rather than "later."
The generator can't infer rules — this is the boundary between it and #[InferRules]. #[InferRules] derives validation from property types, which is a great boilerplate reducer for simple DTOs. But inferred rules can't know your business constraints (max:200, uniqueness, in: lists), so keep #[Rules] explicit where the rule matters and reserve #[InferRules] for internal/queue DTOs where "right type" is genuinely sufficient. Mixing the two on one class works, but read the merged output in a test — silent rule overrides are painful to debug. A freshly generated class has no constraints at all, so "generate then add rules" is the expected sequence, not an optional polish step.
Keep the sample in the repo. Generated code with no provenance is impossible to re-derive six months later when the payload changes. Commit tests/Fixtures/samples/order.json next to the DTO it produced and note the source in a comment. That gives you a regression fixture, a re-generation input, and documentation in one file.
Standalone validation is a real feature — use it in tests and console commands. CreateOrderData::validate($array) throws without a Laravel app. This makes DTO validation testable in pure unit tests (no RefreshApplication, no HTTP) and usable in artisan commands and importers. It's the cheapest way to get high coverage on your input contracts — and the natural companion test to a generated roundtrip assertion (from(toArray()) equals the input).
Pin the version and wrap the dependency. 35 stars and zero dependents as of this writing means you are near the front of the adoption curve, and the changelog shows real pre-1.0-style churn — the interpreted Hydrator was removed in 1.4.3, and fromJson() changed meaning in 1.7.2. 2.8.0 is explicitly backward compatible with 2.7.x, so bumping is low-risk, but the discipline still applies:
composer.json.app/Data — no BaseData subclass leaking into domain services. The generator reinforces this naturally: it only writes into the directory you point it at.Benchmarks are directional, not a target. The 20k-iteration, fully-booted-Laravel methodology is sound, but the ratios come from one hardware profile and one comparison library. If you're justifying this package on performance, run the companion benchmark repo with your own payload shapes. Realistically, most Laravel apps will see the architectural wins (typed collections, streaming, Optional, diff()) — and now the codegen wins — long before the raw ops/s matter.
Debugging tip: when a field arrives null unexpectedly, the pipeline order means the culprit is one of four places — the key mapping (MapInputName / MapPropertyName / TransformKeys), a class-level pipe stripping it, a property-level NullifyEmptyStringValuePipe, or the cast. On generated classes add a fifth: the inferred key name may just be wrong for your traffic, because it was inferred from one sample. jsonSchema() on the DTO is a fast way to confirm what the package thinks the property is named and typed before you start adding dump() calls — and comparing that schema against a fresh capture of the real payload is the quickest way to spot generator drift.
How can I help you explore Laravel packages today?