Laravel Folio + Volt: File-Based Routing Meets Single-File Livewire

· 11 min read

Laravel Folio brings file-based routing (like Next.js) to Laravel. Volt adds single-file Livewire components — state, actions, and template in one file. Together, they eliminate boilerplate and let you ship pages in minutes.

The Problem They Solve

Traditional Laravel page:

1. Define route in web.php
2. Create Controller
3. Create Livewire component class
4. Create Livewire Blade view
5. Wire them together

With Folio + Volt:

1. Create one file in resources/views/pages/
   (Done. Routing, logic, and template in one place.)

Installing Folio

composer require laravel/folio

php artisan folio:install

This creates resources/views/pages/ — any Blade file here automatically becomes a route.

// bootstrap/providers.php
return [
    App\Providers\AppServiceProvider::class,
    Laravel\Folio\FolioServiceProvider::class,
];

File-Based Routing Basics

Simple Pages

resources/views/pages/
├── index.blade.php          → /
├── about.blade.php          → /about
├── contact.blade.php        → /contact
└── blog/
    └── index.blade.php      → /blog
{{-- resources/views/pages/about.blade.php --}}

<x-layouts.app>
    <h1>About Us</h1>
    <p>This page is served by Folio. No route definition needed.</p>
</x-layouts.app>

Route Parameters

Use bracket notation for dynamic segments:

resources/views/pages/
└── blog/
    └── [slug].blade.php     → /blog/{slug}
{{-- resources/views/pages/blog/[slug].blade.php --}}

@php
    $post = App\Models\Post::where('slug', $slug)->firstOrFail();
@endphp

<x-layouts.app>
    <h1>{{ $post->title }}</h1>
    <div>{!! $post->body !!}</div>
</x-layouts.app>

Route Model Binding

Use [Post] to auto-resolve models:

resources/views/pages/
└── posts/
    └── [Post].blade.php     → /posts/{post}
{{-- resources/views/pages/posts/[Post].blade.php --}}

<x-layouts.app>
    <h1>{{ $post->title }}</h1>
    <p>{{ $post->body }}</p>
</x-layouts.app>

Folio resolves the Post model by ID automatically. For custom keys:

[Post:slug].blade.php        → Resolves by slug column

Nested Parameters

resources/views/pages/
└── users/
    └── [User:username]/
        └── posts/
            └── [Post:slug].blade.php

Route: /users/{username}/posts/{slug}

<x-layouts.app>
    <h1>{{ $post->title }}</h1>
    <p>By {{ $user->name }}</p>
</x-layouts.app>

Folio Middleware & Metadata

Add route-level configuration with the @php block:

{{-- resources/views/pages/dashboard.blade.php --}}

<?php
use function Laravel\Folio\{middleware, name};

middleware(['auth', 'verified']);
name('dashboard');
?>

<x-layouts.app>
    <h1>Dashboard</h1>
    <p>Welcome, {{ auth()->user()->name }}!</p>
</x-layouts.app>

Available Directives

use function Laravel\Folio\{middleware, name, withTrashed};

middleware('auth');              // Apply middleware
name('user.profile');           // Name the route
withTrashed();                  // Include soft-deleted models

Installing Volt

composer require livewire/volt

php artisan volt:install

Volt: Single-File Livewire

{{-- resources/views/pages/counter.blade.php --}}

<?php
use function Livewire\Volt\{state, computed};

state(['count' => 0]);

$increment = fn () => $this->count++;
$decrement = fn () => $this->count--;
$doubled = computed(fn () => $this->count * 2);
?>

<x-layouts.app>
    @volt('counter')
        <div>
            <h2>Count: {{ $count }}</h2>
            <p>Doubled: {{ $this->doubled }}</p>

            <button wire:click="increment" class="btn btn-primary">+</button>
            <button wire:click="decrement" class="btn btn-danger">-</button>
        </div>
    @endvolt
</x-layouts.app>

A Real Example: Todo App

{{-- resources/views/pages/todos.blade.php --}}

<?php
use function Livewire\Volt\{state, rules};
use App\Models\Todo;

middleware('auth');

state([
    'title' => '',
    'todos' => fn () => auth()->user()->todos()->latest()->get(),
]);

rules(['title' => 'required|min:3|max:255']);

$addTodo = function () {
    $this->validate();

    auth()->user()->todos()->create(['title' => $this->title]);

    $this->title = '';
    $this->todos = auth()->user()->todos()->latest()->get();
};

