cybercog/laravel-eloquent-flag
Add boolean flags to any Eloquent model with cybercog/laravel-eloquent-flag. Mark records as flagged, unflag or toggle them, and query flagged or unflagged rows using clean, expressive methods and local scopes—no extra boilerplate needed.
Install and wire up in three steps. There is no service provider, no config file, and no publishable assets — the package is pure traits, so "installation" is just Composer plus your own migration.
composer require cybercog/laravel-eloquent-flag
// database/migrations/..._add_published_flag_to_posts_table.php
$table->boolean('is_published')->default(false)->index();
// or, for the timestamp flavour:
$table->timestamp('published_at')->nullable()->index();
use Cog\Flag\Traits\Classic\HasPublishedFlag;
class Post extends Model
{
use HasPublishedFlag;
// The global scope is OPT-IN. Without this method, nothing is filtered.
public function shouldApplyPublishedScope(): bool
{
return true;
}
}
Where to look first. Read the four files that make up a single flag before anything else, because every other flag is a copy of that shape:
src/Traits/Classic/HasPublishedFlag.php — the trait you actually use.src/Traits/Classic/HasPublishedFlagHelpers.php — isPublished(), isNotPublished(), publish(), undoPublish(), and the cast bootstrapping.src/Traits/Classic/HasPublishedFlagScope.php — registers the global scope in boot...().src/Scopes/Classic/PublishedFlagScope.php — the apply() and extend() logic, i.e. the query macros.The agents/AGENTS.md in the repo is unusually good: it documents the "boolean vs timestamp" × "Classic vs Inverse" matrix that governs every trait name.
First useful use case. A blog/CMS Post model where 95% of queries want only live posts. You get $post->publish(), $post->isPublished(), and a default query that silently excludes drafts — plus escape hatches for the admin panel (withoutGlobalScope(PublishedFlagScope::class)) and for the drafts screen (Post::onlyNotPublished()).
Start with one flag on one model. Do not carpet-bomb every model with every trait; the opt-in scope method means you can add traits incrementally.
Pick the flag type by the question you ask. Boolean (is_published) answers "is it in this state right now". Timestamp (published_at) answers "when did it enter this state" and costs you nothing extra — the helpers set the timestamp on the action. If you think you'll ever need "published in the last 7 days" or an audit trail, choose HasPublishedAt from day one; retrofitting means backfilling timestamps.
Classic vs Inverse is a modelling decision about the default. Classic means the default query returns flagged-true/not-null rows. Inverse means the default query returns flagged-false/null rows (per the package docs: "flagged-false/null"). Reach for Inverse when the exceptional state is the one you almost always want, e.g. an Invitation where the default view should be "not yet accepted". You never use both flavours for the same flag on the same model — the scopes would contradict each other.
Opt-in scoping is your tenancy/permission seam, but use it statically.
// Fine: a per-model switch.
public function shouldApplyPublishedScope(): bool
{
return true;
}
// Dangerous: a request-dependent switch.
public function shouldApplyPublishedScope(): bool
{
return auth()->check() && auth()->user()->isAdmin();
}
The second version makes every query in the app behave differently depending on who's logged in — including queued jobs, Artisan commands, and Nova/Telescope. Prefer a static true and explicit withoutGlobalScope(...) at the call sites that need everything (admin controllers, exports, reporting).
Use the query macros instead of hand-rolled wheres. Each scope registers builder macros via extend(); for Classic Published these are withNotPublished() and onlyNotPublished() (Inverse equivalents expose the opposites). They are written to interact correctly with the already-applied global scope — a naive Post::where('is_published', false) still carries the default scope and returns nothing.
Post::all(); // published only
Post::withNotPublished()->get(); // everything
Post::onlyNotPublished()->get(); // drafts only
Post::withoutGlobalScope(PublishedFlagScope::class)->get(); // explicit, no macro
Hook the model events for side effects. Each action fires '{action}' and '{action}Undone' (published, publishedUndone, etc.). This is the cleanest place for cache invalidation, search indexing, and notifications, because it only fires for instance-level calls.
protected static function booted(): void
{
static::published(fn (Post $post) => Cache::forget("post:{$post->id}"));
}
Note the symmetry: undoPublish() fires publishedUndone, so a listener that only handles published will leave stale state behind.
Compose flags freely. Two traits on one model stack their global scopes with AND semantics. HasActiveFlag + HasPublishedFlag gives "visible to end users" with no extra code, and each retains its own macros and events.
Factories and tests. Set flag columns directly in factories ('is_published' => true) rather than calling publish() inside factory definitions — publish() persists and fires events, which pollutes tests that assert event dispatch. Use RefreshDatabase with in-memory SQLite as the package's own suite does.
The number one support question: "my scope isn't working." It won't, unless the model implements shouldApplyXxxScope(): bool returning true. The method name is derived from the flag name (shouldApplyPublishedScope, shouldApplyActiveScope, …) and a typo silently disables filtering — no exception, no warning, just all rows returned.
Column names are not configurable. The trait hard-codes is_published / published_at. Renaming requires a migration column alias or a custom trait; don't fight it. If your schema already uses status or published without the is_ prefix, this package is a poor fit.
Null semantics differ by flag type, and it matters. Boolean Classic scopes filter on truthy values; Inverse scopes cover false and null. Timestamp flags use null/not-null. If you add a boolean column as nullable() without a default, existing rows are null and vanish from Classic queries — always give boolean flags a default(false) and backfill before enabling the scope.
Global scopes and SoftDeletes interact. Both are global scopes; withoutGlobalScope(PublishedFlagScope::class) removes only that one. If you find yourself calling withoutGlobalScopes() you'll also un-scope soft deletes and resurrect trashed rows. Name the scope class explicitly.
Bulk updates bypass everything. Post::where(...)->update(['is_published' => true]) skips publish(), the casts, the timestamp assignment, and the published event. If anything listens (search index, webhooks, cache), use Post::query()->each(fn ($p) => $p->publish()) or chunk it.
undoPublish() still touches updated_at. The helpers save the model; expect updated_at churn and a write per call. For imports/backfills of tens of thousands of rows, disable events and use raw updates, then reindex once.
Casts are registered by the helper's initialize...() method. Don't add a conflicting $casts entry for published_at/is_published; if you need a custom date format or a datetime:Y-m-d cast, check whether the trait's initialization wins before debugging. Same story for $fillable — the traits set attributes directly, so mass-assignment rules only affect your create()/fill() calls, not publish().
HasKeptFlag is a behavioural outlier. It auto-sets is_kept = false on creating and is_kept = true on updating. That is intentional (a "touched/seen" marker), but it means the flag is effectively managed for you — don't write is_kept manually on that model or you'll fight the behaviour trait.
Extension points are limited by design. There is no config, so customisation means: (a) implementing shouldApplyXxxScope() with static logic, (b) calling withoutGlobalScope() / the macros at query sites, or (c) copying the four-file flag pattern into your own namespace for anything non-standard (custom column, three-state flag, composite condition). Option (c) is cheap — the files are tiny — and preferable to subclassing scope classes.
Version and PHP support is broad (Laravel 9–13, PHP 8.0–8.5). All files use declare(strict_types=1), so your calling code should not pass 1/'1' where a bool is expected in any custom wrapper. When upgrading Laravel majors, check the release notes: new Laravel minors occasionally change global-scope registration ordering, which is exactly the seam this package relies on.
How can I help you explore Laravel packages today?