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.
Architecture fit. The package sits at the boundary of the application, which is the right place for a DTO layer: request payloads in, API responses out, third-party webhooks parsed, CSV/queue batches streamed. It is not auto-discovered and works standalone, so it does not modify Laravel's container or boot sequence unless you opt in. That is architecturally clean — nothing in the domain core has to know about it. The one design decision to police is inheritance: every DTO extends StdOut\SimpleDataObjects\BaseData, so DTOs become vendor-coupled. That is acceptable at the edge, unacceptable in the domain model. Keep the rule explicit: no entity, aggregate, or domain service may extend BaseData. This rule now needs a mechanical enforcement point, because v2.8.0 ships a generator that will happily emit BaseData subclasses wherever you point it — constrain the output directory in the tooling config, not just in the style guide.
The feature set maps unusually well to work we already do badly. fromResult() with dot-path error accumulation replaces a large amount of hand-rolled 422 assembly. Optional gives correct PATCH semantics (absent vs. explicit null) that we currently approximate with array_key_exists checks. #[Hidden(except: [...])], #[Computed], and #[Flatten] cover most of what API Resources do, with less ceremony. jsonSchema() plus the TypeScript generator is the highest-value item in the package from a product standpoint: one annotated class becomes the runtime contract, the OpenAPI fragment, and the frontend .d.ts, which removes drift between backend and frontend teams.
v2.8.0 changes the shape of the adoption story, not the runtime. The release adds a document-driven class generator: sdo-generate from sample JSON/XML/stdin, DataClassGenerator::fromValue(), and make:data variants including --from-model=Order --id=1 --with=items. Three things follow from that:
string in the sample but sometimes an integer in production becomes a typed property with a cast that coerces or throws. A key absent from the sample is only marked T|Optional if you remember --optional. An empty array tells you nothing about the element type. A nullable field present as null is a different case from a field that is absent. These are precisely the silent-wrong-output failure modes we already flagged for stale metadata caches, arriving via a different door.The honest framing for the decision log: the generator is a scaffolding tool, not a contract-authoring tool. Generated classes are a first draft that a human freezes, reviews, and then owns. Any workflow where regeneration happens continuously from a live sample is a liability, because it lets production shape drift into the type layer without review.
Integration feasibility. Low. Composer install plus MetadataRegistry::setStoragePath() in a service provider. Validation works with Laravel's validator, so existing rule strings, custom rules, and message translations should carry over — worth confirming for Rule::when, sometimes, and conditional rules before committing. from() accepts Eloquent models and Arrayable, so existing resource/transformer code has a mechanical migration path. The opt-in service provider means we can adopt it in one module without touching the rest of the app — the single most important property for approval. The 2.8.0 additions are explicitly backward compatible with 2.7.x and are purely additive, so nothing in the existing feasibility picture changes.
Technical risk. Four items dominate, none of them about code quality.
eval/var_export-style cache files) are not trivially forkable by a generalist.--from-model=Order --id=1 requires the tooling to open a database connection, which raises a governance question about which database we point it at (and whether generated files or fixtures can carry real customer data into the repo), and make:data --from-model with --with=items produces nested classes whose shape depends on the loaded relations of one record, so relationship coverage is whatever that record happened to have.Key questions.
.meta.php survives, what happens?#[Rules] accept full Laravel rule objects, Rule::when/Rule::unless, bail, and nested wildcards (items.*.price)? Do translations and :attribute substitution still work?#[Rules], #[Hidden], #[Computed]? If it is structure-only, the saved effort is transcription, not design — the estimate should say so.null values, keys absent from some objects, empty arrays, heterogeneous arrays, numeric strings, ISO-8601 date strings versus arbitrary strings, and booleans arriving as 0/1? Each of these has a defensible-but-wrong default.--force, and does the tool warn when the destination class has been hand-edited?-) in CI, so we never need committed sample fixtures that might contain production data?--from-model: which connection does it use, does it respect our app config, and does it write any sampled values (fixtures, doc comments, defaults) into the generated source?sdo-warm write, and is that path shared/per-worker? How does blue/green or rolling deploy interact with a cache directory that persists across releases?fromResult() entry, or silent coercion? Especially IntegerCast/BooleanCast on "abc".EncryptedCast (XSalsa20-Poly1305) expose key handling, and does it fit our KMS/key-rotation story?lazyCollection() — the README's ~12 KB is credible only because it streams; confirm against our widest row.Stack fit. PHP 8.4+ and Laravel 12–13 only. If we are not already there, this becomes a two-phase initiative: platform upgrade, then package adoption. The package also claims Symfony/Slim/PSR-7 support and an opcache.preload recipe, which matters if the same DTOs need to appear in a worker, a queue consumer, or a CLI importer. The generator is a dev-dependency concern only; it does not appear in the production stack fit at all, which is a point in its favour.
Migration path. Do not attempt a big-bang replacement of FormRequests and API Resources. Five stages, each independently shippable and reversible:
sdo-generate and make:data --from-model against two real endpoints and grade the output. That is the cheapest possible test of whether the generator is a real accelerant or a novelty.Optional applied deliberately, validation added by hand).#[Hidden]/#[Computed]/#[Flatten] are drop-in against existing Resources. The generator is least useful here — response shapes usually need judgement — so this stays largely manual and that is fine; a regression here is visible in contract tests immediately.fromResult()-style error accumulation. Publish a shared error-shape contract so the API error envelope stays consistent across converted and unconverted endpoints during the transition.lazyCollection() for CSV/queue imports and report generation, where the flat-memory property is the actual justification. This is where the ROI is largest and where a failure is most visible, so it goes last with load tests attached. Note that XML ingestion here pairs naturally with --from-xml, but only for the structural skeleton — streaming and memory behaviour must be validated empirically.Compatibility. Keep BaseData at the boundary, keep existing FormRequests in place for endpoints with conditional authorization logic, and keep API Resources for anything with deeply conditional linking. Both can coexist indefinitely. Optional and PATCH handlers are worth adopting early because they fix a correctness gap, not just a performance one. 2.8.0 is additive and 2.7.x-compatible, so version pinning is unaffected; the generator is a new capability on an existing pinned version, not a reason to change the pin.
Sequencing with the schema pipeline. Ship sdo-typescript in CI early — it is cheap, and it makes the DTO layer visibly valuable to the frontend team before the backend migration is complete. Generate and diff the .d.ts in CI so contract drift fails the build rather than surfacing as a runtime bug. Do the inverse for the DTO generator: do not wire sdo-generate into CI regeneration. The asymmetry is deliberate — .d.ts is derived output, DTO classes are source.
Deploy sequencing. vendor/bin/sdo-warm must become a mandatory, fail-the-build step after composer install and before traffic. It already fails fast on invalid DTO definitions, which makes it a free static-analysis gate on the DTO layer — that is a genuine bonus, not just cache warming. Generated classes go through the same warm step as hand-written ones, so a bad generation fails at deploy rather than in production, provided the warm step is blocking.
Maintenance. Per-DTO maintenance effort is near zero; the cost concentrates in four places: the deploy pipeline (warm step, cache directory lifecycle), the shared DTO conventions document (naming, when to use a pipe vs. a cast, output mapping rules), the fork, and — new — the ownership of generated code. Generated classes are unauthored code that we nonetheless maintain forever. The convention must be that whoever runs the generator owns the review, and that the diff, not the generator, is the unit of change. Budget a named owner — one engineer with enough PHP internals fluency to read the compiler code — or accept that an upstream bug blocks us until the maintainer responds.
Support. No commercial support, single maintainer, small community. That is a real but bounded exposure because the surface area is small and MIT-licensed. Mitigation: pin the version exactly, vendor the fork into our own Git namespace, and require an ADR before any upgrade. The 100% coverage and security workflow materially reduce the odds of a latent defect, but they do not reduce the odds of abandonment. The generator adds a small amount of new upstream surface to that fork obligation, but it is dev-time code — a bug there blocks authoring, not production, which is the cheapest possible place for an upstream defect to live.
Scaling. The performance story is a genuine capacity lever, not a vanity metric — the win is CPU-seconds per request, which translates into fewer instances for the same throughput. Treat the headline multipliers (~50×/60×) as upper bounds against a reflection-based alternative; the honest planning number is the streaming case (~87% on bulk import) plus a modest latency win on ordinary endpoints. The fromLazy() option is a real lever for cast-heavy DTOs where only ~10% of properties are read, but it hides hydration errors until first access — adopt it selectively, not by default. 2.8.0 changes none of this.
Failure modes, in rough order of likelihood and severity:
| Failure | Impact | Detection | Mitigation |
|---|---|---|---|
| Stale compiled cache after DTO change | Wrong output, silently | Contract tests only | Warm step in deploy; clear cache dir per release; schema-diff in CI |
| Generated type narrower than reality (single-sample inference) | Coercion or exception on real traffic, silently wrong for a while | Contract/E2E tests on real shapes | Widen types in review; sample from multiple records; prefer fromResult() on untrusted input |
| Generated class clobbers hand-edited DTO | Lost work; possible silent behaviour change | Code review of the diff only | Generator output is a reviewed commit; never regenerate in CI; check overwrite policy first |
Real customer data in generated source or fixtures (--from-model) |
Data-protection incident | Manual review, if at all | Point at read replica or committed sanitized samples; ban --from-model against production |
| Invalid DTO definition at deploy | Deploy fails | Immediate, loud | Good failure — keep warm step blocking |
| Cast coercion on malformed input | Bad data persisted | E2E/contract tests | Prefer fromResult() over from() for untrusted input |
| Cache dir permissions / disk | Cold-path reflection on every request | Latency dashboards | Health check that warms and reads the cache path |
| Upstream abandonment | Blocked on bugs | n/a | Fork, pin, ADR |
| Base class creep into domain | Architectural erosion | Code review | Lint rule or architecture test forbidding BaseData outside the boundary namespace; generator output directory constrained to that namespace |
Ramp-up. The attribute-driven API is small and learnable — most engineers will be productive in a day. Two things need real onboarding rather than docs: pipe authoring (ValuePipe/array-pipe contract, three lines but conceptually middleware) and the deploy/cache model, which is where mistakes are expensive and invisible. The generator lowers the floor further — an engineer can now produce a plausible DTO without understanding the package at all — which is a benefit for velocity and a hazard for judgement. Counter it directly: teach the review checklist (when to widen an inferred type, when to add Optional, when to reject the generated class entirely) as part of the 60–90 minute walkthrough, and make the "DTO cookbook" of five approved patterns explicitly cover generated output. The #[InferRules] attribute should be banned by convention initially — inferred validation is exactly the kind of implicit behaviour that produces a production incident nobody can trace, and an inference-from-sample generator sitting next to an inference-from-reflection validator is a combination that will eventually be used together by someone who has not read this document.
Verdict for the decision log. v2.8.0 does not change the gating conditions (PHP 8.4, cache-invalidation answer) and does not change the production risk profile, but it materially improves the business case by cutting the authoring cost of the migration we were already recommending. Adopt the generator in the same narrow, supervised role we would give any scaffolding tool: bootstrap only, output reviewed and committed as hand-owned source, never wired into CI regeneration, never pointed at production data. The performance claims remain the least interesting reason to adopt the package; the contract generation, the error accumulation, and the streaming memory profile are the durable ones. Hold off on lazyCollection() in critical paths and fromLazy() everywhere until the first two conversion phases have run in production for a release cycle.
How can I help you explore Laravel packages today?