Forms
Save Lifecycle
The Form::save() method executes a strict 9-step pipeline. Each step is clearly defined with hooks for customization.
The Form::save() method executes a strict 9-step pipeline. Each step is clearly defined with hooks for customization.
This page describes what happens when a form is saved.
Pipeline Overview
Form::save()│├── 1. VALIDATE│ ├── Collect rules from all fields│ ├── Merge form-level rules│ ├── Run through ValidationPipeline│ └── Throw ValidationException on failure ← STOP│├── 2. MUTATE│ └── mutateDataBeforeSave(Closure $fn)│ Transform validated data before persistence│├── 3. PLUGIN HOOK: form.saving│ └── Plugins may inspect or modify $data│├── 4. BEFORE SAVE│ └── beforeSave(Closure $fn)│ Void hook — side effects, external calls│├── 5. PERSIST│ ├── Default: Model::create($data) or $model->update($data)│ └── Custom: using(Closure $fn)│├── 6. SAVE RELATIONSHIPS│ └── RelationshipSaveHandler cascades Repeater data to model relations│├── 7. AFTER SAVE│ └── afterSave(Closure $fn)│ Void hook — side effects, cache clear, events│├── 8. PLUGIN HOOK: form.saved│ └── Plugins observe the persisted $record│└── 9. NOTIFY ├── Send success notification via Notifications module └── Skip if disableSuccessNotification()
Step 1: Validate
Collects rules from all field components and validates the current state.
// Automatic in save()// Can also be called standalone:$data = $form->validate();
If validation fails, Illuminate\Validation\ValidationException is thrown. Steps 2-9 are skipped entirely.
See Validation for details on field rules, custom messages, and the ValidationPipeline.
Step 2: Mutate Data
Transform the validated data before it reaches the model:
$form->mutateDataBeforeSave(function (array $data): array { // Slugify the title $data['slug'] = Str::slug($data['title']); // Remove temporary fields unset($data['agree_to_terms']); // Encrypt sensitive data $data['ssn'] = encrypt($data['ssn']); return $data; // MUST return the array});
The Closure receives the validated data array and must return the modified array.
Multiple Mutations
$form ->mutateDataBeforeSave(fn (array $data) => array_merge($data, [ 'updated_by' => auth()->id(), ]));
Step 3: Plugin Hook — form.saving
Fires automatically when plugins are registered via PluginManager. Plugins may inspect or modify $data before persistence. User code does not interact with this step directly.
Step 4: Before Save
A void hook that runs after mutation but before persistence:
$form->beforeSave(function (array $data): void { // Validate external service availability if (! ExternalApi::isAvailable()) { throw new \RuntimeException('External service is down'); } // Dispatch a pre-save event event(new UserSaving($data));});
The Closure receives the mutated data but does not return it.
If this hook throws an exception, persistence (step 5) is skipped.
Step 5: Persist
Default Behavior
The persistence logic depends on the model mode:
// Create mode — model is a class string$form->model(User::class);// → User::create($data) // Edit mode — model is an instance$form->model($user);// → $user->update($data)
Custom Persistence
Override the default with using():
$form->using(function (array $data): mixed { // Create $user = User::create($data); $user->assignRole($data['role']); return $user;});
The using() callback replaces the entire default create/update logic. It receives $data (the mutated data array). The return value becomes the result of save().
No Model
If model(null) is set and no using() callback is provided, save() throws an InvalidArgumentException.
Step 6: Save Relationships
After the model is persisted, RelationshipSaveHandler cascades any Repeater field data to the model's relations. This step only runs when the persist result is an Eloquent Model instance.
User code does not interact with this step directly; it is handled automatically for Repeater fields with ->relationship() configured.
Step 7: After Save
A void hook that runs after successful persistence:
$form->afterSave(function (mixed $record): void { // $record is the created/updated Model (or using() return value) Cache::forget("user:{$record->id}"); // Dispatch event event(new UserSaved($record)); // Send notification $record->notify(new WelcomeNotification());});
Receives $record — the return value of the persist step (typically the Model instance).
Step 8: Plugin Hook — form.saved
Fires after afterSave to let plugins observe the persisted record. User code does not interact with this step directly.
Step 9: Notify
Sends a success notification via the Notifications module:
// Custom message$form->successMessage('User saved successfully!'); // Disable entirely$form->disableSuccessNotification();
The notification is sent through NotificationManager using the active driver (session, Livewire, Flasher, etc.).
This step only fires if:
- The Notifications module is available (
app()->bound()check) disableSuccessNotification()was NOT called- The save completed without exceptions
Complete Example
class EditUser extends Component{ use WithForms; public User $user; public array $data = []; public function mount(User $user): void { $this->user = $user; $this->form->fill($user->toArray()); } public function form(Form $form): Form { return $form ->statePath('data') ->model($this->user) ->schema([ TextInput::make('name')->required()->maxLength(255), TextInput::make('email')->email()->required(), Select::make('role') ->options(['admin' => 'Admin', 'editor' => 'Editor']) ->required(), Toggle::make('active'), ]) ->mutateDataBeforeSave(function (array $data): array { $data['updated_by'] = auth()->id(); return $data; }) ->beforeSave(function (array $data): void { Log::info('Updating user', ['id' => $this->user->id]); }) ->afterSave(function (mixed $record): void { Cache::forget("user:{$record->id}"); event(new UserUpdated($record)); }) ->successMessage('User updated.'); } public function save(): void { $this->form->save(); $this->redirect(route('users.index')); }}
Error Handling
| Exception | When | Effect |
|---|---|---|
ValidationException |
Step 1 fails | Steps 2-9 skipped, errors shown in UI |
Any Throwable |
Steps 2-8 throw | Pipeline aborts, no notification sent |
InvalidArgumentException |
No model + no using() |
Step 5 fails |
The save pipeline does not wrap in a database transaction by default. If you need atomicity, wrap in DB::transaction():
public function save(): void{ DB::transaction(fn () => $this->form->save());}