oddvalue/filament-draft-recovery
Auto-save and crash recovery for Filament v4/v5 create and edit pages. Form state is saved as a debounced draft (2s by default), so users can recover unsaved work after a browser crash, tab close, or lost connection. Swappable storage drivers included.
Auto-save draft & crash recovery for Filament v4 and v5 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.
| 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().
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.
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;
}
The trait injects the JavaScript via the page footer. Nothing else to wire up.
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';
}
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).
saveAsAutoDraft() on the record. That is one quietly upserted working copy: never the current draft, never a new revision, read back via the record's autoDraft() relation. The record keeps is_current; intentional drafts ($record->draft) are untouched.laravel-drafts.create_store config value, falling back to your default store (or database when the default is laravel-drafts itself). Any driver works, including custom ones.discardAutoDraft(). Published rows, intentional drafts, and revision history are never touched.database if you need the full form payload.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.
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;
}
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:
TextInput with ->password() in the form schema is excluded automatically, in every driver.excluded_fields config: password, password_confirmation, current_password, token, api_token, secret.Logout event) 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_logout config, enabled by default), so drafts never outlive a logout on a shared machine. Session expiry fires no Logout event, so drafts from an expired session stay recoverable. Server-side drafts are unaffected either way.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.
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:
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, so 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 rather than temporary markers, and always survive drafting.
Limitations
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.livewire.temporary_file_upload.disk) must be shared (e.g. S3) for recovery to find the file.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, because 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.
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.
$wire.data) on input/change, debounced by save_debounce_milliseconds (default 2 seconds).local-storage, drafts stay in the browser; with a server-side driver the payload is sent to the page via a Livewire call.draft-recovery-clear, removing the draft and stopping the auto-save timers.expiry_days (default 7); expired localStorage entries, along with entries belonging to other users of the same browser, are pruned on page load, and logging out purges all of them (see Security).composer test
Inspired by the auto-save draft & crash recovery Filament example, reworked around client-side storage and swappable drivers. Started from the Filament plugin skeleton.
MIT. See LICENSE.md.
How can I help you explore Laravel packages today?