protonemedia/laravel-eloquent-scope-as-select
Laravel Eloquent Scope as Select lets you add local scopes as select subqueries, so aggregated or computed values from related models come back in a single query. Skip extra database hits and N+1 problems while keeping your models clean and readable.
Install the package with Composer; Laravel's package discovery registers the service provider, and there is no config file to publish:
composer require protonemedia/laravel-eloquent-scope-as-select
First, confirm the builder macro is available in your app:
use Illuminate\Database\Eloquent\Builder;
Builder::hasMacro('addSelectSubquery'); // true after package discovery
Look first at the package README and then at vendor/protonemedia/laravel-eloquent-scope-as-select/src if the exact argument order differs from your version. In current releases the primary entry point is addSelectSubquery($alias, $query), where $query is an Eloquent builder that becomes a correlated select subquery.
The first real use case is replacing a hand-written withCount plus duplicated where constraints:
use App\Models\Post;
use App\Models\User;
$users = User::query()
->addSelectSubquery(
'published_posts_count',
Post::query()
->published()
->whereColumn('posts.user_id', 'users.id')
->selectRaw('count(*)')
)
->orderByDesc('published_posts_count')
->paginate();
// Each model now has $user->published_posts_count without an N+1 query.
This is the day-to-day value: the published() scope lives in one place, is reused inside the subquery, and the result is available as a normal selected column.
Reuse an existing scope as the source of truth. Keep constraints on the related model and pass that builder into the subquery:
class Post extends Model
{
public function scopePublished(Builder $query): void
{
$query->where('published', true)
->whereNotNull('published_at');
}
}
$users = User::query()
->addSelectSubquery(
'published_posts_count',
Post::query()
->published()
->whereColumn('posts.user_id', 'users.id')
->selectRaw('count(*)')
)
->get();
The same scope can still be used for filtering, so the definition does not drift between a where clause and a computed column.
Parameterised scopes work naturally. Pass arguments to the scope inside the subquery builder:
Post::query()
->publishedAfter($request->date('since'))
->whereColumn('posts.user_id', 'users.id')
->selectRaw('count(*)');
Chain multiple computed columns. This is common on index pages and API resources:
$users = User::query()
->addSelectSubquery(
'published_posts_count',
Post::query()->published()
->whereColumn('posts.user_id', 'users.id')
->selectRaw('count(*)')
)
->addSelectSubquery(
'latest_post_at',
Post::query()
->whereColumn('posts.user_id', 'users.id')
->selectRaw('max(published_at)')
)
->get();
Sort and filter on the alias in SQL, not PHP. Use orderBy normally, but use having for filtering because the alias is not available in the WHERE clause:
->having('published_posts_count', '>', 0)
If your database does not permit HAVING without grouping, wrap the outer query or move the condition into the subquery.
Use it in API resources instead of loading relations just to count them:
class UserResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'published_posts_count' => (int) $this->published_posts_count,
'latest_post_at' => $this->latest_post_at,
];
}
}
Prefer builder instances over raw SQL strings. Passing Post::query() preserves global scopes such as soft deletes. Passing DB::table('posts') bypasses them and is a common source of mismatched counts.
Test the generated SQL, not just the result. In feature tests, assert the column exists and has the expected type, and use toSql()/getBindings() when debugging:
$query = User::query()->addSelectSubquery('published_posts_count', $subquery);
dump($query->toSql(), $query->getBindings());
whereColumn('posts.user_id', 'users.id'); without it you get a global count repeated on every row. Table aliases must match both sides._count or _at suffix and cast in the model or resource.withCount is still better for simple relationship counts. It is optimised and less error-prone. Reach for this package when the count needs arbitrary scopes, multiple constraints, or an aggregate other than count(*).posts.user_id) and filter columns are indexed. Compare execution plans against a leftJoin/groupBy approach for high-volume endpoints.Post::query() applies them; Post::withTrashed() intentionally does not; raw DB::table() applies neither.addSelectSubquery calls. Clone it or construct a fresh builder, otherwise bindings and constraints accumulate unexpectedly.selectRaw('count(*)'), max(...), sum(...) are safe. Returning multiple columns from the subquery will break the outer select.groupBy interactions. If the outer query groups, adding an aggregate subquery can produce surprising results or SQL errors on strict databases. Keep the subquery correlated and ungrouped unless you deliberately need grouping.DB::listen() or Laravel Telescope to inspect the final SQL. The subquery's bindings are merged into the outer query, so a missing binding usually means the subquery builder was mutated after being added.addSelectSubquery is missing, run composer dump-autoload and check package discovery. If the argument order differs in your version, confirm against the vendor source rather than guessing.User::withPostStats() scope that chains the subqueries keeps controllers and resources clean and gives you one place to change aliases or casts.'published_posts_count' => 'integer' on the model or cast explicitly in the resource.How can I help you explore Laravel packages today?