Xây dựng blog đơn giản với Laravel: model, migration, controller và giao diện Blade

  • author-image

    Nguyễn Đình Cường

  • blog-tag
  • blog-comment 0 comment
  • created-date 09 Nov, 2026
blog-thumbnail

Sau khi cài được Laravel, bước tự nhiên tiếp theo là xây một ứng dụng web có giao diện. Bài này hướng dẫn bạn làm blog nhỏ gồm trang danh sách bài viết, trang chi tiết và form tạo bài, qua đó hiểu mô hình MVC của Laravel.

Luồng làm việc của một yêu cầu

Khi người dùng mở một địa chỉ, route quyết định controller nào xử lý. Controller lấy dữ liệu thông qua model (Eloquent), rồi truyền sang view (Blade) để vẽ HTML. Nắm vững chuỗi route, controller, model, view là nắm được xương sống của Laravel.

Bước 1: Model và migration

php artisan make:model Post -mc

Lệnh tạo model Post, file migration và controller. Mở file migration và khai báo cột:

public function up(): void
{
    Schema::create('posts', function (Blueprint $table) {
        $table->id();
        $table->string('title');
        $table->string('slug')->unique();
        $table->text('body');
        $table->timestamps();
    });
}

Trong app/Models/Post.php:

class Post extends Model
{
    protected $fillable = ['title', 'slug', 'body'];

    public function getRouteKeyName(): string
    {
        return 'slug';
    }
}

Hàm getRouteKeyName khiến Laravel tìm bài theo slug thay vì id, nên địa chỉ có dạng thân thiện như /bai-viet/hoc-laravel. Chạy php artisan migrate để tạo bảng.

Bước 2: Route

use App\Http\Controllers\PostController;

Route::get('/', [PostController::class, 'index'])->name('posts.index');
Route::get('/bai-viet/tao-moi', [PostController::class, 'create'])->name('posts.create');
Route::post('/bai-viet', [PostController::class, 'store'])->name('posts.store');
Route::get('/bai-viet/{post}', [PostController::class, 'show'])->name('posts.show');

Đặt route tao-moi trước route có tham số {post}, nếu không Laravel sẽ hiểu "tao-moi" là một slug. Việc đặt tên route bằng name() cho phép tạo đường dẫn bằng hàm route('posts.show', $post) mà không viết cứng.

Bước 3: Controller

use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Support\Str;

class PostController extends Controller
{
    public function index()
    {
        $posts = Post::latest()->paginate(10);
        return view('posts.index', compact('posts'));
    }

    public function create()
    {
        return view('posts.create');
    }

    public function store(Request $request)
    {
        $data = $request->validate([
            'title' => 'required|string|max:200',
            'body'  => 'required|string',
        ]);

        $data['slug'] = Str::slug($data['title']) . '-' . Str::lower(Str::random(4));

        $post = Post::create($data);

        return redirect()->route('posts.show', $post)
            ->with('status', 'Đã đăng bài viết!');
    }

    public function show(Post $post)
    {
        return view('posts.show', compact('post'));
    }
}

Hàm Str::slug chuyển tiêu đề thành chuỗi không dấu, nối bằng gạch ngang. Ta thêm vài ký tự ngẫu nhiên để tránh trùng slug khi hai bài có cùng tiêu đề. Hàm paginate(10) tự lo phân trang.

Bước 4: Giao diện Blade

Tạo layout chung resources/views/layouts/app.blade.php:

<!DOCTYPE html>
<html lang="vi">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>@yield('title', 'Blog của tôi')</title>
</head>
<body>
    <header><a href="{{ route('posts.index') }}">Blog của tôi</a></header>
    <main>
        @if (session('status'))
            <p>{{ session('status') }}</p>
        @endif
        @yield('content')
    </main>
</body>
</html>

View danh sách resources/views/posts/index.blade.php:

@extends('layouts.app')

@section('content')
    <h1>Bài viết mới</h1>
    @forelse ($posts as $post)
        <article>
            <h2><a href="{{ route('posts.show', $post) }}">{{ $post->title }}</a></h2>
            <p>{{ Str::limit($post->body, 120) }}</p>
        </article>
    @empty
        <p>Chưa có bài viết nào.</p>
    @endforelse

    {{ $posts->links() }}
@endsection

View form tạo bài posts/create.blade.php. Chú ý chỉ thị @csrf bắt buộc cho form POST để chống tấn công CSRF:

@extends('layouts.app')

@section('content')
    <form method="POST" action="{{ route('posts.store') }}">
        @csrf
        <input name="title" value="{{ old('title') }}" placeholder="Tiêu đề">
        @error('title') <small>{{ $message }}</small> @enderror

        <textarea name="body" rows="8">{{ old('body') }}</textarea>
        @error('body') <small>{{ $message }}</small> @enderror

        <button type="submit">Đăng bài</button>
    </form>
@endsection

Hàm old() giữ lại dữ liệu đã nhập khi validation thất bại, còn @error hiển thị thông báo lỗi theo từng trường. Nội dung trong {{ }} luôn được escape tự động.

Mẹo làm việc với Laravel

Khi gặp lỗi, đọc kỹ trang báo lỗi của Laravel ở chế độ debug: nó chỉ đúng file, đúng dòng và gợi ý nguyên nhân. Dùng php artisan route:list để kiểm tra route, php artisan tinker để thử truy vấn Eloquent ngay trong terminal. Nhớ đặt APP_DEBUG=false khi đưa lên môi trường thật để không lộ thông tin nhạy cảm. Và hãy tập thói quen chạy php artisan migrate:fresh --seed trong môi trường phát triển để thử lại từ đầu với dữ liệu mẫu, thay vì sửa tay trong cơ sở dữ liệu.

Bước tiếp theo

  • Thêm xác thực người dùng bằng bộ khởi tạo Laravel Breeze.
  • Thêm chức năng sửa, xóa và phân quyền chỉ cho tác giả.
  • Thêm bảng danh mục và quan hệ một-nhiều bằng Eloquent.
  • Viết test tính năng cho từng route.

Kết luận

Bạn đã đi qua toàn bộ vòng đời của một tính năng trong Laravel: migration, model, route, controller và view. Từ đây thêm tính năng mới chỉ là lặp lại và biến tấu mẫu này.

Nếu bạn muốn học Laravel một cách có hệ thống và làm dự án thực tế, hãy xem các khóa học tại codebem.com.

author_photo
Nguyễn Đình Cường

0 comment