Model Relationships
Revision: 2025/12/27
Define and use relationships between models with hasOne, hasMany, and belongsTo.
- 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. |
save($cascade = false): Default. Saves only the main record.save($cascade = true): Saves main record + related records that haveallowCascadeSave = true.belongsTorelationships 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:trueto 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
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:trueto 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
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);
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);
- 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
- Check foreign key field exists and has correct values
- Verify relationship alias is correct
- Ensure related model class is correct
- Ensure
allowCascadeSave = truein relationship definition - Call
save(true)notsave() - Check that relationship data is provided as array
- Foreign key and primary key must have compatible types
id↔int↔stringare 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() |