0

Hydration — mapping DB row arrays to model instances

Advanced5 min read·fw-08-004
sql

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:

  1. Create a new model instance (without calling the constructor, or with empty constructor).
  2. Populate $attributes from the row array.
  3. Set $original to the same array (baseline for dirty tracking).
  4. Set $exists = true.
  5. 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
<?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);
}