Laravel / Editorial solution guide

N+1 queries, missing relationships, and unsafe write assumptions

Investigate Eloquent through declared schema, relationship keys, scope, query counts, and writable fields. Keep performance, missing data, and authorization as separate diagnoses.

About 4 min readContent reviewed

Read-only knowledge base ยท No public comments

Recognize the symptoms

A list becomes slower as rows increase, author names disappear, or a create operation seems to ignore a field. Capture a synthetic case with the declared tables, keys, model settings, relationship methods, selected columns, and observed query count. A correct-looking page can still issue one additional query per row.

Understand the likely causes

  • Common causes are lazy relationship access in a loop, wrong foreign/local keys, omitting required keys from selected columns, an unintended global or tenant scope, mass-assignment filtering, and stale loaded relationship state. A found record is not permission, and changing scopes or making all attributes writable can introduce a vulnerability while hiding the symptom.

1. Write the relationship contract before changing queries

Identify parent and child tables, primary and foreign keys, nullability, soft-delete policy, and tenant boundaries. Confirm the model actually targets that table and whether timestamps or casts match its columns. A relationship name is not proof the inferred key matches a custom schema. If no row is returned, separate truly absent data from a scope or authorization restriction. Do not remove organizational scoping merely because an unrestricted query finds the record. Use owned fixtures that include both allowed and disallowed synthetic owners.

2. Count queries around the operation being investigated

Enable query observation only in an owned local fixture or an approved diagnostic context, without logging private bindings. Exclude schema setup and seeding from the measured section. Eager-load the relationship required by presentation and verify both the query count and actual related values. The complete SQLite example declares every table and relationship, then expects two reads for three posts. It establishes this local relation behavior only; target-engine plans, application observers, global scopes, and access policies remain outside the demonstration. Do not infer universal performance from this tiny dataset.

php Declared relationships in private SQLite memory
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
$db = new Illuminate\Database\Capsule\Manager();
$db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '', 'foreign_key_constraints' => true]);
$db->setAsGlobal();
$db->bootEloquent();
$db->schema()->create('authors', function (Illuminate\Database\Schema\Blueprint $t) { $t->id(); $t->string('name'); });
$db->schema()->create('posts', function (Illuminate\Database\Schema\Blueprint $t) { $t->id(); $t->integer('author_id'); $t->string('title'); });
final class AuthorRow extends Illuminate\Database\Eloquent\Model { protected $table = 'authors'; public $timestamps = false; }
final class PostRow extends Illuminate\Database\Eloquent\Model {
    protected $table = 'posts'; public $timestamps = false;
    public function author() { return $this->belongsTo(AuthorRow::class, 'author_id'); }
}
$db->table('authors')->insert([['id' => 1, 'name' => 'Ada'], ['id' => 2, 'name' => 'Bob']]);
$db->table('posts')->insert([['author_id' => 1, 'title' => 'A'], ['author_id' => 2, 'title' => 'B'], ['author_id' => 1, 'title' => 'C']]);
$db->connection()->enableQueryLog();
$posts = PostRow::with('author')->orderBy('id')->get();
echo $posts->map(fn($post) => $post->author->name)->implode(','), PHP_EOL;
echo count($db->connection()->getQueryLog()), PHP_EOL;
Expected output
Ada,Bob,Ada
2

One post read and one author read satisfy the loop. No project models, configured database, or real user records are accessed.

3. Repair keys and data selection without broadening access

When selecting only a few columns, include the identifiers required to match related records. Correct custom foreign-key arguments and explicitly handle nullable relationships. Use eager loading or aggregate methods suited to the page rather than fetching every child record for a simple count. Preserve tenant and ownership constraints in the parent and related queries. A nullable author should produce the agreed display state, while a broken mandatory reference should remain a visible data-integrity problem. Test empty, single, and multiple-parent cases so accidental matching or one-row fixtures cannot hide a wrong relationship.

4. Inspect writes through selected input and final state

For missing created attributes, inspect validated selected data, fillable/guarded configuration, casts, and observers before changing protection. Direct attribute assignment is a different path from mass assignment, so neither setting alone is complete authorization. Keep server-owned price, role, and tenant fields out of untrusted writable input. Verify persisted values by reading the row again, and inspect transaction boundaries if several records must change together. A successful create response does not prove every intended field was stored, and a rejected response does not prove no partial write occurred.

Verify the fix

Repeat the focused case and compare intended related identifiers, query count, response fields, and persisted values. Include another owner, a missing optional relation, a broken mandatory relation, and an unexpected sensitive input field. Keep a controlled target-engine check for engine-specific behavior; do not treat SQLite output as evidence for every deployment.

Keep it from recurring

  • Declare relationship keys and writable fields explicitly where conventions differ. Keep query observations free of private data, eager-load intentional presentation dependencies, and retain multi-row fixtures. Review scope and authorization whenever query or serialization changes are made.

Official references