Introduction
In the past, when building Livewire components for our Laravel applications, we needed to keep our backend and frontend code split up into separate files. This can sometimes get a little confusing, especially with larger projects. But with Volt, we can now build single-file Livewire components where the backend and frontend code coexist in the same file.
In this article, we'll look at what Volt is and how Volt components differ from traditional Livewire components. We'll then learn how to write tests for your Laravel Volt components.
By the end of the article, you should understand what Volt is and feel confident using it in your Laravel projects.
What is Laravel Volt?
Volt is a fantastic package in the Laravel ecosystem that essentially adds a layer over Livewire so it supports single-file components.
Traditionally, with Livewire, you would have Blade templates that contains the frontend and user interface (UI) and a separate PHP file for the backend logic (such as interacting with the database or service classes and managing state). With Volt, you can create Livewire components where the frontend and backend code coexist in the same file. As a result, you can build isolated and focused single-file components without jumping between two separate files.
If you've used Vue to create user interfaces, you might already be familiar with putting your UI and logic in the same file.
Laravel Volt components come in two different variations: functional and class-based. We'll look at both of these in this article so you can decide which one fits your workflow and preferences.
A traditional Livewire component
To understand the differences between traditional Livewire components and Volt components, we'll first look at what a standard Livewire component looks like in a Laravel web application.
We'll have a simple component that lists users and allows you to select users to delete using checkboxes. We'll then have a button that will delete the selected users. We'll also display a message with the number of selected users.
It's worth noting that this example is purely for demonstration purposes, so don't worry too much about the actual functionality. We won't be including things such as error handling, validation, authorization, or pagination. You'd likely want to include these in your production components.
We'll start by looking at the Livewire component's backend logic and then discuss what it's doing:
app/Livewire/Users/Index.php
1declare(strict_types=1);23namespace App\Livewire\Users;45use App\Models\User;6use Illuminate\Contracts\View\View;7use Illuminate\Database\Eloquent\Collection;8use Livewire\Attributes\Locked;9use Livewire\Component;1011final class Index extends Component12{13 /** @var Collection<int, User> */14 #[Locked]15 public Collection $users;1617 /** @var int[] */18 public array $selectedUserIds = [];1920 public function mount(): void21 {22 $this->users = User::all();23 }2425 public function render(): View26 {27 return view('livewire.users.index');28 }2930 /**31 * Define a public function that can be called from the32 * frontend and the backend.33 */34 public function deleteSelected(): void35 {36 User::query()->whereKey($this->selectedUserIds)->delete();3738 $this->reloadPage();39 }4041 /**42 * Define a function that can only be called from the backend.43 */44 protected function reloadPage(): void45 {46 $this->redirect(route('users.index'));47 }48}
In the code above, we can see that we have an App\Livewire\Users\Index
class that extends the Livewire\Component
class.
Because the users
property is public, the frontend can access it by default—which we don't want. Adding the Livewire\Attributes\Locked
attribute means the property can only be updated from the backend (within this PHP class).
The class also contains a selectedUserIds
property (an array of integers), which it uses to store the IDs of the selected users on the frontend.
The class contains the following methods:
-
mount
- Called when the component is first mounted and loaded on the page. In this method, we're fetching all the users from the database and storing them in theusers
property. -
render
- Renders the view, which is located inresources/views/livewire/users/index.blade.php
. -
deleteSelected
- Called when the user clicks the "Delete Selected" button. It will delete all the users that were selected on the frontend. After deleting the users, thereloadPage
method reloads the page. ThedeleteSelected
method is public and can be called from both the front and backend. -
reloadPage
- Reloads the page after the users are deleted. I've created this method purely for demonstration purposes so that we can look at protected methods. Since it's protected, the method can only be called from the backend, not the frontend.
Now that we have the backend logic for our Livewire component, let's take a look at the frontend/UI Blade file (located at resources/views/livewire/users/index.blade.php
as defined in our component's render
method):
resources/views/livewire/users/index.blade.php
1<div>23 Users selected: {{ count($selectedUserIds) }}45 @if(count($selectedUserIds))6 <button wire:click="deleteSelected">7 Delete Selected8 </button>9 @endif1011 @foreach($users as $user)12 <div>13 <input14 type="checkbox"15 wire:model.live="selectedUserIds"16 value="{{ $user->id }}"17 >18 {{ $user->name }}19 </div>20 @endforeach2122</div>
In the simple example above, we have a Blade file that contains the HTML for the component. First, we display the number of users that the user selected using the checkboxes. For example, if the user has selected three users, it will output "Users selected: 3".
We're then using an @if
directive to check if any users are selected. If there are selected users, we'll show a button that the user can click to delete the selected users. This button contains the wire:click
directive to call the deleteSelected
method on the backend when clicked.
Following this, we're looping through all the users and displaying a checkbox for each user. The wire:model.live
directive binds the checkbox to the selectedUserIds
property; when a user checks a checkbox, the component adds the ID of the user to the selectedUserIds
array. When a user unchecks a checkbox, the component removes the user's ID from the selectedUserIds
array. It's worth noting that I've used wire:model.live
instead of wire:model
as we want the changes to be reflected immediately on the frontend and cause Livewire to re-render the component. You probably wouldn't want to do this in a real-life Laravel project as it could cause unnecessary Livewire requests to the server; instead, you'd opt for using something like Alpine JS to handle the checkbox changes via JavaScript to prevent needing to make any requests to the server to re-render the page. But since we're focusing purely on Livewire and Volt in this example, we're using wire:model.live
.
Finally, to render the component in a Blade file, you could do something like this:
1<html> 2<head> 3 <title>Users</title> 4</head> 5<body> 6 ... 7 8 <livewire:users.index /> 9 10 ...11</body>12</html>
As we can see in the Blade file above, we're using <livewire:users.index>
to render the Livewire component that exists in the App\Livewire\Users\Index
class.
That covers all of our simple Livewire component. Now, let's look at how we can achieve the same functionality using a Laravel Volt component.
Installing Volt
To get started with using Volt, you'll first need to download it using Composer by running the following command:
1composer require livewire/volt
You can then run Volt's installation command by running the following Artisan command in your terminal:
1php artisan volt:install
Volt is now installed and ready for you to use.
Class-based Volt components
First, we'll look at a class-based Laravel Volt component, which is the most similar to traditional Livewire components. The component's functionality will be the same as the Livewire component we looked at earlier.
To create the component, we can run the following command:
1php artisan make:volt users/index --class
This will create a new resources/views/livewire/users/index.blade.php
file that looks like this:
resources/views/livewire/users/index.blade.php
1<?php23use Livewire\Volt\Component;45new class extends Component {6 //7}; ?>89<div>10 //11</div>
In the component outline above, we can see that there are two separate sections. The first section is the PHP section (between the <?php
and ?>
tags), and the second section is the HTML section (between the <div>
and </div>
elements). The PHP is where our backend logic will go (similar to the Livewire component), and the HTML is where our frontend/UI will go.
Let's update our class-based Laravel Volt component to match the functionality of the Livewire component we looked at earlier:
resources/views/livewire/users/index.blade.php
1<?php23use App\Models\User;4use Illuminate\Contracts\View\View;5use Illuminate\Database\Eloquent\Collection;6use Livewire\Attributes\Locked;7use Livewire\Volt\Component;89new class extends Component {10 /** @var Collection<int, User> */11 #[Locked]12 public Collection $users;1314 public array $selectedUserIds = [];1516 public function mount(): void17 {18 $this->users = User::all();19 }2021 /**22 * Define a public function that can be called from the23 * frontend and the backend.24 */25 public function deleteSelected(): void26 {27 User::query()->whereKey($this->selectedUserIds)->delete();2829 $this->reloadPage();30 }3132 /**33 * Define a function that can only be called from the backend.34 */35 protected function reloadPage(): void36 {37 $this->redirect(route('users.index'));38 }39};4041?>424344<div>45 Users selected: {{ count($selectedUserIds) }}4647 @if(count($selectedUserIds))48 <button wire:click="deleteSelected">49 Delete Selected50 </button>51 @endif5253 @foreach($users as $user)54 <div>55 <input56 type="checkbox"57 wire:model.live="selectedUserIds"58 value="{{ $user->id }}"59 >60 {{ $user->name }}61 </div>62 @endforeach63</div>
In the component above, we can see that the component's PHP logic (in the PHP section) is very similar to that of the Livewire component. The only differences are:
- We're creating an anonymous class rather than a named class.
- The anonymous class is extending the
Livewire\Volt\Component
class instead of theLivewire\Component
class. - We don't have the
render
method because the Blade is included in the same file.
So, apart from those minor differences, this Volt component almost looks like a typical Livewire component but with the backend logic and frontend/UI in the same file. Pretty cool, right?
Functional Volt components
Next, let's look at how we could define the component using the functional approach for our web application. This approach is essentially an elegantly crafted functional API for Livewire.
To create a functional Volt component, you can run the following command:
1php artisan make:volt users/index --functional
This will create a new resources/views/livewire/users/index.blade.php
file that looks like this:
resources/views/livewire/users/index.blade.php
1<?php23use function Livewire\Volt\{state};45//67?>89<div>10 //11</div>
As we can see, the component outline is similar to the class-based Volt component, but it doesn't have a class definition. We're also importing a function from the Livewire\Volt
namespace called state
that we'll be using to define properties.
Let's update this component and then discuss what's happening:
resources/views/livewire/users/index.blade.php
1<?php23use App\Models\User;4use Illuminate\Database\Eloquent\Collection;5use function Livewire\Volt\protect;6use function Livewire\Volt\state;78// Define a property that can be updated from both9// the frontend and the backend.10state([11 'selectedUserIds' => [],12]);1314// Define a property that can only be updated on the backend.15state([16 'users' => fn () => User::all(),17])->locked();1819// Define a public function that can be called from the20// frontend and the backend.21$deleteSelected = function (): void {22 User::query()->whereKey($this->selectedUserIds)->delete();2324 $this->reloadPage();25};2627// Define a function that can only be called from the backend.28$reloadPage = protect(function (): void {29 $this->redirect(route('users.index'));30});3132?>333435<div>36 Users selected: {{ count($selectedUserIds) }}3738 @if(count($selectedUserIds))39 <button wire:click="deleteSelected">40 Delete Selected41 </button>42 @endif4344 @foreach($users as $user)45 <div>46 <input47 type="checkbox"48 wire:model.live="selectedUserIds"49 value="{{ $user->id }}"50 >51 {{ $user->name }}52 </div>53 @endforeach54</div>
In the code above, we can see that the frontend/UI code in the <div>
tags is the same as the class-based Volt component and the Livewire component we looked at earlier. The main differences are in the PHP section.
We're first starting by using Volt's state
function to define the selectedUserIds
and users
properties. We could declare both of these in the same state
function call, but I've split them out since we want to lock the users' property to prevent the frontend from updating it by chaining the locked
method onto the state
function call. This is the same as using the Livewire\Attributes\Locked
attribute in our previous examples.
Following this, we're defining our two methods: deleteSelected
and reloadPage
. This may be slightly confusing if you're coming from the class-based approach because we define them as variables. But by creating a deleteSelected
variable and assigning it a function, we're essentially creating a public method.
You may have noticed that we've wrapped the reloadPage
function in the protect
function. This prevents the method from being called from the frontend, similar to defining a protected method in a class-based component.
As we can see, the functional approach is quite different from the class-based approach. However, you may feel more comfortable with this approach—especially if you're used to working with something like Vue components in this way.
Testing Volt components
Like any other part of your Laravel application, it's important to write tests for your Volt components to ensure they work as expected.
One of the great features of using Volt and Livewire is that they provide testing helpers that make it easy for you to do this. Apart from a few minor differences, you can test your Volt components like you'd typically test your Livewire components. If you're unfamiliar with testing in Livewire, you may want to check out the testing section in the documentation.
Lastly, let's look at how you can write tests for your Volt components.
Testing the component is in the view
You'll likely want to test whether a Volt component is actually in your Blade views to display. To do this, you can use the assertSeeVolt
method that Volt provides.
For example, say you have a route with the name of admin.users.index
, which returns a resources/views/users/index.blade.php
Blade file that renders a resources/views/livewire/users/list.blade.php
Volt component. You could write a test like so:
1declare(strict_types=1); 2 3namespace Tests\Feature; 4 5use Illuminate\Foundation\Testing\LazilyRefreshDatabase; 6use PHPUnit\Framework\Attributes\Test; 7use Tests\TestCase; 8 9final class RoutesTest extends TestCase10{11 use LazilyRefreshDatabase;12 13 #[Test]14 public function volt_component_is_in_the_view(): void15 {16 $this->get(route('admin.users.index'))17 ->assertOk()18 ->assertViewIs('users.index')19 ->assertSeeVolt('users.list');20 }21}
With a test like this, you can have confidence that the users.index
Blade view renders the users.list
Volt component as expected.
Testing the component's logic
You can also test the functionality of the Volt component itself. Thankfully, no matter if you choose the functional or class-based approach, you can test the components in the same way.
In our example above, we had a simple component that allowed users to select users to delete and then delete them. So, we might want to test the following things:
- The component can be rendered and includes the users.
- The selected users can be deleted.
- An error is thrown if the user tries to update the locked
users
property. - An error is thrown if the user tries to call the protected
reloadPage
method.
Of course, in a real-life scenario, we'd also want to test scenarios such as:
- An error is thrown if we press the "Delete Selected" button and no users are selected.
- An error is thrown if we don't have permission to delete users.
- An error is thrown if the user tries to delete a user that doesn't exist.
But for this article, we're keeping things simple.
Let's take a look at our tests and then discuss what's happening:
1declare(strict_types=1); 2 3namespace Tests\Feature\Livewire\Users; 4 5use App\Models\User; 6use Illuminate\Database\Eloquent\Collection; 7use Illuminate\Foundation\Testing\LazilyRefreshDatabase; 8use Livewire\Exceptions\MethodNotFoundException; 9use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;10use Livewire\Volt\Volt;11use PHPUnit\Framework\Attributes\Test;12use Tests\TestCase;13 14final class IndexTest extends TestCase15{16 use LazilyRefreshDatabase;17 18 /** @var Collection<int,User> */19 private Collection $users;20 21 protected function setUp(): void22 {23 parent::setUp();24 25 $this->users = User::factory()26 ->count(5)27 ->create();28 }29 30 #[Test]31 public function livewire_component_can_be_rendered(): void32 {33 Volt::test('users.index')34 ->assertOk()35 ->assertViewHas('users');36 }37 38 #[Test]39 public function users_can_be_deleted(): void40 {41 Volt::test('users.index')42 ->set('selectedUserIds', [43 $this->users->first()->id,44 $this->users->last()->id,45 ])46 ->call('deleteSelected')47 ->assertRedirect(route('admin.users.index'));48 49 $this->assertDatabaseCount('users', 3);50 $this->assertModelMissing($this->users->first());51 $this->assertModelMissing($this->users->last());52 }53 54 #[Test]55 public function error_is_thrown_if_the_user_tries_to_update_the_users_property(): void56 {57 $this->expectException(CannotUpdateLockedPropertyException::class);58 59 Volt::test('users.index')60 ->set('users', 'dummy value');61 }62 63 #[Test]64 public function error_is_thrown_if_trying_to_call_the_reloadPage_protected_method(): void65 {66 $this->expectException(MethodNotFoundException::class);67 68 Volt::test('users.index')69 ->call('reloadPage');70 }71}
You may have noticed in the tests above that we're using the Livewire\Volt\Volt
class to test our Volt components. Livewire provides this class and allows you to test your Volt components similarly to how you'd test your Livewire components. We start our assertions by calling the test
method on the Volt
class and passing in the name of the Volt component we want to test.
In the first test (livewire_component_can_be_rendered
), we're testing that the Volt component can be rendered and includes the users. We're doing this by using the assertViewHas
method to check that the users
property is in the view.
The second test (users_can_be_deleted
) verifies that users can be deleted. We're doing this by setting the selectedUserIds
property using the set
method (as if the user had just selected the checkboxes in the UI). We're then calling the deleteSelected
method (as if the user had clicked the "Delete Selected" button) and asserting that the users have been deleted from the database. We also check that the user is redirected to the correct route.
In the third test (error_is_thrown_if_the_user_tries_to_update_the_users_property
), we're testing that an error is thrown if the user tries to update the locked users
property. We're doing this by attempting to set the users
property to a dummy value and then asserting that a Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException
exception is thrown. This can give us confidence that we're not accidentally allowing any properties to be updated from the frontend when they shouldn't be.
Finally, in the fourth test, we're testing that an error is thrown if the user tries to call the protected reloadPage
method. We're doing this by attempting to call the reloadPage
method and asserting that a Livewire\Exceptions\MethodNotFoundException
exception is thrown. By doing this, we can be confident that we're not exposing any methods that the user shouldn't be able to call.
Should I use Volt?
Now that we've discussed what Volt is and how to use it, you may ask yourself, "Should I use Volt or traditional Livewire?" and "Should I use functional or class-based Volt components?"
The answer to these questions is mainly a personal preference and what works best for you and your team.
One of the things I've found most helpful about Volt is that I can quickly build focused, isolated components where all the logic and view code are in the same file. I've also found this slightly easier when revisiting an existing component, as I don't have to navigate between two separate files to understand what's happening.
However, I've heard from some developers who are part of teams with dedicated frontend and backend developers that using Volt has caused some difficulties for them. They found a higher chance of conflicts in Git due to the lack of separation between the UI and logic. So, they may have had a frontend developer working on the UI and a backend developer working on the same file simultaneously—something to consider if you're working in a team where you might be working on components together. However, in an ideal world, I'd like to think this can be avoided (or at least reduced) with good communication within the team.
The choice between the functional API and class-based Laravel Volt components is also a personal preference. You might feel more comfortable with the functional approach if you come from a Vue background. However, if you're like me and are used to working with traditional Livewire components, you'll likely feel more comfortable with the class-based approach. But if you're unsure, I'd recommend trying both approaches and seeing which one you prefer. The main thing is to choose a single approach you and your team feel comfortable with and stick to it to keep your codebase consistent.
Personally, I really enjoyed working with Volt, and I plan to use the class-based approach in future Laravel projects.
Conclusion
In this article, we looked at what Laravel Volt is and how to write tests for your Volt components. We also looked at the differences between traditional Livewire components and Volt components.
Hopefully, you should now feel confident enough to start using Volt in your Laravel projects.
Will you be using Volt in your new project?