diff --git a/config/dto.schema.json b/config/dto.schema.json
index 04f2771..ba96e42 100644
--- a/config/dto.schema.json
+++ b/config/dto.schema.json
@@ -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": [
{
@@ -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"],
diff --git a/config/dto.xsd b/config/dto.xsd
index 21f310f..f9ff014 100644
--- a/config/dto.xsd
+++ b/config/dto.xsd
@@ -26,6 +26,12 @@
+
+
+
+
+
+
@@ -34,6 +40,7 @@
+
diff --git a/docs/ConfigBuilder.md b/docs/ConfigBuilder.md
index 51a5185..b4cdb4f 100644
--- a/docs/ConfigBuilder.md
+++ b/docs/ConfigBuilder.md
@@ -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.
diff --git a/docs/Validation.md b/docs/Validation.md
index ad1e54c..34a742f 100644
--- a/docs/Validation.md
+++ b/docs/Validation.md
@@ -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
@@ -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
+
+
+
+
+
+
+```
+
+#### 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
@@ -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.
diff --git a/src/Config/DtoBuilder.php b/src/Config/DtoBuilder.php
index 7a720a6..2a9d89d 100644
--- a/src/Config/DtoBuilder.php
+++ b/src/Config/DtoBuilder.php
@@ -19,6 +19,8 @@ class DtoBuilder
protected bool $immutable = false;
+ protected bool $readonlyProperties = false;
+
protected ?string $extends = null;
protected ?string $deprecated = null;
@@ -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.
*/
@@ -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;
}
diff --git a/src/Config/FieldBuilder.php b/src/Config/FieldBuilder.php
index 4219a68..1d15b8b 100644
--- a/src/Config/FieldBuilder.php
+++ b/src/Config/FieldBuilder.php
@@ -51,6 +51,18 @@ class FieldBuilder
protected ?string $transformTo = null;
+ protected ?int $minLength = null;
+
+ protected ?int $maxLength = null;
+
+ protected int|float|null $min = null;
+
+ protected int|float|null $max = null;
+
+ protected ?string $pattern = null;
+
+ protected bool $lazy = false;
+
public function __construct(string $name, string $type)
{
$this->name = $name;
@@ -323,6 +335,68 @@ public function getName(): string
return $this->name;
}
+ /**
+ * Set minimum string length validation.
+ */
+ public function minLength(int $length): static
+ {
+ $this->minLength = $length;
+
+ return $this;
+ }
+
+ /**
+ * Set maximum string length validation.
+ */
+ public function maxLength(int $length): static
+ {
+ $this->maxLength = $length;
+
+ return $this;
+ }
+
+ /**
+ * Set minimum numeric value validation.
+ */
+ public function min(int|float $value): static
+ {
+ $this->min = $value;
+
+ return $this;
+ }
+
+ /**
+ * Set maximum numeric value validation.
+ */
+ public function max(int|float $value): static
+ {
+ $this->max = $value;
+
+ return $this;
+ }
+
+ /**
+ * Set regex pattern validation.
+ *
+ * @example Field::string('email')->pattern('/^[^@]+@[^@]+$/')
+ */
+ public function pattern(string $regex): static
+ {
+ $this->pattern = $regex;
+
+ return $this;
+ }
+
+ /**
+ * Mark field as lazy-loaded. Nested DTO/collection fields will be hydrated on first access.
+ */
+ public function asLazy(): static
+ {
+ $this->lazy = true;
+
+ return $this;
+ }
+
/**
* Build the field configuration array.
*
@@ -343,7 +417,13 @@ public function toArray(): array|string
$this->mapFrom === null &&
$this->mapTo === null &&
$this->transformFrom === null &&
- $this->transformTo === null
+ $this->transformTo === null &&
+ $this->minLength === null &&
+ $this->maxLength === null &&
+ $this->min === null &&
+ $this->max === null &&
+ $this->pattern === null &&
+ !$this->lazy
) {
return $this->type;
}
@@ -406,6 +486,30 @@ public function toArray(): array|string
$config['transformTo'] = $this->transformTo;
}
+ if ($this->minLength !== null) {
+ $config['minLength'] = $this->minLength;
+ }
+
+ if ($this->maxLength !== null) {
+ $config['maxLength'] = $this->maxLength;
+ }
+
+ if ($this->min !== null) {
+ $config['min'] = $this->min;
+ }
+
+ if ($this->max !== null) {
+ $config['max'] = $this->max;
+ }
+
+ if ($this->pattern !== null) {
+ $config['pattern'] = $this->pattern;
+ }
+
+ if ($this->lazy) {
+ $config['lazy'] = true;
+ }
+
return $config;
}
}
diff --git a/src/Dto/Dto.php b/src/Dto/Dto.php
index 6f20149..43d3b4e 100644
--- a/src/Dto/Dto.php
+++ b/src/Dto/Dto.php
@@ -230,6 +230,13 @@ public static function create(?array $data = null, bool $ignoreMissing = false,
*/
protected array $_touchedFields = [];
+ /**
+ * Holds raw data for lazy-loaded fields. Hydrated on first access.
+ *
+ * @var array
+ */
+ protected array $_lazyData = [];
+
/**
* @param array|null $data
* @param bool $ignoreMissing
@@ -1038,6 +1045,31 @@ protected function validate(): void
if ($errors) {
throw new InvalidArgumentException('Required fields missing: ' . implode(', ', $errors));
}
+
+ $validationErrors = [];
+ foreach ($this->_metadata as $name => $field) {
+ if ($this->$name === null) {
+ continue;
+ }
+ if (!empty($field['minLength']) && is_string($this->$name) && mb_strlen($this->$name) < $field['minLength']) {
+ $validationErrors[] = $name . ' must be at least ' . $field['minLength'] . ' characters';
+ }
+ if (!empty($field['maxLength']) && is_string($this->$name) && mb_strlen($this->$name) > $field['maxLength']) {
+ $validationErrors[] = $name . ' must be at most ' . $field['maxLength'] . ' characters';
+ }
+ if (isset($field['min']) && is_numeric($this->$name) && $this->$name < $field['min']) {
+ $validationErrors[] = $name . ' must be at least ' . $field['min'];
+ }
+ if (isset($field['max']) && is_numeric($this->$name) && $this->$name > $field['max']) {
+ $validationErrors[] = $name . ' must be at most ' . $field['max'];
+ }
+ if (!empty($field['pattern']) && is_string($this->$name) && !preg_match($field['pattern'], $this->$name)) {
+ $validationErrors[] = $name . ' must match pattern ' . $field['pattern'];
+ }
+ }
+ if ($validationErrors) {
+ throw new InvalidArgumentException('Validation failed: ' . implode(', ', $validationErrors));
+ }
}
/**
diff --git a/src/Engine/XmlEngine.php b/src/Engine/XmlEngine.php
index a8516b1..d0f3ccd 100644
--- a/src/Engine/XmlEngine.php
+++ b/src/Engine/XmlEngine.php
@@ -107,7 +107,7 @@ public function parse(string $content): array
*/
protected function castBoolValue(string|float|int|bool $value, ?string $key = null): string|float|int|bool
{
- if ($key && !in_array($key, ['required', 'immutable', 'collection', 'associative'], true)) {
+ if ($key && !in_array($key, ['required', 'immutable', 'readonlyProperties', 'collection', 'associative', 'lazy'], true)) {
return $value;
}
@@ -130,6 +130,14 @@ protected function castBoolValue(string|float|int|bool $value, ?string $key = nu
*/
protected function castDefaultValue($value, string $key, array $fieldDefinition)
{
+ if (in_array($key, ['minLength', 'maxLength'], true)) {
+ return (int)$value;
+ }
+
+ if (in_array($key, ['min', 'max'], true)) {
+ return str_contains((string)$value, '.') ? (float)$value : (int)$value;
+ }
+
if (!in_array($key, ['defaultValue'], true) || empty($fieldDefinition['@type'])) {
return $value;
}
diff --git a/src/Generator/Builder.php b/src/Generator/Builder.php
index d6de1ef..89814a9 100644
--- a/src/Generator/Builder.php
+++ b/src/Generator/Builder.php
@@ -87,6 +87,12 @@ class Builder
'mapTo',
'transformFrom',
'transformTo',
+ 'minLength',
+ 'maxLength',
+ 'min',
+ 'max',
+ 'pattern',
+ 'lazy',
];
/**
@@ -204,6 +210,7 @@ protected function createDtos(array $config, string $namespace): array
$dto += [
'immutable' => $this->config['immutable'],
+ 'readonlyProperties' => false,
'namespace' => $namespace . '\Dto',
'className' => $name . $this->getConfigOrFail('suffix'),
'extends' => '\\PhpCollective\\Dto\\Dto\\AbstractDto',
@@ -212,6 +219,10 @@ protected function createDtos(array $config, string $namespace): array
$dto['traits'] = $this->normalizeTraits($dto['traits']);
+ if (!empty($dto['readonlyProperties'])) {
+ $dto['immutable'] = true;
+ }
+
if (!empty($dto['immutable']) && $dto['extends'] === '\\PhpCollective\\Dto\\Dto\\AbstractDto') {
$dto['extends'] = '\\PhpCollective\\Dto\\Dto\\AbstractImmutableDto';
}
diff --git a/src/Generator/FieldCompletor.php b/src/Generator/FieldCompletor.php
index 867e57f..75e854a 100644
--- a/src/Generator/FieldCompletor.php
+++ b/src/Generator/FieldCompletor.php
@@ -140,6 +140,12 @@ protected function addFieldDefaults(array $fields): array
'mapTo' => null,
'transformFrom' => null,
'transformTo' => null,
+ 'minLength' => null,
+ 'maxLength' => null,
+ 'min' => null,
+ 'max' => null,
+ 'pattern' => null,
+ 'lazy' => false,
];
if ($data['required']) {
diff --git a/templates/dto.twig b/templates/dto.twig
index dc9fe47..829a374 100644
--- a/templates/dto.twig
+++ b/templates/dto.twig
@@ -42,10 +42,10 @@ class {{ className }} extends {% if extends starts with '\\' %}{{ extends[1:] |
{% endif %}
{% include 'element/constants.twig' with {'fields': fields} only %}
-{% include 'element/properties.twig' with {'fields': fields} only -%}
+{% include 'element/properties.twig' with {'fields': fields, 'readonlyProperties': readonlyProperties|default(false)} only -%}
{% include 'element/metadata.twig' with {'metaData': metaData} only %}
{% include 'element/map.twig' with {'fields': fields} only %}
-{% include 'element/optimizations.twig' with {'fields': fields, 'immutable': immutable} only %}
-{% include 'element/methods.twig' with {'fields': fields, 'immutable': immutable, 'scalarAndReturnTypes': scalarAndReturnTypes} only -%}
+{% include 'element/optimizations.twig' with {'fields': fields, 'immutable': immutable, 'readonlyProperties': readonlyProperties|default(false)} only %}
+{% include 'element/methods.twig' with {'fields': fields, 'immutable': immutable, 'readonlyProperties': readonlyProperties|default(false), 'scalarAndReturnTypes': scalarAndReturnTypes} only -%}
{% include 'element/array_shape.twig' with {'arrayShape': arrayShape} only %}
}
diff --git a/templates/element/method_get.twig b/templates/element/method_get.twig
index 9968213..65446a2 100644
--- a/templates/element/method_get.twig
+++ b/templates/element/method_get.twig
@@ -9,6 +9,40 @@
public function get{{ name|stripLeadingUnderscore|camelize }}(){% if returnTypeHint %}: {{ nullableReturnTypeHint ?? returnTypeHint }}{% endif %}
{
+{% if lazy|default(false) %}
+ if (isset($this->_lazyData['{{ name }}'])) {
+ $raw = $this->_lazyData['{{ name }}'];
+ unset($this->_lazyData['{{ name }}']);
+{% if dto %}
+ if (is_array($raw)) {
+ $this->{{ name }} = new {{ typeHint }}($raw);
+ } else {
+ $this->{{ name }} = $raw;
+ }
+{% elseif collection and collectionType != 'array' and singularClass is defined and singularClass %}
+{% set adapter = getCollectionAdapter(collectionType) %}
+ $collection = {{ adapter.getCreateEmptyCode(collectionType) }};
+ foreach ($raw as $key => $item) {
+ if (is_array($item)) {
+ $item = new {{ singularClass }}($item);
+ }
+ {{ adapter.getAppendCode('$collection', '$item') }}
+ }
+ $this->{{ name }} = $collection;
+{% elseif collection and collectionType == 'array' and singularClass is defined and singularClass %}
+ $collection = [];
+ foreach ($raw as $key => $item) {
+ if (is_array($item)) {
+ $item = new {{ singularClass }}($item);
+ }
+ $collection[$key] = $item;
+ }
+ $this->{{ name }} = $collection;
+{% else %}
+ $this->{{ name }} = $raw;
+{% endif %}
+ }
+{% endif %}
{% if collection %}
if ($this->{{ name }} === null) {
return {% if collectionType == 'array' %}[]{% else %}new {{ typeHint }}([]){% endif %};
diff --git a/templates/element/method_with.twig b/templates/element/method_with.twig
index e7d0356..1a852a8 100644
--- a/templates/element/method_with.twig
+++ b/templates/element/method_with.twig
@@ -9,6 +9,18 @@
*/
public function with{{ name|stripLeadingUnderscore|camelize }}({% if typeHint %}{{ nullableTypeHint ?? typeHint }} {% endif %}${{ name }}{% if typeHint and nullable %} = null{% endif %})
{
+{% if readonlyProperties|default(false) %}
+ $data = $this->toArray();
+{% if isArray or typeHint == 'array' %}
+ $data['{{ name }}'] = ${{ name }} !== null ? $this->cloneArray(${{ name }}) : null;
+{% elseif isDto %}
+ $data['{{ name }}'] = ${{ name }} !== null ? ${{ name }}->toArray() : null;
+{% else %}
+ $data['{{ name }}'] = ${{ name }};
+{% endif %}
+
+ return new static($data);
+{% else %}
$new = clone $this;
{% if isArray or typeHint == 'array' %}
$new->{{ name }} = ${{ name }} !== null ? $new->cloneArray(${{ name }}) : null;
@@ -18,4 +30,5 @@
$new->_touchedFields[static::FIELD_{{ name | stripLeadingUnderscore | camelize | underscore | upper }}] = true;
return $new;
+{% endif %}
}
diff --git a/templates/element/method_with_added.twig b/templates/element/method_with_added.twig
index 44ada01..a3171b8 100644
--- a/templates/element/method_with_added.twig
+++ b/templates/element/method_with_added.twig
@@ -13,6 +13,19 @@
*/
public function withAdded{{ singular|stripLeadingUnderscore|camelize }}({% if associative %}$key, {% endif %}{% if singularTypeHint %}{% if singularNullable %}?{% endif %}{{ singularTypeHint }} {% endif %}${{ singular }})
{
+{% if readonlyProperties|default(false) %}
+ $data = $this->toArray();
+ if (!isset($data['{{ name }}'])) {
+ $data['{{ name }}'] = [];
+ }
+{% if associative %}
+ $data['{{ name }}'][$key] = ${{ singular }};
+{% else %}
+ $data['{{ name }}'][] = ${{ singular }};
+{% endif %}
+
+ return new static($data);
+{% else %}
$new = clone $this;
if ($new->{{ name }} === null) {
@@ -29,5 +42,6 @@
$new->_touchedFields[static::FIELD_{{ name | stripLeadingUnderscore | camelize | underscore | upper }}] = true;
return $new;
+{% endif %}
}
{% endif -%}
diff --git a/templates/element/method_with_removed.twig b/templates/element/method_with_removed.twig
index a3bcf15..7241476 100644
--- a/templates/element/method_with_removed.twig
+++ b/templates/element/method_with_removed.twig
@@ -10,6 +10,14 @@
*/
public function withRemoved{{ singular|stripLeadingUnderscore|camelize }}($key)
{
+{% if readonlyProperties|default(false) %}
+ $data = $this->toArray();
+ if (isset($data['{{ name }}'])) {
+ unset($data['{{ name }}'][$key]);
+ }
+
+ return new static($data);
+{% else %}
$new = clone $this;
if ($new->{{ name }} === null) {
@@ -27,5 +35,6 @@
$new->_touchedFields[static::FIELD_{{ name | stripLeadingUnderscore | camelize | underscore | upper }}] = true;
return $new;
+{% endif %}
}
{% endif -%}
diff --git a/templates/element/methods.twig b/templates/element/methods.twig
index ebdf76e..4501e22 100644
--- a/templates/element/methods.twig
+++ b/templates/element/methods.twig
@@ -1,6 +1,6 @@
{% set blank = "\n" -%}
{% for field in fields -%}
-{% set fieldVars = field|merge({'scalarAndReturnTypes': scalarAndReturnTypes}) -%}
+{% set fieldVars = field|merge({'scalarAndReturnTypes': scalarAndReturnTypes, 'readonlyProperties': readonlyProperties|default(false)}) -%}
{% if immutable %}{% include 'element/method_with.twig' with fieldVars only %}{% else %}{% include 'element/method_set.twig' with fieldVars only %}{% endif -%}
{% if field.nullable and immutable %}{{ blank }}{% include 'element/method_with_or_fail.twig' with fieldVars only %}{% elseif field.nullable %}{{ blank }}{% include 'element/method_set_or_fail.twig' with fieldVars only %}{% endif -%}
{{ blank }}{% include 'element/method_get.twig' with fieldVars only -%}
diff --git a/templates/element/optimizations.twig b/templates/element/optimizations.twig
index b892e01..c56a6ab 100644
--- a/templates/element/optimizations.twig
+++ b/templates/element/optimizations.twig
@@ -22,7 +22,16 @@
protected function setFromArrayFast(array $data): void
{
{% for field in fields %}
-{% if field.dto %}
+{% if field.lazy|default(false) %}
+ if (isset($data['{{ field.name }}'])) {
+{% if field.transformFrom %}
+ $this->_lazyData['{{ field.name }}'] = $this->transformValue('{{ field.transformFrom }}', $data['{{ field.name }}']);
+{% else %}
+ $this->_lazyData['{{ field.name }}'] = $data['{{ field.name }}'];
+{% endif %}
+ $this->_touchedFields['{{ field.name }}'] = true;
+ }
+{% elseif field.dto %}
if (isset($data['{{ field.name }}'])) {
$value = $data['{{ field.name }}'];
{% if field.transformFrom %}
@@ -150,7 +159,13 @@
return [
{% for field in fields %}
{% set key = field.mapTo is not null ? field.mapTo : field.name %}
-{% if field.dto %}
+{% if field.lazy|default(false) %}
+{% if field.transformTo %}
+ '{{ key }}' => $this->transformValue('{{ field.transformTo }}', $this->_lazyData['{{ field.name }}'] ?? ($this->{{ field.name }} !== null ? {% if field.dto %}$this->{{ field.name }}->toArray(){% elseif field.collection and field.collectionType != 'array' %}(static function (\Traversable $c): array { $r = []; foreach ($c as $k => $v) { $r[$k] = is_object($v) && $v instanceof \PhpCollective\Dto\Dto\Dto ? $v->toArray() : $v; } return $r; })($this->{{ field.name }}){% else %}$this->{{ field.name }}{% endif %} : null)),
+{% else %}
+ '{{ key }}' => $this->_lazyData['{{ field.name }}'] ?? ($this->{{ field.name }} !== null ? {% if field.dto %}$this->{{ field.name }}->toArray(){% elseif field.collection and field.collectionType != 'array' %}(static function (\Traversable $c): array { $r = []; foreach ($c as $k => $v) { $r[$k] = is_object($v) && $v instanceof \PhpCollective\Dto\Dto\Dto ? $v->toArray() : $v; } return $r; })($this->{{ field.name }}){% else %}$this->{{ field.name }}{% endif %} : null),
+{% endif %}
+{% elseif field.dto %}
{% if field.nullable %}
{% if field.transformTo %}
'{{ key }}' => $this->transformValue('{{ field.transformTo }}', $this->{{ field.name }} !== null ? $this->{{ field.name }}->toArray() : null),
@@ -309,10 +324,14 @@
];
}
{% set fieldsWithDefaults = [] %}
+{% set uninitializedNullableFields = [] %}
{% for field in fields %}
{% if field.defaultValue is not null %}
{% set fieldsWithDefaults = fieldsWithDefaults | merge([field]) %}
{% endif %}
+{% if readonlyProperties|default(false) and field.nullable and field.defaultValue is null %}
+{% set uninitializedNullableFields = uninitializedNullableFields | merge([field]) %}
+{% endif %}
{% endfor %}
/**
@@ -322,6 +341,13 @@
*/
protected function setDefaults()
{
+{% if readonlyProperties|default(false) and uninitializedNullableFields | length > 0 %}
+{% for field in uninitializedNullableFields %}
+ if (!isset($this->{{ field.name }})) {
+ $this->{{ field.name }} = null;
+ }
+{% endfor %}
+{% endif %}
{% if fieldsWithDefaults | length > 0 %}
{% for field in fieldsWithDefaults %}
{% if field.nullable %}
@@ -337,14 +363,18 @@
return $this;
}
{% set requiredFields = [] %}
+{% set validatedFields = [] %}
{% for field in fields %}
{% if field.required %}
{% set requiredFields = requiredFields | merge([field]) %}
{% endif %}
+{% if field.minLength is not null or field.maxLength is not null or field.min is not null or field.max is not null or field.pattern is not null %}
+{% set validatedFields = validatedFields | merge([field]) %}
+{% endif %}
{% endfor %}
/**
- * Optimized validate - only checks required fields.
+ * Optimized validate - checks required fields and validation rules.
*
* @throws \InvalidArgumentException
*
@@ -376,5 +406,41 @@
throw new InvalidArgumentException('Required fields missing: ' . implode(', ', $errors));
}
}
+{% endif %}
+{% if validatedFields | length > 0 %}
+
+ $errors = [];
+{% for field in validatedFields %}
+ if ($this->{{ field.name }} !== null) {
+{% if field.minLength is not null %}
+ if (is_string($this->{{ field.name }}) && mb_strlen($this->{{ field.name }}) < {{ field.minLength }}) {
+ $errors[] = '{{ field.name }} must be at least {{ field.minLength }} characters';
+ }
+{% endif %}
+{% if field.maxLength is not null %}
+ if (is_string($this->{{ field.name }}) && mb_strlen($this->{{ field.name }}) > {{ field.maxLength }}) {
+ $errors[] = '{{ field.name }} must be at most {{ field.maxLength }} characters';
+ }
+{% endif %}
+{% if field.min is not null %}
+ if (is_numeric($this->{{ field.name }}) && $this->{{ field.name }} < {{ field.min }}) {
+ $errors[] = '{{ field.name }} must be at least {{ field.min }}';
+ }
+{% endif %}
+{% if field.max is not null %}
+ if (is_numeric($this->{{ field.name }}) && $this->{{ field.name }} > {{ field.max }}) {
+ $errors[] = '{{ field.name }} must be at most {{ field.max }}';
+ }
+{% endif %}
+{% if field.pattern is not null %}
+ if (is_string($this->{{ field.name }}) && !preg_match('{{ field.pattern | e('html') }}', $this->{{ field.name }})) {
+ $errors[] = '{{ field.name }} must match pattern {{ field.pattern | e('html') }}';
+ }
+{% endif %}
+ }
+{% endfor %}
+ if ($errors) {
+ throw new InvalidArgumentException('Validation failed: ' . implode(', ', $errors));
+ }
{% endif %}
}
diff --git a/templates/element/properties.twig b/templates/element/properties.twig
index f84a34f..6fb37f7 100644
--- a/templates/element/properties.twig
+++ b/templates/element/properties.twig
@@ -1,4 +1,4 @@
{% for field in fields %}
-{% include 'element/property.twig' with field only %}
+{% include 'element/property.twig' with field|merge({'readonlyProperties': readonlyProperties|default(false)}) only %}
{% endfor -%}
diff --git a/templates/element/property.twig b/templates/element/property.twig
index db7bde5..a17d14b 100644
--- a/templates/element/property.twig
+++ b/templates/element/property.twig
@@ -1,4 +1,4 @@
/**
* @var {{ docBlockType ?? type }}{% if nullable %}|null{% endif ~%}
*/
- protected {% if returnTypeHint %}{% if collection and collectionType != 'array' %}?{{ typeHint }}{% else %}{{ nullableTypeHint ?? typeHint }}{% endif %} {% endif %}${{ name }}{% if nullable %} = null{% elseif defaultValue is defined and defaultValue is not null %} = {{ defaultValue | phpExport | raw }}{% elseif collection and collectionType != 'array' %} = null{% endif %};
+ {% if readonlyProperties|default(false) %}public readonly{% else %}protected{% endif %} {% if returnTypeHint %}{% if collection and collectionType != 'array' %}?{{ typeHint }}{% else %}{{ nullableTypeHint ?? typeHint }}{% endif %} {% endif %}${{ name }}{% if not readonlyProperties|default(false) %}{% if nullable %} = null{% elseif defaultValue is defined and defaultValue is not null %} = {{ defaultValue | phpExport | raw }}{% elseif collection and collectionType != 'array' %} = null{% endif %}{% endif %};
diff --git a/tests/Config/BuilderTest.php b/tests/Config/BuilderTest.php
index 6009ae8..b7e50a6 100644
--- a/tests/Config/BuilderTest.php
+++ b/tests/Config/BuilderTest.php
@@ -496,4 +496,51 @@ public function testBenchmarkConfigEquivalence(): void
$this->assertSame($expected, $schema->toArray());
}
+
+ public function testFieldValidationRules(): void
+ {
+ $schema = Schema::create()
+ ->dto(Dto::create('Validated')->fields(
+ Field::string('name')->required()->minLength(2)->maxLength(50),
+ Field::string('email')->pattern('/^[^@]+@[^@]+$/'),
+ Field::int('age')->min(0)->max(150),
+ Field::float('score')->min(0.0)->max(100.0),
+ ));
+
+ $result = $schema->toArray();
+ $fields = $result['Validated']['fields'];
+
+ $this->assertSame(2, $fields['name']['minLength']);
+ $this->assertSame(50, $fields['name']['maxLength']);
+ $this->assertSame('/^[^@]+@[^@]+$/', $fields['email']['pattern']);
+ $this->assertSame(0, $fields['age']['min']);
+ $this->assertSame(150, $fields['age']['max']);
+ $this->assertSame(0.0, $fields['score']['min']);
+ $this->assertSame(100.0, $fields['score']['max']);
+ }
+
+ public function testFieldLazy(): void
+ {
+ $schema = Schema::create()
+ ->dto(Dto::create('WithLazy')->fields(
+ Field::string('title'),
+ Field::dto('nested', 'Other')->asLazy(),
+ ));
+
+ $result = $schema->toArray();
+ $this->assertTrue($result['WithLazy']['fields']['nested']['lazy']);
+ }
+
+ public function testDtoReadonlyProperties(): void
+ {
+ $schema = Schema::create()
+ ->dto(Dto::create('ReadonlyUser')->readonlyProperties()->fields(
+ Field::string('name')->required(),
+ Field::int('age'),
+ ));
+
+ $result = $schema->toArray();
+ $this->assertTrue($result['ReadonlyUser']['readonlyProperties']);
+ $this->assertTrue($result['ReadonlyUser']['immutable']);
+ }
}
diff --git a/tests/Dto/LazyTest.php b/tests/Dto/LazyTest.php
new file mode 100644
index 0000000..3bcdc8e
--- /dev/null
+++ b/tests/Dto/LazyTest.php
@@ -0,0 +1,130 @@
+ 'Test',
+ 'nested' => ['name' => 'Nested', 'count' => 5],
+ ]);
+
+ $this->assertSame('Test', $dto->getTitle());
+
+ $nested = $dto->getNested();
+ $this->assertInstanceOf(SimpleDto::class, $nested);
+ $this->assertSame('Nested', $nested->getName());
+ $this->assertSame(5, $nested->getCount());
+ }
+
+ public function testLazyCollectionField(): void
+ {
+ $dto = new LazyDto([
+ 'title' => 'Test',
+ 'items' => [
+ ['name' => 'A', 'count' => 1],
+ ['name' => 'B', 'count' => 2],
+ ],
+ ]);
+
+ $items = $dto->getItems();
+ $this->assertCount(2, $items);
+ $this->assertInstanceOf(SimpleDto::class, $items[0]);
+ $this->assertSame('A', $items[0]->getName());
+ $this->assertSame('B', $items[1]->getName());
+ }
+
+ public function testLazyToArrayWithoutHydration(): void
+ {
+ $dto = new LazyDto([
+ 'title' => 'Test',
+ 'nested' => ['name' => 'Raw'],
+ ]);
+
+ $arr = $dto->toArray();
+ $this->assertSame('Test', $arr['title']);
+ $this->assertIsArray($arr['nested']);
+ $this->assertSame('Raw', $arr['nested']['name']);
+ }
+
+ public function testLazyToArrayAfterHydration(): void
+ {
+ $dto = new LazyDto([
+ 'title' => 'Test',
+ 'nested' => ['name' => 'Hydrated'],
+ ]);
+
+ $dto->getNested();
+ $arr = $dto->toArray();
+ $this->assertSame('Hydrated', $arr['nested']['name']);
+ }
+
+ public function testLazyHasBeforeAndAfterAccess(): void
+ {
+ $dto = new LazyDto([
+ 'title' => 'Test',
+ 'nested' => ['name' => 'Sub'],
+ ]);
+
+ $this->assertTrue($dto->hasNested());
+ $dto->getNested();
+ $this->assertTrue($dto->hasNested());
+ }
+
+ public function testLazyNullField(): void
+ {
+ $dto = new LazyDto([
+ 'title' => 'Test',
+ 'nested' => null,
+ ]);
+
+ $this->assertNull($dto->getNested());
+ }
+
+ public function testLazyFieldNotSet(): void
+ {
+ $dto = new LazyDto([
+ 'title' => 'Test',
+ ]);
+
+ $this->assertFalse($dto->hasNested());
+ $this->assertNull($dto->getNested());
+ }
+
+ public function testLazySetterClearsLazyData(): void
+ {
+ $dto = new LazyDto([
+ 'title' => 'Test',
+ 'nested' => ['name' => 'Original'],
+ ]);
+
+ $replacement = new SimpleDto(['name' => 'Replaced']);
+ $dto->setNested($replacement);
+
+ $this->assertSame('Replaced', $dto->getNested()->getName());
+ }
+
+ public function testLazyCollectionToArrayWithoutHydration(): void
+ {
+ $rawItems = [
+ ['name' => 'X', 'count' => 10],
+ ['name' => 'Y', 'count' => 20],
+ ];
+ $dto = new LazyDto([
+ 'title' => 'Test',
+ 'items' => $rawItems,
+ ]);
+
+ $arr = $dto->toArray();
+ $this->assertCount(2, $arr['items']);
+ $this->assertSame('X', $arr['items'][0]['name']);
+ }
+}
diff --git a/tests/Dto/ReadonlyTest.php b/tests/Dto/ReadonlyTest.php
new file mode 100644
index 0000000..0b69ac5
--- /dev/null
+++ b/tests/Dto/ReadonlyTest.php
@@ -0,0 +1,123 @@
+ 'Alice', 'age' => 30]);
+
+ $this->assertSame('Alice', $dto->getName());
+ $this->assertSame(30, $dto->getAge());
+ $this->assertNull($dto->getActive());
+ }
+
+ public function testPublicReadAccess(): void
+ {
+ $dto = new ReadonlyDto(['name' => 'Bob', 'age' => 25, 'active' => true]);
+
+ $this->assertSame('Bob', $dto->name);
+ $this->assertSame(25, $dto->age);
+ $this->assertTrue($dto->active);
+ }
+
+ public function testDirectAssignmentFails(): void
+ {
+ $dto = new ReadonlyDto(['name' => 'Charlie']);
+
+ $this->expectException(Error::class);
+ $dto->name = 'Modified';
+ }
+
+ public function testWithMethodReturnsNewInstance(): void
+ {
+ $original = new ReadonlyDto(['name' => 'Alice', 'age' => 30]);
+ $modified = $original->withName('Bob');
+
+ $this->assertSame('Alice', $original->getName());
+ $this->assertSame('Bob', $modified->getName());
+ $this->assertSame(30, $modified->getAge());
+ $this->assertNotSame($original, $modified);
+ }
+
+ public function testWithAge(): void
+ {
+ $dto = new ReadonlyDto(['name' => 'Test', 'age' => 20]);
+ $modified = $dto->withAge(25);
+
+ $this->assertSame(20, $dto->getAge());
+ $this->assertSame(25, $modified->getAge());
+ }
+
+ public function testWithActive(): void
+ {
+ $dto = new ReadonlyDto(['name' => 'Test']);
+ $modified = $dto->withActive(true);
+
+ $this->assertNull($dto->getActive());
+ $this->assertTrue($modified->getActive());
+ }
+
+ public function testChainedWithMethods(): void
+ {
+ $dto = new ReadonlyDto(['name' => 'Start']);
+ $result = $dto->withName('Changed')->withAge(40)->withActive(false);
+
+ $this->assertSame('Changed', $result->getName());
+ $this->assertSame(40, $result->getAge());
+ $this->assertFalse($result->getActive());
+ }
+
+ public function testToArray(): void
+ {
+ $dto = new ReadonlyDto(['name' => 'Test', 'age' => 25, 'active' => true]);
+ $result = $dto->toArray();
+
+ $this->assertSame([
+ 'name' => 'Test',
+ 'age' => 25,
+ 'active' => true,
+ ], $result);
+ }
+
+ public function testEmptyConstruction(): void
+ {
+ $dto = new ReadonlyDto();
+ $this->assertNull($dto->getName());
+ $this->assertNull($dto->getAge());
+ $this->assertNull($dto->getActive());
+ }
+
+ public function testHasMethods(): void
+ {
+ $dto = new ReadonlyDto(['name' => 'Test']);
+
+ $this->assertTrue($dto->hasName());
+ $this->assertFalse($dto->hasAge());
+ $this->assertFalse($dto->hasActive());
+ }
+
+ public function testCreateFromArray(): void
+ {
+ $dto = ReadonlyDto::createFromArray(['name' => 'Factory', 'age' => 10]);
+
+ $this->assertSame('Factory', $dto->getName());
+ $this->assertSame(10, $dto->getAge());
+ }
+
+ public function testWithNullValue(): void
+ {
+ $dto = new ReadonlyDto(['name' => 'Test', 'age' => 25]);
+ $modified = $dto->withAge(null);
+
+ $this->assertSame(25, $dto->getAge());
+ $this->assertNull($modified->getAge());
+ }
+}
diff --git a/tests/Dto/ValidationTest.php b/tests/Dto/ValidationTest.php
new file mode 100644
index 0000000..036f68f
--- /dev/null
+++ b/tests/Dto/ValidationTest.php
@@ -0,0 +1,141 @@
+ 'Hello']);
+ $this->assertSame('Hello', $dto->getName());
+ }
+
+ public function testMinLengthFails(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('name must be at least 2 characters');
+
+ new ValidatedDto(['name' => 'A']);
+ }
+
+ public function testMaxLengthFails(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('name must be at most 50 characters');
+
+ new ValidatedDto(['name' => str_repeat('x', 51)]);
+ }
+
+ public function testMinFails(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('age must be at least 0');
+
+ new ValidatedDto(['name' => 'Test', 'age' => -1]);
+ }
+
+ public function testMaxFails(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('age must be at most 150');
+
+ new ValidatedDto(['name' => 'Test', 'age' => 200]);
+ }
+
+ public function testMinFloat(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('score must be at least 0');
+
+ new ValidatedDto(['name' => 'Test', 'score' => -0.1]);
+ }
+
+ public function testMaxFloat(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('score must be at most 100');
+
+ new ValidatedDto(['name' => 'Test', 'score' => 100.1]);
+ }
+
+ public function testPatternFails(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('email must match pattern');
+
+ new ValidatedDto(['name' => 'Test', 'email' => 'not-an-email']);
+ }
+
+ public function testPatternPasses(): void
+ {
+ $dto = new ValidatedDto(['name' => 'Test', 'email' => 'user@example.com']);
+ $this->assertSame('user@example.com', $dto->getEmail());
+ }
+
+ public function testNullFieldsSkipValidation(): void
+ {
+ $dto = new ValidatedDto(['name' => 'Test']);
+ $this->assertNull($dto->getEmail());
+ $this->assertNull($dto->getAge());
+ $this->assertNull($dto->getScore());
+ }
+
+ public function testAllValidFields(): void
+ {
+ $dto = new ValidatedDto([
+ 'name' => 'Test',
+ 'email' => 'a@b.com',
+ 'age' => 25,
+ 'score' => 99.5,
+ ]);
+ $this->assertSame('Test', $dto->getName());
+ $this->assertSame('a@b.com', $dto->getEmail());
+ $this->assertSame(25, $dto->getAge());
+ $this->assertSame(99.5, $dto->getScore());
+ }
+
+ public function testBoundaryValues(): void
+ {
+ $dto = new ValidatedDto([
+ 'name' => 'AB',
+ 'age' => 0,
+ 'score' => 0.0,
+ ]);
+ $this->assertSame('AB', $dto->getName());
+ $this->assertSame(0, $dto->getAge());
+ $this->assertSame(0.0, $dto->getScore());
+
+ $dto2 = new ValidatedDto([
+ 'name' => str_repeat('x', 50),
+ 'age' => 150,
+ 'score' => 100.0,
+ ]);
+ $this->assertSame(150, $dto2->getAge());
+ $this->assertSame(100.0, $dto2->getScore());
+ }
+
+ public function testRequiredFieldStillWorks(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Required fields missing: name');
+
+ new ValidatedDto([]);
+ }
+
+ public function testToArray(): void
+ {
+ $dto = new ValidatedDto(['name' => 'Test', 'age' => 25]);
+ $result = $dto->toArray();
+
+ $this->assertSame('Test', $result['name']);
+ $this->assertSame(25, $result['age']);
+ $this->assertNull($result['email']);
+ $this->assertNull($result['score']);
+ }
+}
diff --git a/tests/TestDto/LazyDto.php b/tests/TestDto/LazyDto.php
new file mode 100644
index 0000000..5cf2ee8
--- /dev/null
+++ b/tests/TestDto/LazyDto.php
@@ -0,0 +1,315 @@
+|null
+ */
+ protected ?array $items = null;
+
+ /**
+ * @var array>
+ */
+ protected array $_metadata = [
+ 'title' => [
+ 'type' => 'string',
+ 'required' => false,
+ 'defaultValue' => null,
+ 'dto' => false,
+ 'collectionType' => null,
+ 'singularType' => null,
+ 'associative' => false,
+ 'key' => null,
+ 'serialize' => null,
+ 'factory' => null,
+ 'isClass' => false,
+ 'enum' => null,
+ 'minLength' => null,
+ 'maxLength' => null,
+ 'min' => null,
+ 'max' => null,
+ 'pattern' => null,
+ 'lazy' => false,
+ ],
+ 'nested' => [
+ 'type' => SimpleDto::class,
+ 'required' => false,
+ 'defaultValue' => null,
+ 'dto' => SimpleDto::class,
+ 'collectionType' => null,
+ 'singularType' => null,
+ 'associative' => false,
+ 'key' => null,
+ 'serialize' => null,
+ 'factory' => null,
+ 'isClass' => true,
+ 'enum' => null,
+ 'minLength' => null,
+ 'maxLength' => null,
+ 'min' => null,
+ 'max' => null,
+ 'pattern' => null,
+ 'lazy' => true,
+ ],
+ 'items' => [
+ 'type' => SimpleDto::class . '[]',
+ 'required' => false,
+ 'defaultValue' => null,
+ 'dto' => SimpleDto::class,
+ 'collectionType' => 'array',
+ 'singularType' => SimpleDto::class,
+ 'associative' => false,
+ 'key' => null,
+ 'serialize' => null,
+ 'factory' => null,
+ 'isClass' => true,
+ 'enum' => null,
+ 'minLength' => null,
+ 'maxLength' => null,
+ 'min' => null,
+ 'max' => null,
+ 'pattern' => null,
+ 'lazy' => true,
+ ],
+ ];
+
+ /**
+ * @var array>
+ */
+ protected array $_keyMap = [
+ 'underscored' => [
+ 'title' => 'title',
+ 'nested' => 'nested',
+ 'items' => 'items',
+ ],
+ 'dashed' => [
+ 'title' => 'title',
+ 'nested' => 'nested',
+ 'items' => 'items',
+ ],
+ ];
+
+ /**
+ * Optimized setFromArrayFast - handles lazy fields.
+ *
+ * @param array $data
+ *
+ * @return void
+ */
+ protected function setFromArrayFast(array $data): void
+ {
+ if (isset($data['title'])) {
+ $this->title = $data['title'];
+ $this->_touchedFields[self::FIELD_TITLE] = true;
+ }
+ if (array_key_exists('nested', $data)) {
+ $this->_lazyData['nested'] = $data['nested'];
+ $this->_touchedFields[self::FIELD_NESTED] = true;
+ }
+ if (array_key_exists('items', $data)) {
+ $this->_lazyData['items'] = $data['items'];
+ $this->_touchedFields[self::FIELD_ITEMS] = true;
+ }
+ }
+
+ /**
+ * @param array $data
+ *
+ * @throws \InvalidArgumentException
+ *
+ * @return void
+ */
+ protected function validateFieldNames(array $data): void
+ {
+ $diff = array_diff(array_keys($data), array_keys($this->_metadata));
+ if ($diff) {
+ throw new InvalidArgumentException('Unexpected fields: ' . implode(', ', $diff));
+ }
+ }
+
+ public function getTitle(): ?string
+ {
+ return $this->title;
+ }
+
+ public function setTitle(?string $title): self
+ {
+ $this->title = $title;
+ $this->_touchedFields[self::FIELD_TITLE] = true;
+
+ return $this;
+ }
+
+ public function hasTitle(): bool
+ {
+ return $this->title !== null;
+ }
+
+ /**
+ * Lazy getter - hydrates from raw data on first access.
+ */
+ public function getNested(): ?SimpleDto
+ {
+ if (array_key_exists('nested', $this->_lazyData)) {
+ $value = $this->_lazyData['nested'];
+ if (is_array($value)) {
+ $this->nested = new SimpleDto($value);
+ } else {
+ $this->nested = $value;
+ }
+ unset($this->_lazyData['nested']);
+ }
+
+ return $this->nested;
+ }
+
+ public function setNested(?SimpleDto $nested): self
+ {
+ unset($this->_lazyData['nested']);
+ $this->nested = $nested;
+ $this->_touchedFields[self::FIELD_NESTED] = true;
+
+ return $this;
+ }
+
+ public function hasNested(): bool
+ {
+ return array_key_exists('nested', $this->_lazyData) || $this->nested !== null;
+ }
+
+ /**
+ * Lazy getter - hydrates collection from raw data on first access.
+ *
+ * @return array<\PhpCollective\Dto\Test\TestDto\SimpleDto>|null
+ */
+ public function getItems(): ?array
+ {
+ if (array_key_exists('items', $this->_lazyData)) {
+ $value = $this->_lazyData['items'];
+ if (is_array($value)) {
+ $this->items = [];
+ foreach ($value as $k => $v) {
+ $this->items[$k] = is_array($v) ? new SimpleDto($v) : $v;
+ }
+ } else {
+ $this->items = $value;
+ }
+ unset($this->_lazyData['items']);
+ }
+
+ return $this->items;
+ }
+
+ /**
+ * @param array<\PhpCollective\Dto\Test\TestDto\SimpleDto>|null $items
+ *
+ * @return $this
+ */
+ public function setItems(?array $items)
+ {
+ unset($this->_lazyData['items']);
+ $this->items = $items;
+ $this->_touchedFields[self::FIELD_ITEMS] = true;
+
+ return $this;
+ }
+
+ public function hasItems(): bool
+ {
+ return array_key_exists('items', $this->_lazyData) || $this->items !== null;
+ }
+
+ /**
+ * Optimized toArray.
+ *
+ * @param string|null $type
+ * @param array|null $fields
+ * @param bool $touched
+ *
+ * @return array
+ */
+ public function toArray(?string $type = null, ?array $fields = null, bool $touched = false): array
+ {
+ if ($type === null && $fields === null && !$touched) {
+ return $this->toArrayFast();
+ }
+
+ return $this->_toArrayInternal($type, $fields, $touched);
+ }
+
+ /**
+ * @return array
+ */
+ protected function toArrayFast(): array
+ {
+ $result = [];
+ $result['title'] = $this->title;
+
+ if (array_key_exists('nested', $this->_lazyData)) {
+ $result['nested'] = $this->_lazyData['nested'];
+ } else {
+ $result['nested'] = $this->nested !== null ? $this->nested->toArray() : null;
+ }
+
+ if (array_key_exists('items', $this->_lazyData)) {
+ $result['items'] = $this->_lazyData['items'];
+ } else {
+ if ($this->items !== null) {
+ $result['items'] = [];
+ foreach ($this->items as $k => $v) {
+ $result['items'][$k] = $v->toArray();
+ }
+ } else {
+ $result['items'] = null;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * @param array $data
+ * @param bool $ignoreMissing
+ * @param string|null $type
+ *
+ * @return static
+ */
+ public static function createFromArray(array $data, bool $ignoreMissing = false, ?string $type = null): static
+ {
+ return new static($data, $ignoreMissing, $type);
+ }
+}
diff --git a/tests/TestDto/ReadonlyDto.php b/tests/TestDto/ReadonlyDto.php
new file mode 100644
index 0000000..1c3ec2e
--- /dev/null
+++ b/tests/TestDto/ReadonlyDto.php
@@ -0,0 +1,283 @@
+>
+ */
+ protected array $_metadata = [
+ 'name' => [
+ 'type' => 'string',
+ 'required' => false,
+ 'defaultValue' => null,
+ 'dto' => false,
+ 'collectionType' => null,
+ 'singularType' => null,
+ 'associative' => false,
+ 'key' => null,
+ 'serialize' => null,
+ 'factory' => null,
+ 'isClass' => false,
+ 'enum' => null,
+ 'minLength' => null,
+ 'maxLength' => null,
+ 'min' => null,
+ 'max' => null,
+ 'pattern' => null,
+ 'lazy' => false,
+ ],
+ 'age' => [
+ 'type' => 'int',
+ 'required' => false,
+ 'defaultValue' => null,
+ 'dto' => false,
+ 'collectionType' => null,
+ 'singularType' => null,
+ 'associative' => false,
+ 'key' => null,
+ 'serialize' => null,
+ 'factory' => null,
+ 'isClass' => false,
+ 'enum' => null,
+ 'minLength' => null,
+ 'maxLength' => null,
+ 'min' => null,
+ 'max' => null,
+ 'pattern' => null,
+ 'lazy' => false,
+ ],
+ 'active' => [
+ 'type' => 'bool',
+ 'required' => false,
+ 'defaultValue' => null,
+ 'dto' => false,
+ 'collectionType' => null,
+ 'singularType' => null,
+ 'associative' => false,
+ 'key' => null,
+ 'serialize' => null,
+ 'factory' => null,
+ 'isClass' => false,
+ 'enum' => null,
+ 'minLength' => null,
+ 'maxLength' => null,
+ 'min' => null,
+ 'max' => null,
+ 'pattern' => null,
+ 'lazy' => false,
+ ],
+ ];
+
+ /**
+ * @var array>
+ */
+ protected array $_keyMap = [
+ 'underscored' => [
+ 'name' => 'name',
+ 'age' => 'age',
+ 'active' => 'active',
+ ],
+ 'dashed' => [
+ 'name' => 'name',
+ 'age' => 'age',
+ 'active' => 'active',
+ ],
+ ];
+
+ /**
+ * Optimized setFromArrayFast - direct assignment during constructor.
+ *
+ * @param array $data
+ *
+ * @return void
+ */
+ protected function setFromArrayFast(array $data): void
+ {
+ if (isset($data['name'])) {
+ $this->name = $data['name'];
+ $this->_touchedFields[self::FIELD_NAME] = true;
+ }
+ if (isset($data['age'])) {
+ $this->age = (int)$data['age'];
+ $this->_touchedFields[self::FIELD_AGE] = true;
+ }
+ if (isset($data['active'])) {
+ $this->active = (bool)$data['active'];
+ $this->_touchedFields[self::FIELD_ACTIVE] = true;
+ }
+ }
+
+ /**
+ * @param array $data
+ *
+ * @throws \InvalidArgumentException
+ *
+ * @return void
+ */
+ protected function validateFieldNames(array $data): void
+ {
+ $diff = array_diff(array_keys($data), array_keys($this->_metadata));
+ if ($diff) {
+ throw new InvalidArgumentException('Unexpected fields: ' . implode(', ', $diff));
+ }
+ }
+
+ /**
+ * Initialize unset nullable readonly properties to null.
+ *
+ * @return $this
+ */
+ protected function setDefaults()
+ {
+ if (!isset($this->name)) {
+ $this->name = null;
+ }
+ if (!isset($this->age)) {
+ $this->age = null;
+ }
+ if (!isset($this->active)) {
+ $this->active = null;
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return array
+ */
+ protected function toArrayFast(): array
+ {
+ return [
+ 'name' => $this->name,
+ 'age' => $this->age,
+ 'active' => $this->active,
+ ];
+ }
+
+ /**
+ * @param string|null $type
+ * @param array|null $fields
+ * @param bool $touched
+ *
+ * @return array
+ */
+ public function toArray(?string $type = null, ?array $fields = null, bool $touched = false): array
+ {
+ if ($type === null && $fields === null && !$touched) {
+ return $this->toArrayFast();
+ }
+
+ return $this->_toArrayInternal($type, $fields, $touched);
+ }
+
+ public function getName(): ?string
+ {
+ return $this->name;
+ }
+
+ /**
+ * @return static
+ */
+ public function withName(?string $name)
+ {
+ $data = $this->toArray();
+ $data['name'] = $name;
+
+ return new static($data);
+ }
+
+ public function hasName(): bool
+ {
+ return $this->name !== null;
+ }
+
+ public function getAge(): ?int
+ {
+ return $this->age;
+ }
+
+ /**
+ * @return static
+ */
+ public function withAge(?int $age)
+ {
+ $data = $this->toArray();
+ $data['age'] = $age;
+
+ return new static($data);
+ }
+
+ public function hasAge(): bool
+ {
+ return $this->age !== null;
+ }
+
+ public function getActive(): ?bool
+ {
+ return $this->active;
+ }
+
+ /**
+ * @return static
+ */
+ public function withActive(?bool $active)
+ {
+ $data = $this->toArray();
+ $data['active'] = $active;
+
+ return new static($data);
+ }
+
+ public function hasActive(): bool
+ {
+ return $this->active !== null;
+ }
+
+ /**
+ * @param array $data
+ * @param bool $ignoreMissing
+ * @param string|null $type
+ *
+ * @return static
+ */
+ public static function createFromArray(array $data, bool $ignoreMissing = false, ?string $type = null): static
+ {
+ return new static($data, $ignoreMissing, $type);
+ }
+}
diff --git a/tests/TestDto/ValidatedDto.php b/tests/TestDto/ValidatedDto.php
new file mode 100644
index 0000000..2b90dc8
--- /dev/null
+++ b/tests/TestDto/ValidatedDto.php
@@ -0,0 +1,237 @@
+>
+ */
+ protected array $_metadata = [
+ 'name' => [
+ 'type' => 'string',
+ 'required' => true,
+ 'defaultValue' => null,
+ 'dto' => false,
+ 'collectionType' => null,
+ 'singularType' => null,
+ 'associative' => false,
+ 'key' => null,
+ 'serialize' => null,
+ 'factory' => null,
+ 'isClass' => false,
+ 'enum' => null,
+ 'minLength' => 2,
+ 'maxLength' => 50,
+ 'min' => null,
+ 'max' => null,
+ 'pattern' => null,
+ ],
+ 'email' => [
+ 'type' => 'string',
+ 'required' => false,
+ 'defaultValue' => null,
+ 'dto' => false,
+ 'collectionType' => null,
+ 'singularType' => null,
+ 'associative' => false,
+ 'key' => null,
+ 'serialize' => null,
+ 'factory' => null,
+ 'isClass' => false,
+ 'enum' => null,
+ 'minLength' => null,
+ 'maxLength' => null,
+ 'min' => null,
+ 'max' => null,
+ 'pattern' => '/^[^@]+@[^@]+$/',
+ ],
+ 'age' => [
+ 'type' => 'int',
+ 'required' => false,
+ 'defaultValue' => null,
+ 'dto' => false,
+ 'collectionType' => null,
+ 'singularType' => null,
+ 'associative' => false,
+ 'key' => null,
+ 'serialize' => null,
+ 'factory' => null,
+ 'isClass' => false,
+ 'enum' => null,
+ 'minLength' => null,
+ 'maxLength' => null,
+ 'min' => 0,
+ 'max' => 150,
+ 'pattern' => null,
+ ],
+ 'score' => [
+ 'type' => 'float',
+ 'required' => false,
+ 'defaultValue' => null,
+ 'dto' => false,
+ 'collectionType' => null,
+ 'singularType' => null,
+ 'associative' => false,
+ 'key' => null,
+ 'serialize' => null,
+ 'factory' => null,
+ 'isClass' => false,
+ 'enum' => null,
+ 'minLength' => null,
+ 'maxLength' => null,
+ 'min' => 0.0,
+ 'max' => 100.0,
+ 'pattern' => null,
+ ],
+ ];
+
+ /**
+ * @var array>
+ */
+ protected array $_keyMap = [
+ 'underscored' => [
+ 'name' => 'name',
+ 'email' => 'email',
+ 'age' => 'age',
+ 'score' => 'score',
+ ],
+ 'dashed' => [
+ 'name' => 'name',
+ 'email' => 'email',
+ 'age' => 'age',
+ 'score' => 'score',
+ ],
+ ];
+
+ public function getName(): ?string
+ {
+ return $this->name;
+ }
+
+ public function setName(?string $name): self
+ {
+ $this->name = $name;
+ $this->_touchedFields['name'] = true;
+
+ return $this;
+ }
+
+ public function hasName(): bool
+ {
+ return $this->name !== null;
+ }
+
+ public function getEmail(): ?string
+ {
+ return $this->email;
+ }
+
+ public function setEmail(?string $email): self
+ {
+ $this->email = $email;
+ $this->_touchedFields['email'] = true;
+
+ return $this;
+ }
+
+ public function hasEmail(): bool
+ {
+ return $this->email !== null;
+ }
+
+ public function getAge(): ?int
+ {
+ return $this->age;
+ }
+
+ public function setAge(?int $age): self
+ {
+ $this->age = $age;
+ $this->_touchedFields['age'] = true;
+
+ return $this;
+ }
+
+ public function hasAge(): bool
+ {
+ return $this->age !== null;
+ }
+
+ public function getScore(): ?float
+ {
+ return $this->score;
+ }
+
+ public function setScore(?float $score): self
+ {
+ $this->score = $score;
+ $this->_touchedFields['score'] = true;
+
+ return $this;
+ }
+
+ public function hasScore(): bool
+ {
+ return $this->score !== null;
+ }
+
+ /**
+ * @param string|null $type
+ * @param array|null $fields
+ * @param bool $touched
+ *
+ * @return array
+ */
+ public function toArray(?string $type = null, ?array $fields = null, bool $touched = false): array
+ {
+ return $this->_toArrayInternal($type, $fields, $touched);
+ }
+
+ /**
+ * @param array $data
+ * @param bool $ignoreMissing
+ * @param string|null $type
+ *
+ * @return static
+ */
+ public static function createFromArray(array $data, bool $ignoreMissing = false, ?string $type = null): static
+ {
+ return static::_createFromArrayInternal($data, $ignoreMissing, $type);
+ }
+}