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.
Eloquent, properly · Lesson 3
Indexes, select and chunking
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.
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.
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.
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 byidbefore 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?