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
30 changes: 30 additions & 0 deletions config/dto.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
"type": "boolean",
"description": "Whether the DTO is immutable (no setters)"
},
"readonlyProperties": {
"type": "boolean",
"description": "Whether to use public readonly properties (implies immutable)"
},
"traits": {
"oneOf": [
{
Expand Down Expand Up @@ -105,6 +109,32 @@
},
"defaultValue": {
"description": "Default value for the field"
},
"minLength": {
"type": "integer",
"minimum": 0,
"description": "Minimum string length"
},
"maxLength": {
"type": "integer",
"minimum": 0,
"description": "Maximum string length"
},
"min": {
"type": "number",
"description": "Minimum numeric value"
},
"max": {
"type": "number",
"description": "Maximum numeric value"
},
"pattern": {
"type": "string",
"description": "Regex pattern for string validation"
},
"lazy": {
"type": "boolean",
"description": "Whether to defer hydration until first access"
}
},
"required": ["type"],
Expand Down
7 changes: 7 additions & 0 deletions config/dto.xsd
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@
<xsd:attribute type="xsd:string" name="deprecated" default="Deprecated"/>
<xsd:attribute type="xsd:boolean" name="required"/>
<xsd:attribute type="xsd:string" name="defaultValue"/>
<xsd:attribute type="xsd:integer" name="minLength"/>
<xsd:attribute type="xsd:integer" name="maxLength"/>
<xsd:attribute type="xsd:decimal" name="min"/>
<xsd:attribute type="xsd:decimal" name="max"/>
<xsd:attribute type="xsd:string" name="pattern"/>
<xsd:attribute type="xsd:boolean" name="lazy"/>
</xsd:complexType>
</xsd:element>
</xsd:choice>
Expand All @@ -34,6 +40,7 @@
<xsd:attribute type="xsd:string" name="extends"/>
<xsd:attribute type="xsd:string" name="deprecated" default="Deprecated"/>
<xsd:attribute type="xsd:boolean" name="immutable"/>
<xsd:attribute type="xsd:boolean" name="readonlyProperties"/>
<xsd:attribute type="xsd:string" name="traits"/>
</xsd:complexType>
</xsd:element>
Expand Down
65 changes: 65 additions & 0 deletions docs/ConfigBuilder.md
Original file line number Diff line number Diff line change
Expand Up @@ -447,3 +447,68 @@ Simple fields without modifiers are optimized to just the type string:
```php
Field::string('name') // Produces: 'string' (not ['type' => 'string'])
```

## Validation Rules

Fields support built-in validation rules that are checked when the DTO is constructed:

```php
Dto::create('User')->fields(
Field::string('name')->required()->minLength(2)->maxLength(100),
Field::string('email')->pattern('/^[^@]+@[^@]+\.[^@]+$/'),
Field::int('age')->min(0)->max(150),
Field::float('score')->min(0.0)->max(100.0),
)
```

### Available Validation Methods

| Method | Applies To | Description |
|--------|-----------|-------------|
| `minLength(int)` | string | Minimum string length (via `mb_strlen`) |
| `maxLength(int)` | string | Maximum string length (via `mb_strlen`) |
| `min(int\|float)` | int, float | Minimum numeric value |
| `max(int\|float)` | int, float | Maximum numeric value |
| `pattern(string)` | string | Regex pattern (must match via `preg_match`) |

Null fields skip validation — rules are only checked when a value is present.

On failure, an `InvalidArgumentException` is thrown with a descriptive message.

## Lazy Properties

DTO and collection fields can be marked as lazy, deferring hydration until first access:

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

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.

This is useful for large nested structures where not all fields are always accessed.

## Readonly Properties

DTOs can use PHP's `readonly` modifier for true immutability at the language level:

```php
Dto::create('Config')->readonlyProperties()->fields(
Field::string('host')->required(),
Field::int('port')->default(8080),
)
```

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`)
- 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.
75 changes: 59 additions & 16 deletions docs/Validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ This document explains what validation php-collective/dto provides and how to in

## Built-in Validation

The library provides **required field validation** only. When a field is marked as `required`, the DTO will throw an exception if that field is missing or null during instantiation.
### Required Fields

When a field is marked as `required`, the DTO will throw an exception if that field is missing or null during instantiation.

```xml
<dto name="User">
Expand All @@ -15,25 +17,66 @@ The library provides **required field validation** only. When a field is marked
```

```php
// This throws RuntimeException: Required field 'email' is missing
// This throws InvalidArgumentException: Required fields missing: email
$user = new UserDto(['id' => 1]);

// This works - nickname is optional
$user = new UserDto(['id' => 1, 'email' => 'test@example.com']);
```

## What's NOT Included
### Validation Rules

Fields support built-in validation rules for common constraints:

```php
use PhpCollective\Dto\Config\Dto;
use PhpCollective\Dto\Config\Field;
use PhpCollective\Dto\Config\Schema;

return Schema::create()
->dto(Dto::create('User')->fields(
Field::string('name')->required()->minLength(2)->maxLength(100),
Field::string('email')->required()->pattern('/^[^@]+@[^@]+\.[^@]+$/'),
Field::int('age')->min(0)->max(150),
Field::float('score')->min(0.0)->max(100.0),
))
->toArray();
```

Or in XML:

```xml
<dto name="User">
<field name="name" type="string" required="true" minLength="2" maxLength="100"/>
<field name="email" type="string" required="true" pattern="/^[^@]+@[^@]+\.[^@]+$/"/>
<field name="age" type="int" min="0" max="150"/>
<field name="score" type="float" min="0" max="100"/>
</dto>
```

#### Available Rules

The library intentionally does **not** include:
| Rule | Applies To | Description |
|------|-----------|-------------|
| `minLength` | string | Minimum string length (via `mb_strlen`) |
| `maxLength` | string | Maximum string length (via `mb_strlen`) |
| `min` | int, float | Minimum numeric value (inclusive) |
| `max` | int, float | Maximum numeric value (inclusive) |
| `pattern` | string | Regex pattern that must match (via `preg_match`) |

- Min/max length validation
- Numeric range validation (min, max)
- Pattern/regex validation
- Email/URL format validation
- Custom validation rules
- Validation error messages
#### Behavior

**Why?** The library focuses on **data structure and transfer**, not business logic validation. This keeps the generated code lean and allows you to choose your preferred validation approach.
- Null fields skip validation — rules are only checked when a value is present
- Required check runs before validation rules
- On failure, an `InvalidArgumentException` is thrown with a descriptive message:

```php
// InvalidArgumentException: Validation failed: name must be at least 2 characters
$user = new UserDto(['name' => 'A', 'email' => 'a@b.com']);

// InvalidArgumentException: Validation failed: email must match pattern /^[^@]+@[^@]+\.[^@]+$/
$user = new UserDto(['name' => 'Test', 'email' => 'invalid']);
```

## Integrating with Validation Libraries

Expand Down Expand Up @@ -185,11 +228,11 @@ This is PHP's native type system at work, not library validation.
|---------|:------------------:|:------------------:|
| Required fields | ✅ | ✅ |
| Type checking | ✅ (PHP native) | ✅ |
| Min/max length | | ✅ |
| Numeric ranges | | ✅ |
| Regex patterns | | ✅ |
| Email/URL format | | ✅ |
| Min/max length | | ✅ |
| Numeric ranges | | ✅ |
| Regex patterns | | ✅ |
| Email/URL format | ✅ (via pattern) | ✅ |
| Custom rules | ❌ | ✅ |
| Error messages | Basic | Rich |

**Recommendation:** Use php-collective/dto for structure and a validation library for business rules. This separation of concerns keeps each tool focused on what it does best.
**Recommendation:** Use the built-in validation rules for simple structural constraints. For complex business logic validation (conditional rules, cross-field dependencies, custom messages), use a dedicated validation library alongside your DTOs.
21 changes: 21 additions & 0 deletions src/Config/DtoBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ class DtoBuilder

protected bool $immutable = false;

protected bool $readonlyProperties = false;

protected ?string $extends = null;

protected ?string $deprecated = null;
Expand Down Expand Up @@ -97,6 +99,21 @@ public function asImmutable(): static
return $this;
}

/**
* Generate readonly properties for this DTO.
*
* When enabled, properties are declared with the `readonly` keyword
* and `with*()` methods use Reflection to set values on cloned instances.
* Implies immutable.
*/
public function readonlyProperties(): static
{
$this->readonlyProperties = true;
$this->immutable = true;

return $this;
}

/**
* Mark this DTO as deprecated.
*/
Expand Down Expand Up @@ -140,6 +157,10 @@ public function toArray(): array
$config['immutable'] = true;
}

if ($this->readonlyProperties) {
$config['readonlyProperties'] = true;
}

if ($this->extends !== null) {
$config['extends'] = $this->extends;
}
Expand Down
Loading