Auto-Save Draft & Crash Recovery
Filament 4/5Add 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.
Get the Source Code:
Only This Example
One-time payment
Sign in with GitHub to buy
Sign in first, then complete your $9 checkout.
FilamentExamples Membership
30-day money-back guarantee
How it works
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.
1. Where the draft lives — the content_drafts table and model
A 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:
- The
unique(['user_id', 'key'])constraint is what makes auto-save idempotent — each user has at most one draft per page, so the trait canupdateOrCreate()on every poll instead of piling up rows; the poller overwrites the same record forever payloadis a plain JSON column cast toarray— the form's entire$this->datais dumped in verbatim, so recovery works for any field the form has (text, selects, repeaters) without a column per input- The
keyis a human-readable string likepost-createorpost-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 edit
2. The auto-save engine — the RecoversContentDraft trait
All 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()isabstract— 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)- The blank-check bails out before touching the database — an empty form isn't worth recovering, so polling an untouched page is free; only once there's a title or content does a row get written
updateOrCreate()keyed onuser_id + keyreuses 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()dispatchescontent-draft-savedrather 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)
3. Not recovering what didn't change — the reference state
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.