Laravel Folio + Volt: File-Based Routing Meets Single-File Livewire
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
Functional API (Recommended)
{{-- 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:
- Install Folio + Volt
- Convert 2-3 of your simplest pages (about, contact, FAQ)
- Add interactive features with Volt as needed
- 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