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: stringrequired: truelength:
min: 3max: 20email:
type: emailrequired: trueage:
type: integerrange:
min: 18max: 120useNeuron\Dto\Factory;
// Create DTO from configuration$factory = newFactory('user.yaml');
$dto = $factory->create();
// Set values$dto->username = 'johndoe';
$dto->email = 'john@example.com';
$dto->age = 25;
// Validateif (!$dto->validate()) {
$errors = $dto->getErrors();
// Handle validation errors
}
// Export as JSONecho$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|etcrequired: true|false# Additional validation rulesdto:
# Simple string propertyfirstName:
type: stringrequired: truelength:
min: 2max: 50# Email with validationemail:
type: emailrequired: true# Integer with rangeage:
type: integerrange:
min: 0max: 150# Date with patternbirthDate:
type: datepattern: '/^\d{4}-\d{2}-\d{2}$/'# YYYY-MM-DD# Nested objectaddress:
type: objectrequired: trueproperties:
street:
type: stringrequired: truelength:
min: 5max: 100city:
type: stringrequired: truestate:
type: stringlength:
min: 2max: 2zipCode:
type: stringpattern: '/^\d{5}(-\d{4})?$/'# US ZIP code# Array of objectsphoneNumbers:
type: arrayitems:
type: objectproperties:
type:
type: stringenum: ['home', 'work', 'mobile']required: truenumber:
type: stringpattern: '/^\+?[\d\s\-\(\)]+$/'required: true# Array of primitivestags:
type: arrayitems:
type: stringlength:
min: 1max: 20useNeuron\Dto\Factory;
// Load from file$factory = newFactory('path/to/neuron.yaml');
$dto = $factory->create();
// Set properties$dto->firstName = 'John';
$dto->email = 'john@example.com';
$dto->age = 30;useNeuron\Dto\Dto;
useNeuron\Dto\Property;
$dto = newDto();
// Create string property$username = newProperty();
$username->setName('username');
$username->setType('string');
$username->setRequired(true);
$username->addLengthValidator(3, 20);
$dto->addProperty($username);
// Create email property$email = newProperty();
$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_timerequired: trueupdatedAt:
type: date_timerequired: falsecommon/address.yaml
dto:
street:
type: stringrequired: truelength:
min: 3max: 100city:
type: stringrequired: truestate:
type: stringrequired: truelength:
min: 2max: 2zipCode:
type: stringrequired: truepattern: '/^\d{5}(-\d{4})?$/'common/user.yaml
dto:
id:
type: uuidrequired: trueusername:
type: stringrequired: truelength:
min: 3max: 20email:
type: emailrequired: truefirstName:
type: stringrequired: truelastName:
type: stringrequired: trueReference these DTOs in your main DTO definition using type: dto with a ref parameter:
dto:
id:
type: uuidrequired: truetitle:
type: stringrequired: true# Reference to reusable timestamps DTOtimestamps:
type: dtoref: 'common/timestamps.yaml'required: true# Reference to reusable user DTOauthor:
type: dtoref: 'common/user.yaml'required: true# Reference to reusable address DTOshippingAddress:
type: dtoref: 'common/address.yaml'required: falseuseNeuron\Dto\Factory;
// Create DTO with referenced DTOs$factory = newFactory('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 = 'john@example.com';
$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 JSONecho$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: dtoref: '/absolute/path/to/timestamps.yaml'The DTO component includes comprehensive validation for each property type:
// Validate entire DTOif( !$dto->validate() ) {
$errors = $dto->getErrors();
foreach( $errorsas$property => $propertyErrors ) {
echo"Property '$property' has errors:\n";
foreach( $propertyErrorsas$error ) {
echo" - $error\n";
}
}
}
// Validate specific property$usernameProperty = $dto->getProperty('username');
if (!$usernameProperty->validate()) {
$errors = $usernameProperty->getErrors();
}username:
type: stringlength:
min: 3max: 20age:
type: integerrange:
min: 18max: 65phoneNumber:
type: stringpattern: '/^\+?[1-9]\d{1,14}$/'# E.164 formatstatus:
type: stringenum: ['active', 'inactive', 'pending']useNeuron\Validation\IValidator;
class CustomValidator implements IValidator
{
publicfunctionvalidate($value): bool
{
// Custom validation logicreturn$value !== 'forbidden';
}
publicfunctiongetError(): string
{
return'Value cannot be "forbidden"';
}
}
// Add to property$property->addValidator(newCustomValidator());Create a mapping configuration (mapping.yaml):
map:
# Simple mappingexternal.username: dto.usernameexternal.user_email: dto.email# Nested mappingexternal.user.profile.age: dto.ageexternal.user.contact.street: dto.address.streetexternal.user.contact.city: dto.address.city# Array mappingexternal.phones: dto.phoneNumbersexternal.phones.type: dto.phoneNumbers.typeexternal.phones.value: dto.phoneNumbers.numberuseNeuron\Dto\Mapper\FactoryasMapperFactory;
// Create mapper$mapperFactory = newMapperFactory('mapping.yaml');
$mapper = $mapperFactory->create();
// External data structure$externalData = [
'external' => [
'username' => 'johndoe',
'user_email' => 'john@example.com',
'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 dataecho$dto->username; // 'johndoe'echo$dto->address->street; // '123 Main St'echo$dto->phoneNumbers[0]->number; // '+1234567890'useNeuron\Dto\Mapper\Dynamic;
$mapper = newDynamic();
// 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 |
image | Image data | Base64/data URI, MIME type |
base64 | Base64 encoded data | Base64 format |
dto:
# String with constraintsusername:
type: stringlength:
min: 3max: 20pattern: '/^[a-zA-Z0-9_]+$/'# Email validationemail:
type: emailrequired: true# URL validationwebsite:
type: urlrequired: false# Date with formatbirthDate:
type: dateformat: 'Y-m-d'# Currencyprice:
type: currencyrange:
min: 0.01max: 999999.99# UUIDuserId:
type: uuidrequired: true# IP AddressclientIp:
type: ip_addressversion: 4# IPv4 only# Phone numberphone:
type: phone_numberformat: international# Image data (base64 or data URI)profilePicture:
type: imagerequired: falsedescription: "User profile picture as base64 or data:image URI"# Multiple imagesgallery:
type: arrayitems:
type: imagedescription: "Array of base64 encoded images"dto:
users:
type: arrayitems:
type: objectproperties:
id:
type: integerrequired: truename:
type: stringrequired: trueemail:
type: emailrequired: true// Adding items to collection$dto->users[] = (object)[
'id' => 1,
'name' => 'John Doe',
'email' => 'john@example.com'
];
// Accessing collection itemsforeach ($dto->usersas$user) {
echo$user->name;
}
// Collection validation$collection = newCollection($dto->users);
if (!$collection->validate()) {
$errors = $collection->getErrors();
}dto:
tags:
type: arrayitems:
type: stringlength:
min: 1max: 20scores:
type: arrayitems:
type: integerrange:
min: 0max: 100The image type provides validation for base64-encoded image data and data URIs. It supports common image formats including JPEG, PNG, GIF, WebP, and SVG.
dto:
# Simple image propertyavatar:
type: imagerequired: false# Image with data URI supportlogo:
type: imagerequired: truedescription: "Company logo as base64 or data:image/png;base64,..."useNeuron\Dto\Factory;
// Create DTO with image property$factory = newFactory([
'profile_pic' => [
'type' => 'image',
'required' => true
]
]);
$dto = $factory->create();
// Set image as base64$imageData = base64_encode(file_get_contents('photo.jpg'));
$dto->profile_pic = $imageData;
// Or use data URI format$dto->profile_pic = 'data:image/jpeg;base64,' . $imageData;
// Validate$dto->validate();
// Get JSON output (image remains as base64 string)$json = $dto->getAsJson();The image validator automatically detects and validates the following formats:
- JPEG/JPG - Detected by JPEG file signature
- PNG - Detected by PNG file signature
- GIF - Supports both GIF87a and GIF89a
- WebP - Modern image format
- SVG - XML-based vector graphics (disabled by default for security - see below)
- Base64 Encoding: Validates proper base64 encoding
- Data URI Support: Accepts
data:image/type;base64,format - MIME Type Detection: Automatically detects image type from file signatures
- Format Validation: Ensures the data actually contains valid image content
- Size Constraints: Can be configured with maximum file size limits (via custom validator)
- SVG Security: SVG images are disabled by default as they can contain embedded scripts (XSS risk)
SVG images are disabled by default because they are XML-based and can contain:
- JavaScript code via
<script>tags - Event handlers that execute JavaScript
- External resource references
- CSS that could be used for attacks
If you need to accept SVG images, you must:
- Explicitly enable SVG support in your validator configuration
- Sanitize SVG content before storage or display
- Serve SVG files with appropriate Content Security Policy headers
- Consider using a dedicated SVG sanitization library
To enable SVG support (use with caution):
// Create a custom validator with SVG enabled$imageValidator = new \Neuron\Validation\IsImage(
[], // allowed MIME types (empty = all)null, // max sizetrue, // check image datatrue// ALLOW SVG (security risk!)
);dto:
# User profile DTOprofile:
type: objectproperties:
personalInfo:
type: objectrequired: trueproperties:
firstName:
type: stringrequired: truelength:
min: 2max: 50lastName:
type: stringrequired: truelength:
min: 2max: 50dateOfBirth:
type: daterequired: truegender:
type: stringenum: ['male', 'female', 'other', 'prefer_not_to_say']contactInfo:
type: objectrequired: trueproperties:
emails:
type: arrayitems:
type: objectproperties:
type:
type: stringenum: ['personal', 'work']required: trueaddress:
type: emailrequired: trueverified:
type: booleandefault: falsephones:
type: arrayitems:
type: objectproperties:
type:
type: stringenum: ['mobile', 'home', 'work']number:
type: phone_numberrequired: trueprimary:
type: booleandefault: falsepreferences:
type: objectproperties:
newsletter:
type: booleandefault: truenotifications:
type: objectproperties:
email:
type: booleandefault: truesms:
type: booleandefault: falsepush:
type: booleandefault: truelanguage:
type: stringenum: ['en', 'es', 'fr', 'de']default: 'en'useNeuron\Dto\Dto;
class UserDto extends Dto
{
publicfunction__construct()
{
parent::__construct();
$this->loadConfiguration('user.yaml');
}
publicfunctiongetFullName(): string
{
return$this->firstName . '' . $this->lastName;
}
publicfunctionisAdult(): bool
{
return$this->age >= 18;
}
publicfunctiontoArray(): array
{
return [
'username' => $this->username,
'email' => $this->email,
'fullName' => $this->getFullName(),
'isAdult' => $this->isAdult()
];
}
}useNeuron\Dto\Factory;
class CachedDtoFactory extends Factory
{
privatestaticarray$cache = [];
publicfunctioncreate(): Dto
{
$cacheKey = md5($this->configPath);
if (!isset(self::$cache[$cacheKey])) {
self::$cache[$cacheKey] = parent::create();
}
// Return deep clone to prevent shared statereturncloneself::$cache[$cacheKey];
}
}usePHPUnit\Framework\TestCase;
useNeuron\Dto\Factory;
class DtoTest extends TestCase
{
private$dto;
protectedfunctionsetUp(): void
{
$factory = newFactory('test-dto.yaml');
$this->dto = $factory->create();
}
publicfunctiontestValidation(): 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);
}
publicfunctiontestValidData(): void
{
$this->dto->username = 'johndoe';
$this->dto->email = 'john@example.com';
$this->dto->age = 25;
$this->assertTrue($this->dto->validate());
$this->assertEmpty($this->dto->getErrors());
}
publicfunctiontestNestedObjects(): 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);
}
publicfunctiontestJsonExport(): void
{
$this->dto->username = 'johndoe';
$this->dto->email = 'john@example.com';
$json = $this->dto->getAsJson();
$decoded = json_decode($json, true);
$this->assertEquals('johndoe', $decoded['username']);
$this->assertEquals('john@example.com', $decoded['email']);
}
}class MapperTest extends TestCase
{
publicfunctiontestDataMapping(): void
{
$factory = newFactory('dto.yaml');
$dto = $factory->create();
$mapperFactory = newMapperFactory('mapping.yaml');
$mapper = $mapperFactory->create();
$sourceData = [
'external' => [
'user_name' => 'johndoe',
'user_email' => 'john@example.com'
]
];
$mapper->map($dto, $sourceData);
$this->assertEquals('johndoe', $dto->username);
$this->assertEquals('john@example.com', $dto->email);
}
}# Good: Clear, consistent namingdto:
firstName:
type: stringrequired: truelastName:
type: stringrequired: trueemailAddress:
type: emailrequired: true# Avoid: Inconsistent or unclear namesdto:
fname: # Too abbreviatedlast_name: # Inconsistent stylemail: # Ambiguous// Always validate before processingif( !$dto->validate() ) {
// Log errors
Log::error('DTO validation failed', $dto->getErrors());
// Return early with error responsereturnnewValidationErrorResponse($dto->getErrors());
}
// Process valid data$result = $service->process($dto);try {
$dto->username = $input['username'];
$dto->email = $input['email'];
if( !$dto->validate() ) {
thrownewValidationException($dto->getErrors());
}
$user = $userService->create($dto);
} catch( ValidationException$e ) {
// Handle validation errorsreturnresponse()->json([
'error' => 'Validation failed',
'details' => $e->getErrors()
], 422);
} catch (PropertyNotFound$e) {
// Handle missing propertyreturnresponse()->json([
'error' => 'Invalid property: ' . $e->getMessage()
], 400);
}// Base DTO for common propertiesabstractclass BaseDto extends Dto
{
protectedfunctionaddTimestamps(): void
{
$createdAt = newProperty();
$createdAt->setName('createdAt');
$createdAt->setType('date_time');
$this->addProperty($createdAt);
$updatedAt = newProperty();
$updatedAt->setName('updatedAt');
$updatedAt->setType('date_time');
$this->addProperty($updatedAt);
}
}
// Specific DTO extending baseclass UserDto extends BaseDto
{
publicfunction__construct()
{
parent::__construct();
$this->loadConfiguration('user.yaml');
$this->addTimestamps();
}
}// Cache DTO definitionsclass DtoCache
{
privatestaticarray$definitions = [];
publicstaticfunctiongetDefinition(string$config): array
{
if (!isset(self::$definitions[$config])) {
self::$definitions[$config] = Yaml::parseFile($config);
}
returnself::$definitions[$config];
}
}
// Use lazy loading for nested objectsclass LazyDto extends Dto
{
privatearray$lazyProperties = [];
publicfunction__get(string$name)
{
if( isset( $this->lazyProperties[ $name ] ) ) {
// Load only when accessed$this->loadProperty($name);
}
returnparent::__get($name);
}
}class ApiController
{
privateFactory$dtoFactory;
publicfunctioncreateUser(Request$request): Response
{
$dto = $this->dtoFactory->create('user');
// Map request data to DTO$mapper = newRequestMapper();
$mapper->map($dto, $request->all());
// Validateif( !$dto->validate() ) {
returnresponse()->json([
'errors' => $dto->getErrors()
], 422);
}
// Process valid data$user = $this->userService->create($dto);
returnresponse()->json($user, 201);
}
}class UserRepository
{
publicfunctionsave(UserDto$dto): User
{
$user = newUser();
$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;
}
publicfunctiontoDto(User$user): UserDto
{
$factory = newFactory('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