h-farm/laravel-excludable
Laravel Excludable adds a lightweight trait to Eloquent models so specific records can be excluded from queries by default and included only when needed. Ideal for hiding archived or inactive rows without soft deletes or custom scopes, keeping filtered results clean and relationships predictable.
maize-tech/laravel-excludable adds an Excludable trait that applies a global scope hiding rows flagged as excluded — a "soft delete" for records you want to keep in the DB but never surface in normal queries. Version 4.1.0 is a compatibility release: it opens the door to Laravel 11 (and its PHP 8.2+ baseline) without API changes, so treat it as a drop-in for existing 4.x apps.
Minimal path:
composer require maize-tech/laravel-excludable:^4.1excluded boolean column to the target table (or add $table->boolean('excluded')->default(false) yourself).use Maize\Excludable\Excludable;
class Article extends Model
{
use Excludable;
}
$article->exclude(); // sets the flag; row disappears from default queries
$article->restore(); // clears the flag
Article::count(); // excludes flagged rows
Article::withExcluded()->count(); // includes them
Article::onlyExcluded()->get(); // flagged rows only
Where to look first: the trait itself (bootExcludable / getExcludedColumn) and the ExcludedScope class, since that is where the global scope is applied. Then the published config/migration stub to confirm the column name and default.
Admin vs. public queries. The default global scope is the "public" view. In admin controllers, be explicit:
public function index() {
return Article::withExcluded()->paginate();
}
Avoid calling withoutGlobalScope(ExcludedScope::class) by hand — the package scopes (withExcluded, onlyExcluded) are clearer and survive scope renaming.
Bulk exclusions. Prefer a single update() over per-model calls when the business action is mass-hiding:
Article::where('category_id', $id)->update(['excluded' => true]);
This skips model events — fine for the flag itself, but any observers you rely on (search index sync, cache busting) will not fire. If those matter, iterate with ->each->exclude() or dispatch a job.
Relationships. The global scope leaks into eager loads and whereHas in ways that surprise people. Article::with('comments') will silently drop excluded comments; Article::with(['comments' => fn ($q) => $q->withExcluded()]) opts back in. Decide per-relation and document it.
Route-model binding. Route::get('/articles/{article}', ...) will 404 for excluded rows, which is usually the desired behaviour and gives you a free "gone" response. If you need a reversible public URL (e.g. an unpublished article preview for editors), resolve with withExcluded() inside a policy-gated controller action rather than disabling the scope globally.
Filtering pipelines / repositories. If you build query objects, expose a includeExcluded(bool) flag and centralise the scope toggle there, so no controller reaches into withoutGlobalScope directly. This keeps the exclusion semantic in one place when the column or trait changes.
Testing. RefreshDatabase plus factory-created excluded records is the cleanest way to assert both sides:
Article::factory()->excluded()->create();
$this->assertSame(0, Article::count());
$this->assertSame(1, Article::withExcluded()->count());
Add a factory state that sets excluded => true; otherwise test authors forget the flag and write assertions that pass for the wrong reason.
Laravel 11 upgrade. 4.1.0 is the version that unblocks Laravel 11. Bump Laravel and this package together, confirm the service provider is still auto-discovered (Laravel 11's slimmer skeleton changes nothing here, but check bootstrap/providers.php if you registered the provider manually), and run the suite against the new skeleton. No migration or API change is expected.
Article::count() ignores flagged rows; it catches missing traits and misnamed columns.exclude() fires model events; raw updates do not. saved/updated observers, audit logs, and search-index sync will behave differently between the two paths. Pick one convention per codebase.Article::count(), pagination totals, and any DB::table('articles') query builder calls will disagree, because the raw builder has no global scope. Anything using DB::table must add where('excluded', false) manually.withExcluded() and onlyExcluded() and expect a sensible result — they are mutually exclusive intent; the last-applied scope wins depending on implementation order.toSql() on the query (is the scope there?), withExcluded()->find($id) (does the row exist?), then the column value. Model::withoutGlobalScopes() is the fastest way to confirm the scope is the culprit.getExcludedColumn() (or the equivalent accessor) in the model to point at a differently named column, and register additional global scopes only after the exclusion scope so ordering stays predictable.composer why-not maize-tech/laravel-excludable 4.1.0 will name the offender.How can I help you explore Laravel packages today?