beyondcode/laravel-scope-checks
Laravel Scope Checks offers simple helpers for verifying that an Eloquent model has a given query scope before you apply it. It helps keep dynamic query building safe and predictable, with readable checks that are easy to use and test.
What you're actually adopting. A single trait that reflects over your model's scope* methods and exposes boolean predicates — scopeActive() becomes isActive(), scopePublished() becomes isPublished(). It answers "does this hydrated record satisfy the scope?" It does not make your queries safer, and it does not replace ->active() on a builder. Read src/ before you write a line of app code: the README and the release notes are inconsistent about whether this is an assertion helper or a generated-predicate layer, and those are different call sites. Confirm the trait's namespace and read the reflection logic for how it handles zero-argument scopes.
Install and wire up.
composer require beyondcode/laravel-scope-checks
Then add the trait to one model — not to a base model, not to ten models:
use BeyondCode\ScopeChecks\...; // confirm exact namespace from src/
class Post extends Model
{
use /* trait */;
public function scopePublished($query)
{
return $query->whereNotNull('published_at')
->where('published_at', '<=', now());
}
public function scopeActive($query)
{
return $query->where('status', 'active');
}
}
Verify the Composer constraint resolves on your Laravel version first. This shipped in February 2020, so treat Laravel 9/10/11 and PHP 8+ support as unverified until you've run it.
Where to look first. src/ — it's roughly 40 lines. Read the reflection loop and find the answer to three questions before you go further: does the generated method issue a query on every call, how does it detect an existing same-named method, and what happens when the scope takes parameters. Those three answers determine whether the rest of your integration is safe.
First real use case. Single-record presentation logic. You have a Blade partial or an API resource that repeatedly writes:
@if ($post->published_at && $post->published_at->lte(now()))
Replace it with @if ($post->isPublished()) and you've achieved the one genuine win: one named definition of "published" instead of five scattered inline variants. Do this on one model, one view, one PR. Write a feature test that asserts the boolean matches the inline equivalent for a published record, an unpublished record, and a record with a null timestamp. That parity test is the whole deliverable — if the booleans diverge from the queries they replaced, you've made the codebase worse.
Predicate over query, not inside loops. The intended shape is a handful of checks on already-hydrated single records:
class PostResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->id,
'title' => $this->title,
// fine: one record, one check
'status_badge' => $this->isActive() ? 'live' : 'draft',
];
}
}
The pattern to avoid is the same call inside a collection. If each check issues a database round trip, a paginated index with 25 rows becomes 25 extra queries the moment you touch the check in a loop.
Cache the result per record when you need it twice. If a Blade view branches on the same status in three places, compute it once:
@php($isPublished = $post->isPublished())
Or better, don't reach for the generated method in hot paths at all — compute the flag in the query with withExists() or a cast and let the predicate read a loaded attribute. The package is a vocabulary tool, not a query-optimization tool.
Use it to consolidate, not to introduce. The highest-value workflow is cleanup of an existing model: inventory every inline status query for that model, pick the two or three with the most call sites, convert those, and stop. Converting a scope that only one call site uses adds indirection for nothing.
Keep parameters out of scope. Only zero-argument scopes convert cleanly. If scopePublishedIn($query, $year) exists on the model, do not expect a generated counterpart — and if the package does generate something, verify what arguments it passes before trusting the result. Leave parameterized scopes alone and call them explicitly.
Pair it with a Policy for anything consequential. If a boolean gates an action rather than describing a record, put the decision in a Policy and let the model predicate feed it:
public function update(User $user, Post $post): bool
{
return $user->id === $post->user_id && $post->isActive();
}
The naming consistency is the point; the package doesn't get to make the authorization decision.
Write the parity test as a test-case-per-scope. For each generated check, one test that loads a matching record and one that loads a non-matching record, asserting the boolean against the original query:
$this->assertSame(
Post::whereKey($post->id)->published()->exists(),
$post->isPublished()
);
That suite is what lets you reimplement the trait yourself later without re-deriving the semantics.
It's archived with a 2020 release date. No upgrade path, no issue triage, zero dependents. If you adopt it for real, you own the code forever. The honest play: spike it pinned to a tag, prove the pattern on one model, then copy the ~40 lines into app/Support/Concerns/ under your own namespace with a named owner. MIT license makes that trivially legal. Don't leave it in composer.json for a production path.
Unsaved models. Reflection-generated predicates typically answer false or throw on a record with no key, depending on how the underlying query is built. Assert on a new Post instance in a test before you rely on it anywhere a model might be unsaved.
Soft deletes and global scopes. A check that runs a query through the model inherits SoftDeletes and any global scopes you've registered. That's usually what you want, but confirm it — a withTrashed() context elsewhere in the app may expect the opposite answer, and a silently wrong false is the failure mode this package is worst at surfacing.
Non-filtering scopes produce nonsense booleans. scopeOrderedByDate or scopeWithComments don't describe a property of a record. If a generated check exists for them, it will return a plausible but meaningless answer. Audit your scope* methods and check the package's behavior on ordering/eager-loading scopes specifically — if it doesn't skip them, you have a footgun in the model.
Naming collisions silently override. A generated isActive() will clobber or be clobbered by a hand-written method or accessor of the same name. Grep the model (and any parent classes and traits) for is* methods before turning the trait on. If the package wins over your hand-written method, existing behavior changes with no error.
Refactor-away scopes fail loudly — that's the good case. Delete scopeActive and calls to isActive() throw. Verify this in the spike: rename a scope on a branch and confirm you get an exception rather than a false. If you get a false, add a test that pins the loud failure, because everything else about this package is silent.
Count your queries. Before merging, run a request through Telescope or DB::listen with the checks in place. If you see a query per record, either hoist the check out of the loop or replace it with an withExists() subselect or a cached column. Make this a review checklist item rather than a memory.
Keep it off the critical path. Never let these booleans govern billing, entitlements, access, or compliance state. A predicate that can return a plausible wrong answer belongs in a badge, not in a gate.
How can I help you explore Laravel packages today?