- Cookbook
- edit-in-a-modal
Edit a row in a modal
A table of team members, an Edit button per row, a modal form that validates, saves, closes and confirms with a toast.
The result
Click Edit on a row, change the name, email or role, save. Try an email already used by someone else, or leave the name empty.
| Member | Role | Actions |
|---|---|---|
| Amara Diallo amara@acme.dev | Owner | |
| Marc Dupont marc@acme.dev | Admin | |
| Sarah Chen sarah@acme.dev | Member | |
| Tom Rivera tom@acme.dev | Viewer |
What it combines
- Table for the list, Modal for the form, Listbox for the role, Toast for the confirmation.
- One Livewire component: the modal lives next to the table it edits, so there is no state to pass around.
The component
The demo keeps its rows in memory; here is the same component with Eloquent. Members are read through the team of the person signed in, so an id from another team is never found.
resources/views/components/⚡team-members.blade.php
<?php
use App\Enums\Role;
use App\Models\Member;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Component;
new class extends Component
{
#[Locked]
public ?int $editingId = null;
public string $name = '';
public string $email = '';
public string $role = '';
/** @return Collection<int, Member> */
#[Computed]
public function members(): Collection
{
return auth()->user()->team->members()->orderBy('name')->get();
}
public function edit(int $id): void
{
$member = $this->findMember($id);
$this->editingId = $member->id;
$this->name = $member->name;
$this->email = $member->email;
$this->role = $member->role->value;
$this->resetValidation();
$this->dispatch('modal:edit-member:open');
}
public function save(): void
{
$member = $this->findMember($this->editingId);
$validated = $this->validate([
'name' => ['required', 'string', 'max:80'],
'email' => ['required', 'email', 'max:255', Rule::unique('members')->ignore($member)],
'role' => ['required', Rule::enum(Role::class)],
]);
$member->update($validated);
$this->dispatch('modal:edit-member:close');
$this->dispatch('toast', type: 'success', message: "{$member->name} updated");
$this->reset('editingId', 'name', 'email', 'role');
}
private function findMember(?int $id): Member
{
abort_if($id === null, 404);
$member = auth()->user()->team->members()->findOrFail($id);
$this->authorize('update', $member);
return $member;
}
};
?>
The view
resources/views/components/⚡team-members.blade.php
<div>
<x-ui.table>
<x-ui.table.columns>
<x-ui.table.column>Member</x-ui.table.column>
<x-ui.table.column>Role</x-ui.table.column>
<x-ui.table.column align="right"><span class="sr-only">Actions</span></x-ui.table.column>
</x-ui.table.columns>
<x-ui.table.rows>
@foreach ($this->members as $member)
<x-ui.table.row wire:key="member-{{ $member->id }}">
<x-ui.table.cell>
<span class="block font-medium">{{ $member->name }}</span>
<span class="block text-sm text-muted-foreground">{{ $member->email }}</span>
</x-ui.table.cell>
<x-ui.table.cell>
<x-ui.badge variant="soft" intent="gray" size="sm">{{ $member->role->label() }}</x-ui.badge>
</x-ui.table.cell>
<x-ui.table.cell align="right">
<x-ui.button size="sm" variant="ghost" intent="gray"
wire:click="edit({{ $member->id }})" aria-label="Edit {{ $member->name }}">
Edit
</x-ui.button>
</x-ui.table.cell>
</x-ui.table.row>
@endforeach
</x-ui.table.rows>
</x-ui.table>
<x-ui.modal id="edit-member">
<x-ui.modal.content size="md">
<x-ui.modal.header title="Edit member" description="Changes apply as soon as you save." />
<form wire:submit="save">
<x-ui.modal.body class="gap-5">
<x-ui.field label="Name" for="member-name" :error="$errors->first('name')">
<x-ui.input id="member-name" wire:model="name" :invalid="$errors->has('name')" />
</x-ui.field>
<x-ui.field label="Email" for="member-email" :error="$errors->first('email')">
<x-ui.input id="member-email" type="email" wire:model="email" :invalid="$errors->has('email')" />
</x-ui.field>
<x-ui.listbox wire:model="role" label="Role" :options="App\Enums\Role::cases()" />
</x-ui.modal.body>
<x-ui.modal.footer justify="end">
<x-ui.modal.close variant="outline" intent="gray">Cancel</x-ui.modal.close>
<x-ui.button type="submit" wire:loading.attr="disabled" wire:target="save">
<span wire:loading.remove wire:target="save">Save changes</span>
<span wire:loading wire:target="save">Saving…</span>
</x-ui.button>
</x-ui.modal.footer>
</form>
</x-ui.modal.content>
</x-ui.modal>
</div>
The listbox reads the enum directly: each case becomes an option, and its label()
method, when it has one, becomes the text.
How it works
-
Edit calls
edit($id). The component loads the member, fills the form, clears old errors, then dispatchesmodal:edit-member:open. The modal opens once the fields are filled, never empty for a split second. -
The form posts with
wire:submit: Enter saves, andwire:modelsends nothing until then — one request per save. -
save()loads the member again from the database. It does not trust anything kept sinceedit(). -
On success, the component closes the modal with
modal:edit-member:close, shows a toast and resets the form. On error, the modal stays open with the messages under their fields.
Security
-
#[Locked]on$editingId: the browser cannot change which row is saved. Without it, someone could edit a member they never opened. -
The member is read through the team (
team->members()), then checked with a policy (authorize('update')). Either one alone would stop most mistakes; both cover a forgotten scope or a forgotten policy. - Every field is validated on save, the role against the enum, the email as unique except for this member.
Variations
-
Create and edit in one modal: leave
$editingIdnull for a new member, and callMember::create()instead ofupdate()insave(). -
Many fields: move them to a
Livewire form object
and keep
editingIdin the component. -
Open without waiting: put
x-on:click="$modal('edit-member').showModal()"next towire:clickand add a loading state in the modal body. Only worth it when loading the record is slow.