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.
Architecture fit
spatie/laravel-model-flags adds a single polymorphic flags table plus a HasFlags trait that exposes flag(), unflag(), hasFlag(), flagNames(), lastFlaggedAt(), and the flagged() / notFlagged() query scopes. That is a narrow, well-understood persistence primitive, and it fits any Eloquent-based codebase without touching existing schema. The best-fit use cases are exactly the ones the README names: idempotent/restartable artisan commands, one-time backfills, per-record "this happened" markers, and lightweight progress checkpoints on jobs. It is a poor fit for anything state-machine-like (no transition rules, no ordering guarantees), for high-cardinality event data (one row per flag per model with no retention policy), and as a substitute for job-level idempotency keys or database advisory locks in concurrent-worker scenarios. Treat it as a convenience layer over a has-many relation, not as workflow infrastructure.
Integration feasibility
Low effort. composer require, publish + run one migration, add one trait to the target models. The published config exposes only flag_model, so extending Spatie\ModelFlags\Models\Flag for custom behaviour (retention, casts, observers) is the supported extension point. Changelog shows an unbroken compatibility ladder — Laravel 11 (1.3.0), 12 (1.4.1), 13 (1.4.2) — and PHP 8.0+ support since 1.0.1. Realistic integration estimate for a mid-level Laravel developer: half a day to wire up and smoke-test, 1–2 days including backfill and test coverage.
Technical risk
The dominant risk is concurrency semantics. The documented idempotency pattern is check-then-act (notFlagged(...)->each(... flag())), which is safe for a single sequential command and unsafe the moment two workers process the same model concurrently — both can read "not flagged" before either writes. The README does not document a unique constraint on (flaggable_type, flaggable_id, name), so concurrent flag() calls on the same name may produce duplicate rows or rely on an undocumented upsert; verify the shipped migration before depending on it. Secondary risks: hasFlag() appears to hit the flags relation per call unless eager-loaded (N+1 in loops); flagged()/notFlagged() are almost certainly whereHas/whereDoesntHave subqueries whose cost scales with the global flags table, which is shared across all flaggable models; and there is no retention story — the table grows monotonically with flag count × model count. Flag names are strings, so drift and typos are a real defect class (mitigated by the enum support added in 1.4.0 — mandate backed enums rather than literals). Ecosystem validation is thin: 454 stars is respectable but the metadata shows 0 dependents, so we are largely on our own for edge-case hardening. Mitigating factor: the surface area is tiny and MIT-licensed, so forking or vendoring is a credible fallback.
Key questions
(flaggable_type, flaggable_id, name), and what are the actual indexes on name?flag() — upsert, insert-or-ignore, or update-then-insert? Is it atomic?hasFlag() lazy-load the relation each call, and can flags be eager-loaded to avoid N+1?flags table size at 12/24 months, and who owns purging it?Stack fit Native Laravel — Eloquent trait, service provider, publishable migration and config, database-agnostic through the query builder. No queue, cache, or external service dependency, so it slots into an existing Laravel app without infrastructure change. It composes cleanly with queued jobs, scheduler entries, and artisan commands, which is where the value is realised.
Migration path Adopt additively; nothing existing is modified. Two entry points:
sent_promo_mail), a bespoke processed_users table, or nothing at all, run a backfill that maps existing truth into flags, dual-write for one release cycle, then retire the old columns. Retiring a boolean column is a separate, reversible migration; the flags table itself can be dropped entirely if we abandon the package, which keeps lock-in low.Compatibility
Confirm the package's composer.json constraints against our pinned Laravel and PHP versions before committing — the changelog confirms Laravel 13 support but not the exact floor. Pin the minor version (currently 1.5.x) in composer.json and let routine dependency updates flow through normal PR review. The flag_model config is the only override knob; if we extend Flag, document it centrally so nobody re-registers a competing model.
Sequencing
hasFlag and scope cost.chunkById the scope queries.Maintenance
A small, single-purpose dependency with a clean changelog. Recent releases are dominated by dependency bumps plus occasional contributor features (enum support in 1.4.0, array unflagging in 1.5.0), which signals steady but low-velocity maintenance rather than active development. Budget near-zero ongoing engineering: version bumps ride normal dependency PRs. The one recurring task is deciding whether to prune the flags table.
Support MIT, community-supported, no SLA. Spatie is a well-known Laravel vendor, so community answers and source readability are strong; escalation path is "read the ~few hundred lines of source" or fork. Because it is a small, isolated package, a fork or a 200-line in-house reimplementation is a viable escape hatch if maintenance stalls — that optionality is the main reason the risk is acceptable.
Scaling
Cost scales with rows, not with traffic. At n models × m flags, the flags table can reach millions of rows in a large user base, and the flagged()/notFlagged() scopes execute correlated subqueries against it. Mitigations: ensure a composite index on (name, flaggable_type, flaggable_id) and the reverse for relation loads; chunk long-running commands; avoid flagging high-frequency events; define a TTL or a periodic purge job for flags that only matter during a run; and prefer eagerly loading flags over repeated hasFlag() calls in loops. Under concurrent workers, do not rely on flags alone for exactly-once semantics — pair them with WithoutOverlapping middleware, a queue-level unique job, or a row lock.
Failure modes
notFlagged and both act. Impact: duplicate emails/charges. Mitigate with locking, not with flags.hasFlag() inside a loop over thousands of records. Impact: latency, DB load. Mitigate with eager loading and chunking.Ramp-up Very low. Any Laravel developer is productive in under an hour; the only non-obvious knowledge is the concurrency caveat and the eager-loading behaviour. The real ramp-up cost is organisational: getting teams to agree on flag naming and to stop inventing per-feature boolean columns. A one-page convention doc at pilot time is the highest-leverage artefact here.
How can I help you explore Laravel packages today?