dto-entity-updater — DTO → Entity with sentinel pattern for DDD
DTO → Entity mapping with a sentinel pattern that separates "not passed" from "passed as default".
Overview
dto-entity-updater is a Symfony bundle for DTO → Domain Entity mapping. A Placeholder value means that a field was not passed; an ordinary value results in a setter call. The bundle provides two implementers: DtoEntityUpdater for lenient calls and RepositoryHelperTrait::updateField for strict calls.
The bundle does not manage the Entity lifecycle and does not require the domain to know about DTOs. The mapping rationale is covered in the note DTO → Entity in DDD: why “not passed” ≠ “passed as default”.
Quick start
Minimum scenario: one DTO with Placeholder sentinels, one handler, one updater call.
composer require elriseio/dto-entity-updater
// config/bundles.php
return [
Elrise\Bundle\DtoEntityUpdater\DtoEntityUpdaterBundle::class => ['all' => true],
];
use Elrise\Bundle\DtoEntityUpdater\Domain\Placeholder;
final readonly class TariffDto
{
public function __construct(
public string $name = Placeholder::NONE_STRING,
public int $pricePerUnit = Placeholder::NONE_INT,
) {
}
}
use Elrise\Bundle\DtoEntityUpdater\Domain\DtoEntityUpdaterInterface;
final class UpdateTariffHandler
{
public function __construct(private DtoEntityUpdaterInterface $updater) {}
public function __invoke(Tariff $tariff, TariffDto $dto): void
{
$this->updater->updateEntity($tariff, [
'name' => $dto->name,
'pricePerUnit' => $dto->pricePerUnit,
]);
}
}
$dto->name === Placeholder::NONE_STRING → the updater skips the field. $dto->name === 'Free' → the updater calls $tariff->setName('Free'). The sentinel type is inferred from the setter parameter type through reflection; the caller does not specify it per field.
What it is and what it is not
The bundle does:
- compare a field value with the sentinel inferred from the setter parameter type and call the setter only for an ordinary value;
- provide the lenient
DtoEntityUpdater, with a PSR-3 info log for skipped fields; - provide the strict
RepositoryHelperTrait::updateField, which throwsInvalidArgumentExceptionfor an unknown accessor.
The bundle does not:
- manage the Entity lifecycle (
persist,flush,refresh); - provide an ORM or CQRS infrastructure;
- validate payloads or build an automatic Entity diff.
Architecture
Entity updater receives an Entity and a field => value map from a command handler. For each field it resolves the setter, infers the sentinel from the parameter type, and calls the setter only for a non-sentinel value. Unknown accessors are handled according to the selected implementer’s policy.
Installation and requirements
The install command and bundle registration are shown in Quick start above.
- PHP 8.3+ (CI verifies PHP 8.3, 8.4, and 8.5).
- Symfony 7.2+.
- Doctrine ORM 3.x — optional, only for
RepositoryHelperTrait::findOrFail.
There are no public configuration keys.
Usage
Sentinel constants (Placeholder)
The full set of sentinels — one constant per primitive type:
| Constant | Type | When to use |
|---|---|---|
Placeholder::NONE_INT | int | fields of type int, integer |
Placeholder::NONE_STRING | string | fields of type string |
Placeholder::NONE_FLOAT | float / double | fields of type float, double |
Placeholder::NONE_ARRAY | array | fields of type array, list, iterable |
Placeholder::NONE_DEFAULT | bool, mixed | type-based default resolution |
The caller does not specify per-field which sentinel applies — the bundle infers the type from the entity setter’s signature. Put the matching Placeholder::NONE_* in the DTO field’s default value, and the updater figures it out.
Service: DtoEntityUpdater (lenient)
For DTO-driven callers — HTTP body, JSON-decode, message-bus payload. Unknown fields (no setter, no getter, unrecognised setter type) are skipped; lenient mode emits a PSR-3 info log. The invocation example is in Quick start.
Trait: RepositoryHelperTrait (strict)
For typed/programmer callers — a command handler inside a bounded context or a service layer. An unknown accessor throws InvalidArgumentException. Reflection failures (missing setter, getter, or recognised parameter type) throw LogicException.
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Elrise\Bundle\DtoEntityUpdater\Domain\RepositoryInterface;
use Elrise\Bundle\DtoEntityUpdater\Infrastructure\Doctrine\Repository\RepositoryHelperTrait;
class TariffRepository extends ServiceEntityRepository implements RepositoryInterface
{
use RepositoryHelperTrait;
public function updateTariff(Tariff $tariff, TariffDto $dto): void
{
$this->updateField($tariff, 'name', $dto->name);
$this->updateField($tariff, 'pricePerUnit', $dto->pricePerUnit);
}
}
Which implementer to pick
| Caller context | Recommended implementer | Why |
|---|---|---|
| HTTP body, JSON-decode, message-bus payload | DtoEntityUpdater (lenient) | Unknown fields are allowed and logged. |
| Command handler inside a bounded context | RepositoryHelperTrait::updateField (strict) | An unknown accessor is a programming error. |
| Service layer between aggregates | RepositoryHelperTrait::updateField (strict) | Typed call; an unknown accessor is a programming error. |
| External webhook with an unpredictable schema | DtoEntityUpdater (lenient) | Unknown fields are allowed and logged. |
Testing
The bundle ships with PHPUnit, PHPStan, php-cs-fixer, and phpbench, all wired through composer scripts:
composer test # full PHPUnit run (unit + integration)
composer test-unit # unit suite only
composer test-integration # integration suite only (reserved for itests/docker)
composer phpstan # phpstan level 6 over src/, tests/, bench/
composer phpstan-baseline # regenerate phpstan-baseline.neon
composer cs:check # php-cs-fixer dry-run
composer cs:fix # php-cs-fixer apply
composer bench # phpbench aggregate report
Domain purity is checked by a separate script:
bash scripts/check_domain_purity.sh
The CI workflow (.github/workflows/ci.yml) runs these checks on every push and pull request against develop and master.
Get hands-on
dummy-market-agent is a reference Symfony service with Application Layer, CQRS, API Platform, and DBAL-based persistence. Start it with Docker to inspect API operations, command/query handlers, persistence, container wiring, layer boundaries, and tests.
License
Proprietary. The terms for use, modification, and redistribution are defined in the repository’s LICENSE file.
Sources
- github.com/elriseio/dto-entity-updater — repository.
- packagist.org/packages/elriseio/dto-entity-updater — Packagist.
- DTO → Entity in DDD: why “not passed” ≠ “passed as default” — architectural rationale for DTO → Entity mapping.
- Eric Evans, Domain-Driven Design — origin of the layered-architecture idea.
- Vaughn Vernon, Implementing Domain-Driven Design — CQRS separation and the “Domain Entity does not know about DTO” invariant.
- Symfony Serializer — official Symfony mapping documentation.
- PSR-3 Logger Interface — info-logging skips in lenient mode.