Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,13 @@ Reading in a template (e.g., Twig, Blade, or plain PHP):
## Features

- **Types**: `int`, `float`, `string`, `bool`, `array`, `mixed`, DTOs, classes, enums
- **Union types**: `int|string`, `int|float|string` (PHP 8.0+)
- **Union types**: `int|string`, `int|float|string`
- **Collections**: `'type' => 'Item[]', 'collection' => true` with add/remove/get/has methods
- **Associative collections**: keyed access with `'associative' => true`
- **Immutable DTOs**: `'immutable' => true` with `with*()` methods
- **Readonly properties**: `public readonly` with direct property access
- **Validation rules**: built-in `minLength`, `maxLength`, `min`, `max`, `pattern` constraints
- **Lazy properties**: deferred DTO/collection hydration with `asLazy()`
- **Default values**: `'defaultValue' => 0`
- **Required fields**: `'required' => true`
- **Deprecations**: `'deprecated' => 'Use newField instead'`
Expand Down
97 changes: 92 additions & 5 deletions docs/ConfigBuilder.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,11 +487,59 @@ Dto::create('Order')->fields(
)
```

Lazy fields store raw array data during construction. When the getter is called for the first time,
the raw data is hydrated into the DTO/collection. If `toArray()` is called before the getter,
the raw data is returned directly — avoiding unnecessary object creation.
### How It Works

This is useful for large nested structures where not all fields are always accessed.
Lazy fields store raw array data during construction in an internal `$_lazyData` property.
When the getter is called for the first time, the raw data is hydrated into the DTO/collection
and cached. Subsequent getter calls return the cached instance.

```php
$order = new OrderDto([
'id' => 1,
'customer' => ['name' => 'John', 'email' => 'john@example.com'],
'items' => [
['product' => 'Widget', 'quantity' => 2],
['product' => 'Gadget', 'quantity' => 1],
],
]);

// No CustomerDto or OrderItemDto objects created yet

$customer = $order->getCustomer(); // Now CustomerDto is hydrated
$items = $order->getItems(); // Now OrderItemDto[] is hydrated
```

### toArray() Behavior

If `toArray()` is called before the getter, the raw data is returned directly — no object
creation occurs. This is useful for pass-through scenarios where DTOs are used for validation
and transport without accessing nested fields:

```php
$order = new OrderDto($apiResponse);
$json = json_encode($order->toArray()); // No nested DTOs created
```

### When to Use Lazy Properties

- **Large nested structures** where not all fields are always accessed
- **API pass-through** where data is validated and forwarded without deep inspection
- **Performance-critical paths** where avoiding unnecessary object creation matters
- **Deep object graphs** where eager hydration would create many unused objects

### Mixing Lazy and Eager Fields

You can mix lazy and eager fields in the same DTO:

```php
Dto::create('Order')->fields(
Field::int('id')->required(),
Field::string('status')->required(), // Eager - always hydrated
Field::dto('customer', 'Customer')->asLazy(), // Lazy - on-demand
Field::dto('summary', 'OrderSummary'), // Eager - always hydrated
Field::collection('items', 'OrderItem')->singular('item')->asLazy(), // Lazy
)
```

## Readonly Properties

Expand All @@ -507,8 +555,47 @@ Dto::create('Config')->readonlyProperties()->fields(
This generates `public readonly` properties instead of `protected` ones, providing:

- Direct public property access (`$dto->host` instead of `$dto->getHost()`)
- Compile-time immutability enforcement (assignment after construction throws `\Error`)
- Immutability enforcement (assignment after construction throws `\Error`)
- Getters are still generated for consistency

Note: `readonlyProperties()` implies `immutable` — the DTO will extend `AbstractImmutableDto`
and use `with*()` methods (which reconstruct from array) instead of setters.

### Usage Example

```php
$config = new ConfigDto(['host' => 'localhost', 'port' => 3306]);

// Direct property access
echo $config->host; // "localhost"
echo $config->port; // 3306

// Getters also work
echo $config->getHost(); // "localhost"

// Attempting to modify throws \Error at runtime
$config->host = 'other'; // Error: Cannot modify readonly property

// Use with*() methods to create modified copies
$newConfig = $config->withPort(5432);
echo $config->port; // 3306 (original unchanged)
echo $newConfig->port; // 5432
```

### Readonly vs Immutable: When to Use Which

| Feature | `immutable()` | `readonlyProperties()` |
|---------|---------------|------------------------|
| Property visibility | `protected` | `public readonly` |
| Property access | `$dto->getName()` | `$dto->name` or `$dto->getName()` |
| Modification protection | Convention (no setters) | Language-enforced |
| `with*()` implementation | Clone + set property | Reconstruct from array |
| API consistency | Same as mutable DTOs | Different from mutable DTOs |

**Choose `immutable()`** when:
- You want consistent getter-based API across all DTOs (mutable and immutable)
- You're migrating between mutable and immutable and want minimal code changes

**Choose `readonlyProperties()`** when:
- You prefer shorter syntax with direct property access
- You want IDE/static analysis to catch accidental mutation attempts
212 changes: 212 additions & 0 deletions docs/Examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -591,3 +591,215 @@ echo json_encode($dto->toArray(), JSON_PRETTY_PRINT);
// Or use __toString which returns JSON
echo $dto; // Calls serialize() internally
```

## Validation Rules

