optimistdigital/nova-drafts
A Laravel Nova package that adds draft and publish workflows to resources, letting editors save unpublished changes, preview drafts, and publish content when ready, with simple integration into Nova resource forms and tools for smoother editorial control.
Minimal path from install to first draft:
composer require optimistdigital/nova-drafts
# publish config + migrations (confirm the tag/provider name in the package's ServiceProvider)
php artisan vendor:publish --provider="OptimistDigital\NovaDrafts\NovaDraftsServiceProvider"
php artisan migrate
Add the trait to the model you want to be draftable:
use OptimistDigital\NovaDrafts\Traits\HasDrafts;
class Page extends Model
{
use HasDrafts;
}
The package assumes two columns on that table (published migration should create them; add them yourself if you're retrofitting an existing table):
draft_parent_id — nullable FK pointing at the original row (null on the live/original record)is_draft — boolean flag (default 0)Then expose the field on the Nova resource. The drafts UI is a detail-only field, not something you put on the index:
use OptimistDigital\NovaDrafts\Drafts;
public function fields(Request $request)
{
return [
// ...normal fields...
Drafts::make('Drafts')->onlyOnDetail(),
];
}
Where to look first: read the published migration and the HasDrafts trait before writing any code. They define the exact column names, the drafts() / parent relationship semantics, and the scopes the package ships. Then open the Drafts field class to see which actions (create draft, publish, discard) it wires up — that's the surface you'll be customizing.
First use case: open a live Page in Nova, click "Create draft" from the Drafts panel, edit the draft row freely (it's a real Eloquent row, so it shows up in its own Nova edit form), then publish it back onto the parent. This is the classic "edit without touching the live version" workflow for marketing pages, product copy, or any content that has a public-facing URL.
Before committing, check the composer constraints against your stack — the package was last released in November 2022 and is archived, so treat Nova 4 / Laravel 9 as the safe upper bound and verify Nova 5 / Laravel 10+ compatibility yourself.
Hide drafts from the index. Drafts are full rows, so they will appear in your Nova resource index unless you filter them out. The package's field handles the detail view; the index is your job:
public static function indexQuery(NovaRequest $request, $query)
{
return $query->where('is_draft', false);
}
Be deliberate here: if you also use this resource as a relationship search source (BelongsTo, MorphTo), the same filter applies and users won't be able to pick drafts. That's usually what you want, but it's a decision, not an accident.
Draft-first editing workflow. Treat the draft as the working copy and the original as the published artifact. In practice:
is_draft stays true, add your own archived_at).If you need revision history, don't delete on publish — add a published_at column and let drafts accumulate. That's the cheapest way to build an audit trail on top of this package.
Duplicate unique fields on draft creation. Anything with a unique index — slug, SKU, external ID, email — will throw when the draft copies the parent's value. Two options:
my-page-slug--draft-{parent_id}.The second survives the unique index; the first is cleaner if the field is nullable.
Scope relationship queries. If a Page hasMany Block, the parent's blocks are shared with the draft, because the draft's id differs but the children still point at draft_parent_id's row. Either:
Cloning is usually what content editors expect; wire it into your "create draft" flow or a model observer.
Combine with a publishing action you control. The field's publish button is convenient, but production workflows usually need cache invalidation, webhook dispatch, and "can this user publish?" checks. Override the publish behavior (custom action or your own controller endpoint) and call the trait's publish method from there, then fire PagePublished and flush caches. Don't rely on the stock button for privileged operations.
Policies. Add draft-aware authorization. A common split:
public function publish(User $user, Page $page) { return $user->hasRole('editor'); }
public function createDraft(User $user, Page $page) { return $user->can('update', $page); }
And in PagePolicy::update(), decide whether drafts are editable by the same people as live pages — often they're more permissive (authors draft, editors publish).
Factories and tests. Add a draft() state so tests don't hand-write is_draft / draft_parent_id:
public function draft(?Page $parent = null): static
{
return $this->state([
'is_draft' => true,
'draft_parent_id' => $parent?->id,
'slug' => null,
]);
}
Localization / multi-site. If you pair this with a page-manager style package, keep drafts per-locale: one draft per (draft_parent_id, locale) and filter the Drafts panel accordingly. The package itself doesn't model locale, so you enforce it.
composer.json constraints before composer require in a modern app.indexQuery and every draft shows up as a duplicate row, search results double, and Count metrics lie. Also audit any custom metrics, global searches, and relation managers you write.draft_parent_id null is the sentinel for "live". Any query that means "published content" should be where('is_draft', false) — or better, add a published() scope on the model so the rule lives in one place. Don't sprinkle is_draft checks through controllers and views.is_draft with a default handles most databases, but if the column is nullable or you run a manual migration, backfill is_draft = 0 and draft_parent_id = NULL before shipping. Half-flagged rows will produce phantom drafts.Media row means deleting media on a draft can break the live page. Either duplicate media records on draft creation or make media immutable.is_draft. If it orphans, you get ghost rows that still match draft_parent_id — your next "create draft" may reuse or collide with them. Add cleanup or an explicit draft_parent_id uniqueness check.Cache::remember block. Fire an event on publish and bust by tag/key — the package has no opinion here.draft_parent_id set. whereNull('deleted_at') plus where('is_draft', false) is the correct "published" query for most resources.SELECT id, draft_parent_id, is_draft FROM pages — is the data actually shaped right? (2) Is the field onlyOnDetail()? It renders nothing on index/forms. (3) Did the trait load — is drafts() callable on the model? (4) Does the policy let the user see the field? Nova silently drops fields via canSee. (5) Are you on a supported Nova version? The field's view components are the most likely casualty of a Nova upgrade.HasDrafts trait methods (wrap them in your own service class rather than calling them from controllers); model events (created/updated with an is_draft check); and finally a full fork — at 29 stars and archived, upstream PRs aren't a strategy.draft_parent_id where is_draft = 1 (partial index on Postgres, or a nullable column trick on MySQL) or handle conflicts with a lock in the publish path.How can I help you explore Laravel packages today?