zero-to-prod/data-model
DataModel hydrates PHP objects from external data with a single from($data) call using reflection and type hints. Validation and casting live in #[Describe] attributes (required, nullable, default, assign), replacing manual property wiring and scattered null checks.
Install it and you're done — there is no service provider, no config file, no facade, and no Laravel coupling whatsoever:
composer require zero-to-prod/data-model
Skip the optional vendor/bin/zero-to-prod-data-model docs publisher unless your team wants the README vendored into ./docs; wiring it into post-install-cmd just creates noise in CI.
The first thing to read is not the installation section — it's the Resolution Order table (assign → default → cast → post → method-cast → class-cast → via → direct assignment) and the exceptions table. Those two tables are the actual contract; everything else is examples.
Your first use case should be a single outward-facing boundary: an API response, a webhook payload, or a third-party SDK return that you currently json_decode(..., true) and then grope at with $data['x'] ?? null.
namespace App\DataTransferObjects;
use Zerotoprod\DataModel\DataModel;
use Zerotoprod\DataModel\Describe;
class InvoiceDto
{
use DataModel;
#[Describe(['required'])]
public string $id;
#[Describe(['from' => 'customer_name', 'required'])]
public string $customerName;
#[Describe(['nullable'])]
public ?string $notes;
#[Describe(['default' => 0])]
public int $amountCents;
public CustomerDto $customer; // recursively hydrated via CustomerDto::from()
}
// In a controller or webhook handler:
$dto = InvoiceDto::from($request->validated());
echo $dto->customer->email;
That's the whole day-one workflow: a plain class, the trait, from(). Confirm two things before you commit to it: your PHP version satisfies the constraint (the README demonstrates PHP 8.1+ first-class callable syntax and enum support), and the trait's use of strict_types — see the coercion note below.
One DTO per external boundary, not per table. This package has no relationship to Eloquent and shouldn't be used to model your database. Use it to model payloads: Stripe webhooks, a partner REST API, a queue message body, a CSV row. Keep the DTO as a final-ish value object in app/DataTransferObjects and let it stop at your action/service layer.
Remap aggressively with from. External APIs are snake_case (or camelCase, or kebab-case); your properties should be idiomatic. #[Describe(['from' => 'customer_name'])] is far better than naming a property $customer_name to match the vendor.
Treat required, default, and nullable as your three-way decision per field. required for identity/correlation fields where a silent null would corrupt downstream state; default for genuinely optional scalars; nullable only when the domain truly permits absence. Anything you don't mark and don't supply stays uninitialized — see gotchas.
Use cast for the awkward four: money (float → integer cents), dates ('2026-01-01' → CarbonImmutable), enums (InvoiceStatus::from($value)), and inbound arrays-of-objects.
#[Describe(['cast' => [self::class, 'toCents']])]
public int $amountCents;
public static function toCents(mixed $value): int
{
return (int) round((float) $value * 100);
}
#[Describe(['cast' => [self::class, 'toLines']])]
public array $lines = [];
public static function toLines(mixed $value): array
{
return array_map(static fn (array $line) => LineDto::from($line), $value ?? []);
}
Prefer one-parameter cast callables. The documented 4-parameter signature ($value, $context, ?ReflectionAttribute, ReflectionProperty) is available when you need the sibling context, but the 1-param form is trivially testable in isolation and won't break if the internal call site changes.
Use method-level casts for derived fields, not side effects. #[Describe('fullName')] on a method that reads $context['first_name'] is the clean way to build a computed property. A class-level cast map is better when several properties share a type conversion — but remember it's the lowest cast priority and any property-level or method-level cast silently wins.
Reach for via when the nested class doesn't hydrate through from(). If a collaborator object only exposes ::make() or a domain factory, #[Describe(['via' => [Address::class, 'make']])] keeps the recursion working without touching that class.
Validate with Laravel, then hydrate. The package does typing, not validation — you get PropertyRequiredException and native TypeError, not a 422 with error messages. The reliable Laravel pattern is:
$validated = $request->validate([
'id' => ['required', 'string'],
'customer_name' => ['required', 'string'],
'amount' => ['required', 'numeric'],
]);
$dto = InvoiceDto::from($validated);
Hydrate from Eloquent with only(), not toArray(). toArray() runs accessors, $appends, and date serialization, so your DTO receives presentation data instead of raw data. InvoiceDto::from($invoice->only(['id', 'customer_name', 'amount_cents'])) is predictable and won't break when someone adds an appended attribute.
Test hydration as a unit, not through the controller. A fixture array plus expect(InvoiceDto::from($fixture))->amountCents->toBe(1250) catches more than an HTTP feature test, and ->throws(PropertyRequiredException::class) is a one-liner contract for your required fields. Add a fixture-per-provider for each external payload shape you support.
Wrap it once. Put your own thin base class or helper trait in app/ (App\DataTransferObjects\DataModel) that composes the vendor trait plus any house conveniences. If you later migrate to spatie/laravel-data — which gives you validation rules, collections, and Laravel integration out of the box — you change one file instead of every DTO.
default beats cast. Per the resolution table, default is priority 2 and cast is priority 3. So a property declared as #[Describe(['default' => '0', 'cast' => [self::class, 'toCents']])] will not run toCents when the key is absent — the raw '0' is assigned directly. Never combine default with cast expecting the cast to normalize the default. Bake the normalization into the default value itself.
required and default are contradictory. Both are triggered by "context key absent," and required doesn't even appear in the priority table. Pick one per property, and add a test asserting which one actually fires.
Dto::from(null) and Dto::from('string') silently succeed with empty context. The README explicitly says strings and null are treated as empty. That means MyDto::from($request->input('payload')) — where payload arrived as a JSON string — returns a hollow object full of defaults instead of throwing. Always decode first, and guard the boundary: abort_unless(is_array($payload), 422).
String-to-int coercion depends on the package's strict_types, not yours. PHP resolves coercion rules from the file containing the assignment statement — i.e. the vendor trait's file. User::from(['age' => '30']) works in the README's example, but you should not build on that: cast explicitly for any numeric field coming off HTTP or a queue, and enable declare(strict_types=1) in your own DTO files for the derived code you control.
The callable signature documentation is internally inconsistent. The reference table says callables auto-detect 1 or 4 parameters, yet a README example passes a 2-param function (function uppercase(mixed $value, array $context)). Stick to exactly 1 parameter (or exactly 4) in your casts and hooks until you've verified the current release's behavior; a 2-param callable is the risky middle ground.
Uninitialized properties are the default failure mode. Any typed public property with no data, no default, and no nullable throws Error: Typed property ... must not be accessed before initialization at read time — far away from the hydration call. That's a miserable debug. Audit every DTO property and give each one an explicit required / default / nullable / ignore.
assign ignores context entirely. #[Describe(['assign' => 'x', 'from' => 'y'])] will not read y. And for assign, the callable receives $value = null — don't write a closure that expects the incoming value.
Reflection runs on every from() call. That's fine for a request-scoped DTO and genuinely bad inside a loop over 50,000 rows. For bulk imports, hydrate in chunks at the boundary, or keep raw arrays in the hot path and only build DTOs for the rows that need type safety.
Nested hydration needs the nested class to be a DataModel. public AddressDto $address only works if AddressDto also uses the trait (or you've supplied via). public array $addresses will never be auto-hydrated — arrays of models require an explicit cast or the DataModelHelper companion package's mapOf.
Describe::$extra is documented as a static. Custom keys land in Describe::$extra, which reads like shared state. If that holds, do not rely on it in Octane or queue workers — verify per-release before building domain metadata (labels, audit flags) on it. Subclassing Describe and reading $Attribute->getArguments() inside a hook/cast (as the README's pre example does) is the safer extension point.
Bad attribute types throw InvalidValue. ['required' => 1] instead of true is a runtime failure, not a compile-time one — no static analysis catches it. Keep your DTOs covered by at least one hydration test each.
Debugging checklist, in order: (1) print the raw $context before from(); (2) check the resolution-order table for which resolver should have fired; (3) check whether a method-level or class-level cast is shadowing your property-level one; (4) confirm the property is actually typed and public. DuplicateDescribeAttributeException means two #[Describe('prop')] methods target the same property — reflection ordering decides nothing there, it's a hard error by design.
There's no toArray() / JsonSerializable. response()->json($dto) falls back to serializing public properties, which mostly works — but backed enums serialize to their scalar value while pure UnitEnum cases throw, and nested DTOs serialize structurally rather than through any presenter. If you need control over outbound shape, use the Transformable companion package or write your own toArray().
Maturity is the real risk to price in. 139 stars, zero dependents, a single maintainer, and a release date that suggests rapid churn. Pin an exact version in composer.json, write a hydration test per DTO so an upgrade fails loudly, and keep the base-class wrapper described above so a swap to spatie/laravel-data is a one-file change. This package is a reasonable choice when you want a tiny, framework-agnostic, dependency-free hydrator; it is not the right choice if you need validation messages, rule inference, lazy collections, or Eloquent-aware casting — those are spatie/laravel-data's territory, and adopting DataModel for them means rebuilding that library one cast at a time.
How can I help you explore Laravel packages today?