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 Global Or Scope Laravel Package

lacodix/laravel-global-or-scope

Add global scopes to Eloquent models combined with OR conditions, so multiple scopes are wrapped in a single or group. Disable some or all or-scopes on the fly with withoutGlobalOrScopes for flexible queries.

View on GitHub
Deep Wiki
Context7

Getting Started

Install is a single composer line — no service provider, no config file, no migrations:

composer require lacodix/laravel-global-or-scope

Requirements are PHP 8.1+ / Laravel 9+ (v1.2.0 adds Laravel 12). The whole public API is one trait, two model statics, and three query-builder methods, so the first thing to read is docs/usage/register-scopes.md, not the README — the README only shows the happy path and glosses over the registration-timing constraint that trips people up.

The first use case worth modelling with it is a visibility rule: "a record is mine or public or published". That's an OR of independent predicates, which Laravel's addGlobalScope() cannot express (stacked global scopes are ANDed), and which you otherwise end up hand-rolling as one giant scope class.

use Illuminate\Database\Eloquent\Model;
use Lacodix\LaravelGlobalOrScope\Traits\GlobalOrScope;

class Post extends Model
{
    use GlobalOrScope;

    protected static function booting(): void
    {
        static::addGlobalOrScopes([
            PublishedScope::class,      // status = 'published'
            OwnedByUserScope::class,    // user_id = auth()->id()
            PublicScope::class,         // visibility = 'public'
        ]);
    }
}

Post::query()->where('user_id', 1000)->get() then produces:

select * from "posts"
where "user_id" = 1000
  and (("status" = 'published') or ("user_id" = 1000) or ("visibility" = 'public'))

Verify this immediately in php artisan tinker with Post::query()->toSql() before you build anything on top of it. The grouping is the entire value proposition of the package; if the SQL isn't grouped, nothing else matters.

Implementation Patterns

Register in booting(), or in boot() before parent::boot(). The trait needs to see the or-scopes before Eloquent's base boot() normalises them into the global scope registry. booting() is the clean way; boot() works only if parent::boot() is the last line. The docs' examples use public static function booting(), which is legal (widening protected → public) but inconsistent — prefer protected static function booting(): void.

Class strings, instances, names, and closures all work for addGlobalOrScopes. Strings are resolved through the container, so constructor injection into scopes is supported:

static::addGlobalOrScopes([
    TenantScope::class,                                    // resolved via container
    new DateRangeScope($start, $end),                      // or pre-built
    'published' => PublishedScope::class,                  // named, for selective disable
    'email_guard' => fn ($q) => $q->where('email', '!=', '[email protected]'),
]);

Named keys are the extension point that matters day-to-day — without a key you can only disable a scope by class name, which fails once you register the same class twice with different arguments.

