← All posts

The Eloquent scope that never ran

A local scope named latest() is silently shadowed by Eloquent's own builder method, so it never runs — and the unit test covering it passed, because it called the scope by the one route that bypasses the collision.

The blog index on this site is ordered newest first by published_at. The model says so:

#[\Illuminate\Database\Eloquent\Attributes\Scope]
protected function latest(Builder $query): Builder
{
    return $query->latest('published_at');
}

Two controllers call it. Neither of them orders by published_at. That method body has never executed once in production.

__call is a fallback, not a hook

Eloquent's local scopes are dispatched entirely from Builder::__call. In Laravel 12 the relevant branch reads:

if ($this->hasNamedScope($method)) {
    return $this->callNamedScope($method, $parameters);
}

hasNamedScope() is what knows about scopeFoo() methods and about the #[Scope] attribute. It is reached from nowhere else in the normal query path.

PHP calls __call only when the requested method cannot be resolved on the object — when it does not exist, or is not accessible from the call site. And Illuminate\Database\Eloquent\Builder already defines a public latest(), around line 434 of Builder.php:

public function latest($column = null)
{
    if (is_null($column)) {
        $column = $this->model->getCreatedAtColumn() ?? 'created_at';
    }

    $this->query->latest($column);

    return $this;
}

So BlogPost::query()->published()->latest() binds straight to that method. __call never fires, hasNamedScope() is never asked, and the scope on the model is unreachable code that static analysis is perfectly happy with.

Dumping the SQL settles it:

select * from "blog_posts" where "published" = ? and "published_at" <= ?
order by "created_at" desc

published() works fine, because Builder has no method by that name. It is only the collision that kills the scope, and the collision is silent: no exception, no deprecation, no log line. You just get the framework's behaviour instead of yours.

The test that made it look fine

There is a unit test for this scope, and it passes:

$latest = BlogPost::query()->scopes(['latest'])->get();

expect($latest->first()->is($newer))->toBeTrue();

Builder::scopes() calls callNamedScope() directly rather than going through __call. It exists precisely as the escape hatch for scopes you cannot invoke by name. So the test exercised the one code path that does reach the scope, and never touched the path both controllers use. Rewriting that single line as BlogPost::query()->latest()->get() makes the test fail immediately.

That is my mistake, not the framework's. The test asserted that the scope's body is correct. It never asserted that calling it the way the application calls it produces that result.

The generated @method static Builder<static>|BlogPost latest() annotation on the model made things worse, not better — it documents latest() as part of the model's API and is equally true whichever implementation answers the call. PHPStan had no reason to complain.

Why nobody noticed

For a blog where posts are written and published in the same sitting, created_at and published_at order identically. The bug only becomes visible with back-dated imports, posts drafted long before they go out, or anything scheduled — which is exactly when you are least likely to be watching the index page.

The rule

Before naming a local scope, check whether the builder already owns the name:

method_exists(\Illuminate\Database\Eloquent\Builder::class, 'latest'); // true

latest and oldest are the obvious traps because they read so naturally as domain vocabulary. find, first, has, with, without, when and the whole where* family are all real methods too.

Two ways out. Rename the scope to something Builder does not define — newestFirst() says more anyway — or drop the scope and order explicitly at the call site. The read-only API controller feeding the SSH client does the latter, with a comment, because a scope that looks like it works is worse than no scope at all:

BlogPost::query()
    ->published()
    ->orderByDesc('published_at')
    ->get()

One honest footnote: the Contact model has the same shadowed latest() scope, and there it changes nothing, because the scope body is $query->latest() — identical to the method shadowing it. Same bug, invisible. That is the part worth remembering: this failure mode is only ever as loud as the difference between your implementation and the framework's.

Contact

Build something that lasts.

Got a Laravel project, a WooCommerce mess or an integration that needs untangling? Tell me what's breaking.

© 2026 Veltix · Kristen LõokeGitHub · Tallinn