Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Local Class Scope Laravel Package

mpyw/laravel-local-class-scope

A tiny Laravel macro that lets you reuse global scope classes as local query scopes. Call User::scoped(ActiveScope::class) or pass constructor arguments, chain scopes, and optionally wrap them in model methods or traits. Includes a PHPStan extension for static analysis.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and confirm the macro is live. One command, no service provider to publish and no config file — the package auto-registers a single scoped() macro on the Eloquent builder.

composer require mpyw/laravel-local-class-scope

Verify with a throwaway scope before you build anything real on top of it:

use Illuminate\Contracts\Database\Eloquent\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

class ActiveScope implements Scope
{
    public function apply(Builder $query, Model $model): void
    {
        $query->where($model->qualifyColumn('active'), true);
    }
}

User::scoped(ActiveScope::class)->get();   // class-string form
User::scoped(new ActiveScope())->get();    // instance form

If both lines return rows, you are done. If they throw BadMethodCallException, check the Laravel/PHP version gates first (^8.2 PHP, Laravel ^11.0 || ^12.0 || ^13.0 || ^14.0) — older versions have different dependency constraints and you should read the changelog for the matching release rather than the README.

Where to look first. Read the README's Usage section end to end; it is short and covers every call style you need. Then read Laravel's Illuminate\Contracts\Database\Eloquent\Scope interface — that is the only contract you are bound to, and the package adds nothing new to learn beyond it. Finally, inventory your existing app/Scopes/ directory and every booted() / addGlobalScope() registration in your models: those classes are the raw material you are about to reuse locally. The package is explicitly a bridge between the global-scope idiom you already have and the local-scope idiom you want.

First real use case. Pick a predicate already duplicated in at least three call sites — "active", "published", "not archived", a tenant/owner constraint, or a soft-delete-adjacent visibility rule. Extract it into App\Scopes\XScope, replace exactly one call site with Model::scoped(XScope::class), and hold the PR to the same review bar as any other abstraction. If the diff is not obviously cleaner than the ->where('active', true) it replaced, stop — this package codifies a style, it does not add query capability.

Implementation Patterns

Parameterized scopes: two equivalent forms, two different reasons to pick one. Pass arguments as a spread after the class-string, or construct the object yourself:

User::scoped(AgeScope::class, '>', 18)->get();
User::scoped(new AgeScope('>', 18))->get();

The class-string form is shorter, greps better, and moves cleanly if you later rename the class. The new form is the one to reach for when a scope needs a dependency that cannot travel as a scalar spread — a Clock, a resolved config object, a value object, or a service pulled from the container. A good rule of thumb: if the constructor arguments are all scalars that read like a query clause, use the spread; if any argument is an object you had to construct, use new.

Write scopes for local reuse, not global application. Because the same class can now be applied globally and ad hoc, write it defensively:

public function apply(Builder $query, Model $model): void
{
    $query->where($model->qualifyColumn('active'), true);
}

$model->qualifyColumn() instead of a bare 'active' keeps the scope correct when a join introduces an active column from another table. Never hardcode the table name. Read everything you need off $model ($model->getTable(), $model->getKeyName(), $model->getQualifiedDeletedAtColumn()), never off a specific subclass.

Chain to compose multi-predicate queries. Chaining is the idiomatic way to build a combined filter, and it mirrors where() chains closely enough that review is trivial:

User::scoped(ActiveScope::class)
    ->scoped(AgeScope::class, '>', 18)
    ->scoped(PublishedScope::class)
    ->latest()
    ->paginate(25);

