Hydration — mapping DB row arrays to model instances
Concept
Hydration is the process of converting a raw database row (an associative array from PDO) into a model instance. It's the bridge from the data layer to the domain object layer.
What happens during hydration:
- Create a new model instance (without calling the constructor, or with empty constructor).
- Populate
$attributesfrom the row array. - Set
$originalto the same array (baseline for dirty tracking). - Set
$exists = true. - Apply casts (type coercion, fw-08-007).
newFromBuilder(array $attributes): static: The method name Laravel uses for hydration — distinguishes from new Model($attributes) which goes through fill() and $fillable checks.
Why not just new Model($row): The constructor triggers mass assignment protection, mutators, and events. During hydration from DB, you want raw data loaded without those guards. newFromBuilder() bypasses them.
hydrate(array $rows): array: Static method that takes an array of raw rows and returns an array of model instances. Useful for bulk hydration.
Collection vs array: Most ORM implementations return an Eloquent Collection (which extends Laravel's base Collection) from get(). For a custom framework, returning a plain array of model instances is simpler and sufficient.
Lazy hydration: For large result sets with cursor() — yield one hydrated model at a time instead of hydrating all at once.
Code Example
<?php
namespace Framework\Orm;
abstract class Model
{
// ... from previous
/**
* Create a model instance from a raw DB row.
* Bypasses mass assignment protection and mutators.
*/
public static function newFromBuilder(array $attributes): static
{
$model = new static(); // empty constructor
$model->attributes = $attributes; // raw assignment, no fill()
$model->original = $attributes; // sync original (for dirty tracking)
$model->exists = true;
$model->applyCasts(); // coerce types (int → int, json → array, etc.)
return $model;
}
/**
* Hydrate an array of raw rows into model instances.
*/
public static function hydrate(array $rows): array
{
return array_map(fn($row) => static::newFromBuilder($row), $rows);
}
protected function applyCasts(): void
{
foreach ($this->casts as $key => $type) {
if (!isset($this->attributes[$key])) continue;
$this->attributes[$key] = $this->castAttribute($key, $this->attributes[$key]);
}
}
// Used in ModelQueryBuilder::get() to transform rows
// (Ensures hydration is consistent whether fetching one or many)
}
class ModelQueryBuilder extends QueryBuilder
{
public function get(): array
{
$rows = $this->execute(); // runs the SQL, returns raw array rows
return ($this->modelClass)::hydrate($rows);
}
public function first(): ?object
{
$rows = $this->limit(1)->execute();
return $rows ? ($this->modelClass)::newFromBuilder($rows[0]) : null;
}
// Cursor — lazy hydration for large results
public function cursor(): \Generator
{
$stmt = $this->prepareAndExecute(); // returns PDOStatement
while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
yield ($this->modelClass)::newFromBuilder($row); // one model at a time
}
}
private function execute(): array
{
return static::$connection->select($this->toSql(), $this->getBindings());
}
}
// Example usage
$user = User::newFromBuilder(['id' => 1, 'name' => 'Alice', 'email' => 'alice@example.com']);
$users = User::hydrate([
['id' => 1, 'name' => 'Alice', 'email' => 'alice@example.com'],
['id' => 2, 'name' => 'Bob', 'email' => 'bob@example.com'],
]);
// Each user has $exists = true, ready for update/delete
// Cursor for large sets
foreach (User::query()->cursor() as $user) {
// Only one User object in memory at a time
processUser($user);
}