| name | modern-livewire |
| description | How we write Livewire components - principles, patterns, and testing. Use when building or reviewing Livewire components, tests, or Blade views. |
Modern Livewire
How we write Livewire components. Principles first, then patterns, then testing.
Principles
The right mindset prevents over-engineering before it starts.
1. Trust the Framework
Laravel validation works. Livewire form binding works. Eloquent relationships work. Don't rebuild what's already there.
Ask yourself: "Does the framework already handle this?"
public function save(): void
{
$this->validate();
$this->form->save();
}
2. Test Behaviour, Not Implementation
Test what users see and what data changes. If a test would break when you refactor (without changing behaviour), it's testing implementation details.
it('creates a new role', function () {
Livewire::test(RolesList::class)
->set('roleName', 'Editor')
->call('saveRole');
expect(Role::where('name', 'Editor')->exists())->toBeTrue();
});
3. Let Errors Surface
Don't hide problems with defensive guards and silent returns. On a trusted network with error monitoring, exceptions are helpful.
public function updateLevel(int $skillId, SkillLevel $level): void
{
$this->user->skills()->updateExistingPivot($skillId, [
'level' => $level->value,
]);
}
4. Simple Over Clever
Three similar lines are better than a premature abstraction. A button that's always visible is simpler than one that tracks whether it "should" be visible.
5. The Happy Path is Enough (Usually)
Trusted staff won't manipulate URLs. Students might - there's always one who'll try it on. Test accordingly.
Questions Before You Code
- Does the framework already handle this?
- Am I testing what users see, or component internals?
- Would this test break if I refactored without changing behaviour?
- Am I adding this "just in case"?
- Who would actually do this? Trusted staff or students?
Component Patterns
Named Modals with Flux Facade
Don't use boolean properties for modal state. Use the Flux facade.
use Flux\Flux;
Flux::modal('item-editor')->show();
Flux::modal('item-editor')->close();
Cancel buttons close directly from Alpine - no server roundtrip:
<flux:button x-on:click="$flux.modal('item-editor').close()">Cancel</flux:button>
No $showModal properties. No cancelEdit() methods that just toggle a boolean.
Form State - Single Array
For components with a handful of fields, use a single array:
public array $editing = [
'id' => null,
'name' => '',
'description' => '',
'cost' => '',
'is_active' => false,
];
public function openCreate(): void {
$this->reset('editing');
}
public function openEdit(int $id): void {
$model = Model::findOrFail($id);
$this->editing = $model->toArray();
}
<flux:input wire:model="editing.name" label="Name" />
Validation uses dot notation:
$this->validate([
'editing.name' => ['required', 'string', 'max:255'],
'editing.description' => ['nullable', 'string'],
]);
For components with many fields, extract to a Livewire Form object:
class CreatePostComponent extends Component
{
public PostForm $form;
public function save(): void
{
$post = $this->form->save();
Flux::toast('Post created');
$this->redirectRoute('posts.edit', $post);
}
}
Create/Update - findOrNew + fill + save
$model = Model::findOrNew($this->editing['id']);
$model->fill($this->editing)->save();
Flux::toast('Saved.', variant: 'success');
fill() only uses $fillable attributes - non-fillable keys like id, created_at are automatically ignored.
If you need separate messages:
$action = $model->wasRecentlyCreated ? 'created' : 'updated';
Flux::toast("Item {$action}.", variant: 'success');
Model Mutators for Data Normalisation
When form fields send empty strings but the database expects null:
protected function supplierId(): Attribute
{
return Attribute::make(
set: fn ($value) => $value ?: null,
);
}
Sentinel Values
Use null for "creating new", not -1 or other magic numbers:
public ?int $editingId = null;
if ($this->editingId === null) { }
Event Dispatching
$this->dispatch('item-saved');
$this->dispatch('refresh')->to(OtherComponent::class);
wire:model Behaviour
Livewire defers wire:model by default. Use .live for real-time, .blur for on-blur:
| Modifier | Behaviour |
|---|
wire:model | Deferred (default) |
wire:model.live | Real-time sync |
wire:model.blur | Sync on blur |
Unnecessary Ternaries
$this->editing['supplier_id'] = (string) $model->supplier_id;
'cost' => $this->editing['cost'],
Testing
Test Behaviour, Not Mechanics
it('creates a role', function () {
Livewire::test(RoleManager::class)
->call('openCreateModal')
->set('roleName', 'Editor')
->call('save')
->assertHasNoErrors();
expect(Role::where('name', 'Editor')->exists())->toBeTrue();
});
->assertSet('isCreating', true)
->assertSet('showModal', false)
->call('resetModal')
Auth via Route, Not Component
$this->actingAs($user)->get(route('admin.items'))->assertForbidden();
Eloquent Assertions
expect(Item::find($item->id))->toBeNull();
expect($item->users()->count())->toBe(0);
Validation - Test That It Exists, Not Every Rule
it('validates required fields', function () {
Livewire::test(CreateUser::class)
->set('name', '')
->set('email', 'invalid')
->call('save')
->assertHasErrors(['name', 'email']);
expect(User::count())->toBe(0);
});
The Refactoring Test
Before committing a test, ask: "If I refactored the component tomorrow without changing behaviour, would this test break?"
- If yes - you're testing implementation. Reconsider.
- If no - you're testing behaviour. Good.
Staff vs Students
- Staff apps on trusted LAN: Happy path is enough. Don't test URL manipulation.
- Student-facing features: Test that students can't access others' data, can't manipulate IDs in forms.
Detailed Examples
For more thorough side-by-side comparisons:
- Component Examples - Form delegation, validation, modal patterns, Blade templates
- Test Examples - Behaviour vs implementation tests, the refactoring test, staff vs student apps