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.
1. Install and pin to your Laravel version.
composer require oddvalue/laravel-drafts
php artisan vendor:publish --tag="drafts-config"
Check the compatibility table first: v1.x for Laravel 9/10, v2.x for 11, >2.1|3.x for 12. Getting this wrong is the most common "why doesn't the trait exist" failure.
2. Add the columns in a migration before you touch the model.
Schema::table('posts', function (Blueprint $table) {
$table->drafts(); // is_current, is_published, is_auto, published_at, uuid, publisher_*, will_publish_at
});
Schema::table('posts', function (Blueprint $table) {
$table->dropDrafts();
});
3. Add the trait (and the contract) to the model.
use Oddvalue\LaravelDrafts\Concerns\HasDrafts;
use Oddvalue\LaravelDrafts\Contracts\Draftable;
class Post extends Model implements Draftable
{
use HasDrafts;
protected array $draftableRelations = ['tags', 'blocks'];
}
Implement the contract now even though the trait satisfies it — the README states a future major will require it.
4. Decide your first use case and verify it end to end. For most teams this is: public site reads published rows, admin edits produce drafts, an explicit publish action flips them live. Write that one test before building anything else:
$post = Post::create(['title' => 'Foo']); // published by default
$post->updateAsDraft(['title' => 'Bar']);
$this->assertSame('Foo', Post::find($post->id)->title); // public sees old
$this->assertSame('Bar', Post::current()->find($post->id)->title); // admin sees new
Where to look first: the published config/drafts.php (column names, revision retention, publisher guard), then the query scopes in the trait — the global scope is the single biggest source of surprise, so read it before writing any query.
Treat the two audiences as two distinct query entry points and never mix them.
// Public controllers — default scope already filters to published
$posts = Post::latest('published_at')->paginate();
// Admin controllers — most recent revision per record
$posts = Post::current()->with('publisher')->latest('updated_at')->paginate();
Explicitly calling published() or withoutDrafts() in public controllers is worth the keystrokes: it documents intent and survives someone later adding previewMode() server-side.
The API surface is small enough to standardize on three verbs:
| Intent | Call |
|---|---|
| Create unpublished | Post::createDraft($data) / ->saveAsDraft() |
| Edit a published record without touching it | $post->updateAsDraft($data) |
| Promote a draft to live | publish flow on the draft model |
public function update(UpdatePostRequest $request, Post $post)
{
$draft = $post->updateAsDraft($request->validated());
return redirect()->route('admin.posts.edit', $draft);
}
Note that updateAsDraft returns the draft, whose id differs from the original. Redirect using the draft's route key or, better, the shared uuid.
Give editors a preview path rather than loosening the global scope everywhere.
Route::middleware(['auth', 'can:edit-posts'])->group(function () {
Route::get('/posts/{post}/preview', PreviewController::class);
});
// In the controller
LaravelDrafts::previewMode();
$post = Post::findOrFail($postId);
LaravelDrafts::disablePreviewMode();
For a narrow need — a single publish/save endpoint that must resolve a draft via route-model binding — use the middleware instead of global preview:
Route::withDrafts(function () {
Route::post('/posts/{post}/publish', [PostController::class, 'publish']);
});
Prefer the middleware/route-group approach; it scopes the relaxation to a route instead of toggling global state.
$revisions = Post::withDrafts()->where('uuid', $post->uuid)->latest('updated_at')->get();
Or from an instance: $post->revisions(). Retention is 10 per record (revisions.keep); bump it in config if editors expect a real audit trail, and plan storage accordingly — every save of a 40-column model is a full row copy.
When a save is incidental (view counter, internal backfill), skip the churn:
$post->withoutRevision()->update(['views' => $post->views + 1]);
Only relations listed in $draftableRelations are handled, and only HasOne/HasMany (duplicated) and BelongsToMany/MorphToMany (synced). This is a publish-time operation, not a save-time one, so nested content editors work naturally:
protected array $draftableRelations = ['blocks', 'tags', 'authors'];
Derive it dynamically if needed by overriding getDraftableRelations(), but keep it cheap — don't run a query inside it.
Enable in config, then use the is_auto row as a scratchpad. It upserts in place, so it doesn't consume revision slots.
$post->saveAsAutoDraft(['body' => $request->input('body')]);
// On editor load
$body = $post->autoDraft?->body ?? $post->body;
// On successful publish
$post->discardAutoDraft();
Because it saves quietly, don't rely on it for anything that needs observers to fire.
'scheduled_drafts' => ['enabled' => true],
$draft->schedulePublishing(now()->addWeek()); // saves
$draft->clearScheduledPublishing()->save(); // note the save()
Schedule::command(PublishScheduledDrafts::class, [Post::class])->everyMinute();
Register one schedule entry per model. In a multi-tenant app where each tenant has its own "publish at" expectations, keep this per-model rather than looping over models at runtime.
Use RefreshDatabase, and reset global state in setUp/tearDown — preview mode is process-wide static state and will bleed between tests.
protected function tearDown(): void
{
LaravelDrafts::disablePreviewMode();
parent::tearDown();
}
Assert against current() for admin expectations and the default scope for public ones. Add a test that publishing duplicates your $draftableRelations, since that behavior is config-driven and silently regresses when relations are renamed.
The global scope reaches further than you think. Post::count(), exists(), whereHas('posts'), relation eager loads, and pagination totals all inherit the published filter. Any admin statistic, sitemap, or search index that needs drafts must opt in explicitly with withDrafts()/current(). If a number looks low, this is the first thing to check.
id is per-revision; uuid is the record. All revisions of a post share one uuid. Never put the auto-increment id in URLs, external references, or cross-system sync keys — link on uuid. Post::current()->find($id) and Post::find($id) can return different rows for the same logical record.
Unique indexes will fight you. A unique on slug or uuid breaks the moment a draft is created, because two rows now share that value. Move to a composite unique (uuid, is_current) or enforce uniqueness at the application layer.
Column renaming must happen before the migration runs. Either use the config file or a class constant (public const IS_CURRENT = 'admin_editing';). Setting a constant after the table exists silently points the package at a column that isn't there — the failure mode is a confusing SQL error on every query, not a clear exception.
Upgrading into opt-in features requires a manual column. is_auto and will_publish_at are only added by $table->drafts() on new tables. For existing tables add them yourself (->default(false) and nullable timestamp respectively) before flipping the config flags.
Disabled features throw, not no-op. While auto_drafts/scheduled_drafts are disabled, the API throws LogicException. If you're writing shared code or a package on top, guard with the config value rather than try/catch.
clearScheduledPublishing() doesn't persist. It returns the model; you still need ->save().
Preview mode is request-global static state. It's fine for a synchronous web request, but it is a landmine under Octane, in queued jobs, and in console commands. Always pair enable with a finally-style disable, and prefer WithDraftsMiddleware for anything request-scoped.
Queued jobs and artisan commands see no drafts. There's no middleware or preview mode in those contexts. Call withDrafts() or current() explicitly in job handlers, exports, and index rebuilds.
Mass assignment can silently publish. If is_published isn't guarded or isn't in $fillable, Post::create($request->all()) from an admin form can create a live row. Prefer createDraft() / saveAsDraft() for anything editor-initiated, and be deliberate about is_published in $fillable.
Revision pruning is per record, not global. revisions.keep => 10 means ten rows per logical record. A bulk import or a loop calling save() in a job can multiply your table size fast; wrap bulk updates in withoutRevision().
Auto drafts are invisible to normal draft APIs. They're excluded from drafts(), the draft accessor, pruning, and publish flows, and they fire no model events. Use onlyAutoDrafts() when you actually want them, and remember caches/observers won't be invalidated.
Soft deletes cascade, hard deletes cascade. Deleting a record removes all revisions, and restore/soft-delete behave symmetrically. Good default — just don't build a "trash" UI assuming only one row disappears.
Index for the scopes you actually use. The global scope adds is_published/is_current predicates to essentially every query. Composite indexes on (is_published, published_at) and (uuid, is_current) are worth adding in the same migration; without them, list pages degrade noticeably once revision counts grow.
Publishing relations only covers four relation types. Morph-to-many pivots are synced, but custom relation classes, HasOneThrough, and pivot models with extra attributes need verification. Write a publish test per relation rather than trusting the config list.
Extension points worth knowing: override getDraftableRelations() for dynamic relation sets, use column-name constants for legacy schemas, and use the publisher morph (with the auth.guard config) to attribute publishes — set the guard correctly if your admin lives behind a non-default guard, otherwise publisher is silently null.
How can I help you explore Laravel packages today?