elriseio / dto-entity-updater
Symfony bundle for safe DTO to Doctrine-entity updates via the Placeholder sentinel pattern
Package info
github.com/elriseio/dto-entity-updater
Type:symfony-bundle
pkg:composer/elriseio/dto-entity-updater
Requires
- php: ~8.3
- psr/log: ^3.0
- symfony/config: ^7.2
- symfony/dependency-injection: ^7.2
- symfony/http-kernel: ^7.2
Requires (Dev)
- doctrine/dbal: ^4.2
- doctrine/orm: ^3.3
- friendsofphp/php-cs-fixer: ^3.74
- phpbench/phpbench: ^1.2
- phpstan/phpstan: ^1.10
- phpunit/phpunit: ^12.0
- symfony/cache: ^7.2
- symfony/phpunit-bridge: ^7.0
This package is auto-updated.
Last update: 2026-07-24 10:11:13 UTC
README
Reusable Symfony 7.2 bundle that applies a DTO-shaped payload to a
Doctrine entity while distinguishing leave unchanged from
set to default via the Placeholder sentinel pattern.
What problem this solves
Updating a Doctrine entity from a typed DTO looks simple until the
DTO has a field the caller wants to leave alone. Three workarounds
exist, and each fails differently. A per-field isset($dto->field)
chain works for nullable DTOs but doubles the boilerplate of every
update. A null sentinel works for nullable fields and silently
corrupts typed ones (int, string, float, array), because PHP
will not accept null for a non-nullable typed property. A "default
value" sentinel — e.g. 0 for int or '' for string — looks safe
until a host invents its own sentinel that does not match the
bundle's, and the comparison $value !== $default then treats
"leave unchanged" as "set to default", silently overwriting fields
the caller meant to keep (see RUNBOOK §F3).
That is the failure mode this bundle prevents: a typed DTO round-trip that silently mutates a Doctrine row into a placeholder default.
How this bundle solves it
The bundle ships a small Placeholder
value holder with one sentinel constant per primitive type —
Placeholder::NONE_INT, NONE_STRING, NONE_FLOAT, NONE_ARRAY
— and a Placeholder::NONE_DEFAULT that triggers type-based default
resolution. A DTO declares the right sentinel as its "unset"
default; the updater compares each incoming field to the per-type
sentinel using reflection on the entity setter's declared parameter
type, so the caller never has to specify per-field which sentinel
applies. The full per-type table and === comparison semantics
live in the entity-update contract.
Two implementers expose the same sentinel logic with deliberately different strictness, so callers can pick the one that matches their context:
DtoEntityUpdateris lenient: missing accessors are skipped silently. Use it for DTO-driven callers (HTTP request bodies, JSON decode) where unknown fields must be ignored, not crash.RepositoryHelperTrait::updateFieldis strict: missing accessors throwInvalidArgumentException. Use it for typed/programmer callers (command handlers, services) where a missing accessor is a bug you want surfaced.
A 5-line DTO plus a one-line handler call is enough to make both paths work:
final class TariffDto { public function __construct( public readonly string $name = '__NONE_STRING__', public readonly int $pricePerUnit = -999999999999999, ) {} } $this->updater->updateEntity($tariff, [ 'name' => $dto->name, 'pricePerUnit' => $dto->pricePerUnit, ]);
For the overall picture — Domain / Infrastructure layering, captured
trade-offs, and the role of the sentinel pattern — see
docs/architecture.md. For the full public
contract, deprecation policy, and Placeholder value stability
rules, see docs/contracts/entity-update-contract.md.
Minimum requirements
- PHP 8.3 or newer (8.4 / 8.5 also supported; CI matrix)
- Symfony 7.2 or newer
- Doctrine ORM 3.x (only required at runtime if you use
RepositoryHelperTrait'sfindOrFailDoctrine exception)
Installation
composer require elriseio/dto-entity-updater
Register the bundle in config/bundles.php if Symfony Flex did not
do it automatically:
return [ // ... Elrise\Bundle\DtoEntityUpdater\DtoEntityUpdaterBundle::class => ['all' => true], ];
The bundle exposes no configuration keys today; the
dto_entity_updater.* namespace is reserved for the optional
DefaultResolver cache toggle (see docs/perf.md).
Usage
Service: DtoEntityUpdater (lenient)
use Elrise\Bundle\DtoEntityUpdater\Domain\DtoEntityUpdaterInterface; 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, ]); } }
Unknown fields (no setter, no getter, unrecognised setter type) are
silently skipped and an info log is emitted via PSR-3 (see
CR-002-IMPL-12).
Trait: RepositoryHelperTrait (strict)
In your Doctrine repository, implement
RepositoryInterface and
use the trait:
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); } }
Missing accessors throw InvalidArgumentException; reflection
failures throw LogicException (see
CR-002-IMPL-01).
Sentinels: Placeholder
Declare a DTO field's "unset" default as the matching sentinel:
use Elrise\Bundle\DtoEntityUpdater\Domain\Placeholder; final class TariffDto { public function __construct( public readonly string $name = Placeholder::NONE_STRING, public readonly int $pricePerUnit = Placeholder::NONE_INT, public readonly array $tags = Placeholder::NONE_ARRAY, public readonly bool $active = false, // bool sentinel is `null` (Placeholder::NONE_DEFAULT path) ) {} }
The full per-type table is in entity-update-contract §2.
Testing
The bundle ships with PHPUnit, PHPStan, php-cs-fixer, and phpbench.
All four are wired through composer scripts:
composer test # full PHPUnit run (unit + integration) composer test-unit # unit suite only composer test-integration # integration suite only (currently empty; reserved for itests/docker scenarios) 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 enforced separately by
scripts/check_domain_purity.sh:
bash scripts/check_domain_purity.sh
A CI workflow (.github/workflows/ci.yml) runs all of the above
on every push and pull request against develop / master /
v2-updates.
Performance
For hot-path characteristics and the
CachedDefaultResolver opt-in, see docs/perf.md.
TL;DR: PHP 8.3+ reflection is fast enough that the cache only
pays off for long-running worker processes; the default config
(no cache) is correct for typical PHP-FPM workloads.
License
Proprietary. See LICENSE for the full text. For licensing enquiries, contact alexk@elrise.io.
Changelog
See CHANGELOG.md for the per-version change history.
The 1.1.0 entry enumerates the Bug-1/2/3 fixes, the DI modernisation,
the test infrastructure scaffold, the DefaultResolver extraction,
and the cache opt-in.