oddvalue / filament-draft-recovery
Auto-save draft & crash recovery for Filament create/edit pages, with swappable storage drivers (browser localStorage, database, or oddvalue/laravel-drafts)
Package info
github.com/oddvalue/filament-draft-recovery
pkg:composer/oddvalue/filament-draft-recovery
Requires
- php: ^8.2
- filament/filament: ^4.0
- spatie/laravel-package-tools: ^1.15.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.0
- nunomaduro/collision: ^8.0
- oddvalue/laravel-drafts: ^3.2
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^3.7|^4.0
- pestphp/pest-plugin-arch: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
- pestphp/pest-plugin-livewire: ^3.0|^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- rector/rector: ^2.0
Suggests
- oddvalue/laravel-drafts: Required to use the laravel-drafts storage driver (^3.2)
This package is auto-updated.
Last update: 2026-08-04 15:37:09 UTC
README
Auto-save draft & crash recovery for Filament v4 create/edit pages, with swappable storage drivers. 100% test coverage, enforced in CI.
While a user edits a create or edit form, the form state is auto-saved (debounced, 2s by default). If their browser crashes, the tab closes, or the session expires, returning to the page shows a persistent notification offering to recover or discard the draft. Drafts are cleared on a successful save and expire after 7 days.
Storage drivers
| Driver | Where drafts live | Notes |
|---|---|---|
local-storage (default) |
The user's browser localStorage | Zero server storage; drafts are plaintext on the user's machine — see Security |
database |
The recoverable_drafts table |
Drafts follow the user across devices; payloads can be encrypted at rest |
laravel-drafts |
On the model being edited, via oddvalue/laravel-drafts | Auto-saves become draft revisions of the record itself |
Custom drivers can be registered with DraftRecovery::extend().
Installation
composer require oddvalue/filament-draft-recovery php artisan filament-draft-recovery:install
The install command publishes the config and (for the server-side drivers) the migrations. Skip running the migrations if you only use the local-storage driver.
Usage
Add the trait to a resource's create and/or edit page:
use Filament\Resources\Pages\CreateRecord; use Oddvalue\FilamentDraftRecovery\Concerns\RecoversDrafts; class CreatePost extends CreateRecord { use RecoversDrafts; protected static string $resource = PostResource::class; }
That's it — the trait injects the JavaScript via the page footer and everything else is automatic.
Choosing a driver
Set the default in config/filament-draft-recovery.php (or FILAMENT_DRAFT_RECOVERY_STORE):
'store' => 'database',
Per panel:
use Oddvalue\FilamentDraftRecovery\DraftRecoveryPlugin; $panel->plugin(DraftRecoveryPlugin::make()->store('database'));
Per page:
class CreatePost extends CreateRecord { use RecoversDrafts; protected ?string $draftStore = 'laravel-drafts'; }
The laravel-drafts driver
composer require oddvalue/laravel-drafts
Edit-page drafts are stored directly on the model being edited via laravel-drafts' first-class auto draft feature. Requirements: the model uses the HasDrafts trait, its table has the drafts columns (including is_auto), and auto drafts are enabled (drafts.auto_drafts.enabled in laravel-drafts' config).
- Edit pages: each auto-save calls
saveAsAutoDraft()on the record — a single, quietly upserted working copy that is never the current draft, never spawns revisions, and reads back via the record'sautoDraft()relation. The record keepsis_current; intentional drafts ($record->draft) are untouched. - Create pages: auto drafts only exist for existing records, so create-page drafts are delegated to another store — the
laravel-drafts.create_storeconfig value, falling back to your default store (ordatabasewhen the default islaravel-draftsitself). Any driver works, including custom ones. - Clearing (successful save / discard) calls
discardAutoDraft()— published rows, intentional drafts, and revision history are never touched. - Saves are best-effort: payloads that violate column constraints (required fields not yet filled) are skipped and retried on the next auto-save.
- Only real table columns are persisted; form-only keys are dropped. Repeater/relation state is not covered by this driver — use
databaseif you need the full form payload.
Custom drivers
Implement Oddvalue\FilamentDraftRecovery\Contracts\DraftStore and register it in a service provider:
use Oddvalue\FilamentDraftRecovery\Contracts\DraftStore; use Oddvalue\FilamentDraftRecovery\Data\DraftContext; use Oddvalue\FilamentDraftRecovery\Data\RecoveredDraft; use Oddvalue\FilamentDraftRecovery\Facades\DraftRecovery; class RedisDraftStore implements DraftStore { public function isClientSide(): bool { return false; } public function get(DraftContext $context): ?RecoveredDraft { $payload = Redis::get($context->key); return $payload ? new RecoveredDraft(data: json_decode($payload, true)) : null; } public function put(DraftContext $context, array $data): void { Redis::setex($context->key, 60 * 60 * 24 * 7, json_encode($data)); } public function forget(DraftContext $context): void { Redis::del($context->key); } } // In a service provider: DraftRecovery::extend('redis', fn () => new RedisDraftStore);
Then select it like any built-in driver ('store' => 'redis', DraftRecoveryPlugin::make()->store('redis'), or protected ?string $draftStore = 'redis';).
Every method receives a DraftContext carrying the unique key (always sufficient for key/value stores) plus the page's modelClass, operation (create/edit), record (edit pages), and userId — everything a record-based store needs.
Save debounce
Auto-saves fire after the user stops typing for save_debounce_milliseconds (default 2000). Change the default in the config:
'save_debounce_milliseconds' => 5000,
Or per page:
protected function draftRecoverySaveDebounceMilliseconds(): int { return 5000; }
Security & sensitive data
Drafts are snapshots of raw form state. With the default local-storage driver they live in plaintext in the browser's localStorage — readable by anyone with access to the machine, the browser profile, or any script running on the page. No client-side scheme can change that, so treat local-storage as suitable for non-sensitive form data only, and point resources that handle sensitive data at a server-side driver:
protected ?string $draftStore = 'database';
Safeguards that apply out of the box:
- Password inputs are never drafted. Any
TextInputwith->password()in the form schema is excluded automatically, in every driver. - Common sensitive keys are excluded by default via the
excluded_fieldsconfig:password,password_confirmation,current_password,token,api_token,secret. - Other users' leftovers are pruned. When a draft-enabled page loads, localStorage drafts belonging to a different user of the same browser are removed.
- Logout purge. An explicit logout (Laravel's
Logoutevent) queues a short-lived cookie, and the next panel page render — normally the login redirect — clears all of the package's localStorage drafts (purge_on_logoutconfig, enabled by default), so drafts never outlive a logout on a shared machine. Session expiry fires noLogoutevent, so drafts from an expired session stay recoverable. Server-side drafts are unaffected either way.
Excluding fields
Exclusions merge from two places and apply to every driver, client- and server-side. Globally, in the config:
'excluded_fields' => [ // ...the defaults above, 'billing.card_number', 'members.*.ssn', ],
Per page, additive to the config:
protected function draftRecoveryExcludedFields(): array { return ['internal_notes', 'items.*.access_code']; }
Patterns use dot notation to reach nested state; * matches a single segment, such as repeater or builder item keys.
File uploads
When a file is selected in a FileUpload field, Livewire immediately moves the bytes to its temporary upload disk; the form state only holds a marker pointing at that temporary file. Whether a draft can bring a pending (not yet saved) upload back depends on the driver:
- Server-side drivers (
database, custom): pending upload markers are kept in the draft. At recovery time each marker is re-checked against Livewire's temporary upload disk — if the temporary file still exists, the upload is restored as a pending upload (and is saved normally when the form is submitted); if Livewire has already pruned it, that upload is silently dropped and the rest of the draft still recovers. local-storage: markers are always stripped. The browser cannot verify that the server-side temporary file still exists, and restoring a dead marker would break the upload field.laravel-drafts: draft data is intersected with the model's real table columns, and pending upload state never matches a column value — pending uploads are not preserved by this driver.
Files already attached to the record (edit pages) are unaffected by all of this: they are stored paths, not temporary markers, and always survive drafting.
Limitations
- The recovery window for pending uploads is bounded by Livewire's temporary file lifetime, not by
expiry_days. On local disks Livewire deletes temporary uploads older than 24 hours (triggered whenever a new upload happens); on S3 you configure expiry via a bucket lifecycle rule. A draft recovered later restores everything except its pending uploads. - The draft only references Livewire's temporary file — it does not copy the bytes. In multi-server setups the temporary upload disk (
livewire.temporary_file_upload.disk) must be shared (e.g. S3) for recovery to find the file.
Encrypting database drafts
The database driver stores payloads as plain JSON by default. To encrypt them at rest (Laravel's encrypted:array cast, using your app key):
'database' => [ 'model' => RecoverableDraft::class, 'encrypt' => true, ],
The payload column must be a text-type column — ciphertext does not fit a MySQL json column. The shipped migration uses longText; if you published an earlier version of the migration that used json, change the column type before enabling encryption.
Pages with their own lifecycle hooks
The trait clears drafts from afterCreate() / afterSave(). A page that defines its own version of either hook silently overrides the trait's — call the clear method yourself:
protected function afterSave(): void { $this->dispatchDraftRecoveryClear(); // your own logic… }
The same applies to getFooter(): if your page overrides it, include the view from Oddvalue\FilamentDraftRecovery\Concerns\RecoversDrafts::getFooter() in your footer.
If Filament gains trait-named lifecycle hook support (filamentphp/filament PR), this caveat goes away.
How it works
- The Alpine component (injected via the page footer) snapshots the Livewire form state (
$wire.data) on input/change, debounced bysave_debounce_milliseconds(default 2 seconds). - With
local-storage, drafts stay in the browser; with a server-side driver the payload is sent to the page via a Livewire call. - On return, a differing draft triggers a persistent Filament notification with Recover draft / Discard actions. Recovery merges the draft over the current form state.
- On successful save the page dispatches
draft-recovery-clear, removing the draft and stopping the auto-save timers. - Drafts expire after
expiry_days(default 7); expired localStorage entries — and entries belonging to other users of the same browser — are pruned on page load, and logging out purges all of them (see Security). - With a server-side driver, pending file uploads are drafted as Livewire temporary upload markers and validated against the temporary upload disk at recovery time — see File uploads.
Testing
composer test
Credits
Inspired by the auto-save draft & crash recovery Filament example, re-imagined with client-side storage and swappable drivers. Started from the Filament plugin skeleton.
License
MIT — see LICENSE.md.