Laravel Folio + Volt: File-Based Routing Kết Hợp Single-File Livewire
Laravel Folio mang file-based routing (như Next.js) vào Laravel. Volt thêm single-file Livewire components — state, actions, và template trong một file. Kết hợp lại, chúng loại bỏ boilerplate và cho phép ship trang trong vài phút.
Bài viết này cover toàn bộ: từ cài đặt, routing patterns nâng cao, đến xây dựng UI interactive hoàn chỉnh — giúp bạn quyết định khi nào nên dùng và cách tận dụng tối đa.
Vấn Đề Chúng Giải Quyết
Cách truyền thống tạo trang Laravel:
1. Định nghĩa route trong web.php
2. Tạo Controller
3. Tạo Livewire component class
4. Tạo Livewire Blade view
5. Kết nối chúng lại
Với Folio + Volt:
1. Tạo một file trong resources/views/pages/
(Xong. Routing, logic, và template ở cùng một chỗ.)
Đây không phải "magic" — đây là convention over configuration giống Rails. File name quyết định URL, PHP block ở đầu file chứa logic, phần còn lại là Blade template. Rất quen thuộc nếu bạn đã dùng Next.js, Nuxt, hoặc SvelteKit.
Cài Đặt Folio
composer require laravel/folio
php artisan folio:install
Lệnh folio:install thực hiện hai việc:
- Tạo thư mục
resources/views/pages/ - Đăng ký
FolioServiceProvider— provider này scan thư mục pages và đăng ký routes tự động
Bất kỳ file Blade nào đặt trong resources/views/pages/ sẽ tự động trở thành route. Không cần khai báo trong web.php.
File-Based Routing Cơ Bản
resources/views/pages/
├── index.blade.php → /
├── about.blade.php → /about
├── contact.blade.php → /contact
└── blog/
├── index.blade.php → /blog
└── [slug].blade.php → /blog/{slug}
Quy tắc đặt tên:
index.blade.php→ route gốc của thư mục đó[param].blade.php→ route parameter (dấu ngoặc vuông)[...slug].blade.php→ catch-all / wildcard parameter- Thư mục lồng nhau → URL segments lồng nhau
Route Parameters
Dấu ngoặc vuông trong tên file tạo route parameters. Biến tự động có sẵn trong view:
{{-- resources/views/pages/users/[id].blade.php → /users/{id} --}}
<x-layouts.app>
<h1>User #{{ $id }}</h1>
</x-layouts.app>
Route Model Binding
Đây là tính năng mạnh nhất — Folio tự động resolve Eloquent model từ URL:
resources/views/pages/
└── posts/
└── [Post].blade.php → /posts/{post} (resolve theo ID)
└── [Post:slug].blade.php → /posts/{slug} (resolve theo cột slug)
{{-- resources/views/pages/posts/[Post:slug].blade.php --}}
<x-layouts.app>
<article>
<h1>{{ $post->title }}</h1>
<time datetime="{{ $post->published_at->toISOString() }}">
{{ $post->published_at->format('d/m/Y') }}
</time>
<div class="prose">
{!! $post->rendered_body !!}
</div>
</article>
</x-layouts.app>
Giải thích: [Post:slug] nói với Folio: "Tìm model App\Models\Post bằng cột slug." Nếu không tìm thấy → tự động 404. Hoạt động giống Route::get('/posts/{post:slug}') nhưng không cần khai báo route.
Soft Deleted Models
Mặc định, Folio bỏ qua soft-deleted models. Để include chúng:
<?php
use function Laravel\Folio\{withTrashed};
withTrashed();
?>
<x-layouts.app>
@if($post->trashed())
<div class="bg-yellow-100 p-4">Bài viết này đã bị xóa.</div>
@endif
<h1>{{ $post->title }}</h1>
</x-layouts.app>
Middleware & Metadata
<?php
use function Laravel\Folio\{middleware, name};
middleware(['auth', 'verified']);
name('dashboard');
?>
<x-layouts.app>
<h1>Dashboard</h1>
<p>Chào mừng, {{ auth()->user()->name }}!</p>
</x-layouts.app>
Giải thích các functions:
middleware()— áp dụng middleware giống route definition. Nhận array hoặc string.name()— đặt tên route, cho phép dùngroute('dashboard')ở nơi khác.
Catch-All Routes
{{-- resources/views/pages/docs/[...slug].blade.php → /docs/anything/nested/here --}}
<?php
// $slug = "anything/nested/here" (string)
?>
<x-layouts.app>
@php
$segments = explode('/', $slug);
$page = loadDocPage($segments);
@endphp
<div class="prose">{!! $page->content !!}</div>
</x-layouts.app>
Multi-Tenancy: Nhiều Thư Mục Pages
Folio không giới hạn một thư mục. Đăng ký nhiều paths với prefixes khác nhau:
// 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'))
->prefix('admin')
->middleware(['web', 'auth', 'admin']);
}
Giờ resources/views/admin/users.blade.php map sang /admin/users với middleware auth và admin tự động.
Cài Đặt Volt
composer require livewire/volt
php artisan volt:install
Volt cần Livewire 3+ đã cài đặt. volt:install đăng ký service provider và publish config.
Volt Functional API: Hiểu Cơ Bản
Volt có hai syntax: Functional (closures) và Class-based. Functional phổ biến hơn cho single-file components:
<?php
use function Livewire\Volt\{state, computed, mount};
use App\Models\Post;
// Khai báo state (reactive properties)
state(['search' => '']);
// Computed property (auto-cache trong mỗi request)
$posts = computed(function () {
return Post::published()
->when($this->search, fn ($q, $s) => $q->where('title', 'like', "%{$s}%"))
->latest()
->paginate(10);
});
?>
<div>
<input wire:model.live.debounce.300ms="search" placeholder="Tìm bài viết...">
@foreach($this->posts as $post)
<article>
<h2><a href="/blog/{{ $post->slug }}">{{ $post->title }}</a></h2>
<p>{{ $post->excerpt }}</p>
</article>
@endforeach
{{ $this->posts->links() }}
</div>
Giải thích:
state()— khai báo Livewire properties. Giốngpublic $search = ''trong Livewire class component.computed()— giống#[Computed]attribute. Kết quả được cache trong request, recalculate khi dependency thay đổi.wire:model.live.debounce.300ms— two-way binding với debounce 300ms, trigger re-render mỗi khi user gõ.
Volt: Ví Dụ Thực Tế — Todo App
<?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">Danh Sách Việc Cần Làm</h1>
<form wire:submit="addTodo" class="flex gap-2 mb-4">
<input wire:model="title" type="text" placeholder="Thêm việc..." class="flex-1 border rounded px-3 py-2">
<button type="submit" class="bg-blue-500 text-white px-4 py-2 rounded">Thêm</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="Bạn chắc chắn?"
class="ml-auto text-red-500 text-sm">Xóa</button>
</li>
@endforeach
</ul>
</div>
@endvolt
</x-layouts.app>
Một file. Full CRUD. Real-time. Không controller, không component class riêng, không route definition.
Giải thích flow:
state()khởi tạotitle(string rỗng) vàtodos(lazy-loaded từ DB)rules()khai báo validation — giống$rulestrong Livewire class- Mỗi function (
$addTodo,$toggleComplete,$deleteTodo) là Livewire action @volt('todos')tạo Livewire component boundary bên trong page Foliowire:submit,wire:click,wire:modelhoạt động như Livewire component thường
Volt Class-Based API
Cho logic phức tạp hơn, dùng anonymous class:
<?php
use Livewire\Volt\Component;
use Livewire\WithPagination;
use Livewire\Attributes\Rule;
use App\Models\Comment;
new class extends Component {
use WithPagination;
#[Rule('required|min:3|max:1000')]
public string $body = '';
public int $postId;
public function mount(int $postId): void
{
$this->postId = $postId;
}
public function addComment(): void
{
$this->validate();
Comment::create([
'post_id' => $this->postId,
'user_id' => auth()->id(),
'body' => $this->body,
]);
$this->reset('body');
}
public function with(): array
{
return [
'comments' => Comment::where('post_id', $this->postId)
->with('user')
->latest()
->paginate(20),
];
}
};
?>
<div>
@auth
<form wire:submit="addComment" class="mb-6">
<textarea wire:model="body" rows="3" class="w-full border rounded p-2"></textarea>
@error('body') <p class="text-red-500 text-sm">{{ $message }}</p> @enderror
<button type="submit" class="mt-2 bg-blue-500 text-white px-4 py-2 rounded">Gửi</button>
</form>
@endauth
@foreach($comments as $comment)
<div class="border-b py-3">
<strong>{{ $comment->user->name }}</strong>
<time class="text-gray-500 text-sm">{{ $comment->created_at->diffForHumans() }}</time>
<p>{{ $comment->body }}</p>
</div>
@endforeach
{{ $comments->links() }}
</div>
Khi nào dùng Class-Based vs Functional:
- Functional: Nhỏ gọn, ít logic, prototype nhanh
- Class-Based: Cần traits (WithPagination, WithFileUploads), logic phức tạp, team quen Livewire class components
Folio + Volt Kết Hợp: Contact Form
Ví dụ thực tế — trang liên hệ với form validation và gửi email:
{{-- 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">Liên Hệ</h1>
@if($sent)
<div class="bg-green-100 text-green-800 p-4 rounded mb-4">
Cảm ơn! Tin nhắn đã được gửi.
</div>
@endif
<form wire:submit="submit" class="space-y-4">
<div>
<label class="block font-medium mb-1">Tên</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">Tin nhắn</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>Gửi</span>
<span wire:loading>Đang gửi...</span>
</button>
</form>
</div>
@endvolt
</x-layouts.app>
Testing Folio Pages
Folio pages được test như route thường — vì chúng là routes:
// tests/Feature/FolioPageTest.php
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: Chỉ dùng một trong hai. Folio ưu tiên file-level over directory index.blade.php.
2. Route Caching
Folio hoạt động với php artisan route:cache, nhưng bạn cần chạy lại cache khi thêm/xóa pages. Trong development, tắt route cache.
3. Performance
Folio scan thư mục pages mỗi request trong development. Trên production, luôn cache routes:
php artisan route:cache
4. Volt Component Naming
@volt('name') phải unique trong toàn app. Nếu hai pages dùng cùng tên → conflict. Convention: dùng tên page làm prefix, ví dụ @volt('contact-form'), @volt('dashboard-stats').
Khi Nào Nên Dùng Folio + Volt
| Dùng Folio + Volt | Giữ Routing Truyền Thống |
|---|---|
| Landing pages, marketing pages | API routes (dùng routes/api.php) |
| Dashboards nội bộ | Middleware chains phức tạp |
| Content sites, blogs | Ứng dụng rất lớn (100+ routes) |
| Admin panels đơn giản | Team thích explicit route files |
| Prototyping nhanh | Logic controller phức tạp |
| Internal tools | Cần fine-grained route testing |
Quan trọng: Folio hoạt động song song với routes truyền thống. Bạn không cần chọn một trong hai — dùng Folio cho pages đơn giản, giữ web.php cho logic phức tạp.
Kết Luận
Folio + Volt là câu trả lời của Laravel cho "Tôi chỉ muốn tạo một trang." Không controllers, không route files, không component classes riêng.
Bắt đầu như thế nào:
- Cài Folio + Volt
- Convert 2-3 trang đơn giản nhất (about, contact, FAQ)
- Thêm interactive features với Volt khi cần
- Giữ routes truyền thống cho phần còn lại
Nhớ:
- File name = URL (convention over configuration)
[Model:column]cho route model binding tự động@volt('name')để nhúng Livewire component inline- Folio và traditional routing sống chung hòa thuận
- Luôn cache routes trên production