$toggleComplete = function (int $todoId) {
    $todo = auth()->user()->todos()->findOrFail($todoId);
    $todo->update(['completed' => !$todo->completed]);
    $this->todos = auth()->user()->todos()->latest()->get();
};

$deleteTodo = function (int $todoId) {
    auth()->user()->todos()->findOrFail($todoId)->delete();
    $this->todos = auth()->user()->todos()->latest()->get();
};
?>

<x-layouts.app>
    @volt('todos')
        <div class="max-w-md mx-auto">
            <h1 class="text-2xl font-bold mb-4">My Todos</h1>

            <form wire:submit="addTodo" class="flex gap-2 mb-4">
                <input
                    wire:model="title"
                    type="text"
                    placeholder="Add a todo..."
                    class="flex-1 border rounded px-3 py-2"
                >
                <button type="submit" class="bg-blue-500 text-white px-4 py-2 rounded">
                    Add
                </button>
            </form>
            @error('title')
                <p class="text-red-500 text-sm">{{ $message }}</p>
            @enderror

            <ul class="space-y-2">
                @foreach($todos as $todo)
                    <li class="flex items-center gap-2 p-2 border rounded">
                        <button
                            wire:click="toggleComplete({{ $todo->id }})"
                            class="{{ $todo->completed ? 'line-through text-gray-400' : '' }}"
                        >
                            {{ $todo->completed ? '✓' : '○' }} {{ $todo->title }}
                        </button>
                        <button
                            wire:click="deleteTodo({{ $todo->id }})"
                            wire:confirm="Are you sure?"
                            class="ml-auto text-red-500 text-sm"
                        >
                            Delete
                        </button>
                    </li>
                @endforeach
            </ul>
        </div>
    @endvolt
</x-layouts.app>

One file. Full CRUD. Real-time. No controller, no separate component class, no route definition.

Volt: Class-Based API

For complex components, Volt also supports a class syntax:

<?php
use Livewire\Volt\Component;
use Livewire\WithFileUploads;

new class extends Component {
    use WithFileUploads;

    public string $title = '';
    public string $body = '';
    public $image;

    public function rules(): array
    {
        return [
            'title' => 'required|min:5|max:255',
            'body' => 'required|min:20',
            'image' => 'nullable|image|max:2048',
        ];
    }

    public function save(): void
    {
        $validated = $this->validate();

        if ($this->image) {
            $validated['image_path'] = $this->image->store('posts', 'public');
        }

        auth()->user()->posts()->create($validated);

        $this->reset();
        session()->flash('message', 'Post created!');
    }
}; ?>

<div>
    @if (session('message'))
        <div class="bg-green-100 p-3 rounded mb-4">{{ session('message') }}</div>
    @endif

    <form wire:submit="save">
        <input wire:model="title" placeholder="Title" class="w-full border p-2 mb-2">
        @error('title') <span class="text-red-500">{{ $message }}</span> @enderror

        <textarea wire:model="body" placeholder="Content..." class="w-full border p-2 mb-2" rows="6"></textarea>
        @error('body') <span class="text-red-500">{{ $message }}</span> @enderror

        <input wire:model="image" type="file" class="mb-2">
        @error('image') <span class="text-red-500">{{ $message }}</span> @enderror

        <button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded">
            Publish
        </button>
    </form>
</div>

Organizing Larger Apps

Multi-Mount Points

Folio supports multiple page directories:

// app/Providers/FolioServiceProvider.php
use Laravel\Folio\Folio;

public function boot(): void
{
    Folio::path(resource_path('views/pages'))
        ->middleware(['web']);

    Folio::path(resource_path('views/admin/pages'))
        ->uri('/admin')
        ->middleware(['web', 'auth', 'admin']);
}

Page-Level Layouts

resources/views/pages/
├── index.blade.php           → Uses default layout
└── admin/
    ├── index.blade.php       → /admin (admin layout)
    └── users/
        └── index.blade.php   → /admin/users

Extracting Shared Logic

Volt components can use traits:

// app/Traits/WithPagination.php — standard Livewire trait

// In Volt file
use Livewire\WithPagination;

new class extends Component {
    use WithPagination;
    // ...
};

Folio + Volt Combined: Contact Form

A real-world example — contact page with form validation and email sending:

{{-- resources/views/pages/contact.blade.php --}}

<?php
use function Laravel\Folio\name;
use function Livewire\Volt\{state, rules};
use App\Mail\ContactFormMail;
use Illuminate\Support\Facades\Mail;

name('contact');

state([
    'name' => '',
    'email' => '',
    'message' => '',
    'sent' => false,
]);