Ad-hoc grouping on the builder, with no trait needed. withGlobalOrScopes() is available on any Eloquent builder (it's macro/extension-based, not trait-based), which makes it the right tool for one-off reporting or admin screens:

Post::query()
    ->withGlobalOrScopes([new PublishedScope(), new PublicScope()])
    ->where('created_at', '>=', $since)
    ->get();

Note the asymmetry: addGlobalOrScopes() accepts class strings, withGlobalOrScopes() does not. This mirrors Laravel's own addGlobalScope() vs withGlobalScope() and is a frequent five-minute debugging session.

Scope exchange for privilege escalation. The pattern the docs call "scope exchange" is the one I'd use most: a model is normally strictly tenant-scoped, but an admin view needs tenant-OR-shared. Remove the strict scope, re-add it inside an OR group:

Post::query()
    ->withoutGlobalScope(TenantScope::class)        // note: plain, not Or
    ->withGlobalOrScopes([new TenantScope(), new SharedScope()]);

Compose multiple ANDed OR-groups by using the OrScope class directly:

use Lacodix\LaravelGlobalOrScope\Scopes\OrScope;

$query->withGlobalScope('visibility', new OrScope([new PublishedScope(), new OwnedByUserScope()]));
$query->withGlobalScope('region',     new OrScope([new EuScope(), new UsScope()]));
// → (published OR owned) AND (eu OR us)

This is the escape hatch for anything the trait doesn't cover, and it's what I'd reach for if I wanted the behaviour without the model-level trait at all.

Scopes compose with Laravel's normal global scopes and relations. addGlobalScope() and addGlobalOrScopes() coexist; the OR group is appended as a single AND (...) term. Relations inherit it ($post->comments and $post->comments()->get() both apply it), and withoutGlobalOrScopes() chains onto relation builders too.

Query objects / repositories are the natural home for ad-hoc or-scopes — keep them out of controllers so the disabled-scope bookkeeping stays in one place. Assert the outcome in tests with toSql(), a DB::listen() spy, or the introspection helpers:

$query = Post::query()->withoutGlobalOrScope('published');
$this->assertSame(['published'], $query->removedOrScopes());

Gotchas and Tips

withoutGlobalOrScopes() and withoutGlobalScope() are different registries. Or-scopes are not in $model->getGlobalScopes(), so withoutGlobalScope(PublishedScope::class) will silently do nothing. Use withoutGlobalOrScope(...). Conversely, withoutGlobalScopes() (all) almost certainly does not clear or-scopes — verify with toSql() rather than assuming.

Each scope must be a complete, standalone predicate. The group is an OR of ANDs, so a scope that "refines" a previous scope's conditions is meaningless — the moment its branch is taken, the other branch's constraints are gone. Write each scope as an independent "row qualifies because…" test. This is also why scopes combining whereNull + where (as in the README) work: the OrScope wraps each scope's conditions in its own nested group.

Your own orWhere() escapes the group. Anything you add at the top level after the or-scope is applied sits outside the parentheses: WHERE (a OR b) OR c. If you need OR semantics in caller code, wrap it yourself in ->where(fn ($q) => ...).

Query-builder writes are scoped too. Post::where(...)->update([...]) and ->delete() include the OR group, which is usually what you want for tenant isolation but is a nasty surprise for batch jobs. Reach for withoutGlobalOrScopes() explicitly in those paths — and remember to also handle SoftDeletes, which is a normal global scope and survives the call.

Scopes are resolved once, at model boot. A scope constructor that reads auth()->user() or request() captures state at boot time, which is fine in classic PHP-FPM-per-request but is a data-leak candidate under Octane or long-running queue workers. Keep constructors dependency-only and read request state inside apply().

clearGlobalOrScopes() is request-wide static mutation. It's useful in test setup and one-off CLI commands; it is not safe to sprinkle in application code, and under Octane/queue workers any static registry needs to be flushed between jobs. Pair it with teardown, and prefer withoutGlobalOrScopes() on the builder wherever possible — disabling on a builder is local and leak-free.

Index behaviour is the real cost. (col1 IS NULL AND col2 = 1) OR (col3 = 2) is a classic index-hostile shape; MySQL/Postgres often fall back to a full scan or index merge. If the or-scoped table is large, budget for composite covering indexes per branch, or evaluate replacing the OR group with a UNION in the hot path. Measure before you ship this onto a big table.

getGlobalOrScopes() and removedOrScopes() are introspection, not contracts. Use them for debugging and test assertions only; don't build application logic on their output shape.

Vendor risk is real. Five stars, zero dependents, one maintainer, and a small surface. That's fine — the entire behaviour is roughly fifteen lines ($builder->where(fn ($q) => /* or-group */)). Pin the version in composer.json, and if the package ever stalls, forking the OrScope class into your app is a half-hour job. Don't let it become load-bearing infrastructure you can't replace.

Debugging recipe: ->toSql() for the shape, ->getBindings() for binding order (or-scopes interleave bindings, so a misordered orWhereRaw shows up here), DB::listen() for the query actually executed in a request, and ->removedOrScopes() when a query returns suspiciously more rows than expected — nine times out of ten a scope was disabled by a parent builder you forgot about.

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.
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log
hsluv/hsluv
seampact/codegen
seampact/hooks
seampact/ir
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table