tenantcloud/data-transfer-objects
A lightweight PHP package for defining typed data transfer objects, making it easy to move structured data between layers of your Laravel application with clear, validated, and reusable DTO classes.
Install and confirm the surface area first
composer require tenantcloud/data-transfer-objects
Before writing a single DTO, spend ten minutes reading vendor/tenantcloud/data-transfer-objects/src/ and the package's test suite. With 4 stars and zero dependents, this is a small maintained fork (the API closely mirrors the archived spatie/data-transfer-object lineage), so upstream blog posts and docs are approximately right but not authoritative. Check the base class for:
from(), fromArray(), make())all(), toArray())#[Cast], #[MapFrom], #[ArrayOf], or docblock-only casting)Pin the version explicitly ("tenantcloud/data-transfer-objects": "1.*") — a fork moving under you is the main risk here.
First use case: request payload → DTO → action
final class CreateInvoiceData extends DataTransferObject
{
public string $number;
public int $customerId;
public ?string $note;
/** @var InvoiceLineData[] */
public array $lines;
}
// In a controller (or a FormRequest::toDto())
$data = CreateInvoiceData::from($request->validated());
$invoice = app(CreateInvoiceAction::class)->execute($data);
This is the highest-value pattern: it gives you a typed seam between HTTP (untyped arrays) and your domain, and it makes the action signature self-documenting.
Keep DTOs dumb. The package gives you construction/casting, not behaviour. Resist adding save(), calculate(), or Eloquent calls inside a DTO — a DTO that touches the DB becomes untestable and stops being a transfer object.
Validation lives in the FormRequest, not the DTO. Use $request->validated() and let the DTO assume shape correctness. If the package exposes a validate() helper, treat it as a secondary net for DTOs built from non-HTTP sources (queues, imports, console commands), not the primary gate.
Nested DTOs and collections are the feature you'll lean on daily. The docblock annotation is load-bearing — without it, nested arrays stay as plain arrays:
/** @var InvoiceLineData[] */
public array $lines;
For strongly-typed collections, check whether the package ships a collection base class (DataTransferObjectCollection in the Spatie lineage). If it does, extending it gives you Iterator, ArrayAccess, and toArray() recursion for free.
DTO as an action/service return type. Symmetry pays off: actions accept DTOs and return DTOs, and your HTTP layer does the final serialisation.
final class InvoiceResource extends JsonResource
{
public function toArray($request): array
{
return InvoiceData::from($this->resource->toArray())->toArray();
}
}
Be careful doing this for large collections — see gotchas.
Hydrating from Eloquent. Model::toArray() is the cheapest bridge, but it drags along created_at, updated_at, and any appended accessors. Either curate the array first ($model->only([...])) or add #[MapFrom]-style attributes if the package supports them. Curating is boring and reliably correct.
Use DTOs at queue boundaries. Serialising a DTO into a job constructor and reconstructing it in handle() means payload changes break loudly at deserialisation instead of silently returning null three layers deep. This is one of the better payoffs for a package like this.
Extension point: a project base class. Put App\Support\Data\DataTransferObject extending the package class and hang shared concerns off it (snake_case→camelCase mapping, date casting, a toJson() helper). Every DTO extends yours, not theirs — this is your escape hatch if the fork stalls and you need to swap the implementation later.
Missing keys throw; they don't default silently. If a payload can omit a field, declare it ?string $note = null and verify the package respects defaults over "key must exist." In the Spatie lineage, a non-nullable typed property with no default throws on missing input — which is usually what you want, but it will surprise you the first time an API client drops an optional field.
Type coercion is loose by default. A numeric string "42" will often satisfy int $customerId. Don't rely on the DTO for strict input validation; that's the FormRequest's job. Conversely, if you need strictness, declare(strict_types=1) in your DTO file does not always propagate through reflection-based assignment — test it.
Deep nesting is recursive and untracked. toArray() on a DTO with 500 nested line items runs the full reflection/casting path at every level. For large collections, map to plain arrays once at the edge rather than converting DTO→array→DTO→array repeatedly.
Dates and enums are the usual breakage points. Check explicitly how the package handles DateTimeInterface, Carbon, and backed enums. If enum casting isn't supported, you'll silently get a string where you expected an enum instance — write one test per special type on day one and pin that behaviour.
Reflection caching. Most packages in this family memoise property metadata per class. If you're constructing thousands of DTOs in a loop (importers, report generation), benchmark before assuming it's free; if there's a cache-clear/warm command, wire it into deploy.
Debugging: inspect the raw input. When casting fails, the error usually names the property but not the offending value. Catch the package's exception, Log::debug() the original array alongside it, and re-throw. Do this once in your base DTO — it will save hours.
Fork-specific risk mitigation. Zero dependents means the ecosystem won't catch regressions for you. Concretely: pin the version, keep a small test suite that exercises your DTOs against a fixed fixture payload, and read the changelog before bumping. Because your DTOs extend a project base class, replacing the package later is a one-file change rather than a codebase-wide search-and-replace.
Don't use DTOs for your API response shape by default. If your responses are stable and simple, arrays or resources are fine. Reach for a DTO on the inbound side first — that's where type safety actually prevents bugs.
How can I help you explore Laravel packages today?