pmill/doctrine-array-hydrator
pmill/doctrine-array-hydrator provides a Doctrine hydrator that populates entities and embedded objects from plain arrays, including nested associations and collections. It is useful for fixtures, imports, and API payloads, simplifying conversion of array data into Doctrine entities.
This package is a small, single-purpose utility: it takes a plain PHP array and maps it onto a Doctrine 2 entity graph using the entity manager's metadata. It is not a serializer, a form binder, or a persistence layer — it only builds/populates objects. You still own validation, persistence, flushing, and transactions.
Minimal setup:
composer require pmill/doctrine-array-hydrator
use Pmill\Doctrine\Hydrator\ArrayHydrator;
$hydrator = new ArrayHydrator($entityManager);
$user = $hydrator->hydrate(User::class, [
'name' => 'Bob',
'email' => '[email protected]',
'address' => [ // nested association -> new Address entity
'line1' => '1 Test Street',
'city' => 'Testville',
],
]);
$entityManager->persist($user);
$entityManager->flush();
Where to look first:
src/ArrayHydrator.php — the two entry points you'll actually use: hydrate($className, array $data) for new objects and hydrateObject($entity, array $data) for existing ones. Read these before anything else; the README is short and the source is the real documentation.src/Strategy/ — the extension point for dates, enums, money, and "resolve this ID to an entity" behavior. If a field type isn't handled correctly, this is where you fix it.composer.json — confirm the Doctrine ORM constraint. This package's last release was January 2020, so it predates ORM 3 and PHP 8 attributes-era defaults.First realistic use case: fixtures and data imports. Seeding a database from JSON/YAML fixtures, or importing CSV rows from a back-office export, is where an array→entity hydrator shines. The array shape matches your fixture format almost one-to-one, and the metadata-driven association handling saves you writing dozens of manual setters.
Import pipeline (the core pattern). Treat the hydrator as one stage in a pipeline, never as the whole thing:
foreach ($rows as $row) {
$entity = $hydrator->hydrate(Product::class, $this->mapRow($row));
$errors = $validator->validate($entity);
if (count($errors) > 0) {
$this->report($row, $errors);
continue;
}
$em->persist($entity);
}
$em->flush(); // one flush per batch, not per row
Keep a mapRow()/DTO step in front so the array keys are explicit. This is also your whitelist — see the mass-assignment note below.
Updates vs. creates. Use hydrate() to construct a new entity and hydrateObject() on a loaded entity for updates. Load the entity first (find() / repository query), then hydrate the fields you want to change. Because missing keys are generally left untouched, a partial array gives you PATCH-like behavior for free — but verify this against the source for your version, and don't rely on it for fields you need cleared (explicitly pass null).
Nested associations. Nested arrays build related entities. The hydrator does not necessarily cascade-persist them for you. The reliable pattern is either to configure cascade={"persist"} on the owning side, or to persist new related entities yourself before flushing the parent. Decide this once per aggregate and be consistent — mixing the two is how you get EntityNotFoundException / "new entity found through relationship" errors at flush time.
Collections. For OneToMany/ManyToMany, pass an array of nested arrays (new items) or an array of already-managed entities (existing items). Do not mix IDs and entity objects in the same array unless you've confirmed the version supports resolving identifiers. For "append vs. replace" semantics on existing collections, test it — the safest approach is to hydrate the collection as a full replacement and let orphan removal handle deletions.
Resolve references with a strategy, not with IDs in the payload. If your API sends {"category_id": 5}, the hydrator will generally try to create a new Category with id 5 rather than load the existing one. The clean fix is a custom strategy that looks up the entity by identifier and returns the managed instance:
// sketch — match the actual StrategyInterface in src/Strategy
final class EntityReferenceStrategy implements StrategyInterface
{
public function __construct(private EntityManagerInterface $em) {}
public function hydrate($value)
{
return $this->em->getReference($this->targetClass, $value);
// or ->find() if you need to fail loudly when missing
}
}
Register it for the association (constructor strategy array or addStrategy(), depending on version) and keep the raw payload as scalar IDs, which is far easier to validate and log than nested arrays.
Framework wiring. In Laravel/Symfony, register ArrayHydrator as a singleton built from the existing EntityManagerInterface, plus a small HydratorFactory service that pre-registers your date/enum/reference strategies. Inject the factory into import commands and test data builders rather than new-ing the hydrator at call sites — the strategy configuration is the part you don't want duplicated.
Test fixtures. This is arguably the best day-to-day use: a makeUser(array $overrides = []) helper that hydrates defaults merged with overrides. It keeps tests readable and avoids long constructor chains for entities with many required fields.
Batch imports. Call $em->flush(); $em->clear(); every N rows (100–500) to keep the identity map from growing unbounded. After clear(), any entity references you held are detached — re-fetch or re-hydrate before continuing.
It is effectively unmaintained. Last release 2020-01-14. Pin doctrine/orm to ^2.x in composer.json. Do not assume ORM 3 compatibility, PHP 8.1+ enum support, or modern attribute-driver behavior. Run it through your test suite on every Doctrine upgrade; treat it as vendored code you may need to patch.
Mass assignment. Passing $request->all() straight into hydrate() lets a client set any mapped field, including isAdmin, role, balance, or an association's fields. Always map the payload through an explicit allow-list before hydration. This is the single biggest security footgun with this package.
Setters and domain invariants are bypassed. Array hydrators typically write via reflection/metadata rather than calling setters. Constructors don't run, validation inside setters doesn't run, and value objects/embedded types may not be built correctly. Validate the hydrated object after hydration, and prefer entities whose invariants are enforced at validation/persistence time rather than only in setters.
No cascade of validation or lifecycle callbacks. Doctrine @PrePersist/@PreUpdate callbacks and embeddable handling may not fire the way you expect during hydration (they fire at flush, but not for intermediate nested objects if you never persist them). Don't rely on lifecycle hooks to fill required fields that the array omitted.
Unmapped or misspelled keys. Depending on version, an unknown key is either silently ignored or throws. Neither is great in production. Diff your input keys against the entity metadata in your mapper layer and fail fast with a clear error — the hydrator's own exception messages are not known for being descriptive.
Date/time handling is the most common source of silent corruption. Check which format the default strategy accepts (Y-m-d, Y-m-d H:i:s, DateTimeInterface, timestamps) and set an explicit strategy for anything ambiguous. Timezone conversion is your responsibility; store UTC and convert at the edges.
Custom DBAL types, enums, UUIDs, and money objects need strategies. If a field is a value object or a custom type, the default strategy will hand it a scalar and Doctrine will fail later at flush with a less obvious error. Register a strategy per type up front — this is the intended extension point, so use it rather than post-processing the entity.
Flush errors point at the wrong place. When a nested entity is missing a required field, the error surfaces at flush() on the parent, not at hydration. Wrap hydration in a try/catch and log the input array (redacted) alongside the exception. For debugging, hydrate a single row and dump() the object graph before persisting.
Hydration does not persist or flush. It never touches the database except possibly for lookups inside strategies. You control the transaction boundary. For imports, wrap each batch in $em->transactional(...) so a partial failure doesn't leave half an aggregate persisted.
Identity map and duplicate rows. Hydrating the same logical record twice creates two distinct objects unless you resolve the existing entity first. If your import can contain duplicates, pre-load the affected IDs into a keyed array and route updates through hydrateObject().
Config quirks to check per version: whether strategies are keyed by field name, field type, or association target class; whether the constructor takes the strategy array; whether hydrateObject returns the entity or void. Confirm all three by reading src/ArrayHydrator.php rather than guessing from the README.
Where to extend if you hit a wall: the strategy layer is the only supported extension point. If you need behavior that strategies can't express (e.g., conditional merging of collections), fork the hydrator into your own project — at 35 stars and no recent releases, you should assume you may need to own this code.
How can I help you explore Laravel packages today?