spatie/laravel-model-flags
A Laravel trait that adds flags to Eloquent models without migrations, plus scopes to query flagged models. Useful for tracking process state and building idempotent, restartable code like Artisan commands.
Install the package and create its storage table:
composer require spatie/laravel-model-flags
php artisan vendor:publish --tag="model-flags-migrations"
php artisan migrate
Optionally publish the config:
php artisan vendor:publish --tag="model-flags-config"
Add the trait to any model that needs lightweight state:
use Illuminate\Database\Eloquent\Model;
use Spatie\ModelFlags\Models\Concerns\HasFlags;
class User extends Model
{
use HasFlags;
}
Then use the core API:
$user->hasFlag('receivedMail'); // false
$user->flag('receivedMail');
$user->hasFlag('receivedMail'); // true
$user->unflag('receivedMail');
The first practical use case is an idempotent Artisan command or queued batch job:
User::notFlagged('wasSentPromotionMail')
->chunkById(100, function ($users) {
foreach ($users as $user) {
Mail::to($user->email)->send(new PromotionMail());
$user->flag('wasSentPromotionMail');
}
});
If the command is interrupted and restarted, previously flagged users are skipped. Look first at vendor/spatie/laravel-model-flags/src/Models/Concerns/HasFlags.php, the generated flags migration, and the config file. Those define the available methods, schema, and extension point for a custom flag model.
Idempotent batch processing. Use notFlagged() to select unprocessed models, perform the side effect, then flag the model. Prefer chunkById() over chunk() for long-running updates, and flag after the side effect so a failure does not mark work as completed prematurely.
User::notFlagged('importedFromLegacy')
->chunkById(500, function ($users) {
foreach ($users as $user) {
$this->import($user);
$user->flag('importedFromLegacy');
}
});
Multiple process states on one model. Flags are cheap enough to represent workflow milestones: onboardingStarted, profileCompleted, invitedToBeta, receivedInvoiceReminder. Use flagNames() for debugging or administration screens, but avoid treating a large flag list as a substitute for a proper state machine or audit log.
Enum-backed flag names. Recent versions support enum flags, which is useful for avoiding typo-prone strings:
enum UserFlag: string
{
case ReceivedPromotionMail = 'receivedPromotionMail';
case CompletedOnboarding = 'completedOnboarding';
}
$user->flag(UserFlag::ReceivedPromotionMail);
$user->hasFlag(UserFlag::ReceivedPromotionMail);
Treat the persisted flag name as a contract. Renaming enum cases is safe only if the stored value remains unchanged; renaming values can orphan existing flags.
Query scopes for segmentation. flagged() and notFlagged() compose with normal Eloquent scopes:
User::flagged('completedOnboarding')
->where('created_at', '>', now()->subMonth())
->count();
User::notFlagged('receivedPromotionMail')
->whereNotNull('email')
->get();
These scopes are the right tool for filtering large sets. Avoid loading every model and calling hasFlag() in a loop when a SQL-level scope can do the work.
Use the flags relation for inspection and cleanup. The trait exposes a polymorphic flags() relation:
$user->flags()->where('name', 'myFlag')->delete();
$user->flags()->delete();
$users = User::with('flags')->get();
Eager loading is useful for admin views that display several flags per model. For a single model, hasFlag() is more convenient.
Use lastFlaggedAt() for recency checks. Since re-flagging updates the flag timestamp, you can build “resend if stale” logic:
$lastSent = $user->lastFlaggedAt('receivedPromotionMail');
if ($lastSent === null || $lastSent->lt(now()->subMonths(3))) {
Mail::to($user->email)->send(new PromotionMail());
$user->flag('receivedPromotionMail');
}
Use lastFlaggedAt() without an argument when you need the most recently touched flag on the model.
Unflag in bulk. Newer releases support unflagging an array, which helps with reset/cleanup flows:
$user->unflag(['receivedPromotionMail', 'receivedInvoiceReminder']);
To remove a flag globally, delete through the package’s Flag model:
use Spatie\ModelFlags\Models\Flag;
Flag::where('name', 'receivedPromotionMail')->delete();
Jobs and service objects. Put flag names in enums or constants, and keep the “check, perform, flag” sequence in one service or job class. This makes the idempotency behavior obvious and testable. Flags complement queue uniqueness (ShouldBeUnique, WithoutOverlapping) but do not replace it.
Testing. Use RefreshDatabase, run the migration, and assert both behavior and persisted flags:
$user = User::factory()->create();
$this->artisan('promotions:send')->assertSuccessful();
$this->assertTrue($user->fresh()->hasFlag('receivedPromotionMail'));
$this->assertDatabaseHas('flags', [
'name' => 'receivedPromotionMail',
'flaggable_id' => $user->id,
'flaggable_type' => $user->getMorphClass(),
]);
Factory states or test helpers for flagged models keep tests readable.
Do not skip the migration. The package does not add a column to your model’s table; it stores flags in the shared flags table. Deployments need the published migration before any flag calls run.
Flags are not locks. A check-then-flag sequence is not atomic across concurrent workers. Two queue workers can both see notFlagged() for the same model and both perform the side effect. Use queue uniqueness, database locks, or an atomic claim strategy when duplicate side effects are unacceptable. Design external side effects to be idempotent where possible.
Flag after the side effect, not before. If you flag first, a failed mail send, API call, or import leaves the model permanently marked as done. If you flag after, a crash between the side effect and the flag can cause a retry. That is usually the safer trade-off, but it means “at least once” processing rather than exactly once.
Re-flagging updates updated_at. Calling flag('x') again does not create a duplicate; it touches the existing flag’s timestamp. This is useful for recency logic but means lastFlaggedAt() is not necessarily the first time the flag was applied. If you need the original completion time, inspect the flag record’s created_at through the relation.
Mass deletes can leave orphan flags. Model deletion cleanup exists, but bulk operations such as User::where(...)->delete() may bypass model events. If you rely on automatic cleanup, delete models individually or add a scheduled cleanup for orphaned rows. Soft-deleted models retain their flags unless you explicitly remove them.
Morph map changes can orphan flags. Flags are polymorphic. If you introduce or change a morph map, existing flaggable_type values may no longer resolve to the same model class. Establish morph maps early or migrate existing flag rows deliberately.
Watch for N+1 queries. hasFlag() is convenient for one model but can become expensive in a loop. For lists, eager load flags or use flagged()/notFlagged() scopes so filtering happens in SQL.
Indexes matter at scale. Check the published migration and confirm the flags table is indexed appropriately for your query patterns, especially on name and the polymorphic columns. Add indexes only through your own migration if your workload needs them.
Use a custom flag model sparingly. The config allows replacing Spatie\ModelFlags\Models\Flag. This is useful if you need extra scopes or relationships on flags, but keep the table name and expected columns compatible with the package. Clear config cache after changing it.
Clean up old flags deliberately. Flags are durable by design. Add a scheduled command to remove obsolete flag names or stale process flags when they are no longer operationally useful. Otherwise the flags table grows with historical state that nobody queries.
How can I help you explore Laravel packages today?