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.
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.
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:
DtoEntityUpdater
is 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::updateField
is strict: missing accessors throw InvalidArgumentException.
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.
RepositoryHelperTrait's findOrFail Doctrine exception)The package is published on Packagist as
elriseio/dto-entity-updater
and can be installed via Composer:
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).
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).
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).
PlaceholderDeclare 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.
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.
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.
Proprietary. See LICENSE for the full text. For licensing enquiries, contact [email protected].
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.
How can I help you explore Laravel packages today?