yorcreative/laravel-argonaut-dto
Laravel Argonaut DTO transforms arrays, objects, and collections into structured, immutable DTOs with deep nested casting, type-safe conversion, Laravel validation, attribute prioritization, and clean toArray/toJson serialization.
Install and orient (5 minutes)
composer require yorcreative/laravel-argonaut-dto
There is no config file to publish, no service provider to register, no migration. The package is two classes plus traits. Before writing a single DTO, open these two files in vendor/yorcreative/laravel-argonaut-dto/src/:
ArgonautDTO.php — the constructor, setAttributes, the cast resolution logic, and how rules() is invoked.ArgonautAssembler.php — how assemble/fromArray/fromCollection resolve the to<ClassName>/from<ClassName> method via class_basename.That reading is worth more than the README, because the whole package's behavior hinges on convention resolution you can't see from the docs alone.
First use case: normalize an external payload.
The highest-value first application is a boundary you don't control — a webhook body, a third-party API response, or a CSV import row. Define the shape you want, and let an assembler map the shape you got:
class CustomerDTO extends ArgonautDTO
{
public string $name;
public string $email;
protected array $casts = [
'name' => 'string',
'email' => 'string',
];
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email'],
];
}
}
class CustomerDTOAssembler extends ArgonautAssembler
{
public static function toCustomerDTO(object $input): CustomerDTO
{
return new CustomerDTO([
'name' => $input->full_name ?? $input->name,
'email' => $input->email_address,
]);
}
}
$dto = CustomerDTOAssembler::assemble($payload, CustomerDTO::class);
If that throws, the payload was wrong — and it throws at the edge, not three service layers deep. That is the whole pitch.
One DTO + one assembler per external source. Don't build a universal UserDTO consumed by every integration. Build StripeCustomerDTO, CsvCustomerDTO, and map each into your domain object. Assemblers are cheap; untangling a DTO that serves five callers is not. The naming convention (to<ClassName>) means the assembler method name is fixed by the DTO basename, so keeping DTOs distinct also keeps assembler methods unambiguous.
FormRequest → DTO → Action. Drop validation from the FormRequest into the DTO's rules() so the same guarantee holds whether input arrives via HTTP, a queued job, or a console command:
public function store(StoreOrderRequest $request)
{
$dto = OrderDTOAssembler::assemble($request->validated(), OrderDTO::class);
return new OrderResource($this->createOrder->handle($dto));
}
Your action's signature becomes handle(OrderDTO $order) — explicitly typed, no array $data bags, and trivially testable without a request.
Aggregate payloads with nested casts. The cast syntax is the part worth memorizing:
| Need | Cast |
|---|---|
| Scalar | 'string', 'int', 'bool' |
| Single nested DTO | UserDTO::class |
| Array of DTOs | [ProductFeatureDTO::class] |
| Collection of DTOs | Collection::class . ':' . ProductReviewDTO::class |
For aggregates (order → lines → product), define one assembler method per node type — toOrderDTO, toOrderLineDTO, toProductDTO — and the recursion resolves itself as long as each method exists on the same assembler.
Nested assemblers when keys don't match your DTO. If a nested field arrives with a different internal shape (display_name vs name), a plain cast will fail or produce garbage. Add:
protected array $nestedAssemblers = [
'user' => UserDTOAssembler::class,
];
The assembler runs before the cast, so raw input is preprocessed first. This is the correct tool for "third-party nested object → my DTO."
Use DI assemblers for normalization services. When mapping needs a formatter, a slugifier, or a repository lookup, make the assembler method non-static and let the container inject:
class UserAssembler extends ArgonautAssembler
{
public function __construct(protected UserFormattingService $formatter) {}
public function toUserDTO(object $input): UserDTO
{
return new UserDTO([
'name' => $this->formatter->formatName($input->first_name, $input->last_name),
'email' => strtolower($input->email),
]);
}
}
// bind in a provider, then:
$dto = resolve(UserAssembler::class)->assembleInstance($input, UserDTO::class);
The rule: static assembler methods get no DI, instance methods do. Pick per-assembler and stay consistent — mixing the two in one class works but produces confusing call sites (assemble(..., $instance) in some places, not others).
Serialize at the exit boundary. toArray() / toJson() give you a stable contract for API resources, cache payloads, and queued job arguments. Because the DTO enforces shape on entry, your serialized output is predictably shaped on exit — that symmetry is what makes DTOs worth the class count.
Test the assembler, not the framework. Your unit tests should assert $dto->email === '[email protected]' and that a missing required field throws. Mock the injected formatting service so the test is about mapping, not about Laravel's container.
The naming convention is magic and it will bite you. class_basename strips the namespace, so two DTOs named UserDTO in different namespaces (App\DTOs\Admin\UserDTO and App\DTOs\UserDTO) both map to toUserDTO. The assembler resolves by basename only — it cannot distinguish them. Keep DTO basenames globally unique, or you'll get silent mis-mapping rather than an error.
Nested assemblers must be static. The README is explicit: assemblers used in $nestedAssemblers are invoked statically, so they cannot receive container injection. If your nested mapping genuinely needs a service, assemble that node manually inside the parent to<Parent>DTO method and pass it through — don't wait for a stack trace to discover the limitation.
Validation is per-DTO, not recursive. rules() on ProductDTO validates reviews as collection, not each review's fields. Each nested DTO validates itself when constructed, so a bad review inside a valid product still throws — but the exception message won't tell you which index failed. For batch imports, catch and log the index in your loop; you'll otherwise be debugging blind.
ValidationException behaves differently outside HTTP. In a controller it's a clean 422. In a queued job or a console command it's an unhandled exception that fails the job. If DTOs are assembled outside the request lifecycle, wrap assembly in a try/catch and translate to your own domain exception.
Casts and typed properties are not redundant, and strict types matters. public int $qty with input "5" coerces in weak-typing files but throws in files declaring declare(strict_types=1). The 'int' cast makes conversion explicit and file-independent. Always declare both — the type documents the shape, the cast guarantees it.
Check null handling before relying on it. For public ?UserDTO $user = null with a UserDTO::class cast, confirm how a null input is treated (skip-cast vs. attempted cast) by reading the cast resolver in ArgonautDTO.php. This is exactly the edge case you want to unit-test yourself rather than assume.
Keep behavior out of DTOs. No save(), no repository calls, no business rules. The moment a DTO knows about persistence you lose the ability to pass it across a queue boundary and you've rebuilt a badly-behaved Eloquent model. Assemblers map, services do.
Watch the cost on large collections. fromCollection assembles item by item with cast resolution per field. For a 50k-row import this is measurably slower than a plain array_map. Use DTOs at the boundary and batch the persistence layer separately; don't DTO-transform every row in a tight loop just for symmetry.
Treat the package as small and pin it. 106 stars, zero dependents — you are effectively on the source of truth. Pin the minor version, and keep assemblers as your only touchpoint so swapping in hand-written factories (or spatie/laravel-data) stays an afternoon's work rather than a refactor. The assembler layer is your insurance policy; violating it to call new UserDTO(...) all over the codebase throws that away.
When not to use it: a single endpoint with a three-field form does not need a DTO, an assembler, and a validation class. Reach for it when the payload is nested, comes from a source you don't control, or crosses a boundary (queue, cache, API). Below that threshold, $request->validated() is the honest answer.
How can I help you explore Laravel packages today?