--- title: "Livewire 4 Components, Forms and Events" description: "Livewire 4 patterns in the Livewire kit: full-page and nested components, Form Request validation, authorization, events, modals, toasts, navigation and Alpine.js." --- # Livewire The kit runs on Livewire 4 (`livewire/livewire` ^4.1) with class-based components. There's no Inertia, no API and no hand-written fetch calls. A page is a component; a button calls a component method; a form submits to a component method. ## Full-page components A route points straight at a component class with `Route::livewire()`: ```php // Modules/User/routes/web.php Route::middleware(['auth', 'verified'])->group(function () { Route::livewire('users', Index::class) ->middleware('permission:View Users|View Tenant Users') ->name('users.index'); }); ``` The component's `render()` returns the page view, the browser title and the breadcrumbs: ```php public function render(UserService $userService): View { return view('pages.users.index', [ 'users' => $userService->getUsers($this->search ?: null), 'stats' => $userService->getStats(), ]) ->title(__('modules/user.index.title')) ->layoutData(['breadcrumbs' => [ ['title' => __('modules/common.nav.dashboard'), 'href' => route('dashboard')], ['title' => __('modules/user.breadcrumbs.users'), 'href' => route('users.index')], ]]); } ``` ```text Route::livewire → mount() → render() → view('pages.users.index') → rendered into layouts::app ($slot), with $title and $breadcrumbs ``` A page gets its data in two ways: | Source | How | Example | | --- | --- | --- | | Public properties | Read in the view as `$name`, bound with `wire:model` | `public string $search = '';` | | Data passed by `render()` | Read in the view as variables, rebuilt on every request | `'users' => $userService->getUsers(...)` | | Computed properties | `#[Computed]` methods, read as `$this->name` | `$this->deletingUser?->name` | | Route parameters | `mount()` arguments with route-model binding | `public function mount(Tenant $tenant)` | Livewire's default layout is `layouts::app`, so most components don't name one. The exceptions call `->layout()`: the landing page uses `layouts::base`, and the invitation pages use `layouts::auth` with a title and description. See [Layouts](/docs/livewire/layouts). ::: tip Keep IDs that the user mustn't change in `#[Locked]` properties (`public ?int $userId`), and load the model again inside each action. The kit never stores Eloquent models in public properties. ::: ## Nested components Modals and widgets that have their own state and actions are separate components, placed in a page view with the module prefix: ```blade ``` | Kind | Class | View | | --- | --- | --- | | Page-only modal | `Modules/User/Livewire/UserFormModal.php` | `pages/users/partials/user-form-modal.blade.php` | | Page-only modal | `Modules/Tenant/Livewire/EditTenantModal.php` | `pages/tenants/partials/edit-tenant-modal.blade.php` | | Shared widget | `Modules/Notification/Livewire/Bell.php` | `livewire/notification-bell.blade.php` | | Shared widget | `Modules/Settings/Livewire/DeleteAccount.php` | `livewire/delete-account.blade.php` | Pass props as attributes, the same way as on a Blade component: ```blade ``` If a piece of UI has no actions of its own, make it a Blade partial instead of a component. The tenant delete dialog is `x-pages::tenants.partials.delete-tenant-modal`, a plain Blade component whose button calls `delete` on the page component. ## Forms Fields bind to public properties with `wire:model`, and the form submits to a method with `wire:submit`: ```blade
{{ __('modules/user.form_modal.create') }} ``` | Binding | Sends the value | Used for | | --- | --- | --- | | `wire:model` | With the next action | Most fields | | `wire:model.live` | On every change | Toggles that show or hide fields (`send_invitation`) | | `wire:model.live.debounce.400ms` | After typing stops | Company name → subdomain on Create tenant | | `wire:model.live.debounce.300ms` | After typing stops | `x-search-input` | The `x-common` fields show the validation error for their `wire:model` key on their own, so you don't add error markup. ### Validation with Form Requests Every write is validated by a Form Request in `Modules//Http/Requests`. Components call `validateFormRequest()` from `App\Livewire\Concerns\ValidatesFormRequests`: ```php use App\Livewire\Concerns\ValidatesFormRequests; class Profile extends Component { use ValidatesFormRequests; public function save(ProfileService $profileService): void { $validated = $this->validateFormRequest(ProfileUpdateRequest::class, [ 'name' => $this->name, 'email' => $this->email, 'locale' => $this->locale ?: null, ]); $profileService->update(Auth::user(), $validated); Flux::toast(variant: 'success', text: __('modules/settings.toasts.profile_updated')); $this->redirectRoute('profile.edit', navigate: true); } } ``` | Argument | Purpose | | --- | --- | | `$formRequest` | The Form Request class | | `$data` | The values to validate, usually `$this->only([...])` | | `$routeParameters` | Values the rules read with `$this->route()`, e.g. `['user' => $user]` for a unique rule that ignores the current record | | `$errorPrefix` | Prefix for the error keys, used by Livewire Form objects (`form.company`) | The method builds the Form Request from the current request, runs `authorize()`, `rules()`, `messages()` and `attributes()` exactly as a controller would, and returns `validated()`. On failure it throws the usual `ValidationException`, and Livewire shows the messages next to the fields. Before validating, it trims strings and turns empty strings into `null`, like Laravel's middleware does for normal requests. `password`, `current_password` and `password_confirmation` aren't trimmed. ::: warning Don't add `rules()` methods or `#[Validate]` attributes to components. Keep the rules in the Form Request so they're written once. ::: ::: details View the Form Request (UserRequest) ```php public function rules(): array { $user = $this->route('user'); $userId = $user instanceof User ? $user->id : $user; $requiresPassword = fn (): bool => ! $userId && ! $this->boolean('send_invitation'); return [ 'name' => ['required', 'string', 'max:255'], 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', Rule::unique(User::class, 'email')->ignore($userId)], 'password' => [Rule::requiredIf($requiresPassword), 'nullable', 'confirmed', Password::defaults()], 'password_confirmation' => [Rule::requiredIf($requiresPassword), 'nullable', 'string'], 'role' => ['nullable', 'string', Rule::exists('roles', 'name')->where('guard_name', tenancy()->initialized ? 'tenant' : 'web')], 'send_invitation' => ['nullable', 'boolean'], ]; } ``` ::: ### Form objects Big forms keep their fields in a Livewire Form object. `TenantForm` is shared by Create tenant and the edit modal, and validates through the same trait with an error prefix, so errors land on `form.company`, `form.domain` and so on: ```php class TenantForm extends Form { use ValidatesFormRequests; #[Locked] public ?int $tenantId = null; public string $company = ''; public function validated(): array { return $this->validateFormRequest( TenantRequest::class, $this->except('tenantId'), ['tenant' => $this->tenantId], $this->getPropertyName(), ); } } ``` ```blade ``` ### Dirty state and discard Settings forms use `x-common.button-row`. It shows up through `wire:dirty` once a field changes, and its Discard button calls a method that reloads the values: ```php public function discard(): void { $this->fillFromUser(); $this->resetValidation(); } ``` ## Authorization Route middleware decides who can open a page. Because `AppServiceProvider` registers the auth, central-only and permission middleware as Livewire persistent middleware, they run again on every request the page makes afterwards. Actions that need a different permission from the page call `authorizePermission()`. List the central and the tenant permission; the user needs any one of them: ```php use App\Livewire\Concerns\AuthorizesPermissions; public function delete(UserService $userService): void { $this->authorizePermission('Delete User', 'Delete Tenant User'); // ... } ``` In the view, hide the button with `@can` / `@canany`. Both are covered in [Architecture → Permissions](/docs/livewire/architecture#permissions-on-the-frontend). ### Throttling `App\Livewire\Concerns\ThrottlesActions` adds `throttled($action, $maxAttempts, $decaySeconds = 60)`. It returns `true` and shows a danger toast once the limit is hit. The key is the component class, the action and the user ID (or IP for guests). ```php if ($this->throttled('resend-invitation:'.$tenant->id, 3)) { return; } ``` The kit uses it for resending a tenant invitation, the tenant admin password reset and accepting invitations. ## Events Components talk to each other with Livewire events. The kit follows one naming pattern: `open-` opens a modal component, and `-saved` tells the page to refresh. ```text Page button: wire:click="$dispatch('open-user-form', { userId: 5 })" → UserFormModal #[On('open-user-form')] open(?int $userId) → Flux::modal('user-form')->show() → save() → $this->dispatch('user-saved') → Users Index #[On('user-saved')] → re-renders the list ``` ```blade ``` ```php #[On('open-user-form')] public function open(?int $userId = null): void { $this->reset(); $this->resetValidation(); // fill the fields when editing ... Flux::modal('user-form')->show(); } ``` | Event | Listened to by | Sent by | | --- | --- | --- | | `open-user-form`, `open-user-permissions` | `UserFormModal`, `AssignPermissionsModal` | Users page | | `user-saved` | Users page | Both user modals | | `open-role-form` / `role-saved` | `RoleFormModal` / Roles page | Roles page / `RoleFormModal` | | `open-tenant-form` / `tenant-saved` | `EditTenantModal` / Tenants list and detail | Tenant pages / `EditTenantModal` | | `open-reset-password` | `ResetPasswordModal` | Tenant detail | | `open-domain-form`, `open-domain-settings`, `open-domain-auth-features` / `domain-saved` | Domain modals / pages using `ManagesDomains` | Domain actions / domain modals | | `start-two-factor-setup` / `two-factor-enabled` | `TwoFactorSetupModal` / `Security` | Security page / `TwoFactorSetupModal` | | `notifications-updated` | `Bell` and the Notifications page | `Bell` and the Notifications page | | `permission-picker-opened` | The Alpine permission picker (`x-on:permission-picker-opened.window`) | `AssignPermissionsModal` | A listener with an empty body, such as `#[On('tenant-saved')] public function refreshTenant(): void {}`, is enough to make the component render again with fresh data. ## Modals A modal is an `x-common.modal` with a `name`. Livewire components open and close it from PHP: ```php Flux::modal('user-form')->show(); Flux::modal('user-form')->close(); ``` In Blade you can open one without a round trip, or close it from a Cancel button: ```blade {{ __('modules/security.two_factor.enable') }} {{ __('modules/user.form_modal.cancel') }} ``` When a modal edits a record, put it in its own component (as `UserFormModal` does) and open it with an `open-…` event. Then the page doesn't hold the modal's fields, and the modal can reset itself on every open. ### Confirm and delete Deletes go through `x-common.confirm-dialog`. The page keeps the selected ID in a locked property, shows the record through a computed property and runs the delete in a second action: ```php #[Locked] public ?int $deletingUserId = null; public function confirmDelete(int $userId): void { $this->authorizePermission('Delete User', 'Delete Tenant User'); $this->deletingUserId = $userId; Flux::modal('delete-user')->show(); } public function delete(UserService $userService): void { $this->authorizePermission('Delete User', 'Delete Tenant User'); $user = User::query()->findOrFail($this->deletingUserId); Flux::modal('delete-user')->close(); $this->deletingUserId = null; $userService->deleteUser($user); Flux::toast(variant: 'success', text: __('modules/user.toasts.deleted')); } #[Computed] public function deletingUser(): ?User { return $this->deletingUserId ? User::query()->find($this->deletingUserId) : null; } ``` ```blade ``` ## Toasts Components show toasts with Flux: ```php Flux::toast(variant: 'success', text: __('modules/tenant.toasts.created')); Flux::toast(variant: 'danger', text: __('modules/user.toasts.cannot_delete_self')); ``` The kit uses `success` and `danger`. The toast container is in `layouts/base.blade.php` inside `@persist('toast')`, so every page has it (auth pages too) and a toast sent just before a `wire:navigate` redirect still shows on the next page. ::: details View the toast container (layouts/base.blade.php) ```blade class('min-h-screen antialiased') }}> {{ $slot }} @persist('toast') @endpersist @fluxScripts ``` ::: ## Navigation | Need | Use | Example in the kit | | --- | --- | --- | | Link to another page | `` or `:href` on `x-common.button` | Sidebar, breadcrumbs, "Back" on tenant detail | | Redirect after an action | `$this->redirectRoute('name', navigate: true)` | Create tenant → tenant list | | Full page redirect | `$this->redirectRoute('dashboard')` | After accepting an invitation and signing in | | Keep filters in the URL | `#[Url]` properties | `search` and `status` on the tenant list | | Paginate | `WithPagination` + `x-table-card` | Users, roles, tenants | | Reorder rows | `wire:sort`, `wire:sort:item`, `wire:sort:handle` | Setup → Menus calls `sortMenu($menuId, $position)` | ```php use WithPagination; #[Url(except: '')] public string $search = ''; #[Url(except: 'all')] public string $status = 'all'; public function updatedSearch(): void { $this->resetPage(); } ``` Sign-in, registration and the other Fortify pages are classic forms that `POST` to Fortify's routes with `@csrf`. Logout in `x-user-menu` is a `POST` form to `route('logout')` as well. ## Alpine.js Alpine ships with Livewire. Use it for behaviour that doesn't need the server: dropdowns in the header navbar, the appearance switch (`$flux.appearance`), the recovery-code toggle on the two-factor challenge, and the permission picker. Bigger Alpine components go in their own file in `resources/js` and are registered in `app.js`: ```js import 'iconify-icon'; import permissionPicker from './permission-picker'; document.addEventListener('alpine:init', () => { window.Alpine.data('permissionPicker', permissionPicker); }); ``` The picker keeps its selection in sync with the component through `$wire.entangle()`, so the checkboxes update instantly and `save()` receives the final list. Trimmed from `pages/users/partials/assign-permissions-modal.blade.php`: ```blade
``` ::: tip `@js()` works on plain HTML tags like the `
` above. On a component tag (``), use {{ Js::from($value) }} instead, because Blade doesn't compile directives inside component attributes. :::