Framework Integration
This library is framework-agnostic by design. It works with any PHP framework without requiring wrapper packages, while still offering deep integration possibilities.
Do We Need Wrapper Libraries?
| Feature | Without Wrapper | With Wrapper |
|---|---|---|
| Basic usage | ✅ Works | ✅ Works |
| Collection factory | 1 line setup | Auto-configured |
| Code generation | bin/dto generate | artisan dto:generate |
| Request → DTO | Manual | Auto via injection |
| Validation | Manual integration | Native framework validators |
| IDE support | ✅ Full | ✅ Full |
Quick Setup
Laravel
// app/Providers/AppServiceProvider.php
use PhpCollective\Dto\Dto\Dto;
public function boot(): void
{
// Enable Laravel collections for DTO collection fields
Dto::setCollectionFactory(fn (array $items) => collect($items));
}Symfony
// src/Kernel.php or an event listener
use PhpCollective\Dto\Dto\Dto;
// In boot or kernel.request listener
Dto::setCollectionFactory(fn (array $items) => new ArrayCollection($items));CakePHP
// config/bootstrap.php or Application.php
use PhpCollective\Dto\Dto\Dto;
use Cake\Collection\Collection;
Dto::setCollectionFactory(fn (array $items) => new Collection($items));CakePHP projectAs() and Special Fields
CakePHP 5.3+ introduces projectAs() for projecting ORM results directly into DTOs. Some CakePHP features use underscore-prefixed field names:
_joinData- BelongsToMany pivot table data_matchingData- Data frommatching()queries
These field names are fully supported:
<!-- config/dto.xml -->
<dto name="Tag" immutable="true">
<field name="id" type="int"/>
<field name="name" type="string"/>
<field name="_joinData" type="Tagged"/>
</dto>
<dto name="UserWithMatching" immutable="true">
<field name="id" type="int"/>
<field name="username" type="string"/>
<field name="_matchingData" type="MatchingData"/>
</dto>The generated methods use proper camelCase (the leading underscore is stripped for method/constant names):
// Field: _joinData
$tag->getJoinData(); // getter
$tag->withJoinData($data); // immutable setter
TagDto::FIELD_JOIN_DATA; // constant
// Field: _matchingData
$user->getMatchingData();
$user->withMatchingData($data);
UserWithMatchingDto::FIELD_MATCHING_DATA;Usage with CakePHP's projectAs():
// BelongsToMany with _joinData
$posts = $postsTable->find()
->contain(['Tags'])
->projectAs(PostDto::class)
->toArray();
foreach ($posts as $post) {
foreach ($post->getTags() as $tag) {
$pivotData = $tag->getJoinData(); // Access pivot table data
}
}
// Matching query with _matchingData
$users = $usersTable->find()
->matching('Roles', fn ($q) => $q->where(['Roles.id' => 1]))
->projectAs(UserWithMatchingDto::class)
->toArray();
foreach ($users as $user) {
$matchingRoles = $user->getMatchingData(); // Access matched association data
}Controller Usage
Laravel
use App\Dto\UserDto;
use Illuminate\Http\Request;
class UserController extends Controller
{
public function store(Request $request): JsonResponse
{
// From validated request
$dto = new UserDto($request->validated());
// Or with ignoreMissing for partial updates
$dto = UserDto::createFromArray($request->all(), ignoreMissing: true);
// Use Laravel collection methods on DTO collections
$activeUsers = $dto->getRoles()->filter(fn ($role) => $role->isActive());
return response()->json($dto->toArray());
}
public function show(int $id): JsonResponse
{
$user = User::findOrFail($id);
$dto = new UserDto($user->toArray());
return response()->json($dto);
}
}Symfony
use App\Dto\UserDto;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;
class UserController extends AbstractController
{
#[Route('/users', methods: ['POST'])]
public function store(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
$dto = new UserDto($data);
return $this->json($dto->toArray());
}
}CakePHP
use App\Dto\UserDto;
class UsersController extends AppController
{
public function add()
{
if ($this->request->is('post')) {
$dto = new UserDto($this->request->getData());
// Use CakePHP collection methods
$items = $dto->getItems()->filter(fn ($item) => $item->getQuantity() > 0);
// ... save logic
}
}
}Validation
The library includes built-in DTO validation for:
- required fields
- PHP-native type enforcement
- common field rules such as
minLength,maxLength,min,max, andpattern
Framework validators still matter for request validation, business rules, custom messages, and cross-field constraints. A practical setup is:
- Let the DTO handle structural validation and type-safe hydration.
- Use your framework validator for request-specific or domain-specific rules.
- Optionally map
validationRules()into framework-native rules if you want to reuse DTO metadata.
Laravel
use Illuminate\Support\Facades\Validator;
$dto = UserDto::createFromArray($request->all(), ignoreMissing: true);
$dtoRules = $dto->validationRules();
$validator = Validator::make($dto->toArray(), [
'name' => [
'required',
'string',
'min:' . ($dtoRules['name']['minLength'] ?? 0),
'max:' . ($dtoRules['name']['maxLength'] ?? 255),
],
'email' => [
'required',
'email',
'unique:users',
],
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}validationRules() returns framework-agnostic metadata such as minLength and pattern. Laravel does not consume that structure directly, so map it into Laravel rule strings or objects first.
If you want to centralize that mapping, extract it into a small helper such as laravelRulesFromDto(UserDto $dto): array and keep request-specific rules on top of the DTO-derived baseline.
Symfony
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class UserController extends AbstractController
{
public function store(Request $request, ValidatorInterface $validator): JsonResponse
{
$dto = new UserDto(json_decode($request->getContent(), true));
// DTO construction already applies built-in field validation.
// Use Symfony Validator for richer request or business rules.
$violations = $validator->validate($dto->toArray(), new Assert\Collection([
'name' => [new Assert\NotBlank(), new Assert\Length(['max' => 255])],
'email' => [new Assert\NotBlank(), new Assert\Email()],
]));
if (count($violations) > 0) {
return $this->json(['errors' => (string) $violations], 422);
}
return $this->json($dto->toArray());
}
}The same pattern works well in Symfony with a helper that converts validationRules() output into an Assert\Collection.
See Validation for more validation patterns.
Code Generation
The CLI tool works the same way in any framework:
# Generate DTOs from config
vendor/bin/dto generate --config-path=config/ --src-path=src/ --namespace=App
# Or with XML
vendor/bin/dto generate --config-path=config/ --src-path=src/ --namespace=App --format=xmlFramework-Specific Paths
| Framework | Config Path | Output Path | Namespace |
|---|---|---|---|
| Laravel | config/dto.php | app/Dto | App\Dto |
| Symfony | config/dto.xml | src/Dto | App\Dto |
| CakePHP | config/dto.php | src/Dto | App\Dto |
Composer Scripts
Add to your composer.json for convenience:
{
"scripts": {
"dto:generate": "vendor/bin/dto generate --config-path=config/ --src-path=src/ --namespace=App"
}
}Then run: composer dto:generate
What Wrapper Packages Could Add
While not required, framework-specific packages could provide:
Potential Laravel Package Features
| Feature | Current (Manual) | With Package |
|---|---|---|
| Collection factory | 1 line in ServiceProvider | Auto-configured |
| Request → DTO | new UserDto($request->validated()) | UserDto::fromRequest($request) |
| Route binding | Manual | Route::dto(UserDto::class) |
| Artisan command | bin/dto generate | php artisan dto:generate |
| Config publishing | Manual | php artisan vendor:publish |
// Hypothetical Laravel package usage
public function store(UserDto $dto): JsonResponse // Auto-injected from request
{
return response()->json($dto);
}Potential Symfony Bundle Features
| Feature | Current (Manual) | With Bundle |
|---|---|---|
| Collection factory | Kernel listener | Auto-configured |
| Request → DTO | Manual JSON decode | ParamConverter |
| Console command | bin/dto generate | bin/console dto:generate |
| Autowiring | Manual | Service definitions |
// Hypothetical Symfony bundle usage
#[Route('/users', methods: ['POST'])]
public function store(#[MapRequestPayload] UserDto $dto): JsonResponse
{
return $this->json($dto);
}Comparison With Other Libraries
| Library | Framework Integration |
|---|---|
| php-collective/dto | Framework-agnostic, optional adapters |
| spatie/laravel-data | Laravel-only, deep integration |
| cuyz/valinor | Framework-agnostic + optional Symfony bundle |
| symfony/serializer | Symfony-native, usable standalone |
Philosophy
This library follows the approach of cuyz/valinor and similar tools:
- Core library - Works everywhere, no dependencies
- Optional adapters - Framework-specific convenience packages
- No lock-in - Switch frameworks without changing DTOs
Input Transformation
Unlike spatie/laravel-data which has built-in Casts and Transformers, this library keeps transformation simple:
Recommended Patterns
Pre-process data before DTO creation:
// Sanitize input
$data = $request->validated();
$data['name'] = trim($data['name'] ?? '');
$data['email'] = strtolower($data['email'] ?? '');
$dto = new UserDto($data);Use a static factory method:
class UserDto extends AbstractDto
{
public static function fromRequest(Request $request): static
{
$data = $request->validated();
// Transform as needed
$data['name'] = trim($data['name'] ?? '');
return new static($data);
}
}Use the factory config option for objects:
// config/dto.php
'Payment' => [
'fields' => [
'amount' => [
'type' => '\Money\Money',
'factory' => 'Money\Parser::parse',
],
],
],Why No Built-in Transformers?
- Performance - Runtime callbacks slow down the optimized
setFromArrayFastpath - Simplicity - Code generation produces simple, debuggable code
- Flexibility - Pre-processing works with any transformation library
- Framework choice - Use Laravel's mutators, Symfony's normalizers, or plain PHP
Service Container Integration
Laravel
// app/Providers/AppServiceProvider.php
public function register(): void
{
$this->app->bind(OrderDto::class, function ($app, $params) {
return new OrderDto($params['data'] ?? []);
});
}Symfony
# config/services.yaml
services:
App\Dto\OrderDto:
autoconfigure: false
autowire: falseDTOs are typically instantiated directly rather than through the container, as they're data holders, not services.
Doctrine ORM Integration
Generated DTOs work well with Doctrine for read-optimized queries.
Partial Select + DTO Hydration
The recommended approach: fetch only needed fields as arrays, then hydrate DTOs:
use App\Dto\UserSummaryDto;
// Fetch partial data as array (fast - no entity hydration)
$rows = $entityManager->createQuery(
'SELECT u.id, u.name, u.email
FROM App\Entity\User u
WHERE u.active = true'
)->getArrayResult();
// Hydrate generated DTOs
$users = array_map(fn($row) => new UserSummaryDto($row), $rows);
// All generated methods available
foreach ($users as $user) {
echo $user->getName();
echo $user->getEmail();
}Why Not SELECT NEW?
Doctrine's SELECT NEW passes positional arguments:
SELECT NEW App\Dto\UserDto(u.id, u.name, u.email)
// Calls: new UserDto($id, $name, $email)Generated DTOs expect an array:
new UserDto(['id' => 1, 'name' => 'John', 'email' => 'john@example.com'])Using getArrayResult() + DTO hydration gives the same performance benefits while working with generated DTOs.
However, if you prefer Doctrine's native SELECT NEW syntax, you can use mapper classes.
Doctrine SELECT NEW with Mappers
Generate mapper classes alongside your DTOs:
vendor/bin/dto generate --mapperThis creates mapper classes that extend DTOs with positional constructors:
src/Dto/
├── UserSummaryDto.php # Standard DTO (array constructor)
└── Mapper/
└── UserSummaryDtoMapper.php # Mapper (positional constructor)Generated Mapper Structure:
namespace App\Dto\Mapper;
use App\Dto\UserSummaryDto;
class UserSummaryDtoMapper extends UserSummaryDto
{
public function __construct(
int $id,
string $name,
?string $email
) {
parent::__construct([
'id' => $id,
'name' => $name,
'email' => $email,
]);
}
}Usage with Doctrine:
use App\Dto\Mapper\UserSummaryDtoMapper;
// Use the mapper in SELECT NEW
$users = $entityManager->createQuery(
'SELECT NEW App\Dto\Mapper\UserSummaryDtoMapper(u.id, u.name, u.email)
FROM App\Entity\User u
WHERE u.active = true'
)->getResult();
// Results are fully-functional DTOs
foreach ($users as $user) {
echo $user->getName(); // All getter methods work
echo $user->toArray(); // Serialization works
}
// Type hints work - mapper IS-A DTO
function processUser(UserSummaryDto $dto): void { /* ... */ }
processUser($users[0]); // Works because UserSummaryDtoMapper extends UserSummaryDtoBenefits of Mappers:
| Feature | getArrayResult + DTO | SELECT NEW + Mapper |
|---|---|---|
| Performance | ✅ Fast | ✅ Fast |
| Type safety | ✅ Full | ✅ Full |
| Code style | Manual mapping | Native DQL |
| Single query | ✅ Yes | ✅ Yes |
| IDE support | ✅ Full | ✅ Full |
Both approaches produce identical results. Choose based on preference:
- getArrayResult: More explicit, works with any DTO
- SELECT NEW + Mapper: Native DQL syntax, requires
--mapperflag
DTO Definition for Query Results
Define lightweight DTOs for specific queries:
// config/dto.php
return Schema::create()
->dto(Dto::create('UserSummary')->fields(
Field::int('id'),
Field::string('name'),
Field::string('email'),
))
->toArray();With Query Builder
$qb = $entityManager->createQueryBuilder();
$rows = $qb->select('o.id', 'o.total', 'c.name AS customerName')
->from(Order::class, 'o')
->join('o.customer', 'c')
->where('o.status = :status')
->setParameter('status', 'completed')
->getQuery()
->getArrayResult();
$orders = array_map(fn($row) => new OrderSummaryDto($row), $rows);Repository Pattern
class UserRepository extends ServiceEntityRepository
{
/**
* @return UserSummaryDto[]
*/
public function findActiveSummaries(): array
{
$rows = $this->createQueryBuilder('u')
->select('u.id', 'u.name', 'u.email')
->where('u.active = true')
->getQuery()
->getArrayResult();
return array_map(fn($row) => new UserSummaryDto($row), $rows);
}
}Entity to DTO Conversion
For converting full entities to DTOs:
// Simple conversion
$user = $entityManager->find(User::class, 1);
$dto = new UserDto($user->toArray());
// Or with a static factory
class UserDto extends AbstractDto
{
public static function fromEntity(User $user): static
{
return new static([
'id' => $user->getId(),
'name' => $user->getName(),
'email' => $user->getEmail(),
'roles' => $user->getRoles()->map(fn ($r) => [
'id' => $r->getId(),
'name' => $r->getName(),
])->toArray(),
]);
}
}Why Use DTOs with Doctrine?
| Approach | Use Case |
|---|---|
| Entity directly | Internal domain logic, persistence |
| DTO via getArrayResult | Simple queries, manual mapping |
| DTO via SELECT NEW + Mapper | Native DQL syntax, type-safe results |
| DTO from Entity | When you need the full entity first, then transform |
Benefits of DTOs for query results:
- Performance: Only hydrate needed fields, no proxy objects
- API safety: No lazy-loading surprises in JSON responses
- Clear contracts: DTOs define exactly what the API returns
- Immutability: Use immutable DTOs for read-only data