user@elrise.io:~
· [live]· 1.1.0tagsphpsymfonydoctrineddddtoarchitecture

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".

→ repository

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 throws InvalidArgumentException for 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:

ConstantTypeWhen to use
Placeholder::NONE_INTintfields of type int, integer
Placeholder::NONE_STRINGstringfields of type string
Placeholder::NONE_FLOATfloat / doublefields of type float, double
Placeholder::NONE_ARRAYarrayfields of type array, list, iterable
Placeholder::NONE_DEFAULTbool, mixedtype-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 contextRecommended implementerWhy
HTTP body, JSON-decode, message-bus payloadDtoEntityUpdater (lenient)Unknown fields are allowed and logged.
Command handler inside a bounded contextRepositoryHelperTrait::updateField (strict)An unknown accessor is a programming error.
Service layer between aggregatesRepositoryHelperTrait::updateField (strict)Typed call; an unknown accessor is a programming error.
External webhook with an unpredictable schemaDtoEntityUpdater (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

Discussion

Comments (0)

No comments yet.