
Laravel Livewire
- 127 installs
- 22 repo stars
- Updated August 3, 2026
- fusengine/agents
For development and infrastructure management.
About
laravel-livewire is an AI coding tool that enhances development workflows. Builders use it for infrastructure, integration, and platform development within the catalog ecosystem.
- laravel-livewire
- Development
Laravel Livewire by the numbers
- 127 all-time installs (skills.sh)
- Ranked #2,750 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/fusengine/agents --skill laravel-livewireAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 127 |
|---|---|
| repo stars | ★ 22 |
| Last updated | August 3, 2026 |
| Repository | fusengine/agents ↗ |
What it does
For development and infrastructure management.
Files
Laravel Livewire
Agent Workflow (MANDATORY)
Before ANY implementation, use TeamCreate to spawn 3 agents:
1. fuse-ai-pilot:explore-codebase - Check existing Livewire components 2. fuse-ai-pilot:research-expert - Verify Livewire 3 patterns via Context7 3. mcp__context7__query-docs - Check specific Livewire features
After implementation, run fuse-ai-pilot:sniper for validation.
---
Overview
| Feature | Description |
|---|---|
| Components | Reactive PHP classes with Blade views |
| wire:model | Two-way data binding |
| Actions | Call PHP methods from frontend |
| Events | Component communication |
| Volt | Single-file components |
| Folio | File-based routing |
---
Critical Rules
1. Always use wire:key in loops 2. Use wire:model.blur for validation, not .live everywhere 3. Debounce search inputs with .debounce.300ms 4. #[Locked] for sensitive IDs 5. authorize() in destructive actions 6. protected methods for internal logic
---
Decision Guide
Component Type
Component choice?
├── Complex logic → Class-based component
├── Simple page → Volt functional API
├── Medium complexity → Volt class-based
├── Quick embed → @volt inline
└── File-based route → Folio + VoltData Binding
Binding type?
├── Form fields → wire:model.blur
├── Search input → wire:model.live.debounce.300ms
├── Checkbox/toggle → wire:model.live
├── Select → wire:model
└── No sync → Local Alpine x-data---
Reference Guide
Core Concepts (WHY & Architecture)
| Topic | Reference | When to Consult |
|---|---|---|
| Components | components.md | Creating components |
| Wire Directives | wire-directives.md | Data binding, events |
| Lifecycle | lifecycle.md | Hooks, mount, hydrate |
| Forms | forms-validation.md | Validation, form objects |
| Events | events.md | Dispatch, listen |
| Alpine | alpine-integration.md | $wire, @entangle |
| File Uploads | file-uploads.md | Upload handling |
| Nesting | nesting.md | Parent-child |
| Loading | loading-states.md | wire:loading, lazy |
| Navigation | navigation.md | SPA mode |
| Testing | testing.md | Component tests |
| Security | security.md | Auth, rate limit |
| Volt | volt.md | Single-file components |
Advanced Features
| Topic | Reference | When to Consult |
|---|---|---|
| Folio | folio.md | File-based routing |
| Precognition | precognition.md | Live validation |
| Reverb | reverb.md | WebSockets |
Templates (Complete Code)
| Template | When to Use |
|---|---|
| BasicComponent.php.md | Standard component |
| FormComponent.php.md | Form with validation |
| VoltComponent.blade.md | Volt patterns |
| DataTableComponent.php.md | Table with search/sort |
| FileUploadComponent.php.md | File uploads |
| NestedComponents.php.md | Parent-child |
| ComponentTest.php.md | Testing patterns |
---
Quick Reference
Basic Component
class Counter extends Component
{
public int $count = 0;
public function increment(): void
{
$this->count++;
}
public function render()
{
return view('livewire.counter');
}
}Volt Functional
<?php
use function Livewire\Volt\{state};
state(['count' => 0]);
$increment = fn() => $this->count++;
?>
<button wire:click="increment">{{ $count }}</button>Wire Directives
<input wire:model.blur="email">
<input wire:model.live.debounce.300ms="search">
<button wire:click="save" wire:loading.attr="disabled">Save</button>---
Best Practices
DO
- Use wire:key in @foreach loops
- Debounce search/filter inputs
- Use Form Objects for reusable logic
- Test with Livewire::test()
- #[Locked] for IDs, #[Computed] for derived data
DON'T
- wire:model.live on every field
- Query in render() method
- Forget authorization in actions
- Skip wire:key in loops
- Store sensitive data in public properties
---
Laravel 13 Notes
Livewire 4 sur Laravel 13
Livewire 4 est la version compatible Laravel 13. Changements clés :
- PHP 8.3 minimum (était 8.2 sur Livewire 3)
wire:model.liverate-limited par défaut (300ms debounce implicite)#[Locked],#[Computed],#[On]toujours supportés- Volt et Folio : versions majeures alignées (Volt 2, Folio 2)
- PreventRequestForgery : routes
/livewire/updategérées automatiquement, pas de config requise
Migration depuis Livewire 3
wire:poll.5s→ toujours valide$this->dispatch('event')→ toujours valide- Form Objects (
#[\Livewire\Attributes\Validate]) → API stable
Alpine.js Integration
Decision Tree
Interactivity type?
├── Read property → $wire.property
├── Call method → $wire.method()
├── Two-way sync → $wire.entangle('prop')
├── Set value → $wire.set('prop', value)
├── Toggle boolean → $wire.toggle('prop')
└── Listen events → $wire.$on('event', fn)$wire API
| Property/Method | Purpose |
|---|---|
$wire.property | Read property value |
$wire.method() | Call component method |
$wire.set('prop', val) | Set property |
$wire.toggle('prop') | Toggle boolean |
$wire.$refresh() | Refresh component |
$wire.$commit() | Commit pending changes |
$wire Properties
| Property | Returns |
|---|---|
$wire.$el | Component DOM element |
$wire.$id | Component ID |
$wire.$parent | Parent $wire |
@entangle
| Syntax | Sync Type |
|---|---|
$wire.entangle('prop') | Deferred |
$wire.entangle('prop').live | Real-time |
Event Methods
| Method | Purpose |
|---|---|
$wire.$on('event', fn) | Listen to event |
$wire.$dispatch('event', {}) | Dispatch event |
$wire.$dispatchTo('comp', 'e') | To component |
$wire.$dispatchSelf('event') | To self |
File Upload
| Method | Purpose |
|---|---|
$wire.$upload('prop', file, ...) | Single file |
$wire.$uploadMultiple(...) | Multiple files |
$wire.$removeUpload(...) | Remove upload |
@script Directive
| Usage | Purpose |
|---|---|
@script | Define component JS |
Access $wire | Inside @script |
| Lifecycle hooks | $wire.$hook() |
Hooks
| Hook | When |
|---|---|
$wire.$hook('commit') | Before commit |
$wire.$hook('message.sent') | Request sent |
$wire.$hook('message.received') | Response received |
Watch Property
| Method | Purpose |
|---|---|
$wire.$watch('prop', fn) | Watch changes |
| Callback args | (newVal, oldVal) |
Best Practices
| DO | DON'T |
|---|---|
| Use @entangle for sync | Manual event sync |
| $wire for simple reads | Complex Alpine state |
| @script for component JS | Inline large scripts |
| await $wire.method() | Ignore async |
→ See template: AlpineIntegration.blade.md
Livewire Components
Decision Tree
Component type?
├── Multi-file (Class + Blade) → php artisan make:livewire
├── Single-file (Volt functional) → state(), computed()
├── Single-file (Volt class) → new class extends Component
├── Inline in page → @volt directive
└── Full-page route → Route::livewire()Component Types
| Type | Files | Best For |
|---|---|---|
| Class-based | PHP + Blade | Complex logic |
| Volt Functional | Single Blade | Simple components |
| Volt Class | Single Blade | Medium complexity |
| Inline @volt | In any Blade | Quick embeds |
Artisan Commands
| Command | Creates |
|---|---|
make:livewire CreatePost | Class + Blade |
make:livewire CreatePost --inline | Class with inline view |
make:livewire post.create | Volt single-file |
Rendering Methods
| Method | Usage |
|---|---|
<livewire:create-post /> | Blade embed |
<livewire:posts.create /> | Nested namespace |
Route::livewire('/path', Component::class) | Full-page |
Volt::route('/path', 'component-name') | Volt route |
Property Types
| Attribute | Purpose |
|---|---|
public $name | Synced with frontend |
#[Locked] | Cannot modify from client |
#[Sensitive] | Hidden in snapshots |
#[Computed] | Cached calculated value |
#[Computed(persist: true)] | Cached between requests |
Class Structure
| Element | Purpose |
|---|---|
public $property | Data binding |
public function action() | Callable from frontend |
protected function internal() | Not callable from frontend |
public function render() | Return view |
public function mount() | Initialization |
Volt Functional API
| Function | Purpose |
|---|---|
state(['key' => 'value']) | Define properties |
computed(fn() => ...) | Computed property |
$action = fn() => ... | Define action |
$mount = fn() => ... | Mount hook |
Best Practices
| DO | DON'T |
|---|---|
| Use #[Locked] for IDs | Expose sensitive IDs |
| Keep render() simple | Complex logic in render |
| Use computed for derived data | Recalculate in view |
| Type hint properties | Use mixed types |
→ See templates: BasicComponent.php.md, VoltComponent.blade.md
Events
Decision Tree
Communication type?
├── Component → Component → dispatch() + #[On]
├── Parent → Child → Props or $parent
├── Child → Parent → dispatch() to parent
├── Component → Browser → dispatch() + JS listener
├── Browser → Component → JS dispatch + wire:event
└── Alpine ↔ Livewire → $wire, @entangleDispatch Methods
| Method | Target |
|---|---|
$this->dispatch('event', data) | Global |
$this->dispatchTo('component', 'event') | Specific component |
$this->dispatchSelf('event') | Self only |
Listening Methods
| Method | Usage |
|---|---|
#[On('event')] | Attribute on method |
#[On(['evt1', 'evt2'])] | Multiple events |
wire:event="method" | In Blade |
wire:event.window="method" | Global in Blade |
JavaScript API
| Method | Usage |
|---|---|
Livewire.dispatch('event', {}) | Global dispatch |
Livewire.dispatchTo('comp', 'event') | To component |
Livewire.on('event', callback) | Listen globally |
Browser Events
| Source | Target |
|---|---|
PHP dispatch() | x-on:event.window |
Alpine $dispatch() | wire:event |
JS dispatchEvent() | wire:event.window |
$wire in Alpine
| Method | Purpose |
|---|---|
$wire.property | Access property |
$wire.method() | Call method |
$wire.set('prop', val) | Set property |
$wire.$refresh() | Refresh component |
$wire.$dispatch('event') | Dispatch event |
@entangle
| Syntax | Behavior |
|---|---|
$wire.entangle('prop') | Two-way sync |
$wire.entangle('prop').live | Real-time sync |
Event Data
| Pattern | Access |
|---|---|
dispatch('e', id: 1) | Named params |
#[On('e')] fn($id) | Receive params |
$event.detail.id | In JavaScript |
Best Practices
| DO | DON'T |
|---|---|
| Use events for decoupling | Tight component coupling |
| Named parameters | Positional arrays |
| #[On] attribute | Old $listeners property |
| dispatchTo for targeted | Global for everything |
→ See template: NestedComponents.php.md
File Uploads
Decision Tree
Upload type?
├── Single file → use WithFileUploads + $file
├── Multiple files → $files array + multiple
├── Image preview → $file->temporaryUrl()
├── Progress bar → wire:loading with target
└── Validation → #[Validate] with file rulesSetup
| Requirement | Implementation |
|---|---|
| Trait | use WithFileUploads |
| Property | public $photo |
| Input | <input type="file" wire:model="photo"> |
Validation Rules
| Rule | Purpose |
|---|---|
image | Must be image |
mimes:jpg,png | Specific types |
max:2048 | Max KB (2MB) |
dimensions:min_width=100 | Image dimensions |
Temporary Files
| Method | Returns |
|---|---|
$file->temporaryUrl() | Preview URL |
$file->getClientOriginalName() | Original name |
$file->getSize() | File size |
$file->getMimeType() | MIME type |
Storage
| Method | Result |
|---|---|
$file->store('path') | Store in default disk |
$file->store('path', 'public') | Store in public disk |
$file->storeAs('path', 'name') | Custom filename |
Multiple Files
| Setup | Usage |
|---|---|
public $photos = [] | Array property |
<input type="file" multiple> | Multiple input |
@foreach($photos as $photo) | Loop files |
Progress Tracking
| Directive | Shows |
|---|---|
wire:loading | While uploading |
wire:target="photo" | Specific field |
| JavaScript | $wire.$upload() with callbacks |
Chunk Upload
| Config | Purpose |
|---|---|
livewire.temporary_file_upload.rules | Default rules |
| Chunked | Large files automatic |
Cleanup
| Method | Purpose |
|---|---|
$this->reset('photo') | Clear upload |
$this->photo = null | Remove file |
| Automatic | Temp files auto-cleanup |
Best Practices
| DO | DON'T |
|---|---|
| Validate file type | Accept any file |
| Use temporary preview | Store before validation |
| Set max size | Allow unlimited |
| Reset after save | Keep temp files |
→ See template: FileUploadComponent.php.md
Folio - File-Based Routing
When to Use
| Scenario | Use Folio? |
|---|---|
| Simple pages without controllers | Yes |
| Marketing/landing pages | Yes |
| Documentation pages | Yes |
| Complex business logic | No → Controller |
| API endpoints | No → API routes |
| Heavy data processing | No → Controller |
---
Installation
composer require laravel/folio
php artisan folio:install---
Route Conventions
| File Path | URL | Notes |
|---|---|---|
pages/index.blade.php | / | Root index |
pages/about.blade.php | /about | Simple page |
pages/users/index.blade.php | /users | Section index |
pages/users/[id].blade.php | /users/{id} | Route parameter |
pages/users/[User].blade.php | /users/{user} | Model binding |
pages/posts/[...ids].blade.php | /posts/{ids} | Catch-all |
---
Decision Tree
Need a page route?
├── Simple static page → pages/name.blade.php
├── Dynamic with ID → pages/[id].blade.php
├── Model binding → pages/[Model].blade.php
│ └── Custom key → pages/[Model:slug].blade.php
├── Catch-all segments → pages/[...segments].blade.php
└── Nested structure → pages/section/page.blade.php---
Key Functions
| Function | Purpose | Example |
|---|---|---|
name() | Named route | name('users.show') |
middleware() | Apply middleware | middleware(['auth']) |
render() | Custom response | render(fn($view) => ...) |
withTrashed() | Include soft-deleted | withTrashed() |
---
Quick Patterns
Named Route
<?php use function Laravel\Folio\name; name('dashboard'); ?>Middleware
<?php use function Laravel\Folio\middleware; middleware(['auth', 'verified']); ?>Model Binding with Custom Key
pages/posts/[Post:slug].blade.php → /posts/my-post-slugRender Hook
<?php
use function Laravel\Folio\render;
render(function ($view, Post $post) {
abort_unless(auth()->user()->can('view', $post), 403);
return $view->with('related', $post->related);
});
?>---
Multi-Path Configuration
// AppServiceProvider.php
Folio::path(resource_path('views/pages/guest'))->uri('/');
Folio::path(resource_path('views/pages/admin'))
->uri('/admin')
->middleware(['*' => ['auth', 'verified']]);---
Subdomain Routing
Folio::domain('{tenant}.example.com')
->path(resource_path('views/pages/tenant'));---
Commands
| Command | Purpose |
|---|---|
php artisan folio:page name | Create page |
php artisan folio:list | List all pages |
php artisan route:cache | Cache routes |
---
Best Practices
DO
- Use for simple, view-centric pages
- Name routes for URL generation
- Apply middleware at path level for groups
- Use model binding for cleaner code
DON'T
- Put complex logic in Blade files
- Forget
route:cachein production - Mix Folio and controller routes for same resource
Forms & Validation
Decision Tree
Form complexity?
├── Simple (few fields) → Inline #[Validate]
├── Reusable → Form Object class
├── Real-time validation → wire:model.blur + rules
├── File upload → WithFileUploads trait
└── Complex logic → Form Object + methodsValidation Attributes
| Attribute | Usage |
|---|---|
#[Validate('required')] | Single rule |
#[Validate(['required', 'min:3'])] | Multiple rules |
#[Validate('required', message: 'Required')] | Custom message |
#[Validate('required', as: 'email')] | Custom name |
Form Object
| Method | Purpose |
|---|---|
extends Form | Base class |
public $property | Form field |
validate() | Run validation |
all() | Get all values |
reset() | Clear form |
fill($data) | Populate form |
Real-Time Validation
| Pattern | Triggers |
|---|---|
wire:model.blur | On field blur |
wire:model.live | On every change |
#[Validate] on property | Auto-validation |
Error Display
| Method | Usage |
|---|---|
@error('field') | Blade directive |
$errors->get('field') | Get errors array |
$this->addError('field', 'msg') | Add manually |
$this->resetValidation('field') | Clear error |
Form Object Methods
| Method | Purpose |
|---|---|
store() | Create new record |
update() | Update existing |
setModel($model) | Initialize from model |
Validation Hooks
| Hook | Purpose |
|---|---|
rules() | Define rules method |
messages() | Custom messages |
validationAttributes() | Custom names |
Error Bag
| Method | Returns |
|---|---|
$this->getErrorBag() | Error bag instance |
assertHasErrors(['field']) | Test assertion |
assertHasNoErrors() | Test no errors |
Best Practices
| DO | DON'T |
|---|---|
| Use Form Objects | Repeat validation logic |
| wire:model.blur for fields | Live on all fields |
| Custom error messages | Generic messages |
| Validate in action | Skip validation |
→ See template: FormComponent.php.md
Lifecycle Hooks
Decision Tree
When to hook?
├── Initial render only → mount()
├── Every request → boot()
├── Subsequent requests → hydrate()
├── Before property update → updating()
├── After property update → updated()
├── Before serialization → dehydrate()
└── Handle exceptions → exception()Hook Order
| # | Hook | When Called |
|---|---|---|
| 1 | boot() | Every request |
| 2 | mount($params) | Initial only |
| 3 | hydrate() | Subsequent only |
| 4 | updating($prop, $val) | Before update |
| 5 | updated($prop, $val) | After update |
| 6 | render() | Always |
| 7 | dehydrate() | End of request |
Property-Specific Hooks
| Hook | Called When |
|---|---|
updatingName($value) | Before $name changes |
updatedName($value) | After $name changes |
updatingItems($value, $key) | Array with key |
mount() Parameters
| Source | Injection |
|---|---|
| Route params | Auto-injected |
| Parent props | Auto-injected |
| Type-hinted | Route model binding |
hydrate() Use Cases
| Use Case | Example |
|---|---|
| Restore non-serializable | $this->dto = new Dto($this->data) |
| Init services | $this->service = app(Service::class) |
| Restore state | Rebuild computed state |
dehydrate() Use Cases
| Use Case | Example |
|---|---|
| Convert objects | $this->dto = $this->dto->toArray() |
| Cleanup | Remove non-serializable |
| Prepare state | For next request |
Form Object Hooks
| Hook | Purpose |
|---|---|
updating() | Before form property update |
updated() | After form property update |
updatingTitle($value) | Specific property |
exception() Hook
| Usage | Purpose |
|---|---|
exception($e, $stop) | Handle errors |
$stop() | Prevent propagation |
| Custom handling | Flash messages |
Best Practices
| DO | DON'T |
|---|---|
| Use mount for init | Query in render |
| Type hint in mount | Manual route binding |
| Use updating for transform | Modify in accessor |
| Clean in dehydrate | Store non-serializable |
→ See template: BasicComponent.php.md
Loading States
Decision Tree
Loading type?
├── Show while loading → wire:loading
├── Hide while loading → wire:loading.remove
├── Disable element → wire:loading.attr="disabled"
├── Add class → wire:loading.class="opacity-50"
├── Target action → wire:target="save"
└── Lazy component → #[Lazy] attributewire:loading Modifiers
| Modifier | Effect |
|---|---|
wire:loading | Show element |
wire:loading.remove | Hide element |
wire:loading.class="..." | Add class |
wire:loading.class.remove="..." | Remove class |
wire:loading.attr="disabled" | Add attribute |
wire:loading.delay | 200ms delay |
wire:loading.delay.long | 500ms delay |
wire:target
| Syntax | Targets |
|---|---|
wire:target="save" | Specific action |
wire:target="save, delete" | Multiple actions |
wire:target="form.title" | Property update |
#[Lazy] Component
| Feature | Purpose |
|---|---|
#[Lazy] | Load on demand |
placeholder() | Custom placeholder |
<livewire:comp lazy /> | Enable lazy |
#[Lazy(isolate: false)] | Disable bundling |
Placeholder Method
| Return Type | Usage |
|---|---|
| String HTML | Inline placeholder |
| View | view('placeholder') |
| Blade component | <x-skeleton /> |
wire:intersect
| Modifier | Triggers |
|---|---|
wire:intersect | When visible |
wire:intersect.once | Only once |
wire:intersect.full | Fully visible |
Skeleton Patterns
| Pattern | Use Case |
|---|---|
| Animated pulse | Loading content |
| Shimmer effect | Cards, lists |
| Spinner | Buttons, small areas |
| Progress bar | File uploads |
Loading Button Pattern
| State | Display |
|---|---|
| Normal | "Save" |
| Loading | Spinner + "Saving..." |
| Disabled | Prevent double-click |
Best Practices
| DO | DON'T |
|---|---|
| Use wire:target | Global loading for all |
| Add delay for fast ops | Flash on quick actions |
| Disable buttons | Allow double submit |
| Use #[Lazy] for heavy | Lazy everything |
→ See template: LoadingStates.blade.md
SPA Navigation
Decision Tree
Navigation need?
├── SPA behavior → wire:navigate
├── Prefetch on hover → wire:navigate.hover
├── Keep element → @persist
├── Active link style → data-current
└── Full page reload → Normal <a> tagwire:navigate
| Attribute | Behavior |
|---|---|
wire:navigate | SPA navigation |
wire:navigate.hover | Prefetch on hover |
| No attribute | Normal page load |
@persist Directive
| Usage | Purpose |
|---|---|
@persist('id') | Keep element between pages |
| Audio/video players | Continue playback |
| Complex widgets | Preserve state |
| Must have unique ID | Per-page |
Active Link Styling
| Selector | Matches |
|---|---|
data-current | Current page link |
data-current:class | Add class when active |
Navigation Events
| Event | When |
|---|---|
livewire:navigate | Before navigation |
livewire:navigating | Navigation started |
livewire:navigated | Navigation complete |
JavaScript Listeners
| Pattern | Purpose |
|---|---|
addEventListener('livewire:navigate') | Before |
addEventListener('livewire:navigated') | After |
event.detail.url | Target URL |
Configuration
| Setting | Default |
|---|---|
navigate.show_progress_bar | true |
navigate.progress_bar_color | #2299dd |
Progress Bar
| Config | Purpose |
|---|---|
| Show progress | Visual feedback |
| Custom color | Match brand |
| Disable | Set false |
Best Practices
| DO | DON'T |
|---|---|
| Use for internal links | External links |
| @persist for players | @persist everything |
| Prefetch hover for common | Prefetch all links |
| Handle navigation events | Ignore JS state |
→ See also: volt.md for Volt routes
Nesting Components
Decision Tree
Communication direction?
├── Parent → Child → Props (normal or #[Reactive])
├── Child → Parent → Events or $parent
├── Two-way binding → #[Modelable]
├── Child updates parent → dispatch() event
└── Access parent method → $parent.method()Passing Props
| Syntax | Type |
|---|---|
:prop="$value" | Dynamic value |
prop="string" | Static string |
:wire:key="$id" | Required in loops |
#[Reactive] Props
| Behavior | When |
|---|---|
| Auto-update child | Parent prop changes |
| No explicit sync | Automatic |
| Read-only in child | Cannot modify |
#[Modelable] Props
| Behavior | When |
|---|---|
| Two-way binding | wire:model on component |
| Child can update | Parent receives changes |
| Like input binding | Component as input |
Accessing Parent
| Method | Usage |
|---|---|
$parent.property | Read parent property |
$parent.method() | Call parent method |
wire:click="$parent.remove" | In Blade |
Event Communication
| Direction | Method |
|---|---|
| Child → Parent | dispatch('event') |
| Parent listens | #[On('event')] |
| Targeted | dispatchTo('parent', 'event') |
wire:key Rules
| Rule | Reason |
|---|---|
| Required in loops | Proper DOM diffing |
| Must be unique | Per iteration |
| Stable ID | Don't use index alone |
| Include model ID | wire:key="item-{{ $id }}" |
Props vs Events
| Use Props | Use Events |
|---|---|
| Data down | Actions up |
| Configuration | Notifications |
| Initial state | State changes |
| Read-only | Side effects |
Computed in Parent
| Pattern | Purpose |
|---|---|
| Pass computed result | Child receives data |
| Child #[Reactive] | Auto-updates |
Best Practices
| DO | DON'T |
|---|---|
| Use wire:key always | Forget keys in loops |
| Events for actions | Direct parent mutation |
| #[Reactive] for sync | Manual refresh |
| #[Modelable] for forms | Complex event chains |
→ See template: NestedComponents.php.md
Precognition - Live Validation
When to Use
| Scenario | Use Precognition? |
|---|---|
| Form with real-time validation | Yes |
| Reuse backend validation rules | Yes |
| Multi-step wizards | Yes |
| Simple forms | No |
---
Installation
| Frontend | Package |
|---|---|
| Vue | laravel-precognition-vue |
| Vue + Inertia | laravel-precognition-vue-inertia |
| React | laravel-precognition-react |
| React + Inertia | laravel-precognition-react-inertia |
| Alpine | laravel-precognition-alpine |
---
Backend Setup
Route::post('/users', fn(StoreUserRequest $r) => ...)
->middleware([HandlePrecognitiveRequests::class]);---
Form Object API
| Method | Purpose |
|---|---|
form.validate('field') | Validate single field |
form.valid('field') | Field passed |
form.invalid('field') | Field failed |
form.errors | Error messages |
form.hasErrors | Has any errors |
form.processing | Request in flight |
form.validating | Validation in flight |
form.submit() | Submit form |
form.reset() | Reset form |
form.forgetError('field') | Clear error |
---
Quick Patterns
Vue
<input v-model="form.name" @change="form.validate('name')">
<div v-if="form.invalid('name')">{{ form.errors.name }}</div>React
<input
value={form.data.name}
onChange={(e) => form.setData('name', e.target.value)}
onBlur={() => form.validate('name')}
/>Alpine
<input x-model="form.name" @change="form.validate('name')">
<template x-if="form.invalid('name')">
<div x-text="form.errors.name"></div>
</template>---
Wizard Validation
form.validate({
only: ['name', 'email'],
onSuccess: () => nextStep(),
});---
Customizing Rules
'password' => [
'required',
$this->isPrecognitive()
? Password::min(8)
: Password::min(8)->uncompromised(),
],---
File Uploads
'avatar' => [
...$this->isPrecognitive() ? [] : ['required'],
'image', 'max:2048',
],---
Testing
$this->withPrecognition()
->post('/register', ['name' => 'John'])
->assertSuccessfulPrecognition();---
Best Practices
DO
- Use
HandlePrecognitiveRequestsmiddleware - Debounce with
setValidationTimeout() - Skip heavy rules with
isPrecognitive()
DON'T
- Include files in precognitive requests
- Count interactions on precognitive requests
Reverb - WebSocket Server
When to Use
| Scenario | Use Reverb? |
|---|---|
| Real-time notifications | Yes |
| Live chat/messaging | Yes |
| Presence channels | Yes |
| Simple HTTP requests | No |
---
Installation
php artisan install:broadcasting---
Environment Variables
| Variable | Purpose | Example |
|---|---|---|
REVERB_APP_ID | App ID | my-app-id |
REVERB_APP_KEY | Public key | my-app-key |
REVERB_APP_SECRET | Secret key | my-app-secret |
REVERB_HOST | Public hostname | ws.example.com |
REVERB_PORT | Public port | 443 |
REVERB_SERVER_HOST | Bind address | 0.0.0.0 |
REVERB_SERVER_PORT | Server port | 8080 |
REVERB_SCALING_ENABLED | Redis scaling | true |
---
Commands
| Command | Purpose |
|---|---|
reverb:start | Start server |
reverb:start --debug | Debug mode |
reverb:restart | Graceful restart |
---
Production Setup
System Limits
# /etc/security/limits.conf
forge soft nofile 10000
forge hard nofile 10000Event Loop (>1000 connections)
pecl install uvNginx Proxy
location / {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_pass http://0.0.0.0:8080;
}Supervisor
[program:reverb]
command=php artisan reverb:start
autostart=true
autorestart=true
[supervisord]
minfds=10000---
SSL with Herd/Valet
php artisan reverb:start --hostname="laravel.test"---
Pulse Monitoring
'recorders' => [
ReverbConnections::class => ['sample_rate' => 1],
ReverbMessages::class => ['sample_rate' => 1],
],---
Horizontal Scaling
1. REVERB_SCALING_ENABLED=true 2. Configure central Redis 3. Deploy multiple instances 4. Load balancer in front
---
Best Practices
DO
- Use Supervisor in production
- Enable ext-uv for high connections
- Monitor with Pulse
DON'T
- Run without process manager
- Skip SSL in production
- Ignore file limits
Security
Decision Tree
Security concern?
├── Protect property → #[Locked]
├── Hide from snapshot → #[Sensitive]
├── Check permission → $this->authorize()
├── Limit requests → rateLimit()
├── Protect method → Use protected/private
└── CSRF → Automatic in forms#[Locked] Attribute
| Purpose | Effect |
|---|---|
| Prevent client modification | Throws exception |
| Use for | IDs, flags, sensitive data |
| Cannot be changed | Via wire:model or JS |
#[Sensitive] Attribute
| Purpose | Effect |
|---|---|
| Hide from snapshots | Not in HTML |
| Use for | API keys, tokens |
| Still usable | In component |
Authorization
| Method | Usage |
|---|---|
$this->authorize('action', $model) | Policy check |
| Throws exception | If unauthorized |
Gate::allows() | Manual check |
auth()->user()->can() | User check |
Protected Methods
| Visibility | Callable from Client |
|---|---|
public | ✅ Yes |
protected | ❌ No |
private | ❌ No |
Rate Limiting
| Method | Purpose |
|---|---|
$this->rateLimit(10) | 10 per minute |
$this->rateLimit(5, key: 'user-'.id) | Per user |
TooManyRequestsException | Caught |
$e->secondsUntilAvailable | Retry time |
CSRF Protection
| Feature | Status |
|---|---|
| Forms | Automatic |
| AJAX requests | Automatic |
| Token refresh | Automatic |
| No @csrf needed | In wire:submit |
XSS Protection
| Syntax | Behavior |
|---|---|
{{ $var }} | Escaped (safe) |
{!! $var !!} | Raw HTML (careful) |
Route Middleware
| Pattern | Usage |
|---|---|
->middleware('auth') | Require auth |
->middleware('can:action,model') | Policy |
->middleware('verified') | Email verified |
Best Practices
| DO | DON'T |
|---|---|
| #[Locked] for IDs | Expose sensitive IDs |
| authorize() in actions | Skip authorization |
| protected for internal | Public everything |
| Rate limit auth actions | Allow unlimited |
→ See template: SecureComponent.php.md
Basic Livewire Component
app/Livewire/CreatePost.php
<?php
namespace App\Livewire;
use App\Models\Post;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
use Livewire\Attributes\Validate;
use Livewire\Component;
class CreatePost extends Component
{
#[Locked]
public int $userId;
#[Validate('required|min:5|max:255')]
public string $title = '';
#[Validate('required|min:10')]
public string $content = '';
#[Validate('nullable|array')]
public array $tags = [];
public bool $published = false;
/**
* Initialize component.
*/
public function mount(): void
{
$this->userId = Auth::id();
}
/**
* Computed property - cached per request.
*/
#[Computed]
public function wordCount(): int
{
return str_word_count($this->content);
}
/**
* Computed with persistence across requests.
*/
#[Computed(persist: true)]
public function userPosts(): int
{
return Post::where('user_id', $this->userId)->count();
}
/**
* Property updated hook.
*/
public function updatedTitle(string $value): void
{
$this->title = ucwords($value);
}
/**
* Save post action.
*/
public function save(): void
{
$validated = $this->validate();
$post = Post::create([
'user_id' => $this->userId,
'title' => $validated['title'],
'content' => $validated['content'],
'tags' => $validated['tags'],
'published_at' => $this->published ? now() : null,
]);
$this->dispatch('post-created', postId: $post->id);
session()->flash('message', 'Post created successfully!');
$this->redirect(route('posts.show', $post));
}
/**
* Reset form.
*/
public function resetForm(): void
{
$this->reset(['title', 'content', 'tags', 'published']);
$this->resetValidation();
}
/**
* Listen for external events.
*/
#[On('tag-selected')]
public function addTag(string $tag): void
{
if (!in_array($tag, $this->tags)) {
$this->tags[] = $tag;
}
}
public function render()
{
return view('livewire.create-post');
}
}resources/views/livewire/create-post.blade.php
<div class="max-w-2xl mx-auto p-6">
<h1 class="text-2xl font-bold mb-6">Create New Post</h1>
@if (session()->has('message'))
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
{{ session('message') }}
</div>
@endif
<form wire:submit="save" class="space-y-4">
{{-- Title --}}
<div>
<label for="title" class="block text-sm font-medium">Title</label>
<input
type="text"
id="title"
wire:model.blur="title"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
placeholder="Enter post title"
>
@error('title')
<span class="text-red-500 text-sm">{{ $message }}</span>
@enderror
</div>
{{-- Content --}}
<div>
<label for="content" class="block text-sm font-medium">Content</label>
<textarea
id="content"
wire:model.live.debounce.500ms="content"
rows="6"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
placeholder="Write your post content..."
></textarea>
@error('content')
<span class="text-red-500 text-sm">{{ $message }}</span>
@enderror
<p class="text-sm text-gray-500 mt-1">
Word count: {{ $this->wordCount }}
</p>
</div>
{{-- Tags --}}
<div>
<label class="block text-sm font-medium">Tags</label>
<div class="flex flex-wrap gap-2 mt-2">
@foreach ($tags as $index => $tag)
<span
wire:key="tag-{{ $index }}"
class="bg-blue-100 text-blue-800 px-2 py-1 rounded"
>
{{ $tag }}
<button
type="button"
wire:click="$set('tags', array_values(array_diff($tags, ['{{ $tag }}'])))"
class="ml-1 text-blue-600 hover:text-blue-800"
>×</button>
</span>
@endforeach
</div>
</div>
{{-- Published --}}
<div class="flex items-center">
<input
type="checkbox"
id="published"
wire:model="published"
class="rounded border-gray-300"
>
<label for="published" class="ml-2 text-sm">
Publish immediately
</label>
</div>
{{-- Actions --}}
<div class="flex gap-4">
<button
type="submit"
class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 disabled:opacity-50"
wire:loading.attr="disabled"
wire:target="save"
>
<span wire:loading.remove wire:target="save">Save Post</span>
<span wire:loading wire:target="save">Saving...</span>
</button>
<button
type="button"
wire:click="resetForm"
class="bg-gray-200 text-gray-700 px-4 py-2 rounded hover:bg-gray-300"
>
Reset
</button>
</div>
</form>
<p class="text-sm text-gray-500 mt-4">
You have {{ $this->userPosts }} posts
</p>
</div>Usage
{{-- In any Blade view --}}
<livewire:create-post />
{{-- Or as full-page route --}}
// routes/web.php
Route::livewire('/posts/create', CreatePost::class)
->middleware(['auth'])
->name('posts.create');Component Testing
tests/Feature/Livewire/CreatePostTest.php
<?php
namespace Tests\Feature\Livewire;
use App\Livewire\CreatePost;
use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class CreatePostTest extends TestCase
{
use RefreshDatabase;
private User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
}
/** @test */
public function can_render_component(): void
{
$this->actingAs($this->user);
Livewire::test(CreatePost::class)
->assertStatus(200)
->assertSee('Create New Post');
}
/** @test */
public function can_create_post(): void
{
$this->actingAs($this->user);
Livewire::test(CreatePost::class)
->set('title', 'My Test Post')
->set('content', 'This is the content of my test post.')
->set('published', true)
->call('save')
->assertRedirect(route('posts.show', Post::first()));
$this->assertDatabaseHas('posts', [
'user_id' => $this->user->id,
'title' => 'My Test Post',
'content' => 'This is the content of my test post.',
]);
$this->assertNotNull(Post::first()->published_at);
}
/** @test */
public function validates_required_fields(): void
{
$this->actingAs($this->user);
Livewire::test(CreatePost::class)
->set('title', '')
->set('content', '')
->call('save')
->assertHasErrors(['title' => 'required', 'content' => 'required']);
}
/** @test */
public function validates_minimum_length(): void
{
$this->actingAs($this->user);
Livewire::test(CreatePost::class)
->set('title', 'Hi')
->set('content', 'Short')
->call('save')
->assertHasErrors([
'title' => 'min',
'content' => 'min',
]);
}
/** @test */
public function can_reset_form(): void
{
$this->actingAs($this->user);
Livewire::test(CreatePost::class)
->set('title', 'My Title')
->set('content', 'My Content')
->call('resetForm')
->assertSet('title', '')
->assertSet('content', '')
->assertHasNoErrors();
}
/** @test */
public function dispatches_event_on_save(): void
{
$this->actingAs($this->user);
Livewire::test(CreatePost::class)
->set('title', 'Event Test Post')
->set('content', 'Testing event dispatching.')
->call('save')
->assertDispatched('post-created');
}
/** @test */
public function listens_to_tag_selected_event(): void
{
$this->actingAs($this->user);
Livewire::test(CreatePost::class)
->dispatch('tag-selected', tag: 'laravel')
->assertSet('tags', ['laravel']);
}
/** @test */
public function transforms_title_on_update(): void
{
$this->actingAs($this->user);
Livewire::test(CreatePost::class)
->set('title', 'my lowercase title')
->assertSet('title', 'My Lowercase Title');
}
/** @test */
public function computes_word_count(): void
{
$this->actingAs($this->user);
$component = Livewire::test(CreatePost::class)
->set('content', 'This is five words here');
$this->assertEquals(5, $component->get('wordCount'));
}
/** @test */
public function shows_flash_message_on_success(): void
{
$this->actingAs($this->user);
Livewire::test(CreatePost::class)
->set('title', 'Flash Message Test')
->set('content', 'Testing flash messages work correctly.')
->call('save');
$this->assertEquals('Post created successfully!', session('message'));
}
}tests/Feature/Livewire/VoltTest.php
<?php
namespace Tests\Feature\Livewire;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Volt\Volt;
use Tests\TestCase;
class VoltTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function counter_increments(): void
{
Volt::test('counter')
->assertSee('0')
->call('increment')
->assertSee('1')
->call('increment')
->assertSee('2');
}
/** @test */
public function counter_can_reset(): void
{
Volt::test('counter')
->set('count', 10)
->call('reset')
->assertSet('count', 0);
}
/** @test */
public function user_profile_validates(): void
{
$user = User::factory()->create();
Volt::test('user-profile', ['user' => $user])
->set('name', '')
->set('email', 'invalid')
->call('save')
->assertHasErrors([
'name' => 'required',
'email' => 'email',
]);
}
/** @test */
public function user_profile_updates(): void
{
$user = User::factory()->create();
Volt::test('user-profile', ['user' => $user])
->set('name', 'New Name')
->set('email', 'new@example.com')
->call('save')
->assertHasNoErrors();
$this->assertDatabaseHas('users', [
'id' => $user->id,
'name' => 'New Name',
'email' => 'new@example.com',
]);
}
}tests/Feature/Livewire/FileUploadTest.php
<?php
namespace Tests\Feature\Livewire;
use App\Livewire\UploadAvatar;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Tests\TestCase;
class FileUploadTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function can_upload_avatar(): void
{
Storage::fake('public');
$user = User::factory()->create();
$this->actingAs($user);
$file = UploadedFile::fake()->image('avatar.jpg', 200, 200);
Livewire::test(UploadAvatar::class)
->set('photo', $file)
->call('save')
->assertHasNoErrors();
Storage::disk('public')->assertExists('avatars/' . $file->hashName());
$this->assertNotNull($user->fresh()->avatar);
}
/** @test */
public function validates_file_type(): void
{
Storage::fake('public');
$user = User::factory()->create();
$this->actingAs($user);
$file = UploadedFile::fake()->create('document.pdf', 100);
Livewire::test(UploadAvatar::class)
->set('photo', $file)
->call('save')
->assertHasErrors(['photo' => 'image']);
}
/** @test */
public function validates_file_size(): void
{
Storage::fake('public');
$user = User::factory()->create();
$this->actingAs($user);
$file = UploadedFile::fake()->image('large.jpg')->size(3000); // 3MB
Livewire::test(UploadAvatar::class)
->set('photo', $file)
->call('save')
->assertHasErrors(['photo' => 'max']);
}
/** @test */
public function can_remove_avatar(): void
{
Storage::fake('public');
$user = User::factory()->create(['avatar' => 'avatars/old.jpg']);
Storage::disk('public')->put('avatars/old.jpg', 'content');
$this->actingAs($user);
Livewire::test(UploadAvatar::class)
->call('removeAvatar');
Storage::disk('public')->assertMissing('avatars/old.jpg');
$this->assertNull($user->fresh()->avatar);
}
}tests/Feature/Livewire/DataTableTest.php
<?php
namespace Tests\Feature\Livewire;
use App\Livewire\UserTable;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class DataTableTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function can_search_users(): void
{
$admin = User::factory()->create();
$this->actingAs($admin);
User::factory()->create(['name' => 'John Doe']);
User::factory()->create(['name' => 'Jane Smith']);
Livewire::test(UserTable::class)
->set('search', 'John')
->assertSee('John Doe')
->assertDontSee('Jane Smith');
}
/** @test */
public function can_sort_by_column(): void
{
$admin = User::factory()->create();
$this->actingAs($admin);
User::factory()->create(['name' => 'Alice']);
User::factory()->create(['name' => 'Bob']);
Livewire::test(UserTable::class)
->call('sortBy', 'name')
->assertSet('sortField', 'name')
->assertSet('sortDirection', 'asc');
}
/** @test */
public function can_filter_by_status(): void
{
$admin = User::factory()->create();
$this->actingAs($admin);
User::factory()->create(['status' => 'active']);
User::factory()->create(['status' => 'inactive']);
Livewire::test(UserTable::class)
->set('status', 'active')
->assertSee('active')
->assertDontSee('inactive');
}
/** @test */
public function can_paginate(): void
{
$admin = User::factory()->create();
$this->actingAs($admin);
User::factory()->count(25)->create();
Livewire::test(UserTable::class)
->set('perPage', 10)
->assertSee('1')
->assertSee('2')
->assertSee('3');
}
}Data Table Component
app/Livewire/UserTable.php
<?php
namespace App\Livewire;
use App\Models\User;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
class UserTable extends Component
{
use WithPagination;
#[Url]
public string $search = '';
#[Url]
public string $sortField = 'created_at';
#[Url]
public string $sortDirection = 'desc';
#[Url]
public int $perPage = 10;
#[Url]
public string $status = '';
/**
* Reset pagination when search changes.
*/
public function updatedSearch(): void
{
$this->resetPage();
}
/**
* Reset pagination when filters change.
*/
public function updatedStatus(): void
{
$this->resetPage();
}
/**
* Sort by column.
*/
public function sortBy(string $field): void
{
if ($this->sortField === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortField = $field;
$this->sortDirection = 'asc';
}
}
/**
* Get paginated users.
*/
#[Computed]
public function users()
{
return User::query()
->when($this->search, function ($query) {
$query->where(function ($q) {
$q->where('name', 'like', "%{$this->search}%")
->orWhere('email', 'like', "%{$this->search}%");
});
})
->when($this->status, function ($query) {
$query->where('status', $this->status);
})
->orderBy($this->sortField, $this->sortDirection)
->paginate($this->perPage);
}
/**
* Delete user.
*/
public function deleteUser(int $id): void
{
$user = User::findOrFail($id);
$this->authorize('delete', $user);
$user->delete();
session()->flash('message', 'User deleted successfully.');
}
/**
* Export users.
*/
public function export(): void
{
// Implementation for export
$this->dispatch('export-started');
}
public function render()
{
return view('livewire.user-table');
}
}resources/views/livewire/user-table.blade.php
<div class="bg-white rounded-lg shadow">
{{-- Header --}}
<div class="p-4 border-b flex justify-between items-center">
<h2 class="text-lg font-semibold">Users</h2>
<button wire:click="export" class="bg-green-500 text-white px-4 py-2 rounded text-sm">
Export
</button>
</div>
{{-- Filters --}}
<div class="p-4 border-b bg-gray-50 flex gap-4 items-center">
{{-- Search --}}
<div class="flex-1">
<input
type="text"
wire:model.live.debounce.300ms="search"
placeholder="Search users..."
class="w-full rounded border-gray-300"
>
</div>
{{-- Status Filter --}}
<select wire:model.live="status" class="rounded border-gray-300">
<option value="">All Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="pending">Pending</option>
</select>
{{-- Per Page --}}
<select wire:model.live="perPage" class="rounded border-gray-300">
<option value="10">10 per page</option>
<option value="25">25 per page</option>
<option value="50">50 per page</option>
</select>
</div>
{{-- Flash Message --}}
@if (session()->has('message'))
<div class="p-4 bg-green-100 text-green-700">
{{ session('message') }}
</div>
@endif
{{-- Table --}}
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left">
<button wire:click="sortBy('name')" class="flex items-center gap-1 font-semibold">
Name
@if ($sortField === 'name')
<span>{{ $sortDirection === 'asc' ? '↑' : '↓' }}</span>
@endif
</button>
</th>
<th class="px-4 py-3 text-left">
<button wire:click="sortBy('email')" class="flex items-center gap-1 font-semibold">
Email
@if ($sortField === 'email')
<span>{{ $sortDirection === 'asc' ? '↑' : '↓' }}</span>
@endif
</button>
</th>
<th class="px-4 py-3 text-left">Status</th>
<th class="px-4 py-3 text-left">
<button wire:click="sortBy('created_at')" class="flex items-center gap-1 font-semibold">
Created
@if ($sortField === 'created_at')
<span>{{ $sortDirection === 'asc' ? '↑' : '↓' }}</span>
@endif
</button>
</th>
<th class="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y">
@forelse ($this->users as $user)
<tr wire:key="user-{{ $user->id }}" class="hover:bg-gray-50">
<td class="px-4 py-3">
<div class="flex items-center gap-3">
<img
src="{{ $user->avatar_url }}"
alt="{{ $user->name }}"
class="w-8 h-8 rounded-full"
>
<span class="font-medium">{{ $user->name }}</span>
</div>
</td>
<td class="px-4 py-3 text-gray-600">{{ $user->email }}</td>
<td class="px-4 py-3">
<span @class([
'px-2 py-1 rounded text-xs font-medium',
'bg-green-100 text-green-800' => $user->status === 'active',
'bg-gray-100 text-gray-800' => $user->status === 'inactive',
'bg-yellow-100 text-yellow-800' => $user->status === 'pending',
])>
{{ ucfirst($user->status) }}
</span>
</td>
<td class="px-4 py-3 text-gray-600">
{{ $user->created_at->format('M d, Y') }}
</td>
<td class="px-4 py-3 text-right">
<a
href="{{ route('users.edit', $user) }}"
class="text-blue-600 hover:underline mr-3"
>
Edit
</a>
<button
wire:click="deleteUser({{ $user->id }})"
wire:confirm="Are you sure you want to delete this user?"
class="text-red-600 hover:underline"
>
Delete
</button>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-4 py-8 text-center text-gray-500">
No users found.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
{{-- Pagination --}}
<div class="p-4 border-t">
{{ $this->users->links() }}
</div>
{{-- Loading Overlay --}}
<div
wire:loading.flex
wire:target="search, sortBy, perPage, status, deleteUser"
class="absolute inset-0 bg-white/50 items-center justify-center"
>
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
</div>Usage
<livewire:user-table />File Upload Component
app/Livewire/UploadAvatar.php
<?php
namespace App\Livewire;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\WithFileUploads;
class UploadAvatar extends Component
{
use WithFileUploads;
#[Validate('nullable|image|max:2048')] // 2MB max
public $photo;
public string $currentAvatar = '';
public function mount(): void
{
$this->currentAvatar = auth()->user()->avatar_url ?? '';
}
/**
* Save avatar.
*/
public function save(): void
{
$this->validate();
if (!$this->photo) {
return;
}
// Delete old avatar
if (auth()->user()->avatar) {
Storage::disk('public')->delete(auth()->user()->avatar);
}
// Store new avatar
$path = $this->photo->store('avatars', 'public');
auth()->user()->update(['avatar' => $path]);
$this->currentAvatar = Storage::disk('public')->url($path);
$this->reset('photo');
session()->flash('message', 'Avatar updated successfully!');
}
/**
* Remove current avatar.
*/
public function removeAvatar(): void
{
if (auth()->user()->avatar) {
Storage::disk('public')->delete(auth()->user()->avatar);
auth()->user()->update(['avatar' => null]);
$this->currentAvatar = '';
session()->flash('message', 'Avatar removed.');
}
}
/**
* Cancel upload.
*/
public function cancelUpload(): void
{
$this->reset('photo');
}
public function render()
{
return view('livewire.upload-avatar');
}
}resources/views/livewire/upload-avatar.blade.php
<div class="max-w-md">
@if (session()->has('message'))
<div class="bg-green-100 text-green-700 px-4 py-3 rounded mb-4">
{{ session('message') }}
</div>
@endif
<div class="flex items-start gap-6">
{{-- Current Avatar --}}
<div class="flex-shrink-0">
@if ($photo)
<img
src="{{ $photo->temporaryUrl() }}"
alt="Preview"
class="w-24 h-24 rounded-full object-cover border-4 border-blue-500"
>
@elseif ($currentAvatar)
<img
src="{{ $currentAvatar }}"
alt="Current avatar"
class="w-24 h-24 rounded-full object-cover"
>
@else
<div class="w-24 h-24 rounded-full bg-gray-200 flex items-center justify-center">
<span class="text-gray-400 text-2xl">?</span>
</div>
@endif
</div>
{{-- Upload Form --}}
<div class="flex-1">
<form wire:submit="save" class="space-y-4">
{{-- File Input --}}
<div>
<label class="block text-sm font-medium mb-2">
Choose new avatar
</label>
<input
type="file"
wire:model="photo"
accept="image/*"
class="block w-full text-sm text-gray-500
file:mr-4 file:py-2 file:px-4
file:rounded file:border-0
file:text-sm file:font-semibold
file:bg-blue-50 file:text-blue-700
hover:file:bg-blue-100"
>
@error('photo')
<span class="text-red-500 text-sm">{{ $message }}</span>
@enderror
</div>
{{-- Upload Progress --}}
<div wire:loading wire:target="photo" class="w-full">
<div class="bg-gray-200 rounded-full h-2">
<div class="bg-blue-500 h-2 rounded-full animate-pulse w-1/2"></div>
</div>
<p class="text-sm text-gray-500 mt-1">Uploading...</p>
</div>
{{-- Actions --}}
<div class="flex gap-2">
@if ($photo)
<button
type="submit"
class="bg-blue-500 text-white px-4 py-2 rounded text-sm"
wire:loading.attr="disabled"
wire:target="save"
>
<span wire:loading.remove wire:target="save">Save Avatar</span>
<span wire:loading wire:target="save">Saving...</span>
</button>
<button
type="button"
wire:click="cancelUpload"
class="bg-gray-200 text-gray-700 px-4 py-2 rounded text-sm"
>
Cancel
</button>
@endif
@if ($currentAvatar && !$photo)
<button
type="button"
wire:click="removeAvatar"
wire:confirm="Remove your avatar?"
class="bg-red-500 text-white px-4 py-2 rounded text-sm"
>
Remove Avatar
</button>
@endif
</div>
</form>
<p class="text-xs text-gray-500 mt-4">
Max file size: 2MB. Formats: JPG, PNG, GIF
</p>
</div>
</div>
</div>Multiple File Upload
<?php // app/Livewire/UploadGallery.php
namespace App\Livewire;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\WithFileUploads;
class UploadGallery extends Component
{
use WithFileUploads;
#[Validate(['photos.*' => 'image|max:2048'])]
public array $photos = [];
public array $uploaded = [];
public function updatedPhotos(): void
{
$this->validate();
}
public function removePhoto(int $index): void
{
array_splice($this->photos, $index, 1);
}
public function save(): void
{
foreach ($this->photos as $photo) {
$path = $photo->store('gallery', 'public');
$this->uploaded[] = $path;
}
$this->reset('photos');
session()->flash('message', count($this->uploaded) . ' photos uploaded!');
}
public function render()
{
return view('livewire.upload-gallery');
}
}{{-- resources/views/livewire/upload-gallery.blade.php --}}
<div>
<input type="file" wire:model="photos" multiple accept="image/*">
{{-- Previews --}}
<div class="grid grid-cols-4 gap-4 mt-4">
@foreach ($photos as $index => $photo)
<div wire:key="photo-{{ $index }}" class="relative">
<img src="{{ $photo->temporaryUrl() }}" class="w-full h-24 object-cover rounded">
<button
wire:click="removePhoto({{ $index }})"
class="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6"
>
×
</button>
</div>
@endforeach
</div>
@if (count($photos) > 0)
<button wire:click="save" class="mt-4 bg-blue-500 text-white px-4 py-2 rounded">
Upload {{ count($photos) }} Photos
</button>
@endif
</div>Form Component with Form Object
app/Livewire/Forms/PostForm.php
<?php
namespace App\Livewire\Forms;
use App\Models\Post;
use Livewire\Attributes\Validate;
use Livewire\Form;
class PostForm extends Form
{
public ?Post $post = null;
#[Validate('required|min:5|max:255')]
public string $title = '';
#[Validate('required|min:10')]
public string $content = '';
#[Validate('nullable|exists:categories,id')]
public ?int $category_id = null;
#[Validate('nullable|array')]
public array $tags = [];
#[Validate('boolean')]
public bool $published = false;
/**
* Set form from existing post.
*/
public function setPost(Post $post): void
{
$this->post = $post;
$this->fill($post->only([
'title',
'content',
'category_id',
'tags',
]));
$this->published = $post->published_at !== null;
}
/**
* Create new post.
*/
public function store(): Post
{
$validated = $this->validate();
return auth()->user()->posts()->create([
...$validated,
'published_at' => $this->published ? now() : null,
]);
}
/**
* Update existing post.
*/
public function update(): Post
{
$validated = $this->validate();
$this->post->update([
...$validated,
'published_at' => $this->published ? now() : null,
]);
return $this->post->fresh();
}
/**
* Hook: Transform title on update.
*/
public function updatedTitle(string $value): void
{
$this->title = ucwords(strtolower($value));
}
}app/Livewire/EditPost.php
<?php
namespace App\Livewire;
use App\Livewire\Forms\PostForm;
use App\Models\Category;
use App\Models\Post;
use Livewire\Attributes\Computed;
use Livewire\Component;
class EditPost extends Component
{
public PostForm $form;
/**
* Initialize with post.
*/
public function mount(Post $post): void
{
$this->authorize('update', $post);
$this->form->setPost($post);
}
/**
* Available categories.
*/
#[Computed]
public function categories()
{
return Category::orderBy('name')->get();
}
/**
* Save changes.
*/
public function save(): void
{
$this->authorize('update', $this->form->post);
$post = $this->form->update();
session()->flash('message', 'Post updated successfully!');
$this->redirect(route('posts.show', $post));
}
/**
* Delete post.
*/
public function delete(): void
{
$this->authorize('delete', $this->form->post);
$this->form->post->delete();
session()->flash('message', 'Post deleted.');
$this->redirect(route('posts.index'));
}
public function render()
{
return view('livewire.edit-post');
}
}resources/views/livewire/edit-post.blade.php
<div class="max-w-2xl mx-auto p-6">
<h1 class="text-2xl font-bold mb-6">Edit Post</h1>
@if (session()->has('message'))
<div class="bg-green-100 text-green-700 px-4 py-3 rounded mb-4">
{{ session('message') }}
</div>
@endif
<form wire:submit="save" class="space-y-4">
{{-- Title with real-time validation --}}
<div>
<label for="title" class="block text-sm font-medium">Title</label>
<input
type="text"
id="title"
wire:model.blur="form.title"
class="mt-1 block w-full rounded-md border-gray-300"
>
@error('form.title')
<span class="text-red-500 text-sm">{{ $message }}</span>
@enderror
</div>
{{-- Content --}}
<div>
<label for="content" class="block text-sm font-medium">Content</label>
<textarea
id="content"
wire:model.blur="form.content"
rows="8"
class="mt-1 block w-full rounded-md border-gray-300"
></textarea>
@error('form.content')
<span class="text-red-500 text-sm">{{ $message }}</span>
@enderror
</div>
{{-- Category --}}
<div>
<label for="category" class="block text-sm font-medium">Category</label>
<select
id="category"
wire:model="form.category_id"
class="mt-1 block w-full rounded-md border-gray-300"
>
<option value="">Select category...</option>
@foreach ($this->categories as $category)
<option value="{{ $category->id }}">
{{ $category->name }}
</option>
@endforeach
</select>
@error('form.category_id')
<span class="text-red-500 text-sm">{{ $message }}</span>
@enderror
</div>
{{-- Published --}}
<div class="flex items-center">
<input
type="checkbox"
id="published"
wire:model="form.published"
class="rounded border-gray-300"
>
<label for="published" class="ml-2 text-sm">Published</label>
</div>
{{-- Actions --}}
<div class="flex justify-between">
<div class="flex gap-4">
<button
type="submit"
class="bg-blue-500 text-white px-4 py-2 rounded"
wire:loading.attr="disabled"
>
<span wire:loading.remove wire:target="save">Save Changes</span>
<span wire:loading wire:target="save">Saving...</span>
</button>
<a
href="{{ route('posts.index') }}"
class="bg-gray-200 px-4 py-2 rounded"
>
Cancel
</a>
</div>
<button
type="button"
wire:click="delete"
wire:confirm="Are you sure you want to delete this post?"
class="bg-red-500 text-white px-4 py-2 rounded"
>
Delete
</button>
</div>
</form>
</div>routes/web.php
use App\Livewire\EditPost;
Route::livewire('/posts/{post}/edit', EditPost::class)
->middleware(['auth'])
->name('posts.edit');Nested Components
Parent Component - TodoList
<?php // app/Livewire/TodoList.php
namespace App\Livewire;
use App\Models\Todo;
use Illuminate\Support\Collection;
use Livewire\Attributes\Computed;
use Livewire\Attributes\On;
use Livewire\Component;
class TodoList extends Component
{
public string $newTodo = '';
/**
* Get todos.
*/
#[Computed]
public function todos(): Collection
{
return Todo::where('user_id', auth()->id())
->orderBy('completed')
->orderByDesc('created_at')
->get();
}
/**
* Add new todo.
*/
public function addTodo(): void
{
$this->validate(['newTodo' => 'required|min:3']);
Todo::create([
'user_id' => auth()->id(),
'title' => $this->newTodo,
'completed' => false,
]);
$this->reset('newTodo');
unset($this->todos); // Clear computed cache
}
/**
* Listen for todo completion from child.
*/
#[On('todo-completed')]
public function handleTodoCompleted(int $todoId): void
{
unset($this->todos); // Refresh list
}
/**
* Listen for todo deletion from child.
*/
#[On('todo-deleted')]
public function handleTodoDeleted(int $todoId): void
{
unset($this->todos); // Refresh list
}
public function render()
{
return view('livewire.todo-list');
}
}Child Component - TodoItem
<?php // app/Livewire/TodoItem.php
namespace App\Livewire;
use App\Models\Todo;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Modelable;
use Livewire\Component;
class TodoItem extends Component
{
public Todo $todo;
public bool $editing = false;
public string $editTitle = '';
/**
* Initialize from parent prop.
*/
public function mount(Todo $todo): void
{
$this->todo = $todo;
$this->editTitle = $todo->title;
}
/**
* Toggle completion.
*/
public function toggleComplete(): void
{
$this->todo->update(['completed' => !$this->todo->completed]);
$this->dispatch('todo-completed', todoId: $this->todo->id);
}
/**
* Start editing.
*/
public function startEditing(): void
{
$this->editing = true;
$this->editTitle = $this->todo->title;
}
/**
* Save edit.
*/
public function saveEdit(): void
{
$this->validate(['editTitle' => 'required|min:3']);
$this->todo->update(['title' => $this->editTitle]);
$this->editing = false;
}
/**
* Cancel edit.
*/
public function cancelEdit(): void
{
$this->editing = false;
$this->editTitle = $this->todo->title;
}
/**
* Delete todo.
*/
public function delete(): void
{
$this->todo->delete();
$this->dispatch('todo-deleted', todoId: $this->todo->id);
}
public function render()
{
return view('livewire.todo-item');
}
}Parent View - todo-list.blade.php
<div class="max-w-md mx-auto p-4">
<h1 class="text-2xl font-bold mb-4">Todo List</h1>
{{-- Add Todo Form --}}
<form wire:submit="addTodo" class="flex gap-2 mb-6">
<input
type="text"
wire:model="newTodo"
placeholder="Add a new todo..."
class="flex-1 rounded border-gray-300"
>
<button type="submit" class="bg-blue-500 text-white px-4 py-2 rounded">
Add
</button>
</form>
@error('newTodo')
<span class="text-red-500 text-sm">{{ $message }}</span>
@enderror
{{-- Todo Items --}}
<div class="space-y-2">
@foreach ($this->todos as $todo)
<livewire:todo-item
:todo="$todo"
:wire:key="'todo-'.$todo->id"
/>
@endforeach
</div>
{{-- Empty State --}}
@if ($this->todos->isEmpty())
<p class="text-gray-500 text-center py-8">
No todos yet. Add one above!
</p>
@endif
</div>Child View - todo-item.blade.php
<div @class([
'flex items-center gap-3 p-3 bg-white rounded shadow',
'opacity-50' => $todo->completed,
])>
{{-- Checkbox --}}
<input
type="checkbox"
wire:click="toggleComplete"
@checked($todo->completed)
class="rounded border-gray-300"
>
{{-- Title or Edit Form --}}
@if ($editing)
<form wire:submit="saveEdit" class="flex-1 flex gap-2">
<input
type="text"
wire:model="editTitle"
class="flex-1 rounded border-gray-300 text-sm"
autofocus
>
<button type="submit" class="text-green-600 text-sm">Save</button>
<button type="button" wire:click="cancelEdit" class="text-gray-600 text-sm">
Cancel
</button>
</form>
@else
<span
@class(['flex-1', 'line-through' => $todo->completed])
wire:dblclick="startEditing"
>
{{ $todo->title }}
</span>
@endif
{{-- Actions --}}
@unless ($editing)
<button
wire:click="startEditing"
class="text-blue-600 text-sm hover:underline"
>
Edit
</button>
<button
wire:click="delete"
wire:confirm="Delete this todo?"
class="text-red-600 text-sm hover:underline"
>
Delete
</button>
@endunless
</div>Using $parent
{{-- Child can call parent methods directly --}}
<button wire:click="$parent.refreshList">
Refresh
</button>
{{-- Access parent property --}}
<span>Filter: {{ $parent.filter }}</span>#[Reactive] Props
<?php // Child with reactive props - auto-updates when parent changes
use Livewire\Attributes\Reactive;
class TodoCount extends Component
{
#[Reactive]
public array $todos;
public function render()
{
return view('livewire.todo-count', [
'count' => count($this->todos),
'completed' => collect($this->todos)->where('completed', true)->count(),
]);
}
}{{-- In parent --}}
<livewire:todo-count :todos="$this->todos->toArray()" />#[Modelable] Two-Way Binding
<?php // Child input component
use Livewire\Attributes\Modelable;
class TextInput extends Component
{
#[Modelable]
public string $value = '';
public string $label = '';
public string $placeholder = '';
}{{-- Parent can wire:model directly to child --}}
<livewire:text-input
wire:model="title"
label="Title"
placeholder="Enter title..."
/>Volt Components
Functional API - Counter
<?php // resources/views/livewire/counter.blade.php
use function Livewire\Volt\{state, computed, mount};
state(['count' => 0, 'step' => 1]);
$increment = fn() => $this->count += $this->step;
$decrement = fn() => $this->count -= $this->step;
$reset = fn() => $this->count = 0;
$doubleCount = computed(fn() => $this->count * 2);
$mount = function() {
$this->count = session('count', 0);
};
$updated = function($property) {
if ($property === 'count') {
session(['count' => $this->count]);
}
};
?>
<div class="p-4 bg-white rounded shadow">
<h2 class="text-xl font-bold">Counter</h2>
<p class="text-3xl my-4">{{ $count }}</p>
<p class="text-gray-500">Double: {{ $this->doubleCount }}</p>
<div class="flex gap-2 mt-4">
<button wire:click="decrement" class="bg-red-500 text-white px-4 py-2 rounded">
-{{ $step }}
</button>
<button wire:click="increment" class="bg-green-500 text-white px-4 py-2 rounded">
+{{ $step }}
</button>
<button wire:click="reset" class="bg-gray-500 text-white px-4 py-2 rounded">
Reset
</button>
</div>
<div class="mt-4">
<label class="text-sm">Step:</label>
<input type="number" wire:model.live="step" class="w-20 border rounded px-2 py-1" min="1">
</div>
</div>Class-Based Volt - User Profile
<?php // resources/views/livewire/user-profile.blade.php
use App\Models\User;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Validate;
use Livewire\Volt\Component;
new class extends Component {
public User $user;
#[Validate('required|min:2|max:100')]
public string $name = '';
#[Validate('required|email')]
public string $email = '';
#[Validate('nullable|url')]
public ?string $website = null;
public function mount(User $user): void
{
$this->user = $user;
$this->fill($user->only(['name', 'email', 'website']));
}
#[Computed]
public function postsCount(): int
{
return $this->user->posts()->count();
}
public function save(): void
{
$validated = $this->validate();
$this->user->update($validated);
session()->flash('message', 'Profile updated!');
}
public function with(): array
{
return [
'recentPosts' => $this->user->posts()->latest()->take(5)->get(),
];
}
};
?>
<div class="max-w-lg mx-auto p-6">
<h1 class="text-2xl font-bold mb-6">Edit Profile</h1>
@if (session()->has('message'))
<div class="bg-green-100 text-green-700 px-4 py-3 rounded mb-4">
{{ session('message') }}
</div>
@endif
<form wire:submit="save" class="space-y-4">
<div>
<label class="block text-sm font-medium">Name</label>
<input type="text" wire:model.blur="name" class="mt-1 block w-full rounded border-gray-300">
@error('name') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
</div>
<div>
<label class="block text-sm font-medium">Email</label>
<input type="email" wire:model.blur="email" class="mt-1 block w-full rounded border-gray-300">
@error('email') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
</div>
<div>
<label class="block text-sm font-medium">Website</label>
<input type="url" wire:model.blur="website" class="mt-1 block w-full rounded border-gray-300">
@error('website') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
</div>
<button type="submit" class="bg-blue-500 text-white px-4 py-2 rounded">
Save Profile
</button>
</form>
<div class="mt-8">
<h2 class="text-lg font-semibold">Recent Posts ({{ $this->postsCount }})</h2>
<ul class="mt-2 space-y-2">
@foreach ($recentPosts as $post)
<li wire:key="post-{{ $post->id }}" class="border-b pb-2">
<a href="{{ route('posts.show', $post) }}" class="text-blue-600 hover:underline">
{{ $post->title }}
</a>
</li>
@endforeach
</ul>
</div>
</div>@volt Inline Directive
{{-- resources/views/dashboard.blade.php --}}
<x-layouts.app>
<h1 class="text-2xl font-bold mb-6">Dashboard</h1>
<div class="grid grid-cols-3 gap-4">
@volt('dashboard.users-count')
<?php
use function Livewire\Volt\{state};
use App\Models\User;
state(['count' => fn() => User::count()]);
?>
<div class="bg-white p-4 rounded shadow">
<h3 class="text-gray-500">Total Users</h3>
<p class="text-3xl font-bold">{{ $count }}</p>
</div>
@endvolt
@volt('dashboard.posts-count')
<?php
use App\Models\Post;
state(['count' => fn() => Post::published()->count()]);
?>
<div class="bg-white p-4 rounded shadow">
<h3 class="text-gray-500">Published Posts</h3>
<p class="text-3xl font-bold">{{ $count }}</p>
</div>
@endvolt
@volt('dashboard.revenue')
<?php
use App\Models\Order;
state(['amount' => fn() => Order::sum('total')]);
?>
<div class="bg-white p-4 rounded shadow">
<h3 class="text-gray-500">Total Revenue</h3>
<p class="text-3xl font-bold">${{ number_format($amount, 2) }}</p>
</div>
@endvolt
</div>
</x-layouts.app>Volt Routes
// routes/web.php
use Livewire\Volt\Volt;
Volt::route('/', 'home');
Volt::route('/counter', 'counter');
Volt::route('/users/{user}', 'user-profile')
->middleware(['auth'])
->name('profile');Testing
Decision Tree
Component type?
├── Class component → Livewire::test(Component::class)
├── Volt component → Volt::test('component-name')
├── Full-page → $this->get('/path')->assertSeeLivewire()
├── Events → assertDispatched()
└── File uploads → UploadedFile::fake()Livewire::test()
| Method | Purpose |
|---|---|
Livewire::test(Class) | Create test instance |
->set('prop', value) | Set property |
->call('method') | Call action |
->assertSee('text') | Check output |
Volt::test()
| Method | Purpose |
|---|---|
Volt::test('name') | Test Volt component |
Volt::test('path.name') | Nested path |
| Same assertions | As Livewire::test |
Property Assertions
| Assertion | Checks |
|---|---|
assertSet('prop', value) | Property equals |
assertNotSet('prop', value) | Property not equals |
assertCount('prop', n) | Array count |
Validation Assertions
| Assertion | Checks |
|---|---|
assertHasErrors(['field']) | Has error |
assertHasErrors(['field' => 'required']) | Specific rule |
assertHasNoErrors() | No errors |
assertHasNoErrors(['field']) | Specific no error |
Event Assertions
| Assertion | Checks |
|---|---|
assertDispatched('event') | Event fired |
assertDispatched('event', key: val) | With data |
assertNotDispatched('event') | Not fired |
View Assertions
| Assertion | Checks |
|---|---|
assertSee('text') | Text visible |
assertDontSee('text') | Text not visible |
assertSeeHtml('<div>') | Raw HTML |
assertViewHas('key', value) | View data |
Navigation Assertions
| Assertion | Checks |
|---|---|
assertRedirect('/path') | Redirected |
assertNoRedirect() | No redirect |
File Upload Testing
| Setup | Purpose |
|---|---|
Storage::fake('public') | Fake storage |
UploadedFile::fake()->image() | Fake image |
assertExists('path') | File stored |
HTTP Testing
| Method | Purpose |
|---|---|
$this->get('/path') | Visit page |
->assertSeeLivewire(Class) | Component rendered |
->assertSeeVolt('name') | Volt rendered |
Best Practices
| DO | DON'T |
|---|---|
| Test behavior, not impl | Test internals |
| Use factories | Hardcode data |
| Test edge cases | Only happy path |
| Assert specific errors | Generic error check |
→ See template: ComponentTest.php.md
Volt Components
Decision Tree
Volt style?
├── Functional API → state(), computed(), $action
├── Class-based → new class extends Component
├── Inline in page → @volt directive
├── Route → Volt::route()
└── Mixed → PHP + Blade in one fileFunctional API
| Function | Purpose |
|---|---|
state(['key' => val]) | Define properties |
computed(fn() => ...) | Computed property |
$action = fn() => ... | Define action |
$mount = fn() => ... | Mount hook |
$updated = fn($prop) => ... | Updated hook |
State Definition
| Pattern | Usage |
|---|---|
state(['count' => 0]) | With default |
state(['user']) | No default (null) |
state()->reactive() | Reactive to parent |
state()->modelable() | Two-way binding |
Class-Based Volt
| Element | Same as Class Component |
|---|---|
new class extends Component | Inline class |
| Properties | public $prop |
| Methods | public function method() |
| Lifecycle | mount(), updated(), etc |
@volt Directive
| Usage | Purpose |
|---|---|
@volt('name') | Start Volt block |
@endvolt | End Volt block |
| In any Blade | Inline component |
Volt Routes
| Method | Purpose |
|---|---|
Volt::route('/path', 'name') | Define route |
->middleware([...]) | Add middleware |
->name('route.name') | Name route |
Accessing $this
| In Functional | Access |
|---|---|
| In actions | $this->property |
| In computed | $this->property |
| In hooks | $this->property |
Computed in Functional
| Syntax | Usage |
|---|---|
$doubleCount = computed(...) | Define |
$this->doubleCount | Access in PHP |
{{ $this->doubleCount }} | Access in Blade |
With Dependencies
| Pattern | Usage |
|---|---|
$action = function(Service $s) | Inject in action |
| Auto-resolved | From container |
Best Practices
| DO | DON'T |
|---|---|
| Volt for simple pages | Complex logic in Volt |
| Functional for few props | Many computed = Class |
| @volt for inline embeds | Overuse inline |
| Name Volt routes | Anonymous routes |
→ See template: VoltComponent.blade.md
Wire Directives
Decision Tree
What to bind?
├── Input value → wire:model
├── Click action → wire:click
├── Form submit → wire:submit
├── Loading state → wire:loading
├── Keyboard → wire:keydown
├── Unique ID → wire:key
└── Ignore updates → wire:ignorewire:model Modifiers
| Modifier | Behavior |
|---|---|
wire:model | Deferred (on action) |
wire:model.live | Real-time sync |
wire:model.blur | Sync on blur |
wire:model.live.debounce.500ms | Debounced real-time |
wire:model.live.throttle.1s | Throttled sync |
wire:model.fill | Ignore initial value |
Event Directives
| Directive | Triggers On |
|---|---|
wire:click | Click |
wire:submit | Form submit |
wire:keydown | Key press |
wire:keydown.enter | Enter key |
wire:change | Input change |
wire:input | Input event |
Event Modifiers
| Modifier | Effect |
|---|---|
.prevent | preventDefault() |
.stop | stopPropagation() |
.self | Only if target is element |
.window | Listen on window |
.document | Listen on document |
wire:loading
| Syntax | Shows When |
|---|---|
wire:loading | Any loading |
wire:loading.remove | Hide when loading |
wire:loading.attr="disabled" | Add attribute |
wire:loading.class="opacity-50" | Add class |
wire:target="save" | Specific action |
wire:key
| Usage | Purpose |
|---|---|
wire:key="item-{{ $id }}" | Unique ID in loops |
| Required in @foreach | Proper diffing |
| Must be unique | Per-loop iteration |
wire:ignore
| Syntax | Behavior |
|---|---|
wire:ignore | Ignore element updates |
wire:ignore.self | Ignore only this element |
| Use for | Third-party JS widgets |
wire:poll
| Syntax | Interval |
|---|---|
wire:poll | 2 seconds (default) |
wire:poll.5s | 5 seconds |
wire:poll.visible | Only when visible |
wire:poll="method" | Call specific method |
wire:navigate
| Syntax | Behavior |
|---|---|
wire:navigate | SPA navigation |
wire:navigate.hover | Prefetch on hover |
Best Practices
| DO | DON'T |
|---|---|
| Use debounce for search | Live without debounce |
| Use blur for validation | Live for every field |
| Always use wire:key in loops | Forget keys in @foreach |
| Target specific actions | Global loading states |
→ See template: FormComponent.php.md