rules([
    'name' => 'required|string|max:255',
    'email' => 'required|email|max:255',
    'message' => 'required|string|min:10|max:5000',
]);

$submit = function () {
    $this->validate();

    Mail::to(config('mail.admin_address'))->queue(
        new ContactFormMail($this->name, $this->email, $this->message)
    );

    $this->reset(['name', 'email', 'message']);
    $this->sent = true;
};
?>

<x-layouts.app>
    @volt('contact-form')
        <div class="max-w-lg mx-auto">
            <h1 class="text-3xl font-bold mb-6">Contact Us</h1>

            @if($sent)
                <div class="bg-green-100 text-green-800 p-4 rounded mb-4">
                    Thank you! Your message has been sent.
                </div>
            @endif

            <form wire:submit="submit" class="space-y-4">
                <div>
                    <label class="block font-medium mb-1">Name</label>
                    <input wire:model="name" type="text" class="w-full border rounded px-3 py-2">
                    @error('name') <p class="text-red-500 text-sm mt-1">{{ $message }}</p> @enderror
                </div>

                <div>
                    <label class="block font-medium mb-1">Email</label>
                    <input wire:model="email" type="email" class="w-full border rounded px-3 py-2">
                    @error('email') <p class="text-red-500 text-sm mt-1">{{ $message }}</p> @enderror
                </div>

                <div>
                    <label class="block font-medium mb-1">Message</label>
                    <textarea wire:model="message" rows="5" class="w-full border rounded px-3 py-2"></textarea>
                    @error('message') <p class="text-red-500 text-sm mt-1">{{ $message }}</p> @enderror
                </div>

                <button type="submit" wire:loading.attr="disabled" class="bg-blue-500 text-white px-6 py-2 rounded">
                    <span wire:loading.remove>Send</span>
                    <span wire:loading>Sending...</span>
                </button>
            </form>
        </div>
    @endvolt
</x-layouts.app>

Testing Folio Pages

Folio pages are tested like regular routes — because they are routes:

class FolioPageTest extends TestCase
{
    use RefreshDatabase;

    public function test_about_page_is_accessible(): void
    {
        $this->get('/about')->assertOk();
    }

    public function test_blog_post_page_shows_content(): void
    {
        $post = Post::factory()->published()->create(['slug' => 'test-post']);

        $this->get('/posts/test-post')
            ->assertOk()
            ->assertSee($post->title);
    }

    public function test_blog_post_404_for_unknown_slug(): void
    {
        $this->get('/posts/nonexistent-post')->assertNotFound();
    }

    public function test_dashboard_requires_auth(): void
    {
        $this->get('/dashboard')->assertRedirect('/login');
    }

    public function test_dashboard_accessible_when_authenticated(): void
    {
        $user = User::factory()->create();

        $this->actingAs($user)
            ->get('/dashboard')
            ->assertOk()
            ->assertSee($user->name);
    }
}

Gotchas & Tips

1. File Naming Conflicts

resources/views/pages/
├── users.blade.php          → /users
└── users/
    └── index.blade.php      → /users (CONFLICT!)

Fix: Use only one approach. Folio prioritizes file-level over directory index.blade.php.

2. Route Caching

Folio works with php artisan route:cache, but you need to re-cache when adding/removing pages. In development, disable route cache.

3. Performance

Folio scans the pages directory every request in development. In production, always cache routes:

php artisan route:cache

4. Volt Component Naming

@volt('name') must be unique across the entire app. If two pages use the same name → conflict. Convention: use the page name as prefix, e.g. @volt('contact-form'), @volt('dashboard-stats').

When to Use Folio + Volt

Use Folio + Volt Keep Traditional Routing
Landing pages, marketing pages API routes (use routes/api.php)
Internal dashboards Complex middleware chains
Content sites, blogs Very large apps (100+ routes)
Simple admin panels Team prefers explicit route files
Rapid prototyping Complex controller logic
Internal tools Fine-grained route testing needed

Important: Folio works alongside traditional routes. You don't have to choose one or the other — use Folio for simple pages, keep web.php for complex logic.

Conclusion

Folio + Volt is Laravel's answer to "I just want to create a page." No controllers, no route files, no separate component classes. Just one file per page.

Getting started:

  1. Install Folio + Volt
  2. Convert 2-3 of your simplest pages (about, contact, FAQ)
  3. Add interactive features with Volt as needed
  4. Keep traditional routes for everything else

Remember:

  • File name = URL (convention over configuration)
  • [Model:column] for automatic route model binding
  • @volt('name') to embed inline Livewire components
  • Folio and traditional routing coexist peacefully
  • Always cache routes in production

Comments