Draft Recovery plugin screenshot
Dark mode ready
Multilingual support
Supports v5.x

Draft Recovery

Community

Auto-save draft & crash recovery for Filament v4 create/edit pages, with swappable storage drivers.

Tags: Forms Action
Supported versions:
5.x 4.x
Jim avatar Author: Jim

Package health

Beta

Automated checks of this plugin's Composer package

100 / 100
Security 100
Maintenance 100
Ecosystem 100
15 checks
  • Passed: GitHub Actions pinned to SHA
  • Skipped: GitLab CI includes pinned to SHA
  • Passed: Open security advisories
  • Passed: Dependabot PR responsiveness — No open Dependabot PRs.
  • Skipped: Renovate MR responsiveness
  • Passed: Dependabot or Renovate configured
  • Passed: Dependency update cooldown configured
  • Passed: Provides a security policy
  • Passed: Abandoned or archived — No consulted source marks the package abandoned (packagist, github).
  • Passed: Commit and release recency — Active: last commit 0 days ago; last release 1 days ago.
  • Passed: composer.lock not committed by library composer.lock is absent from the released dist archive.
  • Passed: Dist archive is lean
  • Passed: Current Laravel version supported — Package dependencies resolve together with current Laravel 13.0.
  • Passed: Current PHP version supported — Constraint ^8.2 supports current PHP 8.5.
  • Skipped: Current Symfony version supported
Third-party plugin. This is built by the community, not the Filament team. Filament does not review, endorse, or vet the security of plugins outside the filament/ namespace. Review the source and install at your own risk. Found malware or an unresolved security issue the author won't address? Report it .
Powered by Plumb Last scanned 1 day ago

Documentation

Filament Draft Recovery

Latest Version on Packagist PHP Support Filament Support GitHub Tests Action Status GitHub Code Style Action Status Total Downloads Coverage

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.

#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's autoDraft() relation. The record keeps is_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_store config value, falling back to your default store (or database when the default is laravel-drafts itself). 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 database if 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 TextInput with ->password() in the form schema is excluded automatically, in every driver.
  • Common sensitive keys are excluded by default via the excluded_fields config: 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 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.

#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 by save_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.

The author

Jim avatar Author: Jim

UK based lead dev & long time Filament appreciator.

Plugins
1