Course outline

Laravel / Application boundaries and reliable workflows / Lesson 7

Relationships and deliberate querying

Model relationships explicitly, choose query boundaries, and prevent hidden per-row work without confusing loaded data with authorization.

About 5 min readContent reviewed
On this page

Lesson goals

  • Describe cardinality and ownership first
  • Declare the foreign-key contract explicitly
  • Review scope, ordering, and resource costs

Describe cardinality and ownership first

A relationship expresses a domain association, not merely a convenient property name. In the synthetic example, each Post belongs to one User through posts.user_id, and one user can have many posts. A foreign key protects referential integrity, while an index supports relevant lookup patterns; neither establishes who may view the record. Decide whether the association is required, what deletion means, and whether soft-deleted parents should be visible under the application’s policy. These choices shape migrations, relationship methods, validation, and authorization. Do not infer business ownership solely because an eager-loaded object happens to be attached to a model.

Distinguish a relation query from its loaded value

Calling author() returns a relationship object that can participate in query construction. Reading author as a dynamic property retrieves the associated value and can trigger lazy loading if it is not already loaded. Similarly, posts() can build a constrained query, while posts refers to a collection once resolved. This distinction matters inside loops and serializers: an innocent property read can issue database work. Build filters before materializing a collection when the database should perform them. Collection filtering after get operates on already retrieved data and does not retroactively reduce the SQL query or the memory used to load it.

Declare the foreign-key contract explicitly

The first fragment belongs in App\Models\Post in a fresh training application with a posts table containing id, user_id, title, body, and published. Its author method names user_id explicitly because the relationship is called author rather than user. Otherwise Eloquent convention can look for author_id. The HasFactory trait supports the separate synthetic factories used later; it does not create their definitions. The User model’s inverse posts relation should use hasMany(Post::class, "user_id"). Define the schema and inverse deliberately rather than assuming a method declaration creates a database constraint or a populated fixture.

php Contextual Laravel 12 application fragment
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Post extends Model
{
    use HasFactory;
    protected $fillable = ["title", "body", "published"];
    public function author(): BelongsTo
    {
        return $this->belongsTo(User::class, "user_id");
    }
}
Expected output
For Post user_id=7, author() targets User primary key 7.

This model fragment requires the stated schema and a real User model. The explicit foreign key connects author() to user_id instead of the method-name convention.

Load the required graph with bounded work

The second fragment retrieves at most ten published posts and eagerly loads each author’s id and name. It retains posts.user_id and the parent id so Eloquent can match results correctly. For a synthetic fixture with one published post by Mira and one unpublished post by Omar, only the published post appears, with Mira available for rendering. Conventional eager loading avoids one separate author query per row in this simple relationship; exact query totals can vary with additional scopes or application behavior. Limit selected columns carefully, use withCount when only a count is required, and inspect query evidence rather than guessing from response size.

php Contextual Laravel 12 application fragment
<?php
use App\Models\Post;
$posts = Post::query()
    ->where("published", true)
    ->with("author:id,name")
    ->orderBy("id")
    ->limit(10)
    ->get(["id", "user_id", "title"]);
Expected output
Synthetic fixture result: post 1; loaded author Mira.

A controller or query service supplies the application context. Given only published post 1 by Mira and draft post 2 by Omar, the collection contains post 1 and its loaded author.

Review scope, ordering, and resource costs

Apply the visibility and tenant scope that the endpoint actually promises before pagination or eager loading. Group OR conditions so they cannot escape an ownership constraint. Use deterministic ordering for repeatable pages, and cap request-controlled limits. Eager loading every reachable relationship can replace an N+1 problem with excessive rows and memory, so request only the graph the response needs. Test missing optional relations, empty collections, unexpected duplicate joins, and authorization exclusions. Read-only query review and isolated test fixtures are sufficient for this lesson; no query should be executed against a configured application database merely to reproduce these examples.

Common errors and fixes

The author relation queries author_id

Why it happens
The method name convention differs from the schema.
How to fix it
Declare user_id explicitly in belongsTo.
Prevent it next time
Align migrations, relationship names, and keys in review.

A list performs one author query per row

Why it happens
A lazy relationship is read in a loop.
How to fix it
Eager-load the required relation before rendering.
Prevent it next time
Inspect bounded query behavior with multiple rows.

Eager-loaded relations appear empty after column trimming

Why it happens
Matching primary or foreign keys were omitted.
How to fix it
Retain the keys needed to associate results.
Prevent it next time
Test selected-column queries with real relationship fixtures.

Check your understanding

1. A page needs each author’s name but only a count of comments. Design the load shape.

Show worked solution

Eager-load the author fields required for the name while retaining matching ids, and use withCount("comments") rather than retrieving every comment. Keep publication and access scopes on the root query, then inspect the resulting query work in an isolated fixture.

2. Why is filtering an already loaded collection not equivalent to a database where clause?

Show worked solution

The collection filter runs after rows have been fetched and allocated. It can produce the same visible subset but still transfers and retains unnecessary data, and it does not replace query-level access scoping. Choose the boundary according to cost and the endpoint’s visibility contract.

Key takeaways

  • Relationship methods and loaded properties have different costs.
  • Keep matching keys when selecting columns.
  • Loading strategy never replaces access policy.

Official references