Laravel Folio + Volt: File-Based Routing Kết Hợp Single-File Livewire

· 15 min read

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:

  1. Tạo thư mục resources/views/pages/
  2. Đă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ùng route('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 authadmin 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ống public $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:

  1. state() khởi tạo title (string rỗng) và todos (lazy-loaded từ DB)
  2. rules() khai báo validation — giống $rules trong Livewire class
  3. Mỗi function ($addTodo, $toggleComplete, $deleteTodo) là Livewire action
  4. @volt('todos') tạo Livewire component boundary bên trong page Folio
  5. wire:submit, wire:click, wire:model hoạ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 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:

  1. Cài Folio + Volt
  2. Convert 2-3 trang đơn giản nhất (about, contact, FAQ)
  3. Thêm interactive features với Volt khi cần
  4. 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

Bình luận