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

Nova Drafts Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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:

  1. Author creates draft from the detail view.
  2. Author edits the draft row in Nova (it's a normal resource, all fields/panels work).
  3. Publish action copies draft attributes onto the parent.
  4. Decide explicitly whether the draft row is deleted, kept as revision history, or archived (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:

  • Null out unique fields on drafts and generate the real value at publish time.
  • Suffix with a marker and strip it on publish: 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:

  • Duplicate children when creating the draft (clone blocks and re-parent to the draft), or
  • Accept shared children and document it — the draft only differs in its own columns.

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.

Gotchas and Tips

  • Archived, low activity. Last release 2022-11-18. Budget time to fork it if you're on Nova 5 or Laravel 10+. Read the composer.json constraints before composer require in a modern app.
  • The index filter is not optional. Forget 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.
  • Existing rows need a backfill. Adding 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.
  • Unique indexes break draft creation. Always the first bug report. Make unique columns nullable, or suffix-and-strip on publish. Test by creating a draft of your most-constrained resource.
  • Media and file attachments are not deep-copied. A draft referencing the same Media row means deleting media on a draft can break the live page. Either duplicate media records on draft creation or make media immutable.
  • Publishing may not clean up after itself. Confirm whether the publish action deletes the draft, leaves it as an orphan row, or flips 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 invalidation is on you. Published pages are usually cached at the HTTP layer or in a Cache::remember block. Fire an event on publish and bust by tag/key — the package has no opinion here.
  • Soft deletes compound. A soft-deleted draft still has draft_parent_id set. whereNull('deleted_at') plus where('is_draft', false) is the correct "published" query for most resources.
  • Debugging checklist when the drafts panel misbehaves: (1) 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.
  • Extension points, in order of usefulness: override the publish action in your resource; the 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.
  • Guard the "one draft" assumption. If the package doesn't enforce a single draft per parent, two authors can draft concurrently and the second publish silently overwrites the first. Add a unique index on 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.
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.
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console