0

Relationships — hasMany and belongsTo without N+1

Expert5 min read·fw-08-006
sql

Concept

Relationships connect models to each other through database foreign keys. hasMany and belongsTo are the two most fundamental relationship types. Getting them right without N+1 is the central challenge.

hasMany: A User hasMany Posts. user_id is on the posts table. $user->posts returns all posts where posts.user_id = $user->id.

belongsTo: A Post belongsTo a User. $post->user returns the User where users.id = $post->user_id. The foreign key is on the posts table.

N+1 problem: Loading 10 users and then accessing $user->posts in a loop fires 10 separate queries (1 + 10). Solution: eager loading — pre-fetch all related records in one query, then associate them.

Lazy loading: $user->posts executes a query on demand. Fine for a single model. Causes N+1 in loops.

Eager loading: User::with('posts')->get() — two queries total:

  1. SELECT * FROM users
  2. SELECT * FROM posts WHERE user_id IN (1, 2, 3, ...)

Then the framework matches posts to users in PHP — no extra DB queries.

Relationship method vs. property access: Calling $user->posts() (with parentheses) returns the Query Builder (for additional constraints). $user->posts (without parentheses) triggers __get(), which runs the query and caches the result.

Code Example

php
<?php
namespace Framework\Orm;

abstract class Model
{
    protected array $relations = []; // cached loaded relations

    // ... from previous

    // hasMany: User hasMany Posts
    protected function hasMany(string $relatedClass, string $foreignKey, string $localKey = 'id'): HasMany
    {
        return new HasMany(
            $relatedClass::query(),
            $this,
            $foreignKey,  // posts.user_id
            $localKey,    // users.id (default)
        );
    }

    // belongsTo: Post belongsTo User
    protected function belongsTo(string $relatedClass, string $foreignKey, string $ownerKey = 'id'): BelongsTo
    {
        return new BelongsTo(
            $relatedClass::query(),
            $this,
            $foreignKey, // posts.user_id (on $this model)
            $ownerKey,   // users.id
        );
    }

    public function __get(string $name): mixed
    {
        // Check cached relation first
        if (array_key_exists($name, $this->relations)) {
            return $this->relations[$name];
        }
        // Check if it's a method (relationship definition)
        if (method_exists($this, $name)) {
            $relation = $this->$name();
            if ($relation instanceof Relationship) {
                return $this->relations[$name] = $relation->getResults();
            }
        }
        return $this->attributes[$name] ?? null;
    }

    public function setRelation(string $name, mixed $value): static
    {
        $this->relations[$name] = $value;
        return $this;
    }
}

// User model
class User extends Model
{
    public function posts(): HasMany
    {
        return $this->hasMany(Post::class, 'user_id');
    }
}

// Post model
class Post extends Model
{
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class, 'user_id');
    }
}

// with() eager loading (sketch)
class ModelQueryBuilder extends QueryBuilder
{
    private array $eagerLoads = [];

    public function with(string ...$relations): static
    {
        $this->eagerLoads = $relations;
        return $this;
    }

    public function get(): array
    {
        $models = parent::get(); // hydrated model instances
        if (!empty($models) && !empty($this->eagerLoads)) {
            $this->eagerLoadRelations($models);
        }
        return $models;
    }

    private function eagerLoadRelations(array $models): void
    {
        foreach ($this->eagerLoads as $name) {
            // Get all related records in ONE query
            $first    = $models[0];
            $relation = $first->$name(); // HasMany or BelongsTo instance
            $related  = $relation->getEager($models); // one query with IN clause

            // Associate: set the relation on each model
            foreach ($models as $model) {
                $model->setRelation($name, $relation->match($model, $related));
            }
        }
    }
}