How we write Livewire components - principles, patterns, and testing. Use when building or reviewing Livewire components, tests, or Blade views.
How we write Livewire components. Principles first, then patterns, then testing.
The right mindset prevents over-engineering before it starts.
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?"
// The framework handles this - you don't need to
public function save(): void
{
$this->validate(); // Laravel validates
$this->form->save(); // Done
}
Test what users see and what data changes. If a test would break when you refactor (without changing behaviour), it's testing implementation details.
// Test behaviour: what changed in the world?
it('creates a new role', function () {
Livewire::test(RolesList::class)
->set('roleName', 'Editor')
->call('saveRole');
expect(Role::where('name', 'Editor')->exists())->toBeTrue();
});
// Not implementation: what happened inside the component?
// (Don't test that $isModalOpen became false)
Don't hide problems with defensive guards and silent returns. On a trusted network with error monitoring, exceptions are helpful.
// Let it fail - this tells you something's wrong
public function updateLevel(int $skillId, SkillLevel $level): void
{
$this->user->skills()->updateExistingPivot($skillId, [
'level' => $level->value,
]);
}
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.
Trusted staff won't manipulate URLs. Students might - there's always one who'll try it on. Test accordingly.
Don't use boolean properties for modal state. Use the Flux facade.
use Flux\Flux;
// Open
Flux::modal('item-editor')->show();
// Close (typically after saving)
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.
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);
}
}
$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');
When form fields send empty strings but the database expects null:
// In your Model
protected function supplierId(): Attribute
{
return Attribute::make(
set: fn ($value) => $value ?: null,
);
}
Use null for "creating new", not -1 or other magic numbers:
public ?int $editingId = null;
if ($this->editingId === null) { /* create */ }
$this->dispatch('item-saved');
$this->dispatch('refresh')->to(OtherComponent::class);
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 - (string) null already gives ''
$this->editing['supplier_id'] = (string) $model->supplier_id;
// Only use ?: null if the distinction actually matters
'cost' => $this->editing['cost'],
// GOOD: tests what changed in the world
it('creates a role', function () {
Livewire::test(RoleManager::class)
->call('openCreateModal')
->set('roleName', 'Editor')
->call('save')
->assertHasNoErrors();
expect(Role::where('name', 'Editor')->exists())->toBeTrue();
});
// BAD: tests component internals
->assertSet('isCreating', true)
->assertSet('showModal', false)
->call('resetModal')
// Test the full HTTP stack including middleware
$this->actingAs($user)->get(route('admin.items'))->assertForbidden();
// Not the component's internal auth check
// Livewire::test(AdminItems::class)->assertForbidden();
expect(Item::find($item->id))->toBeNull();
expect($item->users()->count())->toBe(0);
// Not raw database assertions
// $this->assertDatabaseMissing('items', ['id' => $item->id]);
it('validates required fields', function () {
Livewire::test(CreateUser::class)
->set('name', '')
->set('email', 'invalid')
->call('save')
->assertHasErrors(['name', 'email']);
expect(User::count())->toBe(0);
});
Before committing a test, ask: "If I refactored the component tomorrow without changing behaviour, would this test break?"
For more thorough side-by-side comparisons: