Add auto-saving drafts with crash recovery to a Filament create/edit page — if you close the tab or crash, Filament offers to restore your unsaved work.
One-time payment
Sign in with GitHub to buy
Sign in first, then complete your $9 checkout.
30-day money-back guarantee
The feature is made of five small pieces: a table to hold drafts, a RecoversContentDraft trait that does all the work, the two Post pages that opt in, a scoped render hook that injects the poller, and the Blade view that actually ticks. Nothing overrides Filament internals — it rides entirely on standard Livewire polling, events, and notification actions.
content_drafts table and modelA draft is just the raw form state stored as JSON, owned by a user and identified by a stable key.
database/migrations/xxxx_create_content_drafts_table.php
Schema::create('content_drafts', function (Blueprint $table) { $table->id(); $table->foreignIdFor(User::class)->constrained()->cascadeOnDelete(); $table->string('key'); $table->json('payload'); $table->timestamps(); $table->unique(['user_id', 'key']);});
app/Models/ContentDraft.php
#[Fillable(['user_id', 'key', 'payload'])]class ContentDraft extends Model{ protected function casts(): array { return [ 'payload' => 'array', ]; }}
The key points:
unique(['user_id', 'key']) constraint is what makes auto-save idempotent — each user has at most one draft per page, so the trait can updateOrCreate() on every poll instead of piling up rows; the poller overwrites the same record foreverpayload is a plain JSON column cast to array — the form's entire $this->data is dumped in verbatim, so recovery works for any field the form has (text, selects, repeaters) without a column per inputkey is a human-readable string like post-create or post-edit-5, not a foreign key to the post — a create page has no record yet, so keying by intent rather than by model is what lets the same table serve both create and editRecoversContentDraft traitAll the behaviour lives in one trait a page can use. It snapshots $this->data on demand and knows how to find, restore, and clear the draft. Each page supplies its own key.
app/Filament/Concerns/RecoversContentDraft.php
abstract protected function contentDraftKey(): string; public function saveDraft(): void{ $data = $this->data ?? []; if (blank($data['title'] ?? null) && blank($data['content'] ?? null)) { return; } if ($this->matchesContentDraftReferenceState($data)) { $this->clearContentDraft(); return; } ContentDraft::query()->updateOrCreate( ['user_id' => Auth::id(), 'key' => $this->contentDraftKey()], ['payload' => $data], ); $this->dispatch('content-draft-saved');}
The key points:
contentDraftKey() is abstract — the trait refuses to be used without a page telling it which draft it owns, which is exactly what keeps the create page's draft from colliding with an edit page's draft (see section 4)updateOrCreate() keyed on user_id + key reuses the one unique row — this is the payoff of the unique index: the tenth poll updates the same draft the first poll created, so the table never grows while you typesaveDraft() dispatches content-draft-saved rather than sending a notification — a full notification every 10 seconds would be noise, so the trait just fires a browser event that the view turns into a subtle "Draft saved at …" line (section 5)Auto-save is useful on create pages, but on an edit page you don't want to be prompted to "recover" a draft that's identical to the record already in the database. The trait compares the live form against a reference snapshot and skips (and clears) matching drafts.