guava/filament-drafts
Guava Filament Drafts adds a simple draft-and-publish workflow to Filament resources. Editors can save unfinished changes as private drafts, review them, and publish when ready, keeping live content stable while updates are prepared in Laravel admin panels.
Pin the version, don't float it. This is a 2023-era Filament v3 plugin on a slow-moving 2.x line. A minor bump should not be allowed to change save/publish semantics underneath you:
composer require guava/filament-drafts:2.1.1
Then publish and read the assets before writing any integration code:
php artisan vendor:publish --tag="filament-drafts-config"
php artisan vendor:publish --tag="filament-drafts-migrations"
Read these four things, in this order:
vendor/guava/filament-drafts/src/. Read exactly which methods, casts, and scopes it injects, and whether the "hide drafts from public queries" behaviour is a global scope or an opt-in named scope. This decides how much of your application you must audit (see Gotchas).Register the plugin on your panel, matching the v3 syntax your app already uses and verifying the namespace against the installed version:
->plugin(\Guava\FilamentDrafts\FilamentDraftsPlugin::make())
First use case: one low-risk model, end to end. Pick a flat-schema, non-customer-facing Page, Post, or Announcement. Do not start with your most relationship-heavy resource. Walk the full loop:
If step 3 or step 5 misbehaves, stop and price out a ~300–500 line in-house trait plus shadow table. The abstraction here is thin; replacing it is frequently cheaper than fighting it.
Fit check before you commit. Adopt when: Laravel + Filament v3, drafted entities are ordinary Eloquent resources, and the workflow is the happy path (per-model draft state plus a publish action). This is a poor fit for relationship-heavy content models, regulated audit requirements, or any roadmap that includes Filament v4 in the near term.
Treat the draft model as a first-class model, not a subclass. The pattern is a parallel table mirroring the base table; "publish" promotes the draft into the canonical table. The draft model does not inherit what you expect, so re-declare on it:
$fillable$castsThe division of labour that keeps the codebase sane:
Make schema drift structurally hard to commit. This is the recurring tax of the pattern. Two mitigations pay for themselves in the first month:
// Shared column builder keeps base + draft in lockstep
Schema::create('pages', fn (Blueprint $t) => $this->pageColumns($t));
Schema::create('page_drafts', fn (Blueprint $t) => $this->pageColumns($t));
Plus a CI test that diffs information_schema column sets for every base/draft pair and fails the build on divergence. Without it, adding a column to pages and forgetting page_drafts silently drops editorial content on the next publish — and it presents as a UI bug.
The per-model checklist is mechanical. Because drafting support is identical for every resource, run it as a routine so you never skip a step:
The second and third resources cost a fraction of the first. Ship on one resource, let editors use it for a release cycle, then repeat.
Separate authorization for drafting vs. publishing. Do not reuse update. Define a distinct policy ability (publish, unpublish) and gate the actions on it. This is the actual governance value of the package — a junior editor stages changes, a senior editor flips them live — and it costs almost nothing to wire up. Also note that a silently failing authorize() returns false and hides the action, which is a common "publish is broken" report.
Preview by bypassing the published filter, not by copying records. Implement preview as a signed, short-lived URL that resolves the draft record directly (route-model-binding to the draft model, or a scope that includes drafts when a valid signature is present). Do not implement preview by publishing to a secret slug — that pollutes the canonical table and defeats the point of drafts.
Backfill before anyone sees the UI. Existing rows have no draft state. Write a one-off command or migration marking every current row as published. Otherwise editors open the admin and see your entire historical content set presented as unpublished drafts — confusing at best, an accidental mass "publish" waiting to happen at worst.
Test semantics, not the Livewire response. Four feature tests against the database cover the real contract:
Resource integration tips.
Schedule revision pruning. The package advertises revisions; assume unbounded growth until proven otherwise. Check whether pruning is configurable. If it is not, add a scheduled cleanup command before the revision table becomes the largest thing in your database.
Schema drift is the number-one failure mode. Every future migration on a drafted model becomes a two-table change plus a review-checklist item. Put the convention in your PR template and back it with the CI diff test above. The failure mode is nasty because it looks like a UI bug and takes a day to diagnose.
Relations, media, and nested repeaters do not come free. Draft tables carry columns, not relationship graphs. Before committing, spike all three on your target model:
BelongsToMany with a pivot,Confirm whether publishing resolves these from the draft model or the base model, and whether pivot rows are duplicated or shared. If your content model is relationship-heavy, this package is the wrong tool — an is_published column plus an audit log will serve you better.
Uniqueness constraints collide. If pages.slug is unique, a draft row sharing the live row's slug fails on insert — or forces you to duplicate the slug only at publish time. Decide the rule up front (drafts get a nullable/unenforced slug, or the constraint lives only on the canonical table) and verify the package does not already assume the opposite.
The drafts filter must be everywhere or nowhere. "Automatically filter drafts from public queries" is both the feature and the trap. Audit every consumer of the drafted model: public controllers, sitemaps, search indexes, RSS feeds, API resources, and queued jobs that query independently. Then confirm the admin panel correctly bypasses the scope (via withoutGlobalScopes() or an explicit scope) or editors open the admin to an empty list. In code review, grep for the scope name and for withoutGlobalScope.
Verify publish transaction semantics before trusting side effects. The docs do not state whether publish is an overwrite, a merge, or wrapped in a transaction. Test partial failure yourself. This matters the moment publish fires created/updated model events that you hook for slug generation, cache invalidation, sitemap pings, or notifications — an observer firing on a half-promoted record is exactly how a broken link reaches production.
Plugin collisions on the save pipeline. Filament plugins that hook form/tab/table lifecycle (translatable, media library, other revision packages) are the classic source of silent save bugs. The 2.1.1 release is a French translation fix that also touched tab translations — a concrete hint that label/tab interception is an active collision area. If you run a translatable plugin on the same resource, spike the two together before rollout.
Filament v4 is unverified. This is v3-era code and plugins are effectively version-locked. Adopting it pins your admin panel's upgrade path. Record that as a known constraint; if a v4 migration is on the roadmap, plan for a fork.
Version and maintenance posture. Zero dependents means no production evidence, and this is a single-maintainer package. Pin 2.1.1, commit composer.lock, and keep a short runbook covering: where the trait lives, how to fork, what the publish path does. The low cadence is an upside (few churn-driven upgrades) and a downside (nobody else will find the bugs for you). Budget an owner — a few hours a quarter is enough.
Localization is present but thin. FR strings ship as of 2.1.1. If the editorial team is non-English, confirm the specific locale is covered before promising a translated UI; otherwise budget a lang-file PR upstream or a vendor override.
Debugging checklist when a draft "doesn't publish":
authorize() returns false and hides the action.How can I help you explore Laravel packages today?