Relationships — hasMany and belongsTo without N+1
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:
SELECT * FROM usersSELECT * 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
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));
}
}
}
}