fab2s / dt0
Dt0, a DTO PHP implementation than can both secure mutability and implement convenient ways to take control over input and output in various format
Installs: 1 157
Dependents: 1
Suggesters: 0
Security: 0
Stars: 5
Watchers: 1
Forks: 0
Open Issues: 0
pkg:composer/fab2s/dt0
Requires
- php: ^8.1
- ext-zlib: *
- fab2s/context-exception: ^2.0|^3.0
- fab2s/enumerate: ^0.0.1
Requires (Dev)
- fab2s/math: ^2.0
- laravel/pint: ^1.21.2
- nesbot/carbon: ^2.62|^3.0
- orchestra/testbench: ^8.0|^9.0
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^10.0|^11.0
- spatie/laravel-data: ^4.19
Suggests
- fab2s/Math: v2.x To cast any Dt0 property in arbitrary base ten decimals
- fab2s/laravel-dt0: To use Dt0 in Laravel (the awesome) with full validation and attribute casting
- nesbot/carbon: To use CarbonCaster and handle both Carbon and CarbonImmutable casts
This package is auto-updated.
Last update: 2026-02-10 06:41:32 UTC
README
Dt0 (DeeTO or DeTZerO) is a PHP 8.1+ Data Transfer Object implementation built for true immutability through readonly properties, with powerful bidirectional casting and validation.
Table of Contents
- Why Dt0
- Installation
- Quick Start
- Creating Instances
- Output
- Immutable Operations
- Casting
- Property Renaming
- Default Values
- Attribute Inheritance
- Validation
- Type System Integration
- Extending Attributes
- Performance
- Exceptions
- Requirements
- Contributing
- License
Why Dt0
Real immutability, enforced by PHP. Most DTO packages simulate immutability with magic methods. Dt0 uses native readonly properties - the language itself prevents modifications. Accidental writes cause fatal errors, not silent bugs.
One attribute to rule them all. Where other packages require a dozen attributes for casting, defaults, and renaming, Dt0's #[Cast] handles input transformation, output formatting, defaults, and property renaming in a single, composable attribute.
Framework-agnostic core. Use it anywhere PHP runs. For Laravel projects, laravel-dt0 adds validation and model casting integration.
Compiled once, fast always. Reflection and attribute metadata are processed once per class, then cached. Every subsequent instantiation reuses compiled data with zero reflection overhead.
// One attribute does it all #[Cast( in: DateTimeCaster::class, // Transform on input out: new DateTimeFormatCaster('Y-m-d'), // Format on output both: JsonCaster::class, // Same caster for both directions default: new DateTime(), // Default value renameFrom: 'created_at', // Accept external name )] public readonly DateTime $createdAt;
Flexible, not dogmatic. While immutability is the core feature, Dt0 doesn't force it. Use mutable properties when needed. Expose protected properties via with(). The package provides capabilities; you decide how to use them.
Installation
composer require fab2s/dt0
For Laravel, see Laravel Dt0 for validation and model attribute casting integration.
Quick Start
use fab2s\Dt0\Dt0; use fab2s\Dt0\Attribute\Cast; class UserDto extends Dt0 { public readonly int $id; public readonly string $name; public readonly string $email; #[Cast(default: 'user')] public readonly string $role; } // Create with named arguments $user = UserDto::make( id: 42, name: 'John Doe', email: 'john@example.com', ); // Access properties $user->id; // 42 $user->name; // 'John Doe' $user->role; // 'user' (default applied) // Convert to array/JSON $user->toArray(); // ['id' => 42, 'name' => 'John Doe', 'email' => 'john@example.com', 'role' => 'user'] $user->toJson(); // {"id":42,"name":"John Doe","email":"john@example.com","role":"user"} // Immutable update $admin = $user->update(role: 'admin'); $user->role; // 'user' (unchanged) $admin->role; // 'admin' (new instance)
Creating Instances
Factory Methods
Dt0 provides multiple ways to create instances:
// Named arguments - order doesn't matter $dto = UserDto::make(email: 'a@b.com', name: 'John', id: 1); // From associative array $dto = UserDto::fromArray([ 'id' => 1, 'name' => 'John', 'email' => 'john@example.com', ]); // From JSON string $dto = UserDto::fromJson('{"id": 1, "name": "John", "email": "john@example.com"}'); // Polymorphic - accepts array, JSON string, or existing Dt0 instance $dto = UserDto::from($input); // throws Dt0Exception on failure $dto = UserDto::tryFrom($input); // returns null on failure // From gzipped JSON $dto = UserDto::fromGz($gzippedData);
| Method | Input | On Failure |
|---|---|---|
make(...$args) |
Named/positional args | Throws |
fromArray(array) |
Associative array | Throws |
fromJson(string) |
JSON string | Throws |
fromString(string) |
JSON string (alias) | Throws |
fromGz(string) |
Gzipped JSON | Throws |
from(mixed) |
Array, JSON, or Dt0 | Throws |
tryFrom(mixed) |
Array, JSON, or Dt0 | Returns null |
Using Constructors
Dt0 classes can have custom constructors with promoted properties:
class OrderDto extends Dt0 { public readonly string $notes; public readonly float $total; public function __construct( public readonly string $orderId, #[Cast(in: DateTimeCaster::class)] public readonly DateTime $placedAt, // Non-promoted parameters can also be casted #[Cast(in: ScalarCaster::class)] float $subtotal = 0, // Required: captures remaining args for other properties mixed ...$args, ) { // Custom logic here $this->total = $subtotal * 1.2; // Add tax parent::__construct(...$args); } } // Constructor parameters maintain their order $order = new OrderDto( orderId: 'ORD-123', placedAt: new DateTime(), subtotal: 100.00, notes: 'Gift wrap please', // Goes to props via ...$args ); // Factory methods don't care about order $order = OrderDto::make( notes: 'Gift wrap please', subtotal: 100.00, orderId: 'ORD-123', placedAt: '2024-01-15 10:30:00', );
new vs Factory Methods
When using new directly with promoted readonly properties that have a default value, PHP initializes them immediately, before Dt0 can apply casting. Promoted properties without defaults behave normally.
class EventDto extends Dt0 { public function __construct( // Has default = casting won't apply with `new` #[Cast(in: DateTimeCaster::class)] public readonly DateTime $date = new DateTime(), // No default = casting works fine with `new` #[Cast(in: DateTimeCaster::class)] public readonly DateTime $endDate, mixed ...$args, ) { parent::__construct(...$args); } } // ❌ Casting won't apply to $date (has default) $event = new EventDto(date: '2024-01-15', endDate: new DateTime()); // TypeError for $date // ✅ Casting works for $endDate (no default) $event = new EventDto(endDate: '2024-01-15'); // Works, $date uses its default // ✅ Factory methods always work - casting applies to all properties $event = EventDto::make(date: '2024-01-15', endDate: '2024-01-16'); // Both cast correctly
Best practice: Use factory methods (make, from, fromArray, etc.) for full casting support. Reserve new for cases where you're passing already-correct types or relying on defaults.
Output
$dto->toArray(); // Array with objects intact $dto->toJsonArray(); // Array with objects serialized (JsonSerializable called) $dto->jsonSerialize();// Same as toJsonArray() $dto->toJson(); // JSON string $dto->toGz(); // Gzipped JSON string json_encode($dto); // JSON string (implements JsonSerializable) (string) $dto; // JSON string (implements Stringable)
Output Filtering
Control which properties appear in output using with(), without(), and only().
Adding Properties with with()
By default, only public properties are included in output. Use with() to add protected properties, call getters, or create computed values.
Include a protected property:
class UserDto extends Dt0 { public readonly int $id; public readonly string $name; protected string $internalScore; public function setInternalScore(string $score): static { $this->internalScore = $score; return $this; } } $user = UserDto::make(id: 1, name: 'John'); $user->setInternalScore('A+'); $user->toArray(); // ['id' => 1, 'name' => 'John'] - no internalScore $user->with('internalScore')->toArray(); // ['id' => 1, 'name' => 'John', 'internalScore' => 'A+']
Call a getter method:
class ProductDto extends Dt0 { public readonly int $price; public readonly int $quantity; public function getTotal(): int { return $this->price * $this->quantity; } } $product = ProductDto::make(price: 100, quantity: 3); // with('total', true) calls getTotal() automatically $product->with('total', true)->toArray(); // ['price' => 100, 'quantity' => 3, 'total' => 300] // Or specify a custom method name $product->with('total', 'getTotal')->toArray(); // Same result
Add computed values with closures:
class PersonDto extends Dt0 { public readonly string $firstName; public readonly string $lastName; } $person = PersonDto::make(firstName: 'John', lastName: 'Doe'); $person->with('fullName', fn(PersonDto $dto) => "{$dto->firstName} {$dto->lastName}") ->toArray(); // ['firstName' => 'John', 'lastName' => 'Doe', 'fullName' => 'John Doe']
Declarative with #[With] attribute:
use fab2s\Dt0\Attribute\With; use fab2s\Dt0\Attribute\WithProp; #[With( new WithProp(name: 'internalScore'), new WithProp(name: 'total', getter: 'getTotal'), )] class OrderDto extends Dt0 { public readonly int $price; public readonly int $quantity; protected string $internalScore = 'pending'; public function getTotal(): int { return $this->price * $this->quantity; } } $order = OrderDto::make(price: 50, quantity: 2); $order->toArray(); // ['price' => 50, 'quantity' => 2, 'internalScore' => 'pending', 'total' => 100]
with() getter options:
| Call | Behavior |
|---|---|
with('name') |
Access $this->name directly |
with('name', false) |
Access $this->name directly |
with('name', true) |
Call $this->getName() |
with('name', 'customMethod') |
Call $this->customMethod() |
with('name', fn($dto) => ...) |
Call the closure with $this |
Excluding Properties with without()
class UserDto extends Dt0 { public readonly int $id; public readonly string $name; public readonly string $password; public readonly string $apiKey; } $user = UserDto::make(/* ... */); $user->without('password', 'apiKey')->toJson(); // {"id":1,"name":"John"}
Selecting Specific Properties with only()
$user->only('id', 'name')->toArray(); // ['id' => 1, 'name' => 'John']
Resetting Filters
$user->clearWith(); // Remove all with() additions $user->clearWithout(); // Remove all without() exclusions
Immutable Operations
// Clone creates an identical copy $copy = $dto->clone(); $dto->equals($copy); // true // Update creates a new instance with changed values $updated = $dto->update(name: 'Jane', role: 'admin'); $dto->equals($updated); // false // Original unchanged $dto->name; // 'John' $updated->name; // 'Jane' // Compare instances $dto->equals($other); // true if all properties match // Serialization round-trip $restored = unserialize(serialize($dto)); $dto->equals($restored); // true
Casting
Dt0 supports bidirectional casting: transform values on the way in (hydration) and out (serialization).
Property-Level Casting
Use the #[Cast] attribute on individual properties:
use fab2s\Dt0\Dt0; use fab2s\Dt0\Attribute\Cast; use fab2s\Dt0\Caster\DateTimeCaster; use fab2s\Dt0\Caster\DateTimeFormatCaster; use fab2s\Dt0\Caster\ScalarCaster; use fab2s\Dt0\Caster\ScalarType; class ArticleDto extends Dt0 { public readonly string $title; #[Cast(in: new ScalarCaster(ScalarType::int))] public readonly int $viewCount; #[Cast( in: DateTimeCaster::class, // string -> DateTime out: new DateTimeFormatCaster(DateTimeFormatCaster::ISO), // DateTime -> ISO string )] public readonly DateTime $publishedAt; #[Cast( in: DateTimeCaster::class, out: new DateTimeFormatCaster('Y-m-d'), // Custom format )] public readonly ?DateTime $updatedAt; } $article = ArticleDto::make( title: 'Hello World', viewCount: '42', // String cast to int publishedAt: '2024-01-15', // String cast to DateTime updatedAt: null, ); $article->viewCount; // 42 (int) $article->publishedAt; // DateTime instance $article->toArray(); // ['title' => 'Hello World', 'viewCount' => 42, 'publishedAt' => DateTime, 'updatedAt' => null] $article->jsonSerialize(); // ['title' => 'Hello World', 'viewCount' => 42, 'publishedAt' => '2024-01-15T00:00:00.000000Z', 'updatedAt' => null]
Bidirectional Casting
When a caster applies to both input and output, use the both parameter instead of repeating the same caster for in and out:
use fab2s\Dt0\Dt0; use fab2s\Dt0\Attribute\Cast; use fab2s\Dt0\Caster\JsonCaster; use fab2s\Dt0\Caster\Base64Caster; class PayloadDto extends Dt0 { #[Cast(both: JsonCaster::class)] public readonly array $metadata; #[Cast(both: Base64Caster::class)] public readonly string $data; }
both can be combined with in and/or out for layered casting. When combined, casters are chained using onion ordering:
- Input:
both→in - Output:
out→both
use fab2s\Dt0\Attribute\Cast; use fab2s\Dt0\Caster\DateTimeCaster; use fab2s\Dt0\Caster\DateTimeFormatCaster; use fab2s\Dt0\Caster\TrimCaster; class EventDto extends Dt0 { #[Cast( both: new TrimCaster, // Trims on input AND output in: DateTimeCaster::class, // Input: trim → parse DateTime out: new DateTimeFormatCaster('Y-m-d H:i:s'), // Output: format → trim )] public readonly DateTime $startsAt; }
Class-Level Casting
Define multiple casts at the class level with #[Casts]:
use fab2s\Dt0\Dt0; use fab2s\Dt0\Attribute\Casts; use fab2s\Dt0\Attribute\Cast; use fab2s\Dt0\Caster\DateTimeCaster; #[Casts( // Using named arguments (property name => Cast) status: new Cast(default: 'pending'), priority: new Cast(default: 0), createdAt: new Cast(in: DateTimeCaster::class), // Or using positional with explicit propName new Cast(default: false, propName: 'isArchived'), )] class TaskDto extends Dt0 { public readonly string $title; public readonly string $status; public readonly int $priority; public readonly DateTime $createdAt; public readonly bool $isArchived; } $task = TaskDto::make(title: 'Review PR', createdAt: 'now'); $task->status; // 'pending' $task->priority; // 0 $task->isArchived; // false
Combining class and property casts: You can use both. In case of overlap, property-level #[Cast] takes precedence over class-level #[Casts].
#[Casts(
name: new Cast(default: 'Anonymous'), // Fallback if no property-level Cast
)]
class PersonDto extends Dt0
{
#[Cast(default: 'Unknown')] // Takes precedence
public readonly string $name;
#[Cast(default: 0)] // Applied (no conflict)
public readonly int $age;
}
Available Casters
| Caster | Description |
|---|---|
ScalarCaster |
Cast to int, float, bool, string |
JsonCaster |
Decode JSON on input, encode on output |
TrimCaster |
Trim strings (supports ltrim, rtrim, custom characters) |
Base64Caster |
Decode base64 on input, encode on output |
DateTimeCaster |
Parse strings/arrays to DateTime or DateTimeImmutable |
DateTimeFormatCaster |
Format DateTime for output |
CarbonCaster |
Parse to Carbon (requires nesbot/carbon) |
Dt0Caster |
Cast to nested Dt0 instances |
ArrayOfCaster |
Cast arrays of typed items |
ClassCaster |
Instantiate arbitrary classes |
MathCaster |
Precision numbers (requires fab2s/math) |
CasterCollection |
Chain multiple casters in a pipeline |
See Casters Documentation for detailed usage of each caster.
Built-in Type Support
These types are handled automatically without explicit casters:
Enums - Both UnitEnum and BackedEnum:
enum Status: string { case Draft = 'draft'; case Published = 'published'; } class PostDto extends Dt0 { public readonly string $title; public readonly Status $status; // No caster needed } $post = PostDto::make(title: 'Hello', status: 'published'); $post->status; // Status::Published $post->jsonSerialize(); // ['title' => 'Hello', 'status' => 'published']
Nested Dt0 - Child Dt0 classes are recognized automatically:
class AddressDto extends Dt0 { public readonly string $street; public readonly string $city; } class PersonDto extends Dt0 { public readonly string $name; public readonly AddressDto $address; // No caster needed } $person = PersonDto::make( name: 'John', address: ['street' => '123 Main St', 'city' => 'Boston'], ); $person->address->city; // 'Boston'
Custom Casters
Implement CasterInterface or extend CasterAbstract:
use fab2s\Dt0\Caster\CasterAbstract; use fab2s\Dt0\Dt0; class UpperCaseCaster extends CasterAbstract { public function cast(mixed $value, array|Dt0|null $data = null): ?string { return is_string($value) ? strtoupper($value) : null; } }
The $data parameter provides context:
- On input: The full input array being hydrated
- On output: The Dt0 instance being serialized
This enables casters that need multiple values:
class FullNameCaster extends CasterAbstract { public function cast(mixed $value, array|Dt0|null $data = null): ?string { if (is_array($data)) { // Input: combine first and last name return trim(($data['firstName'] ?? '') . ' ' . ($data['lastName'] ?? '')); } if ($data instanceof Dt0) { // Output: same logic with object access return trim($data->firstName . ' ' . $data->lastName); } return $value; } }
See Casters Documentation for more examples.
Property Renaming
Map between external names (APIs, databases) and internal property names:
class ApiResponseDto extends Dt0 { #[Cast( renameFrom: 'created_at', // Accept this name on input renameTo: 'createdAtStr', // Use this name on output )] public readonly string $createdAt; #[Cast(renameFrom: 'user_id')] public readonly int $userId; } // Input uses external names $dto = ApiResponseDto::make( created_at: '2024-01-15', user_id: 42, ); // Properties use internal names $dto->createdAt; // '2024-01-15' $dto->userId; // 42 // Output uses renamed keys $dto->toArray(); // ['createdAtStr' => '2024-01-15', 'userId' => 42]
Multiple input aliases - Accept several names for the same property:
class UserDto extends Dt0 { // First match wins #[Cast(renameFrom: ['user_name', 'username', 'login', 'userName'])] public readonly string $userName; } // All of these work UserDto::make(user_name: 'john'); UserDto::make(username: 'john'); UserDto::make(login: 'john'); UserDto::make(userName: 'john');
Round-trip consistency: All renameTo values are automatically added to renameFrom, ensuring output can always be used as input:
$dto = ApiResponseDto::make(created_at: '2024-01-15', user_id: 42); $array = $dto->toArray(); // Uses renameTo keys // This always works $dto->equals(ApiResponseDto::fromArray($array)); // true
Default Values
Readonly properties can't have default values unless they're promoted constructor parameters. Casts solve this:
class ConfigDto extends Dt0 { #[Cast(default: 3600)] public readonly int $ttl; #[Cast(default: null)] public readonly ?string $prefix; #[Cast(default: [])] public readonly array $tags; #[Cast(default: true)] public readonly bool $enabled; } $config = ConfigDto::make(); // No arguments needed $config->ttl; // 3600 $config->prefix; // null $config->tags; // [] $config->enabled; // true // Override defaults $config = ConfigDto::make(ttl: 7200, enabled: false); $config->ttl; // 7200 $config->enabled; // false
Default resolution order:
- Value provided during instantiation
- Default from
Castattribute - Default from type (nullable types default to
null) - Default from promoted constructor parameter
The Nil Concept
PHP has no native way to express "never set" vs "set to null". Dt0 uses a null byte ("\0") internally as a sentinel to distinguish these states. This means any value except "\0" can be used as a default.
If you genuinely need "\0" as a default value (extremely rare), use a promoted constructor parameter instead.
Attribute Inheritance
Dt0 supports attribute inheritance across class hierarchies, enabling powerful patterns for code reuse.
Property Attribute Inheritance
When a property doesn't have an attribute, Dt0 walks up the parent class chain looking for the same property with that attribute. This is particularly useful for base DTOs:
use fab2s\Dt0\Dt0; use fab2s\Dt0\Attribute\Cast; use fab2s\Dt0\Caster\DateTimeCaster; use fab2s\Dt0\Caster\DateTimeFormatCaster; // Base DTO with common timestamp handling class TimestampedDto extends Dt0 { #[Cast( in: DateTimeCaster::class, out: new DateTimeFormatCaster(DateTimeFormatCaster::ISO), )] public readonly DateTime $createdAt; #[Cast( in: DateTimeCaster::class, out: new DateTimeFormatCaster(DateTimeFormatCaster::ISO), )] public readonly ?DateTime $updatedAt; } // Child inherits the Cast attributes automatically class ArticleDto extends TimestampedDto { public readonly string $title; public readonly string $content; // createdAt and updatedAt inherit their Cast from TimestampedDto // Prior to PHP 8.4, you need to redeclare the properties: public readonly DateTime $createdAt; public readonly ?DateTime $updatedAt; } $article = ArticleDto::make( title: 'Hello', content: 'World', createdAt: '2024-01-15 10:30:00', // String -> DateTime via inherited Cast updatedAt: null, ); $article->createdAt; // DateTime instance $article->jsonSerialize(); // createdAt formatted as ISO string thanks to inherited 'out' caster
PHP 8.4+: Property hooks make inheritance even cleaner. You no longer need to redeclare parent properties in child classes - they're inherited automatically along with their attributes. The examples above show property redeclaration for compatibility with PHP 8.1-8.3.
Override inherited attributes - Child classes can override parent attributes:
class TimestampedDto extends Dt0 { #[Cast( in: DateTimeCaster::class, out: new DateTimeFormatCaster(DateTimeFormatCaster::ISO), )] public readonly DateTime $createdAt; } class CustomArticleDto extends TimestampedDto { public readonly string $title; // Override with different output format #[Cast( in: DateTimeCaster::class, out: new DateTimeFormatCaster('Y-m-d'), // Different format )] public readonly DateTime $createdAt; }
Multi-level inheritance - Attributes are resolved up the entire chain:
class BaseDto extends Dt0 { #[Cast(default: 'active')] public readonly string $status; } class MiddleDto extends BaseDto { // status inherits Cast from BaseDto public readonly string $status; // Redeclare for PHP < 8.4 public readonly string $type; } class FinalDto extends MiddleDto { // status still inherits Cast from BaseDto (through MiddleDto) public readonly string $status; // Redeclare for PHP < 8.4 public readonly string $type; // Redeclare for PHP < 8.4 public readonly string $name; } $dto = FinalDto::make(name: 'Test', type: 'example'); $dto->status; // 'active' (default inherited from BaseDto)
Class Attribute Inheritance
Class-level attributes (#[Casts], #[With], #[Validate], #[Rules]) also inherit from parent classes:
use fab2s\Dt0\Attribute\Casts; use fab2s\Dt0\Attribute\Cast; use fab2s\Dt0\Attribute\Validate; #[Casts( status: new Cast(default: 'pending'), )] #[Validate(BaseValidator::class)] class BaseTaskDto extends Dt0 { public readonly string $title; public readonly string $status; } // Inherits Casts and Validate from BaseTaskDto class PriorityTaskDto extends BaseTaskDto { public readonly string $title; // Redeclare for PHP < 8.4 public readonly string $status; // Redeclare for PHP < 8.4 #[Cast(default: 0)] public readonly int $priority; } $task = PriorityTaskDto::make(title: 'Review PR'); $task->status; // 'pending' (from inherited Casts) $task->priority; // 0 (from own Cast)
Override class attributes - Define the attribute on the child to override:
#[Casts(
status: new Cast(default: 'pending'),
)]
class BaseTaskDto extends Dt0
{
public readonly string $status;
}
#[Casts(
status: new Cast(default: 'urgent'), // Override parent's default
)]
class UrgentTaskDto extends BaseTaskDto
{
public readonly string $status; // Redeclare for PHP < 8.4
}
$task = UrgentTaskDto::make();
$task->status; // 'urgent'
Validation
Dt0 provides validation architecture without imposing a specific implementation:
use fab2s\Dt0\Dt0; use fab2s\Dt0\Attribute\Validate; use fab2s\Dt0\Attribute\Rule; use fab2s\Dt0\Attribute\Rules; #[Validate(MyValidator::class)] #[Rules( email: new Rule(['required', 'email']), )] class ContactDto extends Dt0 { public readonly string $email; #[Rule(['required', 'min:2', 'max:100'])] public readonly string $name; #[Rule(['string', 'max:1000'])] public readonly ?string $message; } $contact = ContactDto::make( email: 'test@example.com', name: 'Jo', message: 'Hello!', ); // Run validation (throws on failure) $contact->withValidation();
Rule priority: When rules are defined at multiple levels, property-level #[Rule] takes precedence over class-level #[Rules], which takes precedence over rules defined in #[Validate].
For a complete implementation with Laravel's validator, see Laravel Dt0.
Type System Integration
Dt0 works with PHP's type system, not against it. Casters attempt conversion and return null on failure. Your property types decide what's acceptable:
class StrictDto extends Dt0 { public readonly string $required; // null → TypeError public readonly ?string $optional; // null → accepted public readonly int|string $flexible; // int or string accepted }
This approach:
- Avoids duplicating validation logic
- Lets you declare acceptance criteria via types
- Produces clear errors from PHP itself
$dto = StrictDto::make( required: null, // TypeError: cannot be null // ... );
Extending Attributes
Dt0's attributes are extensible. Implement the appropriate interface or extend the abstract class:
| Attribute Type | Interface | Abstract Class |
|---|---|---|
| Class casts | CastsInterface |
CastsAbstract |
| Property cast | CastInterface |
CastAbstract |
| Validation | ValidateInterface |
ValidateAbstract |
| Class rules | RulesInterface |
RulesAbstract |
| Property rule | RuleInterface |
RuleAbstract |
| Output control | WithInterface |
WithAbstract |
Access compiled property metadata:
$properties = MyDto::compile(); // Properties instance (cached) $properties->toArray(); // Property[] indexed by name $property = $properties->get('fieldName'); // Single Property instance // Inspect a property $property->name; // 'fieldName' $property->types; // Types instance with type information $property->cast; // The Cast attribute (or null) $property->in; // Input caster instance (or null) $property->out; // Output caster instance (or null) $property->isDt0; // true if property type is a Dt0 $property->isEnum; // true if property type is an Enum $property->hasDefault(); // true if a default value exists $property->getDefault(); // The default value
Performance
Dt0 compiles reflection and attribute metadata once per class, per process. The first instantiation of a Dt0 class triggers compilation; subsequent instantiations reuse the cached data with zero reflection overhead.
// First call: reflection + attribute parsing $user1 = UserDto::make(/* ... */); // All subsequent calls: cached metadata, no reflection $user2 = UserDto::make(/* ... */); $user3 = UserDto::fromArray(/* ... */); $user4 = UserDto::fromJson(/* ... */);
The cache is bounded by the number of Dt0 classes in your application, not by usage. If you have 20 Dt0 classes, you get 20 cache entries - regardless of how many instances you create.
Benchmarks
Run the benchmark:
php benchmark/compare-spatie.php
Dt0 vs spatie/laravel-data (PHP 8.4, 10,000 iterations)
| Operation | Dt0 | spatie/laravel-data | Speedup |
|---|---|---|---|
| Simple DTO (8 props, 5 casts) | 141.6 µs | 1,158 µs | ~8.2x faster |
| Complex DTO (nested + arrays) | 741.9 µs | 3,628 µs | ~4.9x faster |
| Round-trip (json→dto→json) | 248.4 µs | 2,004 µs | ~8.1x faster |
Repeated serialization (same instance):
| Operation | Dt0 | spatie/laravel-data | Speedup |
|---|---|---|---|
| toArray() (simple) | 3.6 µs | 679.4 µs | ~188.7x faster |
| toArray() (nested) | 3.6 µs | 2,056 µs | ~571.1x faster |
| toJson() | 2.8 µs | 681.8 µs | ~243.5x faster |
The extreme serialization speedup (188-571x) applies when serializing the same instance multiple times - Dt0 caches the output structure on first call. Real-world scenarios where this matters:
- API + logging: serialize response, then log the same DTO
- Event sourcing: serialize for storage, broadcast, and audit trail
- Queue jobs: serialize for the queue, then again for monitoring
- Caching layers: serialize for Redis and for the HTTP response
For single-use serialization, expect ~10x improvement, consistent with hydration benchmarks.
Exceptions
All Dt0 exceptions extend ContextException, providing structured context for logging and debugging:
| Exception | Usage |
|---|---|
Dt0Exception |
General DTO errors (missing properties, invalid input) |
CasterException |
Casting failures |
AttributeException |
Attribute configuration errors |
try { $dto = UserDto::from($invalidInput); } catch (Dt0Exception $e) { $e->getMessage(); // Human-readable message $e->getContext(); // Array with debugging information }
Requirements
- PHP 8.1, 8.2, 8.3, or 8.4
Dependencies
fab2s/context-exception- Contextual exceptionsfab2s/enumerate- Enum utilities
Optional
nesbot/carbon- ForCarbonCasterfab2s/math- ForMathCaster
Contributing
Contributions are welcome. Please open issues and submit pull requests.
# fix code style composer fix # run tests composer test # run tests with coverage composer cov # static analysis (src, level 9) composer stan # static analysis (tests, level 5) composer stan-tests
License
Dt0 is open-sourced software licensed under the MIT license.