honeystone/laravel-dto-tools
Honeystone's Laravel DTO Tools provides a lightweight base DTO with validation, casting, serialization, and Eloquent integration, making it easy to move typed, validated data between requests, services, and models in Laravel applications.
The package is installed via Composer and registered automatically through Laravel package discovery:
composer require honeystone/laravel-dto-tools
php artisan vendor:publish --tag=dto-tools-config
The fastest path to a working DTO is the generator, which scaffolds a class extending the package's base DTO with the MakeableDto trait wired in:
php artisan make:dto UserData
That produces something like:
class UserData extends AbstractDto
{
use MakeableDto;
public function __construct(
public readonly string $name,
public readonly string $email,
) {}
}
From there, the first use case to validate the package against your codebase is hydrating a DTO from an Eloquent model:
$dto = UserData::make($user);
// or explicitly
$dto = UserData::from($user);
Where to look first:
config/dto-tools.php — namespace and path settings the generator will respect. Set this before generating anything, or you'll be moving files later.AbstractDto and the MakeableDto trait source in vendor/honeystone/laravel-dto-tools/src — the magic make/from resolution lives here and determines what "magic" means for every DTO you write.Attributes directory — MapFrom, MapTo, and their variants are the crux of the package; skim this folder before writing your first nested DTO.Model → DTO with attribute mapping. The most valuable pattern is declaring how a DTO field is sourced from a model, which survives model refactors better than constructor plumbing:
class UserData extends AbstractDto
{
use MakeableDto;
public function __construct(
public readonly int $id,
#[MapFrom('profile.full_name')]
public readonly string $name,
#[MapFrom('email')]
public readonly string $email,
) {}
}
$dto = UserData::make($user); // relation paths resolve automatically
Nested DTOs and collections. Children declared as DTO types or DTO collections are hydrated recursively, so a PostData can hold an AuthorData and an array of CommentData without manual loops. Prefer this over manually calling make() inside constructors—the recursive resolver handles collections, paginators, and lazy-loaded relations consistently.
DTO → API payload. Because properties are readonly and typed, json_encode($dto) or ->toArray() produces a predictable shape. This is where the package earns its keep: DTOs become the single source of truth for response contracts and double as a de-facto OpenAPI sketch. Pair with spatie/laravel-data style TypeScript generation scripts only if you already have them; the built-in attribute set won't cover every edge case.
DTO → Model persistence. The MapTo attribute (and corresponding write helpers) lets a DTO round-trip back into a model for updates. Use this for command/queued-job inputs where you want a validated, typed payload rather than a raw array, then hydrate a model from it at the boundary.
Validation coupling. A clean integration is to build DTOs out of FormRequest::validated() output. The MapFrom attribute can reference request keys, and you can make() a DTO directly from a request instance in the controller, keeping controllers thin and DTOs carrying the request→domain translation:
public function store(StoreUserRequest $request)
{
$dto = UserData::make($request->validated());
return UserResource::make($dto); // or a DTO-based resource
}
Service-layer boundary. Adopt a convention: controllers produce DTOs, services accept DTOs, repositories return DTOs. Because the DTOs are readonly and immutable, they're safe to pass through jobs and events without defensive copying—this is the biggest day-to-day ergonomic win.
MapFrom('relation.field') path doesn't error at attribute-declaration time; it fails during hydration, often with a confusing "missing" or null on a non-nullable property. Debug by calling ->toArray() on the DTO while the raw source is still in scope.make:dto on an existing class only with --force; otherwise you'll hand-tune and re-tune. The generator is a starting point, not a live scaffold.?string $x = null; the hydrator will honor defaults only when it doesn't try to write a value.@var CommentData[]) or the resolver falls back to arrays of arrays. This is the single most common source of "why does my nested field return an array instead of a DTO."use imports the package expects. Regenerate or bulk-fix imports; the generator derives imports from config.toArray() vs DTO toArray(). These differ subtly—hidden fields, appended accessors, and date serialization. If your API tests depend on model output, expect breakage when migrating to DTOs. Add a serialization test early.make(), or use the mapper's support for already-loaded collections to avoid repeated queries.MapTo write safety. Round-tripping DTO → model can overwrite fields you didn't intend to touch. Treat MapTo as a partial-update API, and be explicit about which fields are writable, or use a dedicated "update" DTO rather than reusing the read DTO.MapFrom closure. Hooking into the mapper registry is the supported escape hatch and survives package upgrades better than overriding AbstractDto.How can I help you explore Laravel packages today?