A powerful Data Transfer Object (DTO) library for PHP 8.4+ that provides dynamic DTO creation, comprehensive validation, and flexible data mapping capabilities with support for nested structures and YAML configuration.
- Installation
- Quick Start
- Core Features
- DTO Configuration
- Creating DTOs
- Validation
- Data Mapping
- Property Types
- Collections
- Advanced Usage
- Testing
- Best Practices
- More Information
- PHP 8.4 or higher
- Composer
- symfony/yaml (^6.4)
- neuron-php/validation (^0.7.0)
composer require neuron-php/dtoCreate a YAML configuration file (user.yaml):
dto:
username:
type: string
required: true
length:
min: 3
max: 20
email:
type: email
required: true
age:
type: integer
range:
min: 18
max: 120use Neuron\Dto\Factory;
// Create DTO from configuration
$factory = new Factory('user.yaml');
$dto = $factory->create();
// Set values
$dto->username = 'johndoe';
$dto->email = '[email protected]';
$dto->age = 25;
// Validate
if (!$dto->validate()) {
$errors = $dto->getErrors();
// Handle validation errors
}
// Export as JSON
echo $dto->getAsJson();- Dynamic DTO Creation: Generate DTOs from YAML configuration files
- Comprehensive Validation: Built-in validators for 20+ data types
- Nested Structures: Support for complex, hierarchical data models
- DTO Composition: Reuse DTOs by referencing existing DTO definitions
- Data Mapping: Transform external data structures to DTOs
- Type Safety: Strict type checking and validation
- Collections: Handle arrays of objects with validation
- JSON Export: Easy serialization to JSON format
- Custom Validators: Extend with custom validation logic
DTOs are configured using YAML files with property definitions:
dto:
propertyName:
type: string|integer|boolean|array|object|etc
required: true|false
# Additional validation rulesdto:
# Simple string property
firstName:
type: string
required: true
length:
min: 2
max: 50
# Email with validation
email:
type: email
required: true
# Integer with range
age:
type: integer
range:
min: 0
max: 150
# Date with pattern
birthDate:
type: date
pattern: '/^\d{4}-\d{2}-\d{2}$/' # YYYY-MM-DD
# Nested object
address:
type: object
required: true
properties:
street:
type: string
required: true
length:
min: 5
max: 100
city:
type: string
required: true
state:
type: string
length:
min: 2
max: 2
zipCode:
type: string
pattern: '/^\d{5}(-\d{4})?$/' # US ZIP code
# Array of objects
phoneNumbers:
type: array
items:
type: object
properties:
type:
type: string
enum: ['home', 'work', 'mobile']
required: true
number:
type: string
pattern: '/^\+?[\d\s\-\(\)]+$/'
required: true
# Array of primitives
tags:
type: array
items:
type: string
length:
min: 1
max: 20use Neuron\Dto\Factory;
// Load from file
$factory = new Factory('path/to/neuron.yaml');
$dto = $factory->create();
// Set properties
$dto->firstName = 'John';
$dto->email = '[email protected]';
$dto->age = 30;use Neuron\Dto\Dto;
use Neuron\Dto\Property;
$dto = new Dto();
// Create string property
$username = new Property();
$username->setName('username');
$username->setType('string');
$username->setRequired(true);
$username->addLengthValidator(3, 20);
$dto->addProperty($username);
// Create email property
$email = new Property();
$email->setName('email');
$email->setType('email');
$email->setRequired(true);
$dto->addProperty($email);// Setting nested properties
$dto->address->street = '123 Main St';
$dto->address->city = 'New York';
$dto->address->state = 'NY';
$dto->address->zipCode = '10001';
// Accessing nested properties
$street = $dto->address->street;
$city = $dto->address->city;You can create reusable DTO definitions and reference them in other DTOs, making it easy to share common structures like timestamps, addresses, or user records across multiple DTOs.
First, create standalone DTO definition files:
common/timestamps.yaml
dto:
createdAt:
type: date_time
required: true
updatedAt:
type: date_time
required: falsecommon/address.yaml
dto:
street:
type: string
required: true
length:
min: 3
max: 100
city:
type: string
required: true
state:
type: string
required: true
length:
min: 2
max: 2
zipCode:
type: string
required: true
pattern: '/^\d{5}(-\d{4})?$/'common/user.yaml
dto:
id:
type: uuid
required: true
username:
type: string
required: true
length:
min: 3
max: 20
email:
type: email
required: true
firstName:
type: string
required: true
lastName:
type: string
required: trueReference these DTOs in your main DTO definition using type: dto with a ref parameter:
dto:
id:
type: uuid
required: true
title:
type: string
required: true
# Reference to reusable timestamps DTO
timestamps:
type: dto
ref: 'common/timestamps.yaml'
required: true
# Reference to reusable user DTO
author:
type: dto
ref: 'common/user.yaml'
required: true
# Reference to reusable address DTO
shippingAddress:
type: dto
ref: 'common/address.yaml'
required: falseuse Neuron\Dto\Factory;
// Create DTO with referenced DTOs
$factory = new Factory('article.yaml');
$dto = $factory->create();
// Set values on the main DTO
$dto->id = '550e8400-e29b-41d4-a716-446655440000';
$dto->title = 'My Article';
// Set values on referenced DTOs
$dto->timestamps->createdAt = '2024-01-01 10:00:00';
$dto->timestamps->updatedAt = '2024-01-02 12:00:00';
$dto->author->id = '550e8400-e29b-41d4-a716-446655440001';
$dto->author->username = 'johndoe';
$dto->author->email = '[email protected]';
$dto->author->firstName = 'John';
$dto->author->lastName = 'Doe';
$dto->shippingAddress->street = '123 Main St';
$dto->shippingAddress->city = 'New York';
$dto->shippingAddress->state = 'NY';
$dto->shippingAddress->zipCode = '10001';
// Validate entire structure including referenced DTOs
$dto->validate();
// Export to JSON
echo $dto->getAsJson();- Reusability: Define common structures once, use them everywhere
- Consistency: Ensure the same validation rules across all uses
- Maintainability: Update the definition in one place
- Performance: Referenced DTOs are cached automatically
- Type Safety: Full validation support for nested structures
Referenced paths are resolved relative to the parent DTO file:
# If this file is at: project/dtos/article.yaml
# And you reference: 'common/timestamps.yaml'
# The system will look for: project/dtos/common/timestamps.yaml
# You can also use absolute paths:
timestamps:
type: dto
ref: '/absolute/path/to/timestamps.yaml'The DTO component includes comprehensive validation for each property type:
// Validate entire DTO
if( !$dto->validate() )
{
$errors = $dto->getErrors();
foreach( $errors as $property => $propertyErrors )
{
echo "Property '$property' has errors:\n";
foreach( $propertyErrors as $error )
{
echo " - $error\n";
}
}
}
// Validate specific property
$usernameProperty = $dto->getProperty('username');
if (!$usernameProperty->validate()) {
$errors = $usernameProperty->getErrors();
}username:
type: string
length:
min: 3
max: 20age:
type: integer
range:
min: 18
max: 65phoneNumber:
type: string
pattern: '/^\+?[1-9]\d{1,14}$/' # E.164 formatstatus:
type: string
enum: ['active', 'inactive', 'pending']use Neuron\Validation\IValidator;
class CustomValidator implements IValidator
{
public function validate($value): bool
{
// Custom validation logic
return $value !== 'forbidden';
}
public function getError(): string
{
return 'Value cannot be "forbidden"';
}
}
// Add to property
$property->addValidator(new CustomValidator());Create a mapping configuration (mapping.yaml):
map:
# Simple mapping
external.username: dto.username
external.user_email: dto.email
# Nested mapping
external.user.profile.age: dto.age
external.user.contact.street: dto.address.street
external.user.contact.city: dto.address.city
# Array mapping
external.phones: dto.phoneNumbers
external.phones.type: dto.phoneNumbers.type
external.phones.value: dto.phoneNumbers.numberuse Neuron\Dto\Mapper\Factory as MapperFactory;
// Create mapper
$mapperFactory = new MapperFactory('mapping.yaml');
$mapper = $mapperFactory->create();
// External data structure
$externalData = [
'external' => [
'username' => 'johndoe',
'user_email' => '[email protected]',
'user' => [
'profile' => [
'age' => 30
],
'contact' => [
'street' => '123 Main St',
'city' => 'New York'
]
],
'phones' => [
['type' => 'mobile', 'value' => '+1234567890'],
['type' => 'home', 'value' => '+0987654321']
]
]
];
// Map to DTO
$mapper->map($dto, $externalData);
// Now DTO contains mapped data
echo $dto->username; // 'johndoe'
echo $dto->address->street; // '123 Main St'
echo $dto->phoneNumbers[0]->number; // '+1234567890'use Neuron\Dto\Mapper\Dynamic;
$mapper = new Dynamic();
// Define mappings programmatically
$mapper->addMapping('source.field1', 'target.property1');
$mapper->addMapping('source.nested.field2', 'target.property2');
// Map data
$mapper->map($dto, $sourceData);| Type | Description | Validation |
|---|---|---|
string |
Text values | Length, pattern |
integer |
Whole numbers | Range, min, max |
float |
Decimal numbers | Range, precision |
boolean |
True/false values | Type checking |
array |
Lists of items | Item validation |
object |
Nested objects | Property validation |
dto |
Referenced DTO | Full DTO validation |
email |
Email addresses | RFC compliance |
url |
URLs | URL format |
date |
Date values | Date format |
date_time |
Date and time | DateTime format |
time |
Time values | Time format |
currency |
Money amounts | Currency format |
uuid |
UUIDs | UUID v4 format |
ip_address |
IP addresses | IPv4/IPv6 |
phone_number |
Phone numbers | International format |
name |
Person names | Name validation |
ein |
EIN numbers | US EIN format |
upc |
UPC codes | UPC-A format |
numeric |
Any number | Numeric validation |
dto:
# String with constraints
username:
type: string
length:
min: 3
max: 20
pattern: '/^[a-zA-Z0-9_]+$/'
# Email validation
email:
type: email
required: true
# URL validation
website:
type: url
required: false
# Date with format
birthDate:
type: date
format: 'Y-m-d'
# Currency
price:
type: currency
range:
min: 0.01
max: 999999.99
# UUID
userId:
type: uuid
required: true
# IP Address
clientIp:
type: ip_address
version: 4 # IPv4 only
# Phone number
phone:
type: phone_number
format: internationaldto:
users:
type: array
items:
type: object
properties:
id:
type: integer
required: true
name:
type: string
required: true
email:
type: email
required: true// Adding items to collection
$dto->users[] = (object)[
'id' => 1,
'name' => 'John Doe',
'email' => '[email protected]'
];
// Accessing collection items
foreach ($dto->users as $user) {
echo $user->name;
}
// Collection validation
$collection = new Collection($dto->users);
if (!$collection->validate()) {
$errors = $collection->getErrors();
}dto:
tags:
type: array
items:
type: string
length:
min: 1
max: 20
scores:
type: array
items:
type: integer
range:
min: 0
max: 100dto:
# User profile DTO
profile:
type: object
properties:
personalInfo:
type: object
required: true
properties:
firstName:
type: string
required: true
length:
min: 2
max: 50
lastName:
type: string
required: true
length:
min: 2
max: 50
dateOfBirth:
type: date
required: true
gender:
type: string
enum: ['male', 'female', 'other', 'prefer_not_to_say']
contactInfo:
type: object
required: true
properties:
emails:
type: array
items:
type: object
properties:
type:
type: string
enum: ['personal', 'work']
required: true
address:
type: email
required: true
verified:
type: boolean
default: false
phones:
type: array
items:
type: object
properties:
type:
type: string
enum: ['mobile', 'home', 'work']
number:
type: phone_number
required: true
primary:
type: boolean
default: false
preferences:
type: object
properties:
newsletter:
type: boolean
default: true
notifications:
type: object
properties:
email:
type: boolean
default: true
sms:
type: boolean
default: false
push:
type: boolean
default: true
language:
type: string
enum: ['en', 'es', 'fr', 'de']
default: 'en'use Neuron\Dto\Dto;
class UserDto extends Dto
{
public function __construct()
{
parent::__construct();
$this->loadConfiguration('user.yaml');
}
public function getFullName(): string
{
return $this->firstName . ' ' . $this->lastName;
}
public function isAdult(): bool
{
return $this->age >= 18;
}
public function toArray(): array
{
return [
'username' => $this->username,
'email' => $this->email,
'fullName' => $this->getFullName(),
'isAdult' => $this->isAdult()
];
}
}use Neuron\Dto\Factory;
class CachedDtoFactory extends Factory
{
private static array $cache = [];
public function create(): Dto
{
$cacheKey = md5($this->configPath);
if (!isset(self::$cache[$cacheKey])) {
self::$cache[$cacheKey] = parent::create();
}
// Return deep clone to prevent shared state
return clone self::$cache[$cacheKey];
}
}use PHPUnit\Framework\TestCase;
use Neuron\Dto\Factory;
class DtoTest extends TestCase
{
private $dto;
protected function setUp(): void
{
$factory = new Factory('test-dto.yaml');
$this->dto = $factory->create();
}
public function testValidation(): void
{
$this->dto->username = 'ab'; // Too short
$this->dto->email = 'invalid-email';
$this->assertFalse($this->dto->validate());
$errors = $this->dto->getErrors();
$this->assertArrayHasKey('username', $errors);
$this->assertArrayHasKey('email', $errors);
}
public function testValidData(): void
{
$this->dto->username = 'johndoe';
$this->dto->email = '[email protected]';
$this->dto->age = 25;
$this->assertTrue($this->dto->validate());
$this->assertEmpty($this->dto->getErrors());
}
public function testNestedObjects(): void
{
$this->dto->address->street = '123 Main St';
$this->dto->address->city = 'New York';
$this->assertEquals('123 Main St', $this->dto->address->street);
$this->assertEquals('New York', $this->dto->address->city);
}
public function testJsonExport(): void
{
$this->dto->username = 'johndoe';
$this->dto->email = '[email protected]';
$json = $this->dto->getAsJson();
$decoded = json_decode($json, true);
$this->assertEquals('johndoe', $decoded['username']);
$this->assertEquals('[email protected]', $decoded['email']);
}
}class MapperTest extends TestCase
{
public function testDataMapping(): void
{
$factory = new Factory('dto.yaml');
$dto = $factory->create();
$mapperFactory = new MapperFactory('mapping.yaml');
$mapper = $mapperFactory->create();
$sourceData = [
'external' => [
'user_name' => 'johndoe',
'user_email' => '[email protected]'
]
];
$mapper->map($dto, $sourceData);
$this->assertEquals('johndoe', $dto->username);
$this->assertEquals('[email protected]', $dto->email);
}
}# Good: Clear, consistent naming
dto:
firstName:
type: string
required: true
lastName:
type: string
required: true
emailAddress:
type: email
required: true
# Avoid: Inconsistent or unclear names
dto:
fname: # Too abbreviated
last_name: # Inconsistent style
mail: # Ambiguous// Always validate before processing
if( !$dto->validate() )
{
// Log errors
Log::error('DTO validation failed', $dto->getErrors());
// Return early with error response
return new ValidationErrorResponse($dto->getErrors());
}
// Process valid data
$result = $service->process($dto);try
{
$dto->username = $input['username'];
$dto->email = $input['email'];
if( !$dto->validate() )
{
throw new ValidationException($dto->getErrors());
}
$user = $userService->create($dto);
}
catch( ValidationException $e )
{
// Handle validation errors
return response()->json([
'error' => 'Validation failed',
'details' => $e->getErrors()
], 422);
}
catch (PropertyNotFound $e)
{
// Handle missing property
return response()->json([
'error' => 'Invalid property: ' . $e->getMessage()
], 400);
}// Base DTO for common properties
abstract class BaseDto extends Dto
{
protected function addTimestamps(): void
{
$createdAt = new Property();
$createdAt->setName('createdAt');
$createdAt->setType('date_time');
$this->addProperty($createdAt);
$updatedAt = new Property();
$updatedAt->setName('updatedAt');
$updatedAt->setType('date_time');
$this->addProperty($updatedAt);
}
}
// Specific DTO extending base
class UserDto extends BaseDto
{
public function __construct()
{
parent::__construct();
$this->loadConfiguration('user.yaml');
$this->addTimestamps();
}
}// Cache DTO definitions
class DtoCache
{
private static array $definitions = [];
public static function getDefinition(string $config): array
{
if (!isset(self::$definitions[$config])) {
self::$definitions[$config] = Yaml::parseFile($config);
}
return self::$definitions[$config];
}
}
// Use lazy loading for nested objects
class LazyDto extends Dto
{
private array $lazyProperties = [];
public function __get(string $name)
{
if( isset( $this->lazyProperties[ $name ] ) )
{
// Load only when accessed
$this->loadProperty($name);
}
return parent::__get($name);
}
}class ApiController
{
private Factory $dtoFactory;
public function createUser(Request $request): Response
{
$dto = $this->dtoFactory->create('user');
// Map request data to DTO
$mapper = new RequestMapper();
$mapper->map($dto, $request->all());
// Validate
if( !$dto->validate() )
{
return response()->json([
'errors' => $dto->getErrors()
], 422);
}
// Process valid data
$user = $this->userService->create($dto);
return response()->json($user, 201);
}
}class UserRepository
{
public function save(UserDto $dto): User
{
$user = new User();
$user->username = $dto->username;
$user->email = $dto->email;
$user->profile = json_encode([
'firstName' => $dto->firstName,
'lastName' => $dto->lastName,
'address' => [
'street' => $dto->address->street,
'city' => $dto->address->city,
'state' => $dto->address->state,
'zipCode' => $dto->address->zipCode
]
]);
$user->save();
return $user;
}
public function toDto(User $user): UserDto
{
$factory = new Factory('user.yaml');
$dto = $factory->create();
$dto->username = $user->username;
$dto->email = $user->email;
$profile = json_decode($user->profile, true);
$dto->firstName = $profile['firstName'];
$dto->lastName = $profile['lastName'];
$dto->address->street = $profile['address']['street'];
$dto->address->city = $profile['address']['city'];
return $dto;
}
}- Neuron Framework: neuronphp.com
- GitHub: github.com/neuron-php/dto
- Packagist: packagist.org/packages/neuron-php/dto
MIT License - see LICENSE file for details