Built-in validation rules provide field-level constraints checked during construction.

### Basic Validation

```php
// Configuration
Dto::create('User')->fields(
Field::string('username')->required()->minLength(3)->maxLength(20),
Field::string('email')->required()->pattern('/^[^@]+@[^@]+\.[^@]+$/'),
Field::int('age')->min(0)->max(150),
)
```

```php
// Valid - passes all rules
$user = new UserDto([
'username' => 'johndoe',
'email' => 'john@example.com',
'age' => 25,
]);

// Invalid - throws InvalidArgumentException
$user = new UserDto([
'username' => 'jo', // Too short (minLength: 3)
'email' => 'john@example.com',
]);
// Exception: Field 'username' must be at least 3 characters

// Invalid email pattern
$user = new UserDto([
'username' => 'johndoe',
'email' => 'not-an-email',
]);
// Exception: Field 'email' does not match required pattern
```

### Nullable Fields Skip Validation

```php
Dto::create('Profile')->fields(
Field::string('bio')->maxLength(500), // Optional field
Field::int('followers')->min(0),
)
```

```php
// Null values skip validation
$profile = new ProfileDto(['bio' => null, 'followers' => null]); // OK

// Non-null values are validated
$profile = new ProfileDto(['bio' => str_repeat('x', 501)]);
// Exception: Field 'bio' must be at most 500 characters
```

### Extracting Validation Rules

Use `validationRules()` to get framework-agnostic rules for integration with validators:

```php
$dto = new UserDto(['username' => 'test', 'email' => 'test@example.com']);
$rules = $dto->validationRules();

// Returns:
// [
// 'username' => ['required' => true, 'minLength' => 3, 'maxLength' => 20],
// 'email' => ['required' => true, 'pattern' => '/^[^@]+@[^@]+\.[^@]+$/'],
// 'age' => ['min' => 0, 'max' => 150],
// ]

// Use with framework validators
$validator = new FrameworkValidator($rules);
```

## Lazy Loading

Defer nested DTO/collection hydration for performance optimization.

### Basic Lazy Loading

```php
// Configuration
Dto::create('Order')->fields(
Field::int('id')->required(),
Field::string('status'),
Field::dto('customer', 'Customer')->asLazy(),
Field::collection('items', 'OrderItem')->singular('item')->asLazy(),
)
```

```php
// Create order from API response
$order = new OrderDto([
'id' => 123,
'status' => 'pending',
'customer' => ['name' => 'John', 'email' => 'john@example.com'],
'items' => [
['product' => 'Widget', 'quantity' => 2, 'price' => 29.99],
['product' => 'Gadget', 'quantity' => 1, 'price' => 49.99],
],
]);

// At this point: no CustomerDto or OrderItemDto objects exist yet

// Access triggers hydration
$customer = $order->getCustomer(); // CustomerDto created now
echo $customer->getName(); // "John"
```

### Pass-Through Optimization

When data is forwarded without deep inspection, lazy fields avoid unnecessary object creation:

```php
// API gateway scenario
$orderData = $this->fetchFromUpstreamApi();
$order = new OrderDto($orderData);

// Forward to downstream service - no nested objects created
$this->downstreamApi->send($order->toArray());
```

### Checking Lazy State

```php
// Access triggers hydration - subsequent calls return cached instance
$items1 = $order->getItems(); // Hydrated
$items2 = $order->getItems(); // Same instance returned
assert($items1 === $items2);
```

## Readonly Properties

Use `readonly` properties for language-level immutability with direct property access.

### Basic Readonly DTO

```php
// Configuration
Dto::create('DatabaseConfig')->readonlyProperties()->fields(
Field::string('host')->required(),
Field::int('port')->default(3306),
Field::string('database')->required(),
Field::string('username')->required(),
Field::string('password'),
)
```

```php
$config = new DatabaseConfigDto([
'host' => 'localhost',
'database' => 'myapp',
'username' => 'root',
]);

// Direct property access
echo $config->host; // "localhost"
echo $config->port; // 3306 (default)
echo $config->database; // "myapp"

// Getters also work
echo $config->getHost(); // "localhost"

// Modification throws Error
$config->host = 'other'; // Error: Cannot modify readonly property
```

### Creating Modified Copies

```php
$devConfig = new DatabaseConfigDto([
'host' => 'localhost',
'database' => 'myapp_dev',
'username' => 'dev',
]);

// Create production config based on dev
$prodConfig = $devConfig
->withHost('prod-db.example.com')
->withDatabase('myapp_prod')
->withUsername('app_user')
->withPassword('secret');

// Original unchanged
echo $devConfig->host; // "localhost"
echo $prodConfig->host; // "prod-db.example.com"
```

### Readonly vs Immutable Comparison

```php
// Immutable DTO - getter-based access (consistent with mutable DTOs)
Dto::immutable('Event')->fields(
Field::string('name')->required(),
Field::string('payload'),
)

$event = new EventDto(['name' => 'user.created', 'payload' => '{}']);
echo $event->getName(); // Access via getter

// Readonly DTO - direct property access
Dto::create('Event')->readonlyProperties()->fields(
Field::string('name')->required(),
Field::string('payload'),
)

$event = new EventDto(['name' => 'user.created', 'payload' => '{}']);
echo $event->name; // Direct access - shorter syntax
echo $event->getName(); // Getter also works
```