Your Agent’s Confidence Is Not Evidence
September 16, 2026 • 4 min read
An agent handed me a query and told me it used the created_at index. That was true. It also read 50,336 index entries to produce a number the same query, written as a range, reaches after 125.
Most of what comes back from an agent is correct, and often better than what I’d have written at that hour. It’s the rest of the time I’ve had to build a habit around, because a wrong claim arrives with exactly the same confidence as a right one.
Reading catches style, not truth
I’m reasonably good at reviewing agent output for the things that live in the text. Padded functions, bad names, a method doing two jobs, a comment that restates the line below it. All of that is right there on screen.
What I can’t do by reading is tell whether a claim about behaviour is true. “This uses the index.” “This avoids the N+1.” “This is safe for money.” Each of those reads exactly like a sentence that happens to be correct, and there’s nothing in the writing to separate them.
Here are two pieces of code I asked an agent for, along with what it told me about them and what happened when I ran them.
“This uses the created_at index”
Order::query()
->whereDate('created_at', today())
->count();That is the form the documentation uses, and the column is indexed. I checked the plan on a 50,000-row table against the same query written as a range:
whereDate type=index key=orders_created_at_index rows=50336
range type=range key=orders_created_at_index rows=125Both name the index, which is the bit that gets you. If you glance at key to answer “is my index being used”, the answer comes back yes in both cases, and you have to read type to see that one of them is a full index scan. whereDate wraps the column in DATE(), so MySQL can’t seek into a plain index on created_at and walks the whole thing instead: 50,336 entries read to reach a number the range version gets to after 125. That figure is the optimiser’s estimate, which is why it comes out above the 50,000 rows actually in the table. Ask for a column the index doesn’t cover and it gives up on the index altogether, falling back to type=ALL with key=NULL.
A functional index on (DATE(created_at)) would make the first query seekable, which is worth knowing but is a second index to carry for a query that didn’t need one.
The index is used. It’s just being read cover to cover.
“Eager loading the posts avoids the N+1”
$authors = Author::query()->with('posts')->get();
foreach ($authors as $author) {
$published = $author->posts()->where('published', true)->get();
}The with('posts') is right there in the first line, which is what makes this one slippery. Counting queries over 50 authors:
with('posts') + $author->posts()->where(...)->get() 52 queries
with('posts') + $author->posts->where(...) 2 queriesposts() returns the relation’s query builder, so every call issues a fresh select and discards the rows that were already eager loaded. posts returns the loaded collection and filters it in memory. One character between them and twenty-six times the queries.
In-memory filtering is only half a fix, though, because it still hydrates every post and throws most of them away. Constraining the eager load gets the same two queries and half the rows:
Author::query()
->with(['posts' => fn ($query) => $query->where('published', true)])
->get();unconstrained eager load 500 rows hydrated
constrained eager load 250 rows hydratedThis is the example I expected to have to throw away, because modern Laravel is good at this class of bug. preventLazyLoading() turns an unloaded relationship access into an exception, automaticallyEagerLoadRelationships() resolves the textbook version for you, and with Laravel Boost installed the agent is working against Laravel-specific rules rather than generic PHP.
So I turned the guardrails on and ran it again:
preventLazy autoEager
genuine lazy load, no with() exception 2 queries
with() + $author->posts()->where(...) 52 queries 52 queriesStrict mode throws on the N+1 from the tutorial and says nothing about this one. Both guardrails watch for an unloaded relationship being accessed as a property, and posts() is a method call, which is always legal. You’re not lazy loading here. You’re deliberately running a new query and throwing away work you already paid for.
What these have in common
Neither is a typo, and neither would fail a careful read. Both show you the thing you’d actually check: the index is named in the plan, the eager load is sitting in the code. Nor are they exotic - whereDate is in the docs and with() is the first thing anyone learns about N+1.
The guardrails are the interesting part, though. Pint, Larastan, strict mode, automatic eager loading, Boost feeding framework-specific rules to the agent - the floor really has come up, and a lot of what used to get caught in review now gets caught before review. That’s good. It also means whatever still gets through is the stuff no tool is watching for, and you meet it half expecting to have been warned already.
So the check that’s left is running it - the command and what it printed, not a summary of it. I’d rather see the EXPLAIN output than be told the index is fine.