php-collective/dto
php-collective/dto is a lightweight PHP package for defining typed data transfer objects. Hydrate DTOs from arrays or Laravel requests, cast nested DTOs and collections, validate data, and keep application boundaries clean with simple, expressive classes.
Install, then look for the generated fast path before anything else. Run composer require php-collective/dto, read the README.md, but also check the service provider and src/ for a code-generation step. Recent releases compile DTO hydration into a generated fast-path file (with a strictTypes declaration) that bypasses reflection entirely. Find the artisan command or build step that produces it, decide now whether generated files are committed or built at deploy, and wire that decision into CI. If you skip this, you'll be running the reflection fallback and never notice.
Then confirm the API surface from tests/. For a small package (~30 stars) the tests are still the most reliable documentation of casting, nesting, collections, and — importantly — the TransformerRegistry and field-transform conventions.
Define your first DTO as a plain typed class. The selling point is that you don't write a constructor, setters, or a hydrator:
use PhpCollective\Dto\Dto;
class AddressDto extends Dto
{
public string $street;
public string $city;
public ?string $postalCode = null;
public string $country = 'NL';
}
Hydrate from the boundary you already have. In a controller, the first use case is validated request array → typed object:
public function store(StoreOrderRequest $request)
{
$address = AddressDto::fromArray($request->validated('address'));
// or straight from a JSON payload / third-party webhook
$payload = OrderDto::fromJson($request->getContent());
}
from() is the convenience entry point for mixed input — array, JSON string, or an Eloquent model's toArray().
Prove the round trip before building on it. Write one test asserting Dto::fromArray($data)->toArray() equals the input (modulo defaults and casting), including one nested DTO and one list of DTOs. Run it twice: once with the generated fast path present, once after clearing it. The fast path is compiled code — a divergence between the two modes is exactly the class of bug 0.1.20 fixes, so it's worth ten minutes to rule out on your own DTOs.
Request → DTO → service. Keep Form Requests doing authorization and validation; let the DTO carry shape:
$dto = CreateUserDto::fromArray($request->validated());
$user = $this->createUser->handle($dto); // action takes the DTO, not an array
This is where the package pays for itself: self-documenting action signatures, working static analysis, and refactors that rename properties instead of silently missing array keys.
Nested DTOs and collections. Model aggregates as nested typed properties and let the package hydrate children:
class OrderDto extends Dto
{
public string $reference;
public CustomerDto $customer; // nested DTO
/** @var OrderLineDto[] */
public array $lines = []; // collection of DTOs
public ?AddressDto $shippingAddress = null;
}
Annotate collection properties with @var FooDto[] — the fast-path generator needs the item type resolvable at compile time. Verify with dd($dto->lines[0]::class) on the first pass.
Custom casting belongs in the TransformerRegistry, not in your controllers. The package exposes a registry for globally registered casters (and serializers for the outbound direction). Register value-object casters there once — money, UUIDs, phone numbers — and every DTO in the app gets them:
// AppServiceProvider::boot() — confirm the exact API in tests/src
TransformerRegistry::register(Money::class, MoneyCaster::class);
This is the single most important integration point in the package. It means "stringly typed" vendor payloads can map to domain value objects without a hand-written mapper per endpoint.
Field transforms for name and format mismatches. transformFrom / transformTo let you map an inbound key or format onto a DTO property and back out again — the built-in answer to postal_code vs postalCode and Y-m-d vs ISO-8601. As of 0.1.20 these compile into a direct call in the generated fast path (roughly 4x cheaper per transformed field), provided the generated file declares strictTypes and the callable is statically resolvable. Unsafe or unresolvable callables fall back to the runtime transformValue() dispatch — correct, just slower, and silently so.
Generate the fast path as part of your build, and regenerate in CI on drift. Add the generation command to your deploy script (or a composer post-autoload-dump hook) and a CI job that regenerates and fails on a dirty diff. A stale generated file is the number one way to get "works locally, hydrates differently in production" bugs with this package.
Outbound DTOs for third-party APIs. Underrated direction. Define a DTO per external endpoint, build it in an adapter, send $dto->toArray()/toJson(). When the vendor renames a field you change one property instead of hunting Http::post([...]) calls. Register a serializer in the registry if the vendor needs specific output formatting, rather than formatting inside the DTO.
Eloquent ↔ DTO boundary.
OrderDto::from($model->toArray()) for simple tables, or explicit fromArray([...mapping...]) when column names differ. Prefer explicit mapping for anything long-lived — it's the seam that survives schema changes (or express the difference as a field transform on the DTO).Serialize at the response layer, not in the DTO.
return OrderResource::make($orderDto);
// or, for a raw JSON contract test:
return response()->json($orderDto->toArray());
Keep presentation in Resources so the DTO stays reusable across jobs, CLI commands, and tests.
Queue safety. Pass $dto->toArray() into the job constructor and rehydrate in handle(). Jobs then survive a DTO class change deployed mid-flight — a real operational concern when you deploy with jobs queued, and more so now that hydration behaviour is partly compiled.
Testing helpers. Add OrderDto::fake(['reference' => 'X']) per DTO, built on fromArray() with sensible defaults. Tests become about the field under test rather than satisfying required properties.
Stale generated code is the new #1 failure mode. The fast path is compiled from your DTO definitions. Change a property, a docblock, or a transform and forget to regenerate, and you get old behaviour with no error. Regenerate in CI, assert no diff, and never hand-edit generated files.
Upgrade to 0.1.20 if you use TransformerRegistry with the fast path. Before this release, generated hydration bypassed globally registered registry casters on fromArray(). If you registered casters and saw them work on some paths but not others, that was the bug — pin ^0.1.20 and re-run your round-trip tests against both the compiled and fallback paths.
Direction scoping changed in 0.1.20. Guards are now scoped so a caster only affects input and a serializer only affects output. If you previously relied on one caster doing double duty (or worked around the leaking behaviour), verify both directions after upgrading — this is the release most likely to change observed behaviour for existing DTOs.
Compiled transforms require strictTypes. Without it in the generated file you silently get the runtime dispatch: correct results, ~4x cost per transformed field, and no warning. If transform-heavy DTOs show up in a profile, check the generated header first.
Missing keys: null, default, or exception? Determine this early. If the hydrator leaves omitted properties at their declared default, a missing required field becomes null deep in your service. Declare explicit defaults on every property and rely on Form Requests for required-ness — don't assume the DTO validates anything.
Extra keys are usually ignored silently. Typos produce a null field and no error. Use field transforms for deliberate name differences, and add a toArray() round-trip assertion per integration boundary so accidental mismatches surface in CI.
Coercion surprises. "0", "false", "" differ across casters for bool; numeric strings from JSON and float precision are the two most common bug reports. One test per tricky scalar you actually receive, and narrow types (?int rather than int|string).
Nullability vs. nesting. If the payload has "customer": [] or "customer": null, decide whether you get null or an all-defaults CustomerDto. Don't let a half-populated child reach business logic.
Enum failures are loud but late. Invalid enum values throw during hydration — after validation passed — so they surface as 500s. Validate Rule::enum() in the Form Request or map hydration exceptions to 422 in your handler.
Dates: format and timezone. ISO-8601 with offset, Y-m-d H:i:s from MySQL, and Unix timestamps are three different things. Pick one convention per DTO field at the mapping seam; store DateTimeImmutable in UTC, format on output, and express input-format differences with a field transform rather than a mapper class.
Don't put behavior in DTOs. No repositories, no save(), no business rules. Derived fields → computed getter; decisions → an action.
Immutability and updates. If DTOs are readonly, an "update" means constructing a new instance — write a with…() helper or a dedicated UpdateOrderDto. Mixing mutable DTOs across layers causes the classic "who changed this field" session.
Performance in hot loops. The generated fast path largely removes the reflection penalty, so requests and jobs are fine. But benchmark before hydrating thousands of rows for a report: verify the fast path is actually in play, and if not, hydrate arrays from the query and construct DTOs only for returned rows.
Isolate the package behind your own seam. With a small maintainer surface and a fast-moving 0.1.x line, pin the version and route construction through a handful of places (a base App\Dto, a mapper, one service provider registering casters). If the API shifts — or you swap for spatie/laravel-data — you change one file per direction.
Debugging checklist. (1) dd($dto->toArray()) right after construction. (2) Check the property's type and docblock; confirm the generated file was rebuilt. (3) Confirm the config file was published and isn't shadowed by cached config (php artisan config:clear). (4) Confirm your TransformerRegistry casters are actually firing on fromArray() (the pre-0.1.20 bug) and that inbound/outbound guards behave as expected. (5) In tests, assert the round trip, not just hydrated values — that catches dropped or renamed fields.
How can I help you explore Laravel packages today?