← Laravel: requests, data and application decisions
05 / 10 · 65 MIN

Eloquent, relationships and query cost

Distinguish relationship queries from loaded collections. Choose eager loading or aggregates, preserve data scope and recognize operations that bypass per-model events.

Relationship method and loaded property

On Team, tickets() returns a relationship on which a query can be built; tickets accesses related models and may trigger their loading. Adding where before get constrains the query. Applying where to the collection after get filters already retrieved models in memory.

In a team report, Ticket::where without a team condition loses the scope supplied by the relationship. Before optimizing, identify where query construction ends and result consumption begins.

Load what is needed and consume aggregates

If each list row queries owner without eager loading, cost may grow with row count. with retrieves the relationship in advance. When only a count is needed, withCount adds tickets_count without hydrating every ticket. Read that attribute: tickets()->count() on each row queries again. preventLazyLoading helps detect implicit loading but does not prevent these explicit queries.

Measure queries and data volume; the chosen method does not guarantee one universal query count for every relationship.

Select columns without losing association

A reduced projection must preserve the relationship contract. To associate tickets with teams, retain ticket primary keys and the relevant foreign key alongside displayed fields. Selecting only subject may return values without supporting the expected association.

Renaming team_id to an alias unknown to the relationship is also insufficient. In the example, tickets retain their columns and owner retains id; belongsTo uses owner_id present on the ticket. Check the actual keys of customized models.

Preserve scope and interpret visibility

The relationship adds the team condition, but team_id=X AND open=1 OR urgent=1 admits urgent tickets from other teams. Group the alternative in a where closure to obtain team_id=X AND (open=1 OR urgent=1). Also inspect model scopes: SoftDeletes retains the row with deleted_at and excludes it from ordinary queries; withTrashed permits intentional inclusion.

An absent result does not prove physical deletion. Neither grouping nor including deleted rows removes the need for access authorization.

Limit mass-assigned attributes

A profile-name update should not automatically accept every submitted field. Define mass-assignable model attributes, for example through the Fillable attribute documented in this version, and pass only data validated and permitted for the operation.

These boundaries complement each other: authentication does not authorize changing every property, and a sensitive field name does not protect it. Direct assignment and query-level updates have their own contracts; do not generalize mass-assignment protection to every write.

Events and evidence of a mass update

Ticket::where(...)->update(...) changes rows without retrieving each model. Those models’ saving, saved, updating and updated events are not dispatched by that mass operation. An updated observer writing audit evidence can therefore leave no records even though rows changed.

Before accepting the process, choose instance updates with the required events or explicit evidence writing. In either case, define transaction, failure and retry behavior; dispatching an event alone does not prove durable audit delivery.

Traverse a changing set

A process reading pending rows and marking them done shrinks the set between pages. With offsets, advancing the position can skip rows that moved into earlier positions. chunkById or lazyById progresses by key and avoids relying on that offset.

Keep the progression key stable and group OR alternatives because the method adds its own ID conditions. Changing keys during traversal can omit records. This pagination strategy does not itself create an immutable snapshot or authorize processing every row.

<?php
// Reading excerpts: imports, models and authorization are omitted.
// $accountId and $team come from an already authorized application context.
$teams = Team::query()->where('account_id', $accountId)
    ->withCount('tickets')->get();
foreach ($teams as $item) {
    $counts[$item->id] = $item->tickets_count;
}

// Team::tickets() is a hasMany relation using team_id.
$tickets = $team->tickets()
    ->where(fn ($q) => $q->where('open', 1)->orWhere('urgent', 1))
    ->with('owner:id,name')
    ->get();
// Tickets retain their columns; owner uses id and belongsTo owner_id.
IN PRACTICE

The first excerpt obtains counts and reads tickets_count without running count on each relationship. The second retains the open-or-urgent alternative inside the team and loads owners together. These are reading excerpts: models, authorization, imports and presentation are not supplied.

Execution is not requested, and an account-scoped query must not be assumed to replace the application policy.

Common pitfalls

Filtering only after get; loading all tickets to count them; omitting keys from a projection; allowing orWhere to escape team scope; treating Fillable as authorization; expecting per-model updated events from a mass update.

Related topics: Validation and request contracts · Transactions, concurrency and migrations · Authentication, authorization and sessions

Take this idea with you

Query shape determines retrieved data, cost and available events. Read the aggregates already requested, preserve necessary keys and parentheses, and specify evidence for each change. Lazy-loading and mass-assignment guards address different concerns.

Create account

Reference: Relationship queries and loaded properties · Laravel 13.x; PHP 8.3+