samasend/laravel-make-scope
A Laravel package that adds a make:scope Artisan command to quickly scaffold reusable Eloquent query scopes, generating the class and wiring it into your models so you can keep query logic clean and DRY.
laravel-make-scope is a single-purpose Artisan generator: it adds php artisan make:scope {name} so you can extract a block of Eloquent query logic into a dedicated, testable class instead of piling up scopeX methods on the model.
Install (mind the archive status):
composer require samasend/laravel-make-scope --dev
It's a dev-time generator, so --dev is usually correct. Because the package is archived (last release July 2021), pin it and verify it resolves against your Laravel/PHP version before committing — on Laravel 10/11 you may need --ignore-platform-reqs or a replace/fork. If composer refuses, copy the stubs/ directory into your own app and hand-roll the provider.
Where to look first, in order:
vendor/samasend/laravel-make-scope/src/ — the service provider (command registration) and the command class. This tells you the default namespace (App\Scopes), the output path, and whether a --model/--force option exists.vendor/samasend/laravel-make-scope/stubs/ — the actual template for the generated class. This is the source of truth. Read it before writing anything; the exact interface, method signature, and return type live here, and archived packages rarely match their README.composer.json under extra.laravel.providers — confirms auto-discovery. If absent, register Samasend\MakeScope\MakeScopeServiceProvider in config/app.php.First use case — the classic "same where chain copied across three models" problem. You have Post::where('published', true)->where('published_at', '<=', now()) repeated in a controller, a console command, and a Nova resource. Run:
php artisan make:scope Published
Open the generated app/Scopes/Published.php, move the where chain into the body, and invoke it from a single place. That's the entire value proposition: one named, autoloaded, unit-testable home for a query fragment.
One class per scope, not per model. Resist PostScopes god-classes. Published, Active, Recent, OwnedBy as separate classes compose better and diff cleanly in reviews. This is the pattern the generator nudges you toward and the one worth adopting even if you later replace the package.
Invoke scopes explicitly from the model. Since the generated class is a plain class (not a global scope), the idiomatic wiring is a thin trait or a small __call on the builder:
// app/Concerns/HasScopes.php
trait HasScopes
{
public function scopeWithClass(Builder $query, string $scope, ...$args): Builder
{
return (new $scope(...$args))->apply($query, $this);
}
}
Then Post::query()->withClass(Published::class)->get(). Confirm the generated method name/signature against the stub — if the package ships its own trait or a Scope::query() helper, use that instead of inventing one.
Parameterize via the constructor, not method args. Scopes with runtime values (a date cutoff, a user) are cleaner as new RecentPosts(now()->subWeek()) than as variadic method parameters. It keeps apply() to the two-argument contract Laravel's Scope interface already implies and makes the class instantiable in a test.
Compose in the right order. Scopes applied left-to-right chain onto the same builder, so Active → NotBanned → Recent reads top-down and each class stays ignorant of the others. Keep each apply() a pure where/with/orderBy addition with no side effects and no ->get() calls — a scope that terminates the query can't be composed or reused.
Reuse across models via a shared interface. If Published applies to Post and Article, type-hint an interface (Publishable) rather than checking instanceof. The scope class only ever sees a Builder + Model, so duck-typing on columns works, but a shared contract documents intent and catches mistakes at the call site.
Map API filters to scope classes. A ?filter=published query string maps cleanly to a whitelist:
$scopes = ['published' => Published::class, 'recent' => Recent::class];
if ($class = $scopes[$request->query('filter')] ?? null) {
(new $class)->apply($query, $model);
}
Whitelist, never reflect on user input — that's the security boundary.
Test the scope, not the model. Because it's a standalone class, you can assert on the generated SQL without touching a database, or use RefreshDatabase and assert results. This is the strongest argument for the pattern over scopeX methods.
Archived means frozen against Laravel's internals. The package itself is thin, but the command extends Illuminate\Console\Command and the generated class may type-hint Illuminate\Database\Eloquent\Builder. Both are stable, so it usually keeps working — but there will be no fix when a future Laravel changes the Scope interface or the generator's getStub() path. Fork the stub into your repo on day one and treat the package as optional.
Don't confuse this with global scopes. The name "scope" is overloaded. Laravel's Illuminate\Database\Eloquent\Scope + addGlobalScope() applies automatically to every query; classes generated here are local, applied on demand. If you register one as a global scope you'll silently filter reads everywhere, including find() and relationship lazy-loads — a classic production incident.
Stub publishing may not exist. If php artisan vendor:publish shows nothing, the package ships no publishable stubs/config; copy the vendor stub manually and customize your own generator, or just edit generated files by hand. Check vendor/.../src/ServiceProvider for publishes() calls before assuming.
Name collisions with local scopes. make:scope Active creates App\Scopes\Active, but if the model also has scopeActive(), both exist and it's unclear which path a developer used. Pick one convention per codebase and delete the scopeX version when you extract it.
Return types matter. If the generated apply() declares : void but you return $builder->where(...), PHP fatals on the mismatch. Match the stub exactly; if it's void, mutate the builder and return nothing.
Auto-discovery is the usual silent failure. Command "not found" almost always means the provider isn't registered — either extra.laravel.providers is missing from the installed version or you're on a package-manifest cache. Run php artisan package:discover and php artisan clear-compiled before debugging the command itself.
Debugging a scope = dump the SQL, not the results. ->toSql() and ->getBindings() on the builder after apply() show whether the fragment landed; a scope returning a fresh Builder instead of mutating the passed one is the #1 bug and is invisible in result-based tests.
Extension points are limited by design. There is no scope registry, no discovery, no priority ordering. If you need conditional/composable query logic at scale, the maintained alternatives are native scopeX methods (zero dependency), spatie/laravel-query-builder (filter/sort/include from request), or a custom query-object layer. Adopt this package for the class-per-scope convention, not for any runtime capability — you can replicate the whole thing with one stub file and ten lines of Artisan.
Pin and audit. Archived, 101 stars, no security surface of its own (it only writes a file at dev time), but Composer will happily pull transitive dev dependencies. Keep it in require-dev and out of production builds.
How can I help you explore Laravel packages today?