Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Dto Laravel Package

romanzipp/dto

Lightweight data transfer objects for PHP and Laravel. Define typed properties, cast and validate values, and convert DTOs to and from arrays with ease, keeping request and response data structured and type-safe.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and check the actual constraint first.

composer require romanzipp/dto

The description claims PHP 7.4+, but the package ships PHP attributes (#[Required]), which require PHP 8.0+, and the TPM notes readonly semantics depend on 8.1+. Before anything else, open composer.json in vendor/romanzipp/dto and read the real require.php constraint. Treat that as authoritative over the README description.

The mental model is one sentence long: a DTO is a plain PHP class with typed public properties that extends AbstractData, and you build it from an array. There is no __get, no __set, no fluent factory, no reflection-driven magic. If you can't see it in the class body, it isn't happening.

use romanzipp\DTO\AbstractData;
use romanzipp\DTO\Attributes\Required;

final class AddressData extends AbstractData
{
    #[Required] public string $street;
    #[Required] public string $city;
    public ?string $postalCode = null;
}

final class PersonData extends AbstractData
{
    #[Required] public string $name;
    public ?int $age = null;
    public ?AddressData $address = null;
}

$person = new PersonData([
    'name' => 'Roman',
    'age' => 24,
    'address' => ['street' => 'Main St 1', 'city' => 'Berlin'],
]);

$person->address->city;   // nested array hydrated into AddressData
$person->toArray();       // back to a nested array

Where to look first in the source — it's small enough to read in one sitting, and you should:

  1. src/AbstractData.php — the constructor (hydration), toArray(), and how it discovers which properties exist.
  2. src/Attributes/ — Required, Nullable, Between, In. These are the entire validation story.
  3. src/Exceptions/ — what actually throws, and with what messages. This tells you what failure looks like in production.

First use case: hydrate $request->validated() into a request DTO inside a controller, pass that DTO into a single action class, and serialize the result with toArray(). That one boundary — HTTP in, typed object through the domain, array out — is where the package pays for itself. Everywhere else, you're just adding ceremony.

Do not start by converting your domain layer. Start with one endpoint.

Implementation Patterns

One DTO per boundary, not per table. Name them after the crossing, not the entity: CreateOrderRequest, OrderData, OrderLineData. Request DTOs describe incoming shape; domain DTOs are what services accept as parameters and declare as return types. A DTO that mirrors an Eloquent table is a smell — you've just written a worse model.

Hydrate from validated input, never $request->all(). Laravel's form requests stay in charge of validation rules (existence, uniqueness, authorization, cross-field rules). The DTO enforces shape and type only. The layering is:

public function store(CreateOrderRequest $request, CreateOrder $action): JsonResponse
{
    $data = new OrderData($request->validated());   // shape enforced here
    $order = $action->handle($data);                 // typed contract into domain
    return response()->json($order->toArray());      // explicit serialization out
}

Map from Eloquent explicitly, in one place. Don't invent a generic fromModel(). Write a static factory per DTO and keep it boring:

public static function fromModel(Order $order): self
{
    return new self([
        'id'     => $order->id,
        'status' => $order->status->value,
        'lines'  => $order->lines->map(fn ($l) => OrderLineData::fromModel($l))->all(),
    ]);
}

Collections are your loop, not the package's. Because there's no magic, nested arrays of DTOs generally need you to map them yourself unless the source proves otherwise (verify this against AbstractData before you rely on it). Wrap the loop in the static factory and nobody downstream writes it again. Put a cap on it — hydrating 10,000 rows eagerly into typed objects is a memory decision, not a free one.

Serialize at the edge, deliberately. toArray() is the exit door to JSON responses, queue payloads, and cache values. If you also have API Resources, pick one per endpoint and stick to it — running both means two serialization paths that drift. For jobs and events, DTOs are excellent payload contracts, but serialize them to arrays in the constructor of the job and rehydrate on handle(); don't hand a live object to the queue.

Isolate the package behind your own base class. This is the single highest-leverage pattern given the adoption numbers:

namespace App\Support\Data;

abstract class Data extends \romanzipp\DTO\AbstractData
{
    // local conventions: date handling, enum casting, toJson(), etc.
}

Every DTO in your app extends App\Support\Data, never AbstractData directly. When (not if) you fork, replace, or patch, you change one file. This also gives you a place to add the enum/date mapping the package deliberately doesn't provide — without forking.

Test hydration and serialization as contract tests. One test per DTO: valid array round-trips through toArray() unchanged; missing required fields throw; wrong types throw. These are cheap and they're your real safety net against a package that could go unmaintained mid-project.

Gotchas and Tips

Uninitialized typed properties throw Error, not a friendly exception. If a required field is missing and the package doesn't initialize it, accessing $dto->field yields Typed property ... must not be accessed before initialization — a raw PHP Error. Confirm how #[Required] missing fields actually fail (exception in the constructor vs. lazily on access) by reading AbstractData and writing a failing test. Your error handling and API error responses depend on the answer.

Extra/unknown array keys may be silently ignored. Verify this. If an unknown key is dropped, a typo in a controller ('emial' => ...) disappears without a trace. If you care, add a debug-mode assertion in your base Data class that diffs array_keys($input) against declared properties.

Empty strings and null are different problems. #[Nullable] governs nullability; it does not turn '' into null. Laravel's ConvertEmptyStringsToNull middleware covers HTTP requests, but queue and CLI payloads skip it. Normalize before hydration if that matters to you.

Dates, enums, and money are not converted. The package handles typed properties and array hydration — nothing else. A DateTimeImmutable or backed enum property will need a from() conversion before you assign it, or a static factory that does it. This is the most common reason a team quietly outgrows the package; decide now whether you'll extend your base class or stop and re-evaluate against spatie/laravel-data.

Readonly and constructor promotion: check before you style. If you want public readonly string $name (PHP 8.1+) with constructor promotion, confirm AbstractData's constructor signature permits it. If the parent constructor takes an array, promoted properties in a child may fight it. A quick spike answers this faster than reading.

Static analysis will be shallow. No generics, no @template for typed collections. PHPStan/Psalm will happily type array for a collection of OrderLineData, so the type safety stops at the collection boundary. Either accept it or annotate @var list<OrderLineData> manually in your factories.

Pin the commit, record the fork point. With 12 stars and 0 dependents, this is source you're adopting, not a dependency you're trusting. Pin an exact version (or a commit hash) in composer.json, write the fork point into an ADR, and re-read the source at every upgrade. Budget for the day you own it fully.

Debugging is genuinely pleasant — lean on it. No __get means dd($dto), Xdebug breakpoints, and "go to definition" all land on real code. That's the actual justification for choosing this over a magic-heavy DTO library, and it's worth stating explicitly to your team so the constraint is understood as a feature.

Don't let the DTO layer become the validation layer. #[Between] and #[In] are shape guards, not replacement rules. The moment you're tempted to add cross-field validation attributes, custom messages, or exists: checks, you're rebuilding form requests badly — keep validation in Laravel and let the DTO throw on malformed shapes only.

Have the exit criteria written down before you start. Something like: "If we need nested collection mapping, Eloquent casting, partial hydration, or a validation pipeline, we stop and compare against spatie/laravel-data rather than extending this ourselves." Without that sentence, a small utility quietly becomes a bespoke framework component that only one person understands.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons