Milk Admin

Model Relationships

Revision: 2025/12/27

Define and use relationships between models with hasOne, hasMany, and belongsTo.

Key Features:
  • Lazy Loading: Related data loads only when accessed
  • Batch Loading: Prevents N+1 queries automatically
  • Eager Loading: Preload relationships with with()
  • Cascade Save: Save related records with save($cascade = true)
  • Cascade Delete: Auto-delete child records with CASCADE

Relationship Methods Reference

Method Parameters Cascade Save Description
hasOne() $alias, $relatedModel, $foreignKey, $onDelete = 'CASCADE', $allowCascadeSave = false ✅ If allowCascadeSave = true 1:1 relationship. Foreign key in related table.
hasMany() $alias, $relatedModel, $foreignKey, $onDelete = 'CASCADE', $allowCascadeSave = false ✅ If allowCascadeSave = true 1:N relationship. Foreign key in related table.
belongsTo() $alias, $relatedModel, $relatedKey = 'id' ❌ Never saved N:1 relationship. Foreign key in this table.
withCount() $alias, $relatedModel, $foreignKey ❌ Read-only Adds a COUNT subquery. Returns count of related records.
where() $condition, $params = [] N/A Filter relationship query. Must be called immediately after a relationship method.
Important - Cascade Save Behavior:
  • save($cascade = false): Default. Saves only the main record.
  • save($cascade = true): Saves main record + related records that have allowCascadeSave = true.
  • belongsTo relationships are never saved (read-only).

Disabling Relationships

Control when and how relationships are loaded or disabled for performance optimization or to prevent circular dependencies.

Methods Reference

Method Parameters What it Disables Use Case
withoutGlobalScope() string|array $scopes Specific withCount scope(s) Disable COUNT subqueries for specific relationships
withoutGlobalScopes() None All withCount scopes and default scopes Disable all COUNT subqueries and default query filters
with([]) array (empty) Eager loading for data export Prevent relationships from being included in getFormattedData()
Don't use with() N/A Eager loading (default behavior) Relationships load only on access (lazy), not pre-loaded

Usage Examples

// 1. Block lazy loading completely
$corso = $corsiModel->getById(1);

$frequenze = $corso->frequenze; // Returns null instead of loading data


// Re-enable lazy loading


// 2. Disable specific withCount
$authors = $authorsModel
    ->withoutGlobalScope('withCount:books_count')
    ->getAll();
// books_count will be null in results

// 3. Disable all withCount and default scopes
$authors = $authorsModel
    ->withoutGlobalScopes()
    ->getAll();
// All COUNT subqueries and default WHERE clauses removed

// 4. Query without eager loading for export
$corsi = $corsiModel->getAll();
// Relationships NOT included in getFormattedData()
// But lazy loading still works: $corso->frequenze

// 5. Combine methods for complete control
$corsi = $corsiModel
    ->withoutGlobalScopes()           // No withCount
    ->getAll()
// Maximum performance - no relationship queries at all

Performance Comparison

Scenario SQL Queries Methods Used
Default behavior 1 main + withCount subqueries + lazy loading on access None
Disable withCount only 1 main + lazy loading on access withoutGlobalScopes()

Understanding Relationships

Type Foreign Key Location Cardinality Example
hasOne() In the RELATED table 1:1 Actor → Biography
belongsTo() In THIS table N:1 Post → User
hasMany() In the RELATED table 1:N Author → Books

hasOne Relationship

One record owns one related record. Foreign key is in the related table.

Definition

// ActorsModel
protected function configure($rule): void {
    $rule->table('#__actors')
        ->id()
            ->hasOne('biography', BiographyModel::class, 'actor_id', 'CASCADE', true)
        ->string('name', 100)->required();
}

// BiographyModel (contains actor_id foreign key)
protected function configure($rule): void {
    $rule->table('#__biography')
        ->id()
        ->int('actor_id')->nullable()
        ->text('bio_text')->nullable();
}

