Learn Livewire 3, Volt, and Folio by building a podcast player

Published on by

Learn Livewire 3, Volt, and Folio by building a podcast player image

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).

laravel new
 
composer 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.

<?php
 
namespace App\Models;
 
use Illuminate\Database\Eloquent\Model;
use Sushi\Sushi;
 
class Episode extends Model
{
use Sushi;
 
protected $casts = [
'released_at' => 'datetime',
];
 
protected $rows = [
[
'number' => 195,
'title' => 'Queries, GPT, and sinking downloads',
'notes' => '...',
'audio' => 'https://media.transistor.fm/c28ad926/93e5fe7d.mp3',
'image' => 'https://images.transistor.fm/file/transistor/images/show/6405/full_1646972621-artwork.jpg',
'duration_in_seconds' => 2579,
'released_at' => '2023-07-06 10:00:00',
],
[
'number' => 194,
'title' => 'Squeezing lemons, punching cards, and bellowing forges',
'notes' => '...',
'audio' => 'https://media.transistor.fm/6d2d53fe/f70d9278.mp3',
'image' => 'https://images.transistor.fm/file/transistor/images/show/6405/full_1646972621-artwork.jpg',
'duration_in_seconds' => 2219,
'released_at' => '2023-06-21 10:00:00',
],
[
'number' => 193,
'title' => 'Precognition, faking Stripe, and debugging Blade',
'notes' => '...',
'audio' => 'https://media.transistor.fm/d434305e/975fbb28.mp3',
'image' => 'https://images.transistor.fm/file/transistor/images/show/6405/full_1646972621-artwork.jpg',
'duration_in_seconds' => 2146,
'released_at' => '2023-06-06 10:00:00',
],
[
'number' => 192,
'title' => 'High octane, sleepy code, and Aaron Francis',
'notes' => '...',
'audio' => 'https://media.transistor.fm/b5f81577/c58c90c8.mp3',
'image' => 'https://images.transistor.fm/file/transistor/images/show/6405/full_1646972621-artwork.jpg',
'duration_in_seconds' => 1865,
'released_at' => '2023-05-24 10:00:00',
],
// ...
];
}

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.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Laravel News Podcast Player</title>
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
</head>
<body class="min-h-screen bg-gray-50 font-sans text-black antialiased">
<div class="mx-auto max-w-2xl px-6 py-24">
<a
href="/episodes"
class="mx-auto flex max-w-max items-center gap-3 font-bold text-[#FF2D20] transition hover:opacity-80"
>
<img
src="/images/logo.svg"
alt="Laravel News"
class="mx-auto w-12"
/>
<span>Laravel News Podcast</span>
</a>
 
<div class="py-10">{{ $slot }}</div>
</div>
</body>
</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.

<?php
 
use App\Models\Episode;
use Illuminate\Support\Stringable;
use function Livewire\Volt\computed;
use function Livewire\Volt\state;
 
$episodes = computed(fn () => Episode::get());
 
$formatDuration = function ($seconds) { ...
return str(date('G\h i\m s\s', $seconds))
->trim('0h ')
->explode(' ')
->mapInto(Stringable::class)
->each->ltrim('0')
->join(' ');
};
 
?>
 
<x-layout>
@volt
<div class="rounded-xl border border-gray-200 bg-white shadow">
<ul class="divide-y divide-gray-100">
@foreach ($this->episodes as $episode)
<li
wire:key="{{ $episode->number }}"
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"
>
<div>
<h2>
No. {{ $episode->number }} - {{ $episode->title }}
</h2>
<div
class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-500"
>
<p>
Released:
{{ $episode->released_at->format('M j, Y') }}
</p>
&middot;
<p>
Duration:
{{ $this->formatDuration($episode->duration_in_seconds) }}
</p>
</div>
</div>
<button
type="button"
class="flex shrink-0 items-center gap-1 text-sm font-medium text-[#FF2D20] transition hover:opacity-60"
>
<img
src="/images/play.svg"
alt="Play"
class="h-8 w-8 transition hover:opacity-60"
/>
<span>Play</span>
</button>
</li>
@endforeach
</ul>
</div>
@endvolt
</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.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Laravel News Podcast Player</title>
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
</head>
<body class="min-h-screen bg-gray-50 font-sans text-black antialiased">
<div class="mx-auto max-w-2xl px-6 py-24">
<a
href="/episodes"
class="mx-auto flex max-w-max items-center gap-3 font-bold text-[#FF2D20] transition hover:opacity-80"
>
<img
src="/images/logo.svg"
alt="Laravel News"
class="mx-auto w-12"
/>
<span>Laravel News Podcast</span>
</a>
 
