lenorix / filament-autosave
Save Filament forms automatically, with a live status indicator, undo support, and local drafts
Requires
- php: ~8.4.0 || ~8.5.0
- filament/filament: ^4.0 || ^5.0
- illuminate/contracts: ^12.0 || ^13.0
- spatie/laravel-package-tools: ^1.93.2
Requires (Dev)
- filament/spatie-laravel-media-library-plugin: ^4.0 || ^5.0
- laravel/pint: ^1.32
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^4.7.8 || ^5.1.4
- phpstan/phpstan: ^2.2
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Filament Autosave gives your forms a safety net: it saves changes after a short pause, keeps unfinished forms as drafts, and lets users undo their latest edit.
- Edit pages: eligible changes are saved to the database automatically.
- Create pages: unfinished values are kept in Laravel Cache until the form is submitted.
- Any form: relation managers, actions, modals, table forms, and standalone
Livewire components can opt in with
HasAutosaveForForm.
Requirements
- PHP 8.4 or 8.5
- Laravel 12 or 13
- Filament 4 or 5
- Livewire 3 with Filament 4, or Livewire 4 with Filament 5
The test suite covers both PHP versions, both Filament versions, and both Laravel versions.
Installation
composer require lenorix/filament-autosave
Register the plugin in your panel provider:
use Lenorix\FilamentAutosave\AutosavePlugin; public function panel(Panel $panel): Panel { return $panel->plugin(AutosavePlugin::make()); }
The status indicator is included automatically and uses Filament's built-in components. No extra frontend build step is required.
Publish configuration, translations, or views only when you need to customise them:
php artisan vendor:publish --tag="filament-autosave-config" php artisan vendor:publish --tag="filament-autosave-translations" php artisan vendor:publish --tag="filament-autosave-views"
Quick start
Edit pages
Add HasAutosave to an Edit page:
use Filament\Resources\Pages\EditRecord; use Lenorix\FilamentAutosave\HasAutosave; class EditArticle extends EditRecord { use HasAutosave; protected static string $resource = ArticleResource::class; }
The browser watches the form's state path automatically. After 1.5 seconds without changes, the page saves the eligible state and updates the indicator. After a successful save, users can undo it for a short time.
The locked autosaveDataPath property contains the resolved state path when it
is useful to integrate with custom frontend code.
Create pages
Add HasAutosaveForCreate to keep an unfinished form as a draft:
use Filament\Resources\Pages\CreateRecord; use Lenorix\FilamentAutosave\HasAutosaveForCreate; class CreateArticle extends CreateRecord { use HasAutosaveForCreate; protected static string $resource = ArticleResource::class; }
Drafts can be restored or discarded when the user returns. They are removed after a successful create, including “Create and create another”, and remain available when validation fails.
The same trait works on custom pages that use the default data state path:
use Filament\Pages\Page; use Lenorix\FilamentAutosave\HasAutosaveForCreate; class UserPreferences extends Page { use HasAutosaveForCreate; public ?array $data = []; public function save(): void { // Persist the form state here. $this->clearAutosaveDraft(); } }
Create and custom-page drafts do not create records, store permanent uploads, or attach Spatie media before the user explicitly submits the form.
What gets saved?
Autosave works with Filament's dehydrated form state. Dehydration transforms are
applied, while fields marked dehydrated(false) are left out.
| Field | Autosaved? |
|---|---|
| Regular field backed by a database column | Yes |
Repeater or CheckboxList stored in one column |
Yes |
Relationship field with a top-level saveRelationships() callback |
Yes, when changed |
RichEditor, with or without a file attachment provider |
Yes, as a column; attachments cleaned up via its callback |
FileUpload backed by a column, including nested fields |
Yes, after upload validation |
Top-level SpatieMediaLibraryFileUpload |
Yes, changed collections only |
FileUpload or SpatieMediaLibraryFileUpload inside a relationship Repeater row |
Yes, with the row's relationship write |
SpatieMediaLibraryFileUpload inside a JSON (non-relationship) repeater |
No |
| Relationships inside groups, repeaters, and builders | Yes, when the relationship changes |
Other dehydrated(false) fields |
No |
Groups, sections, repeaters, and builders stored in one column are treated as a single value. The container is written only when all of its children pass the safety checks; if one child is invalid or incomplete, the existing column is left untouched. This also prevents a container containing a password field from being saved, while unrelated fields can continue to autosave.
Validation and hooks
Autosave uses Filament's form state and validation pipeline, then applies the
package's additional safety checks and any rules returned by
getAutosaveValidationRules():
- blank required values are skipped, so
NOT NULLcolumns are not overwritten; Select,CheckboxList, andToggleButtonsvalues must be allowed values, including tenant- or team-specific options;- invalid fields are skipped so valid, unrelated fields can still be saved;
- nested rules such as
items.*.qtydiscard the affected top-level container.
protected function beforeAutosave(array $data): array { return $data; } protected function getAutosaveValidationRules(): array { return ['slug' => ['required', 'string', 'max:120']]; } protected function afterAutosave(object $record): void { Cache::forget("user-{$record->id}"); }
beforeAutosave() runs with the complete eligible state, so cross-field rules
can inspect unchanged siblings. mutateFormDataBeforeSave() receives the
column and relationship state after pending Spatie media fields have been
removed. dirty_only is applied immediately before the column write.
On Edit pages, a successful autosave runs Filament's save lifecycle, including
beforeValidate, afterValidate, beforeSave, and afterSave, dispatches
RecordUpdated and RecordSaved, and sends the standard saved notification.
Use afterAutosave() for package-specific work that should run after each
autosave.
Explicit saves with flushAutosave()
autosave() is designed for the background loop: it reports failures through
the indicator and never throws. Explicit actions — a "Round prices" button, an
"Add from catalogue" modal — usually need the opposite: the same dirty-only
write, refresh, and Undo behaviour, but with errors reaching the caller.
Action::make('roundPrices') ->action(function (): void { $this->data['price'] = round($this->data['price']); $written = $this->flushAutosave(); });
flushAutosave() runs one cycle synchronously and returns whether anything was
written. Validation errors abort the cycle before any write and are thrown as a
ValidationException keyed by state path (data.title), so Filament shows
them inline. Exceptions thrown by beforeAutosave(), custom rules, hooks, or
persistence propagate unchanged, and Filament's Halt propagates so the
surrounding action can stop cleanly. It is available on every autosave trait.
Undo
Undo is available for five seconds after a successful Edit save. The snapshot
itself is kept for 90 minutes by default; configure that period with
getUndoTtlMinutes() or undo_ttl.
Undo restores the previous values for:
- model columns, including
BelongsToandMorphTokeys; BelongsToManypivot data;HasOneandHasManychild records;- supported
HasManyThroughgraphs, including rows that must be restored, updated, or removed.
Before restoring anything, Undo checks that the current value still matches the value written by the autosave. If another user changed that same value, Undo is cancelled with a conflict status so their update is preserved. Changes to other columns do not block the one-step Undo.
Undo also runs the relevant Filament save hooks and events and sends the normal
saved notification. It is limited to the current live page instance.
Nested relationship snapshots are bounded by relationship_undo_depth (eight
levels by default). If a pending graph exceeds that limit, Undo is disabled for
the cycle instead of restoring only part of the graph.
File operations and RichEditor attachment operations keep Undo disabled by
default because a database transaction cannot roll back filesystem or external
storage changes. Register a reversible AutosaveExternalUndoAdapter when the
provider can snapshot, compare, and restore its state; if any changed external
field lacks an adapter, the whole Undo operation remains disabled for safety.
Forms outside resource pages
Relation managers, action and modal forms, table forms, and standalone Livewire
components can use HasAutosaveForForm:
use Lenorix\FilamentAutosave\HasAutosaveForForm; class EditCommentAction extends RelationManager { use HasAutosaveForForm; public ?array $data = []; protected function getAutosaveFormContext(): string { return 'comment:'.($this->ownerRecord->getKey() ?? 'new'); } }
Call mountHasAutosaveForForm() from mount() and include the indicator in the
component view:
@include('filament-autosave::autosave-indicator', [ 'mode' => 'form', 'debounce' => $autosaveDebounceMs ?? 1500, ])
By default, the trait stores drafts for forms without an existing record. When the resolved schema is bound to an Eloquent record — for example, an Edit action or modal — it can persist columns and invoke relationship callbacks, run the standard save lifecycle, and provide one-step Undo for changed columns and supported relationships with optimistic conflict detection.
Action and table forms often keep their state under mountedActions.*.data.
Point the trait to the active schema and state path when necessary:
protected function resolveAutosaveForm(): ?object { return $this->getMountedActionSchema(); } protected function getAutosaveStatePath(): string { return 'mountedActions.'.array_key_last($this->mountedActions).'.data'; }
Override persistAutosaveForm() when the component has a custom action
lifecycle or side effects beyond the form schema. Use a context that identifies
the owner, record, or action so unrelated forms never share a draft.
Set require_form_context to true to turn a missing context into a
LogicException; this is recommended for reusable Relation Manager, action,
modal, and table-form components.
Record-backed generic forms use the same upload lifecycle as Edit pages, while recordless drafts never store permanent files or media. Generic Undo snapshots cover model columns and supported relationship state. File, media, and RichEditor attachment operations remain outside Undo unless a reversible external adapter is registered; host actions still own any additional side effects.
External Undo can be enabled safely for a provider by registering an
AutosaveExternalUndoAdapter in external_undo_adapters. The adapter must
implement supports, snapshot, matches, and restore. Fields without a
reversible adapter keep Undo disabled.
For a generic form with dirty_only enabled, each partial autosave is merged
into its existing draft so earlier field changes remain available. Empty and
null values are retained as explicit deletions.
File uploads
Edit pages get upload support automatically through HasAutosave; they do not
need to use HasAutosaveUploads directly.
FileUpload supports new files, removal, and reordering. Upload validation runs
before permanent storage, including file size and type rules. An invalid upload
leaves its entire column untouched, while unrelated fields can still save.
The following are skipped:
storeFiles(false)uploads;- disabled or hidden upload fields;
- excluded fields;
- incomplete nested containers.
The controller waits for active uploads to finish, and its request-end hash also
detects server-side actions such as removing a row or reordering files.
Livewire's temporary upload is used as the staging area until validation
passes. Permanent paths are tracked until the owning database transaction has
run its afterCommit callbacks, so a rollback can remove every path created by
the cycle.
SpatieMediaLibraryFileUpload uses its relationship callback for additions,
removals, and ordering. Unchanged collections are not synchronised. Install
Filament's Spatie plugin in the host application to use it; the plugin is only a
development dependency of this package.
Upload fields inside a Repeater bound to a relationship are persisted
together with that relationship, on Edit pages and record-backed generic forms. Media in an existing row is attached
to the row's own record; media in a new row is attached once the relationship
component has created the row. If any field in the repeater fails validation,
the whole relationship write is skipped and no file is stored. Media inside a
repeater stored in a JSON column is not autosaved, because every row would
share the parent record's media collection.
Autosaves involving files keep Undo disabled by default. A registered external
adapter can opt a provider into reversible Undo; without one, a later
validation, hook, relationship, or database write failure still triggers
cleanup of new paths and tracked media where the provider supports it.
Additional side effects performed by a custom storage callback remain the
application's responsibility. Removing a normal FileUpload path updates the
column, and physical deletion follows the component's configured behaviour.
Newly stored paths are also recorded in a short-lived cleanup ledger. Register the pruning command in the host scheduler so an interrupted PHP process cannot leave those paths indefinitely:
$schedule->command('filament-autosave:prune-uploads')->everyThirtyMinutes();
The ledger is a recovery net for storage providers; database and filesystem transactions still cannot commit as one distributed transaction.
Configuration
Values are resolved in this order: config, plugin, then page. The last value
wins, while except entries are merged across levels.
| Option | Config | Plugin | Page |
|---|---|---|---|
debounce (milliseconds) |
Yes | Yes | Yes |
except (field names) |
Yes | Yes | Yes |
draft_ttl (hours) |
Yes | Yes | No |
undo_ttl (minutes) |
Yes | Yes | No |
dirty_only |
Yes | No | No |
refresh_unchanged_fields |
Yes | No | No |
require_form_context |
Yes | No | No |
relationship_undo_depth |
Yes | No | No |
external_undo_adapters |
Yes | No | No |
upload_ledger_ttl (minutes) |
Yes | No | No |
show_saved_at |
Yes | Yes | No |
position |
Yes | Yes | No |
exceptPages |
No | Yes | No |
Page settings are methods. Do not redeclare properties supplied by either trait; conflicting trait properties cause a PHP fatal error.
AutosavePlugin::make() ->debounce(2000) ->cacheTtl(48) // draft lifetime in hours ->undoCacheTtl(45) // snapshot lifetime in minutes ->except(['internal_notes']) ->exceptPages([EditPayment::class]) ->showTimestamp(false) ->indicatorPosition('after');
Equivalent page and config settings include:
protected function autosaveDebounce(): ?int { return 2000; } protected function autosaveExcept(): array { return ['internal_notes']; } protected function shouldAutosave(): bool { return true; }
// config/filament-autosave.php 'debounce' => 1500, 'except' => ['password', 'password_confirmation'], 'draft_ttl' => 24, 'undo_ttl' => 90, 'dirty_only' => true, 'refresh_unchanged_fields' => true, 'require_form_context' => false, 'relationship_undo_depth' => 8, 'external_undo_adapters' => [], 'upload_ledger_ttl' => 180,
shouldAutosave() is evaluated server-side and cannot be changed from the
browser. The indicator accepts before or after as its position; when a page
has no usable header, it is rendered at the end of the page.
Dirty-only updates
With dirty_only enabled, Edit autosave tracks a hash for each top-level field
and writes only fields that changed since the last successful save. Hashes
survive Livewire requests, while the original values do not need to be kept in
memory. Manual saves and Undo reset the baseline.
refresh_unchanged_fields can refresh untouched model-backed fields after a
successful autosave. Local dirty values remain in the form. This is a
response-time refresh, not polling, and concurrent edits to the same column are
still last-write-wins. Set dirty_only to false to send the complete eligible
column payload instead. File operations remain limited to changed upload fields
in either mode.
Nested groups and repeaters are compared as one value when they are stored in a single database column.
Sensitive data
Autosave removes or skips:
- temporary uploads in Create/custom-page drafts;
- password fields marked with
password()ortype('password'), at any depth; - fields listed in
except; - client keys that are not declared form fields, at any depth.
The except list matches top-level names. For nested secrets, use a password
field or dehydrated(false). Create drafts read raw form state before autosave
validation and dehydration transforms, so explicitly exclude any secret that
must never be cached.
Translations
php artisan vendor:publish --tag="filament-autosave-translations"
Available labels include unsaved, saving, saved, saved_at, undo,
undone, conflict, error, draft_available, restore, discard, and
restored. Validation messages use the validation translation key and the
indicator lists their pending field paths.
Testing
composer test
vendor/bin/pest --testsuite=Unit
vendor/bin/pest --testsuite=Integration