elriseio/dto-entity-updater

Symfony bundle for safe DTO to Doctrine-entity updates via the Placeholder sentinel pattern

Maintainers

Package info

github.com/elriseio/dto-entity-updater

Documentation

Type:symfony-bundle

pkg:composer/elriseio/dto-entity-updater

Transparency log

Statistics

Installs: 4

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.1.1 2026-07-24 10:11 UTC

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:

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

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's findOrFail Doctrine 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.