laravelmastery
SnippetsArticlesSeriesNews

Eloquent, properly · Lesson 3 of 3

Eloquent, properly · Lesson 3

Indexes, select and chunking

Query it safely and fastEloquentQueries

The guard now throws when a list touches a relation it did not load. The index should match the query that list actually runs. The scopes and eager-loading lessons list a team with forTeam()->latest('issued_on'). That list is the (team_id, issued_on) index, not issued_on on its own. A column you might filter on one day is not an index you pay to maintain today.

Video

01 Read the columns the page needs

select() keeps a wide text column off a list page. The notes on an invoice can be long. The list renders number, date and status. Leave notes out of that query. chunkById() walks a large table without loading it, and without the skipped-row surprises of offset pagination. The select still includes id, because chunkById() pages by the primary key.

PHP
Schema::table('invoices', function (Blueprint $table): void {
    $table->index(['team_id', 'issued_on']);
});

The list query then matches the index, and a command that must touch every issued invoice does not load them all at once. forTeam() and issued() are the local scopes. The select list is the columns this command is allowed to read. Dispatch the id, not the half-loaded model, so the job can load the row it actually needs.

PHP
Invoice::query()
    ->forTeam($team)
    ->issued()
    ->select(['id', 'number', 'issued_on', 'status'])
    ->chunkById(200, function ($invoices): void {
        foreach ($invoices as $invoice) {
            ArchiveInvoice::dispatch($invoice->id);
        }
    });

chunk() with offset is the wrong tool when the callback deletes or filters rows: each page shifts, and you skip records. chunkById() remembers the last id. Use it. Put the composite index in a migration of its own once the invoices table has shipped, and drop it in down(). An index on issued_on alone does not serve a query that also filters by team.

02 A missing notes attribute is not an empty note

select() does not set the columns it omitted to null in the database. It leaves them off the model. Read notes on that instance and Eloquent returns null, unless you have told it to throw. That null is not the note the team wrote. A job that receives the partial model and saves notes will store the empty value over the real one. The chunk above avoids that by dispatching the id. The job then loads the full invoice and edits the note it actually read.

PHP
public function boot(): void
{
    Model::preventAccessingMissingAttributes(! app()->isProduction());
}

public function handle(): void
{
    $invoice = Invoice::query()->findOrFail($this->invoiceId);

    $invoice->forceFill([
        'notes' => trim($invoice->notes.' Archived.'),
    ])->save();
}

preventAccessingMissingAttributes() belongs next to the lazy-load guard, and it stays off in production for the same reason. Outside production, a list page that renders notes without selecting notes throws MissingAttributeException instead of showing a blank. Add notes to the select list if this response shows it. Leave it off, and do not read it, if the response does not. The exception is how you tell those two cases apart.

findOrFail() in the job is a full row. forceFill() is appropriate because notes may not be on $fillable, and this write is not a form. Loading by id is what makes the existing sentence survive. chunkById() can keep its narrow select.

Recap

You can index (team_id, issued_on) for the list you run, walk a large table with chunkById(), and refuse to treat a column you did not select as empty.

  • The index matches the filter and the order, not a column you might query one day.
  • select() keeps a wide column off the list.
  • chunk() with offset skips rows when the callback deletes. chunkById() does not.
  • A partial model returns null for notes. Load the full row by id before you write that column back.

Check yourself · 3 questions

Question 1 of 3The list is a team's invoices by issued date. Which index matches?

The list is indexed and the staff note stays off it. Next, the API decides which of those fields a client may see.

← PreviousCatching N+1 queries

On this page

  • Read the columns the page needs
  • A missing notes attribute is not an empty note
  • Recap
  • Check yourself

Eloquent, properly

  • 1 Overview

Shape the data

  • 2 Models that match the domain
  • 3 Migrations you can still read next year
  • 4 Casts, enums and custom casters

Connect it

  • 5 One to many, without the surprises
  • 6 Belongs-to and whereBelongsTo
  • 7 Many to many and pivot data
  • 8 Has many through

Query it safely and fast

  • 9 Local and global scopes
  • 10 Eager loading on purpose
  • 11 Catching N+1 queries
  • 12 Indexes, select and chunking
  • 13 API resources without leaking attributes

Eloquent, properly

Lessons

  1. 01Overview

Shape the data

  1. 02Models that match the domain
  2. 03Migrations you can still read next year
  3. 04Casts, enums and custom casters

Connect it

  1. 05One to many, without the surprises
  2. 06Belongs-to and whereBelongsTo
  3. 07Many to many and pivot data
  4. 08Has many through

Query it safely and fast

  1. 09Local and global scopes
  2. 10Eager loading on purpose
  3. 11Catching N+1 queries
  4. 12Indexes, select and chunking
  5. 13API resources without leaking attributes

Get the Friday email

One snippet and the week’s Laravel news, in your inbox every Friday morning. Unsubscribe whenever you like.

laravelmastery

Practical Laravel learning for working developers, made in the UK.

Learn

  • Snippets
  • Articles
  • Series

Stay current

  • News
  • Friday email
  • RSS feed

Company

  • About
  • Privacy
  • Terms
© 2026 Laravel Mastery. Independent and not affiliated with Laravel Holdings Inc.Designed and built by Half Shell Studios

We remember the videos you watch, where you left off, and which series lessons you mark as read, against this connection’s address. Nothing is stored until you agree.