• Reading time ~ 14 min
  • 28.07.2023

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 and @bosunski created a phpsandbox 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!

Comments

No comments yet
Yurij Finiv

Yurij Finiv

Full stack

ABOUT

Professional Fullstack Developer with extensive experience in website and desktop application development. Proficient in a wide range of tools and technologies, including Bootstrap, Tailwind, HTML5, CSS3, PUG, JavaScript, Alpine.js, jQuery, PHP, MODX, and Node.js. Skilled in website development using Symfony, MODX, and Laravel. Experience: Contributed to the development and translation of MODX3 i...

About author CrazyBoy49z
WORK EXPERIENCE
Contact
Ukraine, Lutsk
+380979856297