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.
Adopt vs. build vs. defer (a micro-adoption, not a platform bet). The entire capability is one MIT-licensed macro that registers Model::scoped(...) on Eloquent builders. There is no infrastructure, no schema change, no vendor contract, and no data migration. That means the PM's real decision is narrow and reversible: do we want this idiom in our codebase? An internal equivalent is a few dozen lines, so "build" is viable; "adopt" wins only if the team wants the pattern without owning it. The exit ramp (remove the package, inline or wrap call sites) is cheap, which is exactly the profile that justifies a low-ceremony trial rather than a formal architecture review.
Turning business rules into reviewable, named objects. The product value is not new query capability — the same SQL is expressible today with closures, scopeX() methods, or global scopes. The value is that recurring predicates (active/published visibility, tenant or ownership constraints, plan entitlements, date windows, lifecycle states) become named, parameterized, injectable classes. For a PM this translates into concrete lever movement:
->scoped(ActiveScope::class) and knows the intent, instead of re-deriving ->where('active', true)->whereNull('deleted_at').Consistency across surfaces, which is a user-facing concern. Divergence in filtering logic shows up as user-visible bugs: a record visible in the app but missing from the export, an admin count that disagrees with a list view, a tenant boundary enforced on one endpoint and not another. Standardizing predicates as shared scope classes is a correctness and trust play as much as a code-style play — the sort of thing that shows up as support tickets long before it shows up as a P1.
Forcing useful decisions rather than deferring them. Adoption makes the team answer questions it has been silently answering by accident: where scope classes live (app/Scopes/), whether scopes take constructor arguments, whether to expose friendly scopeActive() wrappers or share via traits, and how chained-scope precedence is documented. Those are decisions worth making explicitly, and the package is a low-cost forcing function for them.
Roadmap risk management via standards alignment. The pattern is directly inspired by Laravel's own open proposal for class-based local query scopes (laravel/ideas#636). If the framework ships a native equivalent, migration is largely mechanical: swap the macro call for the native API. That asymmetric upside — cheap to adopt now, cheap to migrate later — is the strongest strategic argument in the package's favor, and it should be stated as reduced future rework, not as a differentiator.
Pilot go/no-go as the actual decision on the table. The recommendation this analysis supports is a time-boxed pilot in one non-critical domain, not a repo-wide mandate. Concrete success criteria a PM can hold people to: measurable duplication removed, zero new static-analysis suppressions, zero test regressions, and engineers preferring the class form over closures after a sprint. A reasonable kill criterion: if fewer than ~5 predicates are genuinely shared across two or more models, treat this as a style preference and decline — it is not buying you anything.
Adopt (via pilot) when:
Scope classes you'd like to reuse locally, rather than authoring new classes specifically to feed the macro.scoped method, which strengthens an existing analysis gate rather than weakening it.Look elsewhere, defer, or explicitly decline when:
remove() calls against other scopes, an assumption of single invocation, dependence on model-boot timing. These are written to be applied globally and can behave surprisingly when applied locally and repeatedly. This is the highest-risk technical category and deserves explicit review before exposure.scoped macro collision is plausible — another package today, or a future framework method of the same name. That is an ambiguous-call upgrade hazard worth naming up front.Verify before committing to a plan. Confirm the future-dated 2026-06-13 release timestamp against the live repository and read the intervening changelogs. The visible v2.2.0 entry is a version bump with no functional content — that could mean deliberate stability or low investment, and those imply very different risk postures. Know which one you are relying on before you write it into a roadmap.
For executives. This is a small developer-productivity utility, not a platform investment. It lets engineers package recurring database filters — "active users," "tenants on this plan," "age over 18," "published content" — as reusable, named classes instead of copy-pasted query code. The business payoff is consistency: fewer cases where the app, an admin screen, and an export disagree about what "active" means, and one place to change when a rule changes. It is MIT-licensed, installs with a single command, adds no infrastructure, requires no data migration, and can be removed cheaply. Adoption is currently unproven (23 stars, no known dependents, a single maintainer), so the ask is deliberately modest: a time-boxed pilot in one product area with explicit success measures — duplication removed, engineer feedback, no test or static-analysis regressions — followed by a clear standardize-or-stop decision. It also aligns with a direction Laravel has publicly discussed, which caps the downside by making future migration mechanical.
For engineering. Install with composer require mpyw/laravel-local-class-scope, pin the version, and pilot on a single model that already has duplicated filtering. Extract the predicate into App\Scopes\ActiveScope implementing Laravel's Scope contract, qualify columns with $model->qualifyColumn(...) so the scope survives joins, then replace inline clauses with Model::scoped(ActiveScope::class) or parameterized forms such as Model::scoped(AgeScope::class, '>', 18). Use the class-string form for scalar parameters and the new Scope(...) form when a scope needs a dependency that a spread can't express — a clock, a config object, a value object. Chain scopes freely, and expose familiar scopeActive() wrappers or traits where the team wants a friendly API. Before exposing any scope, review it for local-use safety: no remove() calls, no assumption of single invocation, no dependence on model-boot timing. Enable the bundled PHPStan extension before Larastan's in phpstan.neon so analysis covers scoped without suppressions. Cover scope output and chaining with tests, document precedence, and backfill only where a predicate appears three or more times — resist a repo-wide sweep, because a sweep maximizes the surface you'd later have to unwind. If the native Laravel proposal lands, migrate; the change should be mechanical.
How can I help you explore Laravel packages today?