Parameters:

  • $alias: Property name ('biography')
  • $relatedModel: BiographyModel::class
  • $foreignKey: Field in related table ('actor_id')
  • $onDelete: CASCADE | SET NULL | RESTRICT
  • $allowCascadeSave: true to enable cascade save

Usage

// Read
$actor = $actorsModel->getById(1);
$bio = $actor->biography; // Lazy loaded

if ($bio !== null) {
    echo $bio->bio_text;
}

// Cascade Save (requires allowCascadeSave = true)
$actor = new ActorsModel();
$actor->name = 'Robert De Niro';
$actor->biography = [
    'bio_text' => 'American actor...'
];

$actor->save(true); // Saves actor + biography
// biography.actor_id automatically set to actor.id

belongsTo Relationship

Record belongs to a parent record. Foreign key is in this table. Read-only - never saved via cascade.

Definition

// PostModel (contains user_id foreign key)
protected function configure($rule): void {
    $rule->table('#__posts')
        ->id()
        ->string('title', 200)->required()
        ->int('user_id')
            ->nullable()
            ->belongsTo('user', UserModel::class, 'id');
}

// UserModel
protected function configure($rule): void {
    $rule->table('#__users')
        ->id()
        ->string('username', 100)->required();
}

Parameters:

  • $alias: Property name ('user')
  • $relatedModel: UserModel::class
  • $relatedKey: Primary key in parent table (default: 'id')

Usage

// Read
$post = $postsModel->getById(1);
$user = $post->user; // Lazy loaded

if ($user !== null) {
    echo "Author: " . $user->username;
}

// Save - belongsTo is NEVER saved automatically
$post = new PostModel();
$post->title = 'My Post';
$post->user_id = 5; // Must set foreign key manually

$post->save(); // Only saves the post
// User with ID 5 must already exist
Why belongsTo is Read-Only: The parent record (User) exists independently. Creating/modifying it when saving a child (Post) doesn't make semantic sense.

hasMany Relationship

One record owns multiple related records. Foreign key is in the related table.

Definition

// AuthorModel
protected function configure($rule): void {
    $rule->table('#__authors')
        ->id()
            ->hasMany('books', BookModel::class, 'author_id', 'CASCADE', true)
        ->string('name', 100)->required();
}

// BookModel (contains author_id foreign key)
protected function configure($rule): void {
    $rule->table('#__books')
        ->id()
        ->int('author_id')->nullable()->index()
        ->string('title', 200)->required();
}

Parameters:

  • $alias: Property name ('books')
  • $relatedModel: BookModel::class
  • $foreignKey: Field in related table ('author_id')
  • $onDelete: CASCADE | SET NULL | RESTRICT
  • $allowCascadeSave: true to enable cascade save

Usage

// Read
$author = $authorsModel->getById(1);
$books = $author->books; // Returns array of BookModel

foreach ($books as $book) {
    echo $book->title . "\n";
}

// Cascade Save (requires allowCascadeSave = true)
$author = new AuthorModel();
$author->name = 'J.K. Rowling';
$author->books = [
    ['title' => 'Harry Potter 1'],
    ['title' => 'Harry Potter 2'],
    ['title' => 'Harry Potter 3']
];

$author->save(true); // Saves author + all books
// Each book.author_id automatically set to author.id

withCount - Count Related Records

Add a COUNT subquery to efficiently count related records without loading them. The count is automatically included in query results as a virtual field.

Definition

// AuthorModel
protected function configure($rule): void {
    $rule->table('#__authors')
        ->id()
            ->withCount('books_count', BookModel::class, 'author_id')
        ->string('name', 100)->required();
}

// BookModel (contains author_id foreign key)
protected function configure($rule): void {
    $rule->table('#__books')
        ->id()
        ->int('author_id')->nullable()->index()
        ->string('title', 200)->required()
        ->string('status', 20); // 'published', 'draft', 'deleted'
}

Parameters:

  • $alias: Virtual field name for the count ('books_count')
  • $relatedModel: BookModel::class
  • $foreignKey: Field in related table ('author_id')

Key Features

  • Efficient: Single SQL query with COUNT subquery - no N+1 problem
  • Automatic: Count always included in queries (like default scopes)
  • Scope-Aware: Applies default scopes from related model (e.g., excludes soft-deleted records)
  • Disableable: Can be disabled with withoutGlobalScope('withCount:alias')

Usage

// Read single record
$author = $authorsModel->getById(1);
echo "Books: " . $author->books_count; // 5

// Read all records
$authors = $authorsModel->getAll();
foreach ($authors as $author) {
    echo $author->name . ": " . $author->books_count . " books\n";
}

// Use in table views (GetDataBuilder)
// Count automatically appears in list columns

Generated SQL

SELECT
    authors.*,
    (SELECT COUNT(*)
     FROM books
     WHERE books.author_id = authors.author_id) AS books_count
FROM authors

Applying Related Model Scopes

withCount automatically applies default scopes from the related model:

// BookModel with default scope
class BookModel extends AbstractModel {
    protected function configure($rule): void {
        $rule->table('#__books')
            ->id()
            ->int('author_id')
            ->string('status', 20);
    }

    #[App\Attributes\DefaultQuery]
    protected function onlyPublished($query) {
        return $query->where('status = ?', ['published']);
    }
}

// AuthorModel
protected function configure($rule): void {
    $rule->table('#__authors')
        ->id()
            ->withCount('published_books', BookModel::class, 'author_id')
        ->string('name', 100);
}

// Usage
$author = $authorsModel->getById(1);
// published_books counts ONLY published books (scope applied)

Disabling withCount

// Disable specific withCount
$author = $authorsModel
    ->withoutGlobalScope('withCount:books_count')
    ->getById(1);

// books_count will be null

// Disable all default scopes (including withCount)
$authors = $authorsModel
    ->withoutGlobalScopes()
    ->getAll();

Multiple withCount

protected function configure($rule): void {
    $rule->table('#__authors')
        ->id()
            ->withCount('books_count', BookModel::class, 'author_id')
            ->withCount('reviews_count', ReviewModel::class, 'author_id')
        ->string('name', 100);
}

// Usage
$author = $authorsModel->getById(1);
echo "Books: " . $author->books_count;
echo "Reviews: " . $author->reviews_count;

Filtering Relationships with where()

Apply custom WHERE conditions to relationship queries using the where() method. This method must be called immediately after a relationship method (withCount, hasMany, hasOne, belongsTo).

Syntax

->relationshipMethod(...)->where($condition, $params)

Parameters:

  • $condition: SQL WHERE condition with ? placeholders
  • $params: Array of parameters to bind to placeholders
Important: where() must be called immediately after a relationship method. Calling it elsewhere will throw a LogicException.

Usage with withCount

// Count only available lessons
protected function configure($rule): void {
    $rule->table('corsi')
        ->id()
            ->withCount('lezioni_disponibili', LezioniModel::class, 'MATR_CRS')
                ->where('DISPONIBILE = ?', ['D'])
        ->string('nome', 100);
}

// Usage
$corso = $corsiModel->getById(1);
echo "Available lessons: " . $corso->lezioni_disponibili;

// Multiple conditions with AND
->withCount('lezioni_attive', LezioniModel::class, 'MATR_CRS')
    ->where('DISPONIBILE = ? AND BLOCCO != ?', ['D', 'S'])

Generated SQL for withCount

SELECT
    corsi.*,
    (SELECT COUNT(*)
     FROM lezioni
     WHERE lezioni.MATR_CRS = corsi.MATR_CRS
     AND DISPONIBILE = 'S') AS lezioni_disponibili
FROM corsi

Usage with hasMany

// Load only active books
protected function configure($rule): void {
    $rule->table('#__authors')
        ->id()
            ->hasMany('active_books', BookModel::class, 'author_id')
                ->where('status = ?', ['published'])
        ->string('name', 100);
}

// Usage
$author = $authorsModel->getById(1);
$publishedBooks = $author->active_books; // Only published books

Usage with hasOne

// Load only verified profile
protected function configure($rule): void {
    $rule->table('#__users')
        ->id()
            ->hasOne('verified_profile', ProfileModel::class, 'user_id')
                ->where('is_verified = ?', [1])
        ->string('username', 100);
}

Usage with belongsTo

// Load only active author
protected function configure($rule): void {
    $rule->table('#__books')
        ->id()
        ->int('author_id')
            ->belongsTo('active_author', AuthorModel::class, 'id')
                ->where('status = ?', ['active'])
        ->string('title', 200);
}

Combining where() with Default Scopes

The where() method is applied in addition to any default scopes defined on the related model:

// BookModel has a default scope to exclude deleted
class BookModel extends AbstractModel {
    #[App\Attributes\DefaultQuery]
    protected function notDeleted($query) {
        return $query->where('deleted_at IS NULL');
    }
}

// AuthorModel adds additional filter
protected function configure($rule): void {
    $rule->table('#__authors')
        ->id()
            ->withCount('published_books', BookModel::class, 'author_id')
                ->where('status = ?', ['published'])
        ->string('name', 100);
}

// Generated SQL includes BOTH conditions
SELECT authors.*,
(SELECT COUNT(*) FROM books
 WHERE books.author_id = authors.id
 AND deleted_at IS NULL          -- From default scope
 AND status = 'published') AS published_books  -- From where()
FROM authors

Error Handling

// ❌ WRONG - where() not after relationship method
$rule->table('authors')
    ->id()
    ->where('name = ?', ['John'])  // LogicException!

// ✅ CORRECT - where() immediately after relationship
$rule->table('authors')
    ->id()
        ->withCount('books', BookModel::class, 'author_id')
            ->where('status = ?', ['published'])  // OK!
    ->string('name', 100);
Error Message: If you call where() without an active relationship, you'll get:
LogicException: where() can only be called immediately after a relationship method (withCount, hasMany, hasOne, belongsTo).

Difference from hasMany

Feature hasMany withCount
Returns Array of related records Integer count only
Loading Lazy (on access) or Eager (with with()) Always included in query
Performance Loads all data (can be heavy) Fast - single COUNT subquery
Use Case When you need the actual records When you only need the count
Cascade Save ✅ Supported (if enabled) ❌ Read-only

Cascade Save

Overview

Control whether related records are saved with the main record:

// Default: save(false) - Only saves main record
$post->comments = [...];
$post->save(); // Comments NOT saved

// Cascade: save(true) - Saves main + related (if allowCascadeSave = true)
$post->comments = [...];
$post->save(true); // Comments saved

Requirements

Relationship Type Requires Saved when
hasOne allowCascadeSave = true save(true) AND allowCascadeSave = true
hasMany allowCascadeSave = true save(true) AND allowCascadeSave = true
belongsTo N/A Never

Example: Posts with Comments

// PostsModel - Enable cascade save for comments
protected function configure($rule): void {
    $rule->table('#__posts')
        ->id()
            ->hasMany('comments', CommentsModel::class, 'post_id', 'CASCADE', true)
        ->title();
}

// Usage
$post = new PostsModel();
$post->title = 'My Post';
$post->comments = [
    ['comment' => 'Great post!'],
    ['comment' => 'Thanks for sharing!']
];

// Save with cascade
if ($post->save(true)) {
    $results = $post->getCommitResults();
    echo "Post ID: " . $results[0]['id'] . "\n";
    echo "Comments saved: " . count($results[0]['comments']) . "\n";
}

Cascade Delete

Control what happens to related records when parent is deleted:

Behavior Effect
CASCADE Delete child records automatically
SET NULL Set foreign key to NULL in child records
RESTRICT Prevent deletion if child records exist
// CASCADE: Delete author deletes all books
->hasMany('books', BookModel::class, 'author_id', 'CASCADE')

$authorsModel->delete(1); // Author + all books deleted

// SET NULL: Delete author keeps books
->hasMany('books', BookModel::class, 'author_id', 'SET NULL')

$authorsModel->delete(1); // Books remain with author_id = NULL

// RESTRICT: Cannot delete if books exist
->hasMany('books', BookModel::class, 'author_id', 'RESTRICT')

if (!$authorsModel->delete(1)) {
    echo $authorsModel->getLastError(); // "Cannot delete: has child records"
}

Querying Relationships

whereHas() - Filter by Related Data

// Find authors with books after 2020
$authors = $authorsModel
    ->whereHas('books', 'published_year > ?', [2020])
    ->getResults();

// Combine with WHERE
$usAuthors = $authorsModel
    ->where('country = ?', ['USA'])
    ->whereHas('books', 'price > ?', [20])
    ->getResults();

Eager Loading with with()

Preload relationships for data export (API responses, JSON, etc.):

// Single relationship
$appointments = $appointmentModel->getAll()->with('doctor');
$data = $appointments->getFormattedData();

foreach ($data as $appointment) {
    echo $appointment->doctor->name; // Already loaded
}

// Multiple relationships
$posts = $postModel->getAll()->with(['author', 'comments']);

// All relationships
$authors = $authorModel->getAll()->with(null);
with() vs Lazy Loading:
  • Without with(): Relationships load on access (magic properties)
  • With with(): Relationships included in exported data (getFormattedData, etc.)

Batch Loading (N+1 Prevention)

Automatically optimized to prevent N+1 queries:

$authors = $authorsModel->getAll(); // 1 query

foreach ($authors as $author) {
    // First access loads ALL biographies in 1 query
    $bio = $author->biography; // Total: 2 queries (not N+1)
}

Nested Relationships

// Author -> Books -> Reviews
class ReviewModel extends AbstractModel {
    protected function configure($rule): void {
        $rule->table('#__reviews')
            ->id()
            ->int('book_id')->index()
            ->int('rating');
    }
}

class BookModel extends AbstractModel {
    protected function configure($rule): void {
        $rule->table('#__books')
            ->id()
                ->hasMany('reviews', ReviewModel::class, 'book_id')
            ->int('author_id')->index()
            ->string('title', 200);
    }
}

class AuthorModel extends AbstractModel {
    protected function configure($rule): void {
        $rule->table('#__authors')
            ->id()
                ->hasMany('books', BookModel::class, 'author_id')
            ->string('name', 100);
    }
}

// Usage
$author = $authorsModel->getById(1);
foreach ($author->books as $book) {
    echo $book->title . "\n";
    foreach ($book->reviews as $review) {
        echo "  Rating: " . $review->rating . "\n";
    }
}

Troubleshooting

Relationship returns null:
  • Check foreign key field exists and has correct values
  • Verify relationship alias is correct
  • Ensure related model class is correct
Cascade save not working:
  • Ensure allowCascadeSave = true in relationship definition
  • Call save(true) not save()
  • Check that relationship data is provided as array
Type compatibility error:
  • Foreign key and primary key must have compatible types
  • idintstring are compatible

Debug Relationships

// Check relationship configuration
$rules = $model->getRules();
if (isset($rules['id']['relationship'])) {
    print_r($rules['id']['relationship']);
}

// Check if relationship exists
if ($model->hasRelationship('books')) {
    echo "Relationship exists";
}

Quick Reference

Task Method Example
Define 1:1 relationship hasOne() ->id()->hasOne('profile', ProfileModel::class, 'user_id')
Define 1:N relationship hasMany() ->id()->hasMany('posts', PostModel::class, 'author_id')
Define N:1 relationship belongsTo() ->int('user_id')->belongsTo('user', UserModel::class)
Add count subquery withCount() ->id()->withCount('posts_count', PostModel::class, 'author_id')
Filter relationship query where() ->withCount('active', Model::class, 'fk')->where('status = ?', ['active'])
Cascade save child records save(true) $author->save(true) (requires allowCascadeSave = true)
Preload for export with() $posts->with(['author', 'comments'])
Disable withCount withoutGlobalScopes() $model->withoutGlobalScopes()->getAll()

See Also

Loading...