oddvalue/laravel-drafts
A simple, drop-in drafts and revisions system for Laravel models. Add a trait to your model, update the database, and get published revisions, current revisions, auto drafts, and preview mode via middleware, with minimal changes to your existing code.
Architecture fit
oddvalue/laravel-drafts is an Eloquent-native, single-table versioning system: every revision is a full row in the same table as the live record, correlated by a uuid column plus is_published / is_current flags, and a global scope transparently restricts reads to published rows. That is a good fit for editorial content models (pages, posts, product copy) where the unit of change is a whole document and where "preview the working copy" is a first-class requirement. It is a poor fit for high-write, high-cardinality domain tables, for anything whose primary key is referenced by foreign keys from other tables, and for models where partial-column updates must not be versioned. The design choice that matters most for architecture review is that it is not a side-table audit log — it is row multiplication in the primary table, which changes uniqueness, indexes, aggregates, and route-model binding semantics for every model it touches. The package is deliberately narrow in scope (flags, scopes, publish/copy relations, pruning), which is a strength: it composes with existing Eloquent conventions rather than introducing a parallel persistence layer.
Integration feasibility
Integration cost is genuinely low per model: composer require, publish config, add the HasDrafts trait (optionally implement the Draftable contract), and run a migration using the $table->drafts() / $table->dropDrafts() schema helpers. Per-model column-name overrides via class constants (IS_CURRENT, etc.) and a $draftableRelations list keep the footprint configurable. The friction is not installation — it is retrofit. Existing tables need uuid backfilled to a stable value, flags set consistently, and unique constraints re-scoped (a slug unique index will break the moment a draft copies a published row). Optional features (auto drafts, scheduled publishing) are gated behind config and, per the README, no query references their columns while disabled — so they can be deferred, though adding is_auto and will_publish_at in the first migration avoids a second schema change later.
Technical risk
withDrafts() / onlyDrafts() returns published rows only. Reports, counters, API resources, policy checks, and admin listings written before the trait existed will change behavior with no compiler warning. This is the single largest source of surprise defects.LaravelDrafts::previewMode() is process-wide. Under Octane, queue workers, or any long-lived process this can leak across requests/jobs unless scrupulously reset; the provided WithDraftsMiddleware is the safer pattern for route-scoped access.oddvalue/filament-draft-recovery is referenced as not-yet-migrated to the new auto-draft API. Bus factor and long-term roadmap are the real risks, not code quality (tests, coverage, and CS workflows are present).HasOne/HasMany children and syncs BelongsToMany/MorphToMany. Whether this is wrapped in a transaction, and how it behaves under concurrent publish attempts, is not documented.Key questions
is_current when two editors work the same record — last-write-wins, or does a second saveAsDraft collide?current() always return exactly one row per uuid, including for records that were never drafted?post_id on comments) expected to point at the published row only, and what breaks when they point at a revision?keep — oldest-first by created_at or id, and are published/current rows ever pruned?Draftable become mandatory, and is there a supported upgrade path (the README references a Rector rule — confirm it's shipped)?Stack fit Fits any standard Laravel 9–12 application using Eloquent: Blade/Livewire/Inertia front ends, API resources, queue-driven content pipelines. It is framework-idiomatic (scopes, traits, middleware, artisan command, facade) and requires no new infrastructure — no Redis, no separate revisions store, no search dependency. Admin panel integration depends on the surrounding tooling; the sibling Filament auto-draft package is a signal of intent but is described as pre-release, so treat admin-panel UX as custom work in the initial plan.
Migration path
uuid, is_current), (uuid, is_published), and a rewritten unique index on any natural key (e.g. unique(['uuid', 'slug']) or a partial/filtered index where is_published = true).uuid from a stable identifier, is_published = true, published_at = created_at. Verify against real row counts before running on production-sized tables.uuid becomes the route key for published records, existing URLs and inbound links must be preserved (keep the integer key as the public key, or add redirects).Draftable contract, then audit every existing query touching the model for the new default.Compatibility
Gate the package version to the Laravel major in use and pin it in composer.json with a caret constraint tested against the current framework version. Implement Draftable on new models immediately, since a future major is stated to require it. Enable auto_drafts and scheduled_drafts only after their columns exist; verify the LogicException behavior while disabled so tests don't accidentally depend on enabled-only paths. Note the publisher morph requires a configured auth guard — confirm it resolves the intended user model in non-web contexts (queues, artisan).
Sequencing Start with a single low-risk, high-value content model selected as a spike (a Page or a marketing Post) and take it end-to-end: schema, backfill, trait, admin "current" listing, publish action, public read path, and one publish-flow integration test. Only after that spike proves out index changes, route binding, and cache/search invalidation should the pattern be replicated model by model. Schedule scheduled-publishing enablement last, since it introduces a recurring artisan command per model that must be registered in the scheduler and observed in production.
Maintenance Small surface area, MIT licensed, single maintainer, with changelog, tagged releases, and CI workflows present — healthy signals for a package of this size. The maintenance burden lands internally in three places: the publish orchestration code around the package, the index/uniqueness design on each draftable table, and the version-upgrade work tied to Laravel majors. Budget a recurring line item for each Laravel upgrade rather than treating this as install-and-forget. Vendor the config file into version control and treat column-name constants as an interface.
Support No commercial support or SLA; issue response depends on one maintainer. Escalation path must be internal: designate an owner (a senior backend engineer) who can read the source and patch a fork if needed. Keep the fork-on-short-notice option viable by pinning exact versions and keeping the diff surface small (prefer config over per-model constants where possible).
Scaling
Storage and write amplification are the primary scaling concerns: each save inserts a row, and default retention is 10 revisions per record. For a content model with 50k records and an active editorial team, that is up to 500k rows — manageable, but the pruning job and the composite indexes must exist before volume arrives, or current() and published-read queries will degrade. Publishing duplicates child rows for HasOne/HasMany, so relation-heavy models multiply storage on every publish. Read paths should be checked for N+1 exposure introduced by per-row draft resolution. If the app runs Octane, verify preview mode is not retained across requests under load.
Failure modes
drafts:publish was not registered per model in the scheduler.keep is changed without checking whether current/published rows are protected.saved rather than publish.Ramp-up
Learning cost is low for anyone fluent in Eloquent scopes and traits — roughly half a day for a mid-level engineer to be productive on a single model, plus a day to internalize the row-per-revision mental model. The durable cost is team discipline: add a code-review checklist item asking "should this query see drafts?" for every new query on a draftable model, document the two access patterns (current() for admin, default for public) in the team's conventions, and add a test template that exercises both. Onboarding a new engineer to a codebase where some models are draftable and some are not is the realistic ramp-up risk, so make the draftable model list explicit in documentation rather than discoverable by reading traits.
How can I help you explore Laravel packages today?