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

1.0.0 2026-02-08 22:15 UTC

This package is auto-updated.

Last update: 2026-02-10 06:41:32 UTC


README

CI QA codecov PHPStan Latest Stable Version Total Downloads PRs Welcome License

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

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: bothin
  • Output: outboth
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:

  1. Value provided during instantiation
  2. Default from Cast attribute
  3. Default from type (nullable types default to null)
  4. 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

Optional

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.