Each scoped() call returns the builder, so existing builder methods interleave freely. Precedence is simply left-to-right application order — there is no scope-resolution engine, no ordering graph, no way for one scope to reorder another unless it calls remove() (see the Gotchas section for why it usually shouldn't).

Re-expose as a conventional local method scope. When the class-based form is too verbose at a hot call site, or when the team wants User::active() to keep working, wrap it:

class User extends Model
{
    public function scopeActive(Builder $query): Builder
    {
        return $this->scoped(ActiveScope::class);
    }
}

The wrapper costs one line and gives you a stable, discoverable API even if you later swap ActiveScope for something else.

Share wrappers across models via traits. This is where the pattern earns its keep — the same business rule reachable from multiple models without duplicating the predicate or the wrapper:

trait ScopesActive
{
    public function scopeActive(Builder $query): Builder
    {
        return $this->scoped(ActiveScope::class);
    }
}

class User extends Model  { use ScopesActive; }
class Admin extends Model { use ScopesActive; }

Keep the trait and the scope class in the same namespace (App\Scopes and App\Concerns\ScopesActive, or similar) so the pairing is obvious.

Use scopes where you already build query objects. The most natural integration points day-to-day are: API list endpoints that apply a standard visibility filter, repository/service methods that compose a base query, form-request-driven admin filters that conditionally add predicates, and test fixtures that want the same "valid row" baseline as production.

class UserRepository
{
    public function activeAdults(): Collection
    {
        return User::scoped(ActiveScope::class)
            ->scoped(AgeScope::class, '>=', 18)
            ->get();
    }
}

Enable the PHPStan extension — before Larastan. The scoped() method is a macro, so without the bundled extension your analysis gate will not know the return type and you will end up either suppressing errors or dropping to mixed. In phpstan.neon:

includes:
    - vendor/mpyw/laravel-local-class-scope/extension.neon
    - vendor/larastan/larastan/extension.neon

The order matters — this package's extension must come first. Getting this wrong produces confusing unresolved-method-type errors, not a clear message.

Migrate call sites incrementally, not in a sweep. Replace inline predicates where duplication is demonstrated (three or more occurrences, ideally across two or more models). Resist a repo-wide conversion: the abstraction is cheap to adopt and cheap to reverse, but only if the blast radius is small. A sweep maximizes the surface you would later have to unwind if the idiom does not stick with the team.

Gotchas and Tips

Classic Scope classes were written for global application, and global assumptions break locally. Before exposing an existing global scope via scoped(), audit it for three specific hazards:

  • remove() calls on other scopes — a scope that removes SoftDeletingScope or similar was written assuming it is the primary visibility rule. Applied locally and repeatedly, it can silently widen results in a way that is very hard to trace from the call site.
  • Assumption of single invocation — a scope holding mutable state (accumulating counters, mutating $this->parameters) will behave differently on the second and third application. Keep scopes stateless; if you need parameters, store them in a readonly promoted property in the constructor.
  • Model-boot timing dependence — anything that reads $model->getGlobalScopes(), relies on booted() having run in a particular order, or assumes $model is a freshly hydrated instance will not necessarily hold in a local call.

If a scope fails this audit, write a dedicated local-safe class instead of adapting the global one. The two classes can share a trait for the common where clause.

Name collision on scoped is a real upgrade hazard. scoped() is a fairly natural name for Laravel to reach for, and it is registered as a macro on the Eloquent builder. Two ways this bites you: another package registering the same macro name (last-write-wins, silent), or the framework adding a native method of the same name in a future release (an ambiguous-call break on upgrade). Neither is likely, but both are worth a line in your dependency-upgrade checklist. Because the pattern mirrors Laravel's own proposal (laravel/ideas#636), a native equivalent landing is plausible and the migration would be mechanical — which also means waiting is cheap if your team is on the fence.

Do not pass request input straight into a scope's spread arguments. The spread lands in the scope constructor, which in the parameterized examples is then spread into where('age', ...$this->parameters). That is operator injection if the operator is user-supplied. Validate the operator against an allowlist (>, >=, <, <=, =) in the scope constructor and prefer where('col', $operator, $value) with a validated $operator rather than an unrestricted spread.

Static analysis is not optional here. Because scoped() is a macro, IDE navigation into the scope class works but return-type inference does not without the extension. If you skip it you may find yourself writing /** @var Builder<User> $q */ annotations at every call site, which defeats a chunk of the readability argument. Enable the extension, put it first in phpstan.neon, and treat a new suppression as a signal that the scope is doing something the analyser cannot follow — usually a sign of the mutable-state problem above.

Debugging a suspicious result: dump the SQL at the boundary, not inside the scope. Because scopes chain and each returns the builder, the fastest triage is:

User::scoped(ActiveScope::class)
    ->scoped(AgeScope::class, '>', 18)
    ->toSql();

Compare that against the baseline User::query()->toSql(). If the scope added an unexpected orWhere, the generated SQL will reveal it immediately — a bare orWhere inside an apply() is the single most common source of "we lost our tenant filter" bugs, since it escapes the grouping that a preceding where established. Wrap disjunctions in a ->where(function ($q) { ... }) closure inside the scope.

Alias columns when scopes will be chained on joins. If two scopes both constrain a status column on different tables, qualifyColumn() keeps them from colliding. Test chains with at least one join before shipping — a scope that passes unit tests on a single table can still produce an ambiguous-column error in a real chain.

Testing scopes is straightforward and worth doing. Instantiate the query, apply the scope, assert on toSql() and getBindings():

$query = User::scoped(AgeScope::class, '>', 18);
$this->assertStringContainsString('"age" > ?', $query->toSql());
$this->assertContains(18, $query->getBindings());

Cover each parameterized form and each chain you actually use in the app. This is the cheap insurance that makes the pattern safe to adopt.

Extension points are deliberately few — that is a feature. There is no config to publish, no provider to extend, no registry. If you need anything beyond scoped(), wrap it in a model method or trait (as above) rather than patching or forking. If the single-maintainer risk becomes a concern, vendoring the small macro under MIT is a realistic option — read src/ before relying on anything undocumented, because the README is the whole specification.

Confirm the release you are pinning. The dataset lists a future-dated 2026-06-13 release and a v2.2.0 entry that is a version bump with no functional content. Pin an explicit version in composer.json and read the changelog for that exact tag before adopting; know whether you are relying on stability-by-design or low investment, because the two have different support implications.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin