Learn Livewire 3, Volt, and Folio by building a podcast player
Yesterday, the Laravel team released Laravel Folio - a powerful page-based router designed to simplify routing in Laravel applications. Today, they released Volt -an elegantly crafted functional API for Livewire, allowing a component's PHP logic and Blade templates to coexist in the same file with reduced boilerplate.
Although they may be used separately, I think using them together is a new, incredibly productive way to build Laravel apps.
In this article, I will teach you how to build a simple app that lists out episodes of the Laravel News podcast and allows users to play them, with a player that can seamlessly continue playing across page loads.
Setup Livewire, Volt, and Folio
To get started, we need to create a new Laravel app and install Livewire, Volt, Folio, and Sushi (to make some dummy data).
1laravel new23composer require livewire/livewire:^3.0@beta livewire/volt:^1.0@beta laravel/folio:^1.0@beta calebporzio/sushi
Livewire v3, Volt, and Folio are all still in beta. They should be pretty stable, but use them at your own risk.
After requiring the packages, we need to run php artisan volt:install
and php artisan folio:install
. This will scaffold out some folders and service providers Volt and Folio need.
The Episode
model
For dummy data, I'm going to create a Sushi model. Sushi is a package written by Caleb Pozio that allows you to create Eloquent models that query their data from an array written directly in the model file. This works great when you're building example apps or have data that doesn't need to change very often.
Create a model, then remove the HasFactory
trait and replace it with the Sushi
trait. I added the details of the 4 latest Laravel News Podcast episodes as the data for this example.
I won't go into detail on how all this works since this isn't the point of the article, and you'll likely use a real Eloquent model if you were to build your own podcast player.
1<?php 2 3namespace App\Models; 4 5use Illuminate\Database\Eloquent\Model; 6use Sushi\Sushi; 7 8class Episode extends Model 9{10 use Sushi;11 12 protected $casts = [13 'released_at' => 'datetime',14 ];15 16 protected $rows = [17 [18 'number' => 195,19 'title' => 'Queries, GPT, and sinking downloads',20 'notes' => '...',21 'audio' => 'https://media.transistor.fm/c28ad926/93e5fe7d.mp3',22 'image' => 'https://images.transistor.fm/file/transistor/images/show/6405/full_1646972621-artwork.jpg',23 'duration_in_seconds' => 2579,24 'released_at' => '2023-07-06 10:00:00',25 ],26 [27 'number' => 194,28 'title' => 'Squeezing lemons, punching cards, and bellowing forges',29 'notes' => '...',30 'audio' => 'https://media.transistor.fm/6d2d53fe/f70d9278.mp3',31 'image' => 'https://images.transistor.fm/file/transistor/images/show/6405/full_1646972621-artwork.jpg',32 'duration_in_seconds' => 2219,33 'released_at' => '2023-06-21 10:00:00',34 ],35 [36 'number' => 193,37 'title' => 'Precognition, faking Stripe, and debugging Blade',38 'notes' => '...',39 'audio' => 'https://media.transistor.fm/d434305e/975fbb28.mp3',40 'image' => 'https://images.transistor.fm/file/transistor/images/show/6405/full_1646972621-artwork.jpg',41 'duration_in_seconds' => 2146,42 'released_at' => '2023-06-06 10:00:00',43 ],44 [45 'number' => 192,46 'title' => 'High octane, sleepy code, and Aaron Francis',47 'notes' => '...',48 'audio' => 'https://media.transistor.fm/b5f81577/c58c90c8.mp3',49 'image' => 'https://images.transistor.fm/file/transistor/images/show/6405/full_1646972621-artwork.jpg',50 'duration_in_seconds' => 1865,51 'released_at' => '2023-05-24 10:00:00',52 ],53 // ...54 ];55}
The layout view
We'll need a layout file to load Tailwind, add a logo, and add some basic styling. Since Livewire and Alpine automatically inject their scripts and styles now, we don't even need to load those in the layout! We'll create the layout as an anonymous Blade component at resources/views/components/layout.blade.php
.
1<!DOCTYPE html> 2<html lang="en"> 3 <head> 4 <meta charset="UTF-8" /> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 <title>Laravel News Podcast Player</title> 7 <script src="https://cdn.tailwindcss.com?plugins=typography"></script> 8 </head> 9 <body class="min-h-screen bg-gray-50 font-sans text-black antialiased">10 <div class="mx-auto max-w-2xl px-6 py-24">11 <a12 href="/episodes"13 class="mx-auto flex max-w-max items-center gap-3 font-bold text-[#FF2D20] transition hover:opacity-80"14 >15 <img16 src="/images/logo.svg"17 alt="Laravel News"18 class="mx-auto w-12"19 />20 <span>Laravel News Podcast</span>21 </a>22 23 <div class="py-10">{{ $slot }}</div>24 </div>25 </body>26</html>
The episode list page
First, we need a page to display all the episodes of the podcast.
Using Folio, we can easily create a new page in the resources/views/pages
directory, and Laravel will automatically create a route for that page. We want our route to be /episodes
, so we can run php artisan make:folio episodes/index
. That will create a blank view at resources/views/pages/episodes/index.blade.php
.
On this page, we'll insert the layout component, then loop over all the podcast episodes. Volt provides namespaced functions for most of the Livewire features. Here, we'll open regular <?php ?>
open and close tags. Inside those, we'll use the computed
function to create an $episodes
variable that runs a query to get all the Episode models ($episodes = computed(fn () => Episode::get());
). We can access the computed property in the template using $this->episodes
.
I also created a $formatDuration
variable that's a function to format each episode's duration_in_seconds
property to a readable format. We can call that function in the template using $this->formatDuration($episode->duration_in_seconds)
.
We also need to wrap the dynamic functionality on the page in the @volt
directive to register it as an "anonymous Livewire component" within the Folio page.
1<?php 2 3use App\Models\Episode; 4use Illuminate\Support\Stringable; 5use function Livewire\Volt\computed; 6use function Livewire\Volt\state; 7 8$episodes = computed(fn () => Episode::get()); 9 10$formatDuration = function ($seconds) {
11 return str(date('G\h i\m s\s', $seconds))12 ->trim('0h ')13 ->explode(' ')14 ->mapInto(Stringable::class)15 ->each->ltrim('0')16 ->join(' ');17}; 18 19?>20 21<x-layout>22 @volt23 <div class="rounded-xl border border-gray-200 bg-white shadow">24 <ul class="divide-y divide-gray-100">25 @foreach ($this->episodes as $episode)26 <li27 wire:key="{{ $episode->number }}"28 class="flex flex-col items-start gap-x-6 gap-y-3 px-6 py-4 sm:flex-row sm:items-center sm:justify-between"29 >30 <div>31 <h2>32 No. {{ $episode->number }} - {{ $episode->title }}33 </h2>34 <div35 class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-500"36 >37 <p>38 Released:39 {{ $episode->released_at->format('M j, Y') }}40 </p>41 ·42 <p>43 Duration:44 {{ $this->formatDuration($episode->duration_in_seconds) }}45 </p>46 </div>47 </div>48 <button49 type="button"50 class="flex shrink-0 items-center gap-1 text-sm font-medium text-[#FF2D20] transition hover:opacity-60"51 >52 <img53 src="/images/play.svg"54 alt="Play"55 class="h-8 w-8 transition hover:opacity-60"56 />57 <span>Play</span>58 </button>59 </li>60 @endforeach61 </ul>62 </div>63 @endvolt64</x-layout>
The episode player
From there, we need to add some interactivity. I want to add an episode player so we can listen to the episodes from the episode list. This can be a regular Blade component we render in the layout file.
1<!DOCTYPE html> 2<html lang="en"> 3 <head> 4 <meta charset="UTF-8" /> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 <title>Laravel News Podcast Player</title> 7 <script src="https://cdn.tailwindcss.com?plugins=typography"></script> 8 </head> 9 <body class="min-h-screen bg-gray-50 font-sans text-black antialiased">10 <div class="mx-auto max-w-2xl px-6 py-24">11 <a12 href="/episodes"13 class="mx-auto flex max-w-max items-center gap-3 font-bold text-[#FF2D20] transition hover:opacity-80"14 >15 <img16 src="/images/logo.svg"17 alt="Laravel News"18 class="mx-auto w-12"19 />20 <span>Laravel News Podcast</span>21 </a>22 23 <div class="py-10">{{ $slot }}</div>24 25 <x-episode-player />26 </div>27 </body>28</html>
We can create that component by adding a resources/views/components/episode-player.blade.php
file. Inside the component, we'll add an <audio>
element with some Alpine code to store the active episode and a function that updates the active episode and starts the audio. We'll only show the player if an active episode is set, and we'll add a nice fade transition to the wrapper.
1<div 2 x-data="{ 3 activeEpisode: null, 4 play(episode) { 5 this.activeEpisode = episode 6 7 this.$nextTick(() => { 8 this.$refs.audio.play() 9 })10 },11 }"12 x-show="activeEpisode"13 x-transition.opacity.duration.500ms14 class="fixed inset-x-0 bottom-0 w-full border-t border-gray-200 bg-white"15 style="display: none"16>17 <div class="mx-auto max-w-xl p-6">18 <h319 x-text="`Playing: No. ${activeEpisode?.number} - ${activeEpisode?.title}`"20 class="text-center text-sm font-medium text-gray-600"21 ></h3>22 <audio23 x-ref="audio"24 class="mx-auto mt-3"25 :src="activeEpisode?.audio"26 controls27 ></audio>28 </div>29</div>
If we reload the page, we don't see any changes. That's because we haven't added a way to play episodes. We'll use events to communicate from our Livewire components to the player. First, in the player, we'll add x-on:play-episode.window="play($event.detail)"
to listen for the play-episode
event on the window, then call the play
function.
1<div 2 x-data="{ 3 activeEpisode: null, 4 play(episode) { 5 this.activeEpisode = episode 6 7 this.$nextTick(() => { 8 this.$refs.audio.play() 9 })10 },11 }"12 x-on:play-episode.window="play($event.detail)"13 ...14>15 <!-- ... -->16</div>
Next, back in the episodes/index
page, we'll add a click listener on the play buttons for each episode. The buttons will dispatch the play-episode
event, which will be received by the episode player and handled there.
1<button 2 x-data 3 x-on:click="$dispatch('play-episode', @js($episode))" 4 ... 5> 6 <img 7 src="/images/play.svg" 8 alt="Play" 9 class="h-8 w-8 transition hover:opacity-60"10 />11 <span>Play</span>12</button>
The episode details page
Next, I'd like to add an episode details page to display each episode's show notes and other details.
Folio has some pretty cool conventions for route model binding in your filenames. To make an equivalent route for /episodes/{episode:id}
, create a page at resources/views/pages/episodes/[Episode].blade.php
. To use a route parameter other than the primary key, you can use the [Model:some_other_key].blade.php
syntax in your filename. I want to use the episode number in the URL, so we'll create a file at resources/views/pages/episodes/[Episode:number].blade.php
.
Folio will automatically query the Episode models for an episode with the number we pass in the URL and make that available as an $episode
variable in our <?php ?>
code. We can then convert that to a Livewire property using Volt's state
function.
We'll also include a play button on this page so users can play an episode while viewing its details.
1<?php 2use Illuminate\Support\Stringable; 3use function Livewire\Volt\state; 4 5state(['episode' => fn () => $episode]); 6 7$formatDuration = function ($seconds) {
8 return str(date('G\h i\m s\s', $seconds)) 9 ->trim('0h ')10 ->explode(' ')11 ->mapInto(Stringable::class)12 ->each->ltrim('0')13 ->join(' ');14}; 15?>16 17<x-layout>18 @volt19 <div class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow">20 <div class="p-6">21 <div class="flex items-center justify-between gap-8">22 <div>23 <h2 class="text-xl font-medium">24 No. {{ $episode->number }} -25 {{ $episode->title }}26 </h2>27 <div28 class="mt-1 flex items-center gap-3 text-sm text-gray-500"29 >30 <p>31 Released:32 {{ $episode->released_at->format('M j, Y') }}33 </p>34 ·35 <p>36 Duration:37 {{ $this->formatDuration($episode->duration_in_seconds) }}38 </p>39 </div>40 </div>41 42 <button43 x-on:click="$dispatch('play-episode', @js($episode))"44 type="button"45 class="flex items-center gap-1 text-sm font-medium text-[#FF2D20] transition hover:opacity-60"46 >47 <img48 src="/images/play.svg"49 alt="Play"50 class="h-8 w-8 transition hover:opacity-60"51 />52 <span>Play</span>53 </button>54 </div>55 <div class="prose prose-sm mt-4">56 {!! $episode->notes !!}57 </div>58 </div>59 <div class="bg-gray-50 px-6 py-4">60 <a61 href="/episodes"62 class="text-sm font-medium text-gray-600"63 >64 ← Back to episodes65 </a>66 </div>67 </div>68 @endvolt69</x-layout>
Now, we need to link to the details page from the index page. Back in the episodes/index
page, let's wrap each episode's <h2>
in an anchor tag.
1@foreach ($this->episodes as $episode) 2 <li 3 wire:key="{{ $episode->number }}" 4 class="flex flex-col items-start gap-x-6 gap-y-3 px-6 py-4 sm:flex-row sm:items-center sm:justify-between" 5 > 6 <div> 7 <a 8 href="/episodes/{{ $episode->number }}" 9 class="transition hover:text-[#FF2D20]"10 >11 <h2>12 No. {{ $episode->number }} -13 {{ $episode->title }}14 </h2>15 </a>16 </div>17 {{-- ... --}}18 </li>19@endforeach
SPA-mode
We're almost there. The app looks pretty good and functions well, but there's one issue. If a user is listening to an episode, and navigates to a different page, the episode player loses its active episode state and disappears.
Thankfully, Livewire has the wire:navigate
and the @persist
directive to help with these problems now!
In our layout file, let's wrap the logo and episode player in @persist
blocks. Livewire will detect this and skip re-rendering those blocks when we change pages.
1<!DOCTYPE html> 2<html lang="en"> 3 ... 4 <body class="min-h-screen bg-gray-50 font-sans text-black antialiased"> 5 <div class="mx-auto max-w-2xl px-6 py-24"> 6 @persist('logo') 7 <a 8 href="/episodes" 9 class="mx-auto flex max-w-max items-center gap-3 font-bold text-[#FF2D20] transition hover:opacity-80"10 >11 <img12 src="/images/logo.svg"13 alt="Laravel News"14 class="mx-auto w-12"15 />16 <span>Laravel News Podcast</span>17 </a>18 @endpersist19 20 <div class="py-10">{{ $slot }}</div>21 22 @persist('player')23 <x-episode-player />24 @endpersist25 </div>26 </body>27</html>
Finally, we need to add the wire:navigate
attribute to all the links through the app. For example:
1<a 2 href="/episodes/{{ $episode->number }}" 3 class="transition hover:text-[#FF2D20]" 4 wire:navigate 5> 6 <h2> 7 No. {{ $episode->number }} - 8 {{ $episode->title }} 9 </h2>10</a>
When you use the wire:navigate
attribute, behind the scenes, Livewire will fetch the new page's contents using AJAX, then magically swap out the contents in your browser without doing a full page reload. This makes page loads feel incredibly fast and enables features like persist to work! It enables features that previously you could only accomplish by building a SPA.
Conclusion
This was a really fun demo app to build while learning Volt and Folio. I've uploaded the demo app here if you want to see the full source code or try it out yourself!
What do you think? Is Livewire v3 + Volt + Folio the simplest stack for building Laravel apps now? I think it's really cool and might feel more familiar to people who are used to building apps in JavaScript frameworks like Next.js and Nuxt.js. It's also nice to have all your code for a page collocated - styling (via Tailwind), JS (via Alpine), and backend code all in one file. Send me your thoughts on Twitter!