Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Drafts Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.


Implementation Patterns

Split read paths: public vs. admin

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 draft/edit/publish lifecycle

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.

Preview mode and the drafts middleware

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.

Revision history UI

$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]);

Relations at publish time

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.

Auto drafts for form autosave

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 publishing

'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.

Testing

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.


Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
snowsoft/faker
meditationsteps/blogetc
animalyoung/laravel-influxdb
onix-solutions/sms-telera
lonely-lights/prosetta
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log
hsluv/hsluv
seampact/codegen