Search by

hydrastat / dto-hydrator

Ulsyairil

Framework-agnostic DTO hydrator with Laravel, CodeIgniter, Symfony, Yii 2, and CakePHP integrations

v1.1.0 2026-11-09 14:06 UTC

This package is auto-updated.

Last update: 2026-11-09 14:10:19 UTC


README

PHP Packagist Version Packagist Downloads License Tests External calls

Framework-agnostic DTO hydrator with Laravel, CodeIgniter, Symfony, Yii 2, and CakePHP integrations.

Installation

composer require hydrastat/dto-hydrator

Quick Start

1. Define your DTO with attributes

<?php

namespace App\DTO;

use Hydrastat\DtoHydrator\Mapping\Attribute\MapFrom;
use Hydrastat\DtoHydrator\Mapping\Attribute\Validate;

class UserCreateDTO
{
    #[MapFrom('name', caster: 'trim')]
    #[Validate('required')]
    #[Validate('min:2')]
    #[Validate('max:100')]
    public string $name;

    #[MapFrom('email_address', caster: 'email')]
    #[Validate('required')]
    #[Validate('email')]
    public string $email;

    #[MapFrom('password', caster: 'string', writeonly: true)]
    #[Validate('required')]
    #[Validate('min:8')]
    public string $password;

    #[MapFrom('age', caster: 'int', required: false, default: 18)]
    #[Validate('integer')]
    #[Validate('min:13')]
    public int $age = 18;

    #[MapFrom('is_active', caster: 'bool', required: false, default: true)]
    public bool $isActive = true;

    #[MapFrom('created_at', caster: 'datetime', readonly: true)]
    public \DateTimeImmutable $createdAt;
}

2. Use in your framework

Laravel

// config/app.php - auto-discovered via service provider

use Hydrastat\DtoHydrator\Contracts\HydratorInterface;

class UserController
{
    public function __construct(private HydratorInterface $hydrator) {}

    public function store(Request $request)
    {
        $dto = $this->hydrator->hydrateAndValidate(
            UserCreateDTO::class, 
            $request->validated()
        );

        $user = $this->userService->create($dto);

        return response()->json($this->hydrator->extract($user));
    }
}

// Or via Facade
use Hydrastat\DtoHydrator\Integrations\Laravel\Facade as Hydrator;

$dto = Hydrator::hydrateAndValidate(UserCreateDTO::class, $data);

CodeIgniter 4

// app/Config/Services.php - auto-registered

use Hydrastat\DtoHydrator\Contracts\HydratorInterface;

class UserController extends BaseController
{
    private HydratorInterface $hydrator;

    public function __construct()
    {
        $this->hydrator = service('hydrator');
    }

    public function create()
    {
        $dto = $this->hydrator->hydrateAndValidate(
            UserCreateDTO::class,
            $this->request->getJSON(true)
        );

        // ...
    }
}

Symfony

// config/bundles.php - auto-registered
// Hydrastat\DtoHydrator\Integrations\Symfony\Bundle::class => ['all' => true],

use Hydrastat\DtoHydrator\Contracts\HydratorInterface;

class UserController
{
    public function __construct(private HydratorInterface $hydrator) {}

    #[Route('/api/users', methods: ['POST'])]
    public function create(Request $request): JsonResponse
    {
        $data = json_decode($request->getContent(), true);
        
        $dto = $this->hydrator->hydrateAndValidate(UserCreateDTO::class, $data);
        
        $user = $this->userService->create($dto);

        return $this->json($this->hydrator->extract($user));
    }
}

Yii 2

// config/main.php
return [
    'bootstrap' => [\Hydrastat\DtoHydrator\Integrations\Yii\Bootstrap::class],
    'params' => [
        'hydrator' => [
            'allowPrivateAccess' => true,
            'defaultOptions' => ['groups' => ['default']],
            'customValidators' => [],
            'customCasters' => [],
        ],
    ],
];

The bootstrap registers HydratorInterface::class and the hydrator alias in Yii::$container:

use Hydrastat\DtoHydrator\Contracts\HydratorInterface;

final class UserController extends \yii\web\Controller
{
    public function actionCreate(): array
    {
        $hydrator = \Yii::$container->get(HydratorInterface::class);
        $dto = $hydrator->hydrateAndValidate(UserCreateDTO::class, \Yii::$app->request->post());

        return $hydrator->extract($this->userService->create($dto));
    }
}

You can also build one directly with \Hydrastat\DtoHydrator\Integrations\Yii\Service::hydrator().

CakePHP

// config/bootstrap.php
use Cake\Core\Configure;

Configure::write('Hydrator', [
    'allowPrivateAccess' => true,
    'defaultOptions' => ['groups' => ['default']],
    'customValidators' => [],
    'customCasters' => [],
]);
use Hydrastat\DtoHydrator\Contracts\HydratorInterface;
use Hydrastat\DtoHydrator\Integrations\CakePHP\Service;

class UsersController extends AppController
{
    public function add()
    {
        $hydrator = Service::hydrator();
        $dto = $hydrator->hydrateAndValidate(
            UserCreateDTO::class,
            (array) $this->request->getData()
        );

        // ...
    }
}

For dependency injection, register shared definitions in a CakePHP container:

$container = new \Cake\Core\Container();
Service::register($container);

$hydrator = $container->get(HydratorInterface::class);

Framework-agnostic (standalone)

use Hydrastat\DtoHydrator\Core\Hydrator;
use Hydrastat\DtoHydrator\Core\MappingResolver;
use Hydrastat\DtoHydrator\Core\PropertyAccessor;
use Hydrastat\DtoHydrator\Core\TypeCaster;
use Hydrastat\DtoHydrator\Core\Validator;

$hydrator = new Hydrator(
    new MappingResolver(new PropertyAccessor()),
    new PropertyAccessor(),
    new TypeCaster(),
    new Validator()
);

$dto = $hydrator->hydrateAndValidate(UserCreateDTO::class, $_POST);

Mapping Attributes

#[MapFrom]

Parameter Type Default Description
source string - Source key in input data
caster string null null
required bool true Whether field is required
default mixed null Default value if missing (supports Closure)
groups array ['default'] Extraction groups
readonly bool false Skip during hydration, include in extraction
writeonly bool true Include in hydration, skip during extraction
validator string null null

#[Validate]

#[Validate('required')]
#[Validate('email')]
#[Validate('min:2')]
#[Validate('max:100')]
#[Validate('between:1,100')]
#[Validate('in:admin,user,guest')]
#[Validate('regex:/^[a-z]+$/')]
#[Validate('confirmed')] // expects {field}_confirmation
public string $field;

Built-in Casters

int, integer, float, double, bool, boolean, string, trim, 
lower, upper, email, array, json, datetime, date, timestamp

Built-in Validators

required, email, min, max, between, numeric, integer, boolean,
date, in, regex, unique, confirmed

Custom Casters & Validators

Laravel, CodeIgniter, Yii 2, and CakePHP expose the same four settings through their own config convention: allowPrivateAccess, defaultOptions, customValidators, and customCasters. Laravel uses snake_case keys in config/hydrator.php; CodeIgniter uses app/Config/Hydrator.php, Yii 2 uses params.hydrator, and CakePHP uses Configure::write('Hydrator', ...) with the camelCase names.

// Laravel: config/hydrator.php
'custom_casters' => [
    'uuid' => fn($v) => (string) $v,
    'carbon' => fn($v) => \Carbon\Carbon::parse($v),
],

'custom_validators' => [
    'unique' => function($value, $dto, $params) {
        [$table, $column] = $params;
        $exists = DB::table($table)->where($column, $value)->exists();
        return $exists ? 'Already exists' : null;
    },
],

Extraction Groups

// Only extract 'api' group
$hydrator->extract($dto, ['groups' => ['api']]);

// Exclude null values
$hydrator->extract($dto, ['exclude_null' => true]);

Testing

use Hydrastat\DtoHydrator\Core\Hydrator;
use Hydrastat\DtoHydrator\Core\MappingResolver;
use Hydrastat\DtoHydrator\Core\PropertyAccessor;
use Hydrastat\DtoHydrator\Core\TypeCaster;
use Hydrastat\DtoHydrator\Core\Validator;

$hydrator = new Hydrator(
    new MappingResolver(new PropertyAccessor()),
    new PropertyAccessor(),
    new TypeCaster(),
    new Validator()
);

$dto = $hydrator->hydrate(UserCreateDTO::class, [
    'name' => '  John Doe  ',
    'email_address' => 'JOHN@EXAMPLE.COM',
    'password' => 'secret123',
]);

assert($dto->name === 'John Doe');
assert($dto->email === 'john@example.com');
assert($dto->age === 18); // default
assert($dto->isActive === true); // default

License

MIT

Why Local?

This library has zero external HTTP calls — no telemetry, no license checks, no update pings. Everything runs in-process. The "external calls: zero" badge reflects this design principle.