PHPackages                             elriseio/dto-entity-updater - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. elriseio/dto-entity-updater

ActiveSymfony-bundle

elriseio/dto-entity-updater
===========================

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

00PHP

Since Jul 23Pushed todayCompare

[ Source](https://github.com/elriseio/dto-entity-updater)[ Packagist](https://packagist.org/packages/elriseio/dto-entity-updater)[ RSS](/packages/elriseio-dto-entity-updater/feed)WikiDiscussions master Synced today

READMEChangelogDependenciesVersions (1)Used By (0)

Entity Updater Bundle
=====================

[](#entity-updater-bundle)

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

[](#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](docs/RUNBOOK.md)).

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

[](#how-this-bundle-solves-it)

The bundle ships a small [`Placeholder`](src/Domain/Placeholder.php)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](docs/contracts/entity-update-contract.md).

Two implementers expose the same sentinel logic with deliberately different strictness, so callers can pick the one that matches their context:

- **[`DtoEntityUpdater`](docs/components/dto-entity-updater.md)**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`](docs/components/repository-helper-trait.md)**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](docs/architecture.md). For the full public contract, deprecation policy, and `Placeholder` value stability rules, see [docs/contracts/entity-update-contract.md](docs/contracts/entity-update-contract.md).

Minimum requirements
--------------------

[](#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
------------

[](#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](docs/perf.md)).

Usage
-----

[](#usage)

### Service: `DtoEntityUpdater` (lenient)

[](#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](../Issues/open/developer/CR-002_production_ready_improvements_hardening.md)).

### Trait: `RepositoryHelperTrait` (strict)

[](#trait-repositoryhelpertrait-strict)

In your Doctrine repository, implement [`RepositoryInterface`](src/Domain/RepositoryInterface.php) 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](../Issues/open/developer/CR-002_production_ready_improvements_hardening.md)).

### Sentinels: `Placeholder`

[](#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](docs/contracts/entity-update-contract.md).

Testing
-------

[](#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`](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
-----------

[](#performance)

For hot-path characteristics and the `CachedDefaultResolver` opt-in, see [docs/perf.md](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
-------

[](#license)

Proprietary. See [LICENSE](LICENSE) for the full text. For licensing enquiries, contact .

Changelog
---------

[](#changelog)

See [CHANGELOG.md](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.

###  Health Score

20

—

LowBetter than 12% of packages

Maintenance65

Regular maintenance activity

Popularity0

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity11

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/207833371?v=4)[Elrise IO](/maintainers/elriseio)[@elriseio](https://github.com/elriseio)

---

Top Contributors

[![alexk136](https://avatars.githubusercontent.com/u/21287520?v=4)](https://github.com/alexk136 "alexk136 (35 commits)")

### Embed Badge

![Health badge](/badges/elriseio-dto-entity-updater/health.svg)

```
[![Health](https://phpackages.com/badges/elriseio-dto-entity-updater/health.svg)](https://phpackages.com/packages/elriseio-dto-entity-updater)
```

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
