If you've been building Livewire components for a while, you know the drill: create a PHP class in app/Livewire, drop a Blade view in resources/views/livewire, wire them together, and repeat. For complex components this separation is a gift. For simpler ones — a counter, a search input, a toggle — it's friction.
Laravel Volt solves this. Introduced alongside Livewire v3, Volt lets you define your component's logic and template in a single .blade.php file. Think of it as the single-file component (SFC) pattern from Vue, but for the TALL stack. No new class file. No matching view file. One file, full Livewire power.
This guide walks through everything you need to know to use Volt effectively in a real project.
Installing and Setting Up Volt
Volt ships with Laravel Breeze when you choose the Livewire stack, but you can add it to any Livewire v3 project manually:
composer require livewire/volt
php artisan volt:install
This publishes the service provider and registers the Volt component discovery path. By default Volt scans resources/views/livewire. You can customise this in app/Providers/VoltServiceProvider.php:
Volt::mount([
resource_path('views/livewire'),
resource_path('views/pages'),
]);
Anything in those directories that uses the Volt API will be treated as a Volt component.
Your First Volt Component
Create resources/views/livewire/counter.blade.php:
<?php
use function Livewire\Volt\{state, computed};
state(['count' => 0]);
$increment = fn () => $this->count++;
$decrement = fn () => $this->count--;
?>
<div class="flex items-center gap-4 p-4">
<button wire:click="decrement" class="px-3 py-1 bg-red-500 text-white rounded">
−
</button>
<span class="text-xl font-bold">{{ $count }}</span>
<button wire:click="increment" class="px-3 py-1 bg-green-500 text-white rounded">
+
</button>
</div>
Render it anywhere with the standard Livewire tag:
<livewire:counter />
That's the entire component. No class file, no separate Blade file. The PHP block above the ?> closing tag is your component definition.
The Volt API: State, Computed, Actions, and Lifecycle
Volt exposes a clean functional API. Here's a practical reference:
State
use function Livewire\Volt\{state};
// Simple defaults
state(['search' => '', 'page' => 1]);
// Lazy initialisation (runs once when accessed)
state(['posts' => fn () => Post::latest()->get()]);
Computed Properties
use function Livewire\Volt\{state, computed};
state(['search' => '']);
$filteredUsers = computed(function () {
return User::where('name', 'like', "%{$this->search}%")->get();
});
Access it in the template as $this->filteredUsers or simply $filteredUsers.
Actions
Actions are just closures assigned to variables:
$save = function () {
$this->validate([
'title' => 'required|min:3',
'body' => 'required',
]);
Post::create([
'title' => $this->title,
'body' => $this->body,
]);
$this->dispatch('post-saved');
$this->reset('title', 'body');
};
Lifecycle Hooks
use function Livewire\Volt\{state, on, mount};
state(['notifications' => []]);
mount(function () {
$this->notifications = auth()->user()->unreadNotifications->toArray();
});
on(['notification-received' => function (string $message) {
$this->notifications[] = $message;
}]);
A Real-World Example: Live Search Component
Here's a component you'd actually ship — a debounced live search over a products table:
<?php
use App\Models\Product;
use function Livewire\Volt\{state, computed};
state(['query' => '']);
$results = computed(function () {
if (strlen($this->query) < 2) {
return collect();
}
return Product::where('name', 'like', "%{$this->query}%")
->orWhere('sku', 'like', "%{$this->query}%")
->limit(10)
->get();
});
?>
<div x-data="{ open: false }" class="relative">
<input
wire:model.live.debounce.300ms="query"
@focus="open = true"
@click.outside="open = false"
type="text"
placeholder="Search products…"
class="w-full border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-400"
/>
@if($this->results->isNotEmpty())
<ul
x-show="open"
class="absolute z-10 w-full bg-white border rounded-lg shadow-lg mt-1 divide-y"
>
@foreach($this->results as $product)
<li class="px-4 py-2 hover:bg-gray-50 cursor-pointer">
<a href="/products/{{ $product->id }}" class="flex justify-between">
<span>{{ $product->name }}</span>
<span class="text-sm text-gray-400">{{ $product->sku }}</span>
</a>
</li>
@endforeach
</ul>
@endif
</div>
This blends Livewire's reactive state with Alpine.js for the dropdown visibility — exactly the kind of elegant synergy the TALL stack is built for. At the agency where I work, we've moved most UI components like this one to Volt precisely because the reduced file count keeps feature branches clean and code reviews faster.
Class-Based Volt Components
For components with significant logic, Volt also supports a class-based syntax inside the same file:
<?php
use Livewire\Volt\Component;
new class extends Component {
public string $email = '';
public bool $subscribed = false;
public function subscribe(): void
{
$this->validate(['email' => 'required|email']);
Newsletter::subscribe($this->email);
$this->subscribed = true;
}
};
?>
<div>
@if ($subscribed)
<p class="text-green-600">You're in! Check your inbox.</p>
@else
<form wire:submit="subscribe" class="flex gap-2">
<input wire:model="email" type="email" placeholder="your@email.com" class="border rounded px-3 py-2" />
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded">Subscribe</button>
</form>
@endif
</div>
This gives you IDE autocompletion, type hints, and the ability to use PHP 8 attributes — all while keeping the view co-located.
When to Use Volt vs. Traditional Livewire
Volt isn't a replacement for traditional Livewire components — it's a complement. Here's a practical heuristic:
| Scenario | Recommendation |
|---|---|
| Simple UI widget (toggle, counter, search input) | Volt functional API |
| Form with moderate validation | Volt class-based |
| Complex component with services, repositories, or events | Traditional Livewire class |
| Full-page components with routing | Traditional Livewire class |
| Shared, reusable UI library | Either, but Volt is more portable |
The boundary is blurry and personal. The key is consistency within a project.
Testing Volt Components
Volt components are tested exactly like standard Livewire components:
use Livewire\Volt\Volt;
it('increments the counter', function () {
Volt::test('counter')
->call('increment')
->assertSet('count', 1);
});
it('filters products by search query', function () {
Product::factory()->create(['name' => 'Blue Widget']);
Product::factory()->create(['name' => 'Red Gadget']);
Volt::test('live-search')
->set('query', 'Blue')
->assertSeeHtml('Blue Widget')
->assertDontSeeHtml('Red Gadget');
});
The Volt::test() helper resolves the component by its view path name, just like <livewire:counter />.
Conclusion
Laravel Volt is a well-considered addition to the Livewire ecosystem. It doesn't reinvent anything — Livewire's reactivity model is unchanged. It simply removes the file-creation ceremony for components that don't need it. The result is faster development, fewer context switches, and codebases that are noticeably easier to navigate.
If you're building on Livewire v3 and haven't tried Volt yet, start with your next small component. The API clicks quickly, and you'll likely find yourself reaching for it more often than you expect. For teams building production Laravel applications — particularly those mixing Livewire with Alpine.js for nuanced UI interactions — Volt is now a default tool, not an optional extra.
If you're curious about how modern Laravel patterns like this fit into larger application architectures, you can find out more about the kind of work being done in that space.
Top comments (0)