<div class="py-10">{{ $slot }}</div>
 
<x-episode-player />
</div>
</body>
</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.

<div
x-data="{
activeEpisode: null,
play(episode) {
this.activeEpisode = episode
 
this.$nextTick(() => {
this.$refs.audio.play()
})
},
}"
x-show="activeEpisode"
x-transition.opacity.duration.500ms
class="fixed inset-x-0 bottom-0 w-full border-t border-gray-200 bg-white"
style="display: none"
>
<div class="mx-auto max-w-xl p-6">
<h3
x-text="`Playing: No. ${activeEpisode?.number} - ${activeEpisode?.title}`"
class="text-center text-sm font-medium text-gray-600"
></h3>
<audio
x-ref="audio"
class="mx-auto mt-3"
:src="activeEpisode?.audio"
controls
></audio>
</div>
</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.

<div
x-data="{
activeEpisode: null,
play(episode) {
this.activeEpisode = episode
 
this.$nextTick(() => {
this.$refs.audio.play()
})
},
}"
x-on:play-episode.window="play($event.detail)"
...
>
<!-- ... -->
</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.

<button
x-data
x-on:click="$dispatch('play-episode', @js($episode))"
...
>
<img
src="/images/play.svg"
alt="Play"
class="h-8 w-8 transition hover:opacity-60"
/>
<span>Play</span>
</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.

<?php
use Illuminate\Support\Stringable;
use function Livewire\Volt\state;
 
state(['episode' => fn () => $episode]);
 
$formatDuration = function ($seconds) { ...
return str(date('G\h i\m s\s', $seconds))
->trim('0h ')
->explode(' ')
->mapInto(Stringable::class)
->each->ltrim('0')
->join(' ');
};
?>
 
<x-layout>
@volt
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow">
<div class="p-6">
<div class="flex items-center justify-between gap-8">
<div>
<h2 class="text-xl font-medium">
No. {{ $episode->number }} -
{{ $episode->title }}
</h2>
<div
class="mt-1 flex items-center gap-3 text-sm text-gray-500"
>
<p>
Released:
{{ $episode->released_at->format('M j, Y') }}
</p>
&middot;
<p>
Duration:
{{ $this->formatDuration($episode->duration_in_seconds) }}
</p>
</div>
</div>
 
<button
x-on:click="$dispatch('play-episode', @js($episode))"
type="button"
class="flex items-center gap-1 text-sm font-medium text-[#FF2D20] transition hover:opacity-60"
>
<img
src="/images/play.svg"
alt="Play"
class="h-8 w-8 transition hover:opacity-60"
/>
<span>Play</span>
</button>
</div>
<div class="prose prose-sm mt-4">
{!! $episode->notes !!}
</div>
</div>
<div class="bg-gray-50 px-6 py-4">
<a
href="/episodes"
class="text-sm font-medium text-gray-600"
>
&larr; Back to episodes
</a>
</div>
</div>
@endvolt
</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.

@foreach ($this->episodes as $episode)
<li
wire:key="{{ $episode->number }}"
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"
>
<div>
<a
href="/episodes/{{ $episode->number }}"
class="transition hover:text-[#FF2D20]"
>
<h2>
No. {{ $episode->number }} -
{{ $episode->title }}
</h2>
</a>
</div>
{{-- ... --}}
</li>
@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.

<!DOCTYPE html>
<html lang="en">
...
<body class="min-h-screen bg-gray-50 font-sans text-black antialiased">
<div class="mx-auto max-w-2xl px-6 py-24">
@persist('logo')
<a
href="/episodes"
class="mx-auto flex max-w-max items-center gap-3 font-bold text-[#FF2D20] transition hover:opacity-80"
>
<img
src="/images/logo.svg"
alt="Laravel News"
class="mx-auto w-12"
/>
<span>Laravel News Podcast</span>
</a>
@endpersist
 
<div class="py-10">{{ $slot }}</div>
 
@persist('player')
<x-episode-player />
@endpersist
</div>
</body>
</html>

Finally, we need to add the wire:navigate attribute to all the links through the app. For example:

<a
href="/episodes/{{ $episode->number }}"
class="transition hover:text-[#FF2D20]"
wire:navigate
>
<h2>
No. {{ $episode->number }} -
{{ $episode->title }}
</h2>
</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!

Jason Beggs photo

TALL stack (Tailwind CSS, Alpine.js, Laravel, and Livewire) consultant and owner of roasted.dev.

Cube

Laravel Newsletter

Join 40k+ other developers and never miss out on new tips, tutorials, and more.

image
Laravel Forge

Easily create and manage your servers and deploy your Laravel applications in seconds.

Visit Laravel Forge
Laravel Forge logo

Laravel Forge

Easily create and manage your servers and deploy your Laravel applications in seconds.

Laravel Forge
Tinkerwell logo

Tinkerwell

The must-have code runner for Laravel developers. Tinker with AI, autocompletion and instant feedback on local and production environments.

Tinkerwell
No Compromises logo

No Compromises

Joel and Aaron, the two seasoned devs from the No Compromises podcast, are now available to hire for your Laravel project. ⬧ Flat rate of $7500/mo. ⬧ No lengthy sales process. ⬧ No contracts. ⬧ 100% money back guarantee.

No Compromises
Kirschbaum logo

Kirschbaum

Providing innovation and stability to ensure your web application succeeds.

Kirschbaum
Shift logo

Shift

Running an old Laravel version? Instant, automated Laravel upgrades and code modernization to keep your applications fresh.

Shift
Bacancy logo

Bacancy

Supercharge your project with a seasoned Laravel developer with 4-6 years of experience for just $2500/month. Get 160 hours of dedicated expertise & a risk-free 15-day trial. Schedule a call now!

Bacancy
Lucky Media logo

Lucky Media

Bespoke software solutions built for your business. We ♥ Laravel

Lucky Media
Lunar: Laravel E-Commerce logo

Lunar: Laravel E-Commerce

E-Commerce for Laravel. An open-source package that brings the power of modern headless e-commerce functionality to Laravel.

Lunar: Laravel E-Commerce
LaraJobs logo

LaraJobs

The official Laravel job board

LaraJobs
Larafast: Laravel SaaS Starter Kit logo

Larafast: Laravel SaaS Starter Kit

Larafast is a Laravel SaaS Starter Kit with ready-to-go features for Payments, Auth, Admin, Blog, SEO, and beautiful themes. Available with VILT and TALL stacks.

Larafast: Laravel SaaS Starter Kit
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit: Laravel SaaS Starter Kit

SaaSykit is a Laravel SaaS Starter Kit that comes with all features required to run a modern SaaS. Payments, Beautiful Checkout, Admin Panel, User dashboard, Auth, Ready Components, Stats, Blog, Docs and more.

SaaSykit: Laravel SaaS Starter Kit
Rector logo

Rector

Your partner for seamless Laravel upgrades, cutting costs, and accelerating innovation for successful companies

Rector

The latest

View all →
New Video Series: The Laravel Ecosystem image

New Video Series: The Laravel Ecosystem

Read article
DirectoryTree Authorization is a Native Role and Permission Management Package for Laravel image

DirectoryTree Authorization is a Native Role and Permission Management Package for Laravel

Read article
Anonymous Event Broadcasting in Laravel 11.5 image

Anonymous Event Broadcasting in Laravel 11.5

Read article
Microsoft Clarity Integration for Laravel image

Microsoft Clarity Integration for Laravel

Read article
Apply Dynamic Filters to Eloquent Models with the Filterable Package image

Apply Dynamic Filters to Eloquent Models with the Filterable Package

Read article
Property Hooks Get Closer to Becoming a Reality in PHP 8.4 image

Property Hooks Get Closer to Becoming a Reality in PHP 8.4

Read article