NTM Solutions

Thứ Bảy, 8 tháng 8, 2026

🚀 Laravel 12 (2026) — Bài 27 — CRUD Posts trong Laravel 12

Sau khi hoàn thành module Categories, chúng ta sẽ xây dựng chức năng quan trọng nhất của Blog CMS: quản lý bài viết (Posts).

Khác với Category chỉ có vài trường dữ liệu, Post sẽ liên kết với nhiều bảng khác nhau như Category, User, đồng thời hỗ trợ upload hình ảnh, kiểm tra dữ liệu đầu vào và hiển thị danh sách bài viết chuyên nghiệp.

Đây cũng là module có số lượng kiến thức nhiều nhất trong toàn bộ dự án.


Mục tiêu bài học

Sau bài này bạn sẽ thực hiện được:

  • CRUD bài viết

  • Upload ảnh đại diện

  • Chọn Category

  • Gán tác giả (User)

  • Validation dữ liệu

  • Hiển thị Relationship

  • Tự động tạo slug

  • Phân trang dữ liệu


MỤC LỤC

Kết quả sau bài học

Danh sách bài viết sẽ hiển thị như sau:

ẢnhTiêu đềCategoryTác giảNgày đăngThao tác
Laravel 12 mới có gì?LaravelAdmin07/08/2026Edit / Delete

1. Cấu trúc Database

Bảng posts

FieldKiểu
idbigint
user_idforeignId
category_idforeignId
titlestring
slugstring
imagestring
excerpttext
contentlongText
created_attimestamp

Quan hệ

User
   │
   ├──────< Posts >────── Category

Một User có nhiều Post.

Một Category có nhiều Post.

Một Post chỉ thuộc một User và một Category.

Chú ý:

1. Sửa migration create_posts_table

Đưa luôn cấu trúc hoàn chỉnh vào migration tạo posts:

Schema::create('posts', function (Blueprint $table) {
            $table->id();

            $table->foreignId('user_id')
                ->constrained()
                ->cascadeOnDelete();

            $table->foreignId('category_id')
                ->nullable()
                ->constrained()
                ->nullOnDelete();

            $table->string('title');

            $table->string('slug')
                ->unique();

            $table->string('image')
                ->nullable();

            $table->text('excerpt')
                ->nullable();

            $table->longText('content');

            $table->enum('status', [
                'draft',
                'published',
                'hidden',
                'scheduled'
            ])->default('draft');

            $table->timestamp('published_at')->nullable();

            $table->timestamps();
        });

Như vậy bạn không cần 3 migration riêng để:

  • thêm category_id
  • thêm image
  • thêm slug
  • thêm excerpt

nữa, nếu đây vẫn là database đang phát triển và chưa cần bảo toàn dữ liệu production.


2. Xóa các migration bổ sung cũ

Nếu bạn đã có các migration kiểu:

add_category_id_to_posts_table
add_image_to_posts_table
add_slug_and_excerpt_to_posts_table

thì có thể xóa chúng sau khi đã đưa cấu trúc cuối cùng vào migration create_posts_table.

Mục tiêu là migration tạo bảng posts ngay từ đầu đã hoàn chỉnh.

3. Sửa Post model

app/Models/Post.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    protected $fillable = [
        'user_id',
        'category_id',
        'title',
        'slug',
        'image',
        'excerpt',
        'content',
        'status',
        'published_at',
    ];

     protected $casts = [
        'published_at' => 'datetime',
    ];
}

4. Seeder sửa Factory

<?php

namespace Database\Factories;

use App\Models\Model;
use App\Models\User;
use App\Models\Category;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends Factory<Model>
 */
class PostFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */

    public function definition(): array
    {
        $status = fake()->randomElement([
            'draft',
            'published',
            'hidden',
            'scheduled',
        ]);

        return [
            // Định nghĩa dữ liệu mẫu
            'title' => fake()->sentence(),

            'slug' => fake()->unique()->slug(),

            'image' => fake()->imageUrl(1200, 800),

            'excerpt' => fake()->paragraph(),

            'content' => fake()->paragraphs(5, true),

            'user_id' => fn () =>
                User::query()->inRandomOrder()->value('id'),

            'category_id' => fn () =>
                Category::query()->inRandomOrder()->value('id'),

            'status' => $status,

            'published_at' => match ($status) {

                'published' =>
                    fake()->dateTimeBetween('-1 year', 'now'),

                'scheduled' =>
                    fake()->dateTimeBetween('now', '+1 month'),

                default => null,
            },
        ];
    }

}

Chú ý: phải tạo bảng chứa khóa ngoại trước nếu không sẽ báo lỗi.

thứ tự migration nên là:

1. create_countries_table
2. create_users_table
3. create_categories_table
4. create_posts_table

Sau đó chạy:

php artisan migrate:fresh --seed

2. Relationship

User.php

public function posts()
{
    return $this->hasMany(Post::class);
}

Category.php

public function posts()
{
    return $this->hasMany(Post::class);
}

Post.php

public function category()
{
    return $this->belongsTo(Category::class);
}

public function user()
{
    return $this->belongsTo(User::class);
}

Đây là ba relationship được sử dụng xuyên suốt dự án.


3. Resource Controller

Tạo controller

php artisan make:controller PostController --resource

Đăng ký route

Route::resource('posts', PostController::class);

Laravel sẽ tạo sẵn:

index
create
store
show
edit
update
destroy

4. Hiển thị danh sách bài viết

Controller

public function index()
{
    //dd(Auth::user());
    //lấy sẵn user để sau này chặn ngay từ view chỉ có admin được xem all posts
    $user = Auth::user();

    $posts = Post::with(['category','user'])
        ->latest()
        //->paginate(10); chỗ này dùng dataTables quản lý rồi nên k cần
        ->get();

    return view('posts.index', compact('posts'));
}

Điểm quan trọng

with()

Laravel sẽ eager loading.

Không xảy ra lỗi N+1 Query.

Chú ý: nhớ kiểm tra route list trước coi đủ 7 món hay không.

php artisan optimize:clear

Kiểm tra:

php artisan route:list --name=posts

trong x-app-layout đã có sẵn thư viên datatables để xử lý gọn phần bảng
do file views/layouts/app.blade.php đã nạp đủ thư viện
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <meta name="csrf-token" content="{{ csrf_token() }}">

        <title>{{ config('app.name', 'Laravel') }}</title>

        <!-- Fonts -->
        <link rel="preconnect" href="https://fonts.bunny.net">
        <link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet">

        <link rel="stylesheet"
              href="https://cdn.datatables.net/2.3.4/css/dataTables.dataTables.min.css">

        <!-- Scripts -->
        @vite(['resources/css/app.css', 'resources/js/app.js'])

    </head>

    <body class="font-sans antialiased">

        <div class="min-h-screen bg-gray-100">

            @include('layouts.navigation')

            <!-- Page Heading -->
            @isset($header)

                <header class="bg-white shadow">

                    <div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">

                        {{ $header }}

                    </div>

                </header>

            @endisset

            <!-- Page Content -->
            <main>

                {{ $slot }}

            </main>

        </div>

    </body>
</html>

File resources/js/app.js đã import đủ và xử lý JavaScript.

import $ from 'jquery';

window.$ = $;
window.jQuery = $;


import DataTable from 'datatables.net';


import Alpine from 'alpinejs';

window.Alpine = Alpine;

Alpine.start();



//cáu hình cho table posts index blade
const table = document.querySelector('#postsTable');

if (table) {
    new DataTable(table, {
        language: {
            search: "🔍 Tìm kiếm:",
            lengthMenu: "Hiển thị _MENU_ dòng",
            info: "Hiển thị _START_ đến _END_ trong tổng số _TOTAL_ bài viết",
            paginate: {
                first: "Đầu",
                last: "Cuối",
                next: "Sau",
                previous: "Trước"
            },
            zeroRecords: "Không tìm thấy dữ liệu",
            emptyTable: "Chưa có dữ liệu"
        }
    });
}

File blade posts.index

<x-app-layout>

    <div class="py-6">
        <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">

            <!-- Header -->
            <div class="mb-6 flex items-center justify-between">
                <div>
                    <h2 class="text-2xl font-bold text-gray-800">
                        Quản lý bài viết
                    </h2>

                    <p class="mt-1 text-sm text-gray-500">
                        Danh sách tất cả bài viết trong hệ thống
                    </p>
                </div>

                <a href="{{ route('posts.create') }}"
                   class="inline-flex items-center rounded-lg bg-indigo-600 px-4 py-2.5
                          text-sm font-semibold text-white shadow-sm
                          hover:bg-indigo-700">

                    + Thêm bài viết
                </a>
            </div>


            <!-- Card -->
            <div class="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-200">

                <!-- Card Header -->
                <div class="border-b border-gray-200 px-6 py-4">

                    <div class="flex items-center justify-between">

                        <div>
                            <h3 class="text-lg font-semibold text-gray-800">
                                Danh sách bài viết
                            </h3>

                            <p class="text-sm text-gray-500">
                                Tổng cộng {{ $posts->total() }} bài viết
                            </p>
                        </div>

                    </div>

                </div>
                {{-- Alert --}}
                <x-alert />
                
                <!-- Table -->
                <div class="overflow-x-auto">

                    <table class="min-w-full divide-y divide-gray-200">

                        <thead class="bg-gray-50">
                            <tr>

                                <th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
                                    Tiêu đề
                                </th>

                                <th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
                                    Ngày
                                </th>

                                <th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
                                    Tác giả
                                </th>

                                <th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
                                    Danh mục
                                </th>

                                <th class="px-6 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500">
                                    Thao tác
                                </th>

                            </tr>
                        </thead>

                        <tbody class="divide-y divide-gray-200 bg-white">

                            @forelse($posts as $post)

                                <tr class="hover:bg-gray-50">

                                    {{-- TITLE --}}
                                    <td class="px-6 py-4">

                                        <a href="{{ route('posts.show', $post) }}"
                                        class="font-semibold text-gray-800 hover:text-indigo-600">

                                            {{ $post->title }}

                                        </a>

                                    </td>


                                    {{-- DATE --}}
                                    <td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">

                                        {{ $post->created_at->format('d/m/Y') }}

                                    </td>


                                    {{-- AUTHOR --}}
                                    <td class="whitespace-nowrap px-6 py-4">

                                        @if($post->user)

                                            <span class="text-sm font-medium text-gray-700">
                                                {{ $post->user->name }}
                                            </span>

                                        @else

                                            <span class="text-sm text-gray-400">
                                                —
                                            </span>

                                        @endif

                                    </td>


                                    {{-- CATEGORY --}}
                                    <td class="whitespace-nowrap px-6 py-4">

                                        @if($post->category)

                                            <span class="inline-flex rounded-full
                                                        bg-blue-100 px-3 py-1
                                                        text-xs font-medium text-blue-700">

                                                {{ $post->category->name }}

                                            </span>

                                        @else

                                            <span class="text-sm text-gray-400">
                                                —
                                            </span>

                                        @endif

                                    </td>


                                    {{-- ACTIONS --}}
                                    <td class="whitespace-nowrap px-6 py-4">

                                        <div class="flex justify-end gap-2">

                                            {{-- VIEW --}}
                                            <a href="{{ route('posts.show', $post) }}"
                                            class="rounded-lg bg-gray-100 px-3 py-2
                                                    text-sm font-medium text-gray-700
                                                    hover:bg-gray-200">

                                                Xem

                                            </a>


                                            {{-- EDIT --}}
                                            <a href="{{ route('posts.edit', $post) }}"
                                            class="rounded-lg bg-blue-100 px-3 py-2
                                                    text-sm font-medium text-blue-700
                                                    hover:bg-blue-200">

                                                Sửa

                                            </a>


                                            {{-- DELETE --}}
                                            <form action="{{ route('posts.destroy', $post) }}"
                                                method="POST"
                                                onsubmit="return confirm('Bạn có chắc muốn xóa bài viết này?');">

                                                @csrf
                                                @method('DELETE')

                                                <button type="submit"
                                                        class="rounded-lg bg-red-100 px-3 py-2
                                                            text-sm font-medium text-red-700
                                                            hover:bg-red-200">

                                                    Xóa

                                                </button>

                                            </form>

                                        </div>

                                    </td>

                                </tr>

                            @empty

                                <tr>

                                    <td colspan="5"
                                        class="px-6 py-12 text-center text-sm text-gray-400">

                                        Chưa có bài viết nào.

                                    </td>

                                </tr>

                            @endforelse

                        </tbody>

                    </table>

                </div>


                <!-- Pagination -->
                @if($posts->hasPages())

                    <div class="border-t border-gray-200 bg-white px-6 py-4">

                        {{ $posts->links() }}

                    </div>

                @endif

            </div>

        </div>
    </div>

</x-app-layout>

5. Hiển thị Relationship

Trong Blade

{{ $post->category->name }}
{{ $post->user->name }}

Không cần viết Query JOIN.

Đó chính là sức mạnh của Eloquent ORM.


6. Form tạo bài viết

Form sẽ có:

  • Tiêu đề

  • Slug

  • Category

  • Hình ảnh

  • Mô tả ngắn

  • Nội dung

Category

<select name="category_id">

@foreach($categories as $category)

<option value="{{ $category->id }}">
    {{ $category->name }}
</option>

@endforeach

</select>

Laravel sẽ lấy toàn bộ Category để người dùng lựa chọn.

Bước 1: sửa hàm store trong PostController

//lưu data cho form create (tạo mới - chưa có $post)
public function store(Request $request)
    {
        /*
        |--------------------------------------------------------------------------
        | VALIDATE
        |--------------------------------------------------------------------------
        */

        $data = $this->validatePost($request);


        /*
        |--------------------------------------------------------------------------
        | Lưu ảnh
        |--------------------------------------------------------------------------
        */

        $image = null;

        if ($request->hasFile('image')) {

            $image = $request
                ->file('image')
                ->store(
                    'posts',
                    'public'
                );
        }


        /*
        |--------------------------------------------------------------------------
        | Published At
        |--------------------------------------------------------------------------
        */

        $publishedAt = $request->published_at;

        // Nếu chọn Published nhưng không nhập ngày
        // thì lấy thời điểm hiện tại
        if (
            $request->status === 'published'
            && empty($publishedAt)
        ) {
            $publishedAt = now();
        }

        
        /*
        |--------------------------------------------------------------------------
        | SANITIZE CONTENT
        |--------------------------------------------------------------------------
        */

        $content = sanitizeHtml($request->content);


        /*
        |--------------------------------------------------------------------------
        | KIỂM TRA NỘI DUNG THỰC
        |--------------------------------------------------------------------------
        */
        //dd($content);

        if (mb_strlen(trim(strip_tags($content))) < 20) {

            return back()
                ->withInput()
                ->withErrors([
                    'content' => 'Nội dung phải có ít nhất 20 ký tự.',
                ]);
        }

        /*
        |--------------------------------------------------------------------------
        | Tạo Post
        |--------------------------------------------------------------------------
        */

        $post = Post::create([

            'user_id' => auth()->id(),

            'category_id' => $data['category_id'] ?? null,

            'title' => $data['title'],

            'slug' => $data['slug'],

            'image' => $image,

            'excerpt' => $data['excerpt'] ?? null,

            // Lưu HTML đã sanitize 
            'content' => $content,

            'status' => $data['status'],

            'published_at' => $publishedAt,

        ]);


        /*
        |--------------------------------------------------------------------------
        | Lưu Tags
        |--------------------------------------------------------------------------
        */

        $post->tags()->sync(
            $data['tags'] ?? []
        );


        /*
        |--------------------------------------------------------------------------
        | Redirect
        |--------------------------------------------------------------------------
        */

        return redirect()
            ->route('posts.show', $post)
            ->with(
                'success',
                'Bài viết đã được tạo thành công.'
            );
    }
    //END store function

Phần Create Form và Edit Form có dùng editor nên cần hàm xử lý làm sạch nội dung HTML tránh chèn mã độc.

Xem cách tạo hàm riêng sanitizeHtml() trong helper để xử lý tại đây.

Create Blade Form:

<x-app-layout>

<div class="max-w-3xl mx-auto py-8">
    {{-- Alert --}}
    <x-alert />

    <x-ui.card
        title="Thêm Bài Viết"
        description="Nhập thông tin bài viết">

        <form
            action="{{ route('posts.store') }}"
            method="POST"
            enctype="multipart/form-data">

            @csrf

            {{-- TITLE --}}
            <x-ui.input
                label="Tiêu đề"
                name="title"
            />

            {{-- SLUG --}}
            <x-ui.input-slug
                label="Slug"
                name="slug"
                source="title"
            />

            {{-- EXCERPT --}}
            <x-ui.textarea
                label="Mô tả ngắn"
                name="excerpt"
                rows="3"
            />

            {{-- CONTENT --}}
            <x-ui.editor
                label="Nội dung"
                name="content"
                height="500px"
            />

            {{-- CATEGORY --}}
            <x-ui.select
                label="Danh mục"
                name="category_id"
                :options="$categories"
            />

            {{-- TAGS --}}
            <x-ui.select
                label="Tags"
                name="tags"
                :options="$tags"
                multiple
            />

            {{-- IMAGE --}}
            <x-ui.file-input
                label="Ảnh đại diện"
                name="image"
            />

            {{-- STATUS --}}
            <x-ui.select
                label="Trạng thái"
                name="status"
                :options="[
                    'draft' => 'Nháp',
                    'published' => 'Xuất bản',
                    'hidden' => 'Ẩn',
                    'scheduled' => 'Lên lịch',
                ]"
            />

            {{-- PUBLISHED AT --}}
            <x-ui.input
                label="Ngày xuất bản"
                name="published_at"
                type="datetime-local"
            />


            <div class="mt-6">

                <x-ui.button>
                    Lưu bài viết
                </x-ui.button>

            </div>

        </form>

    </x-ui.card>
    <!--card chứa form-->

</div>

</x-app-layout>

Xem thêm phần tạo view component ở đây.


7. Validation

//xác thực có thể có $post (update) hoặc không (create form)
private function validatePost(Request $request, ?Post $post = null): array
    {
        $slugUnique = $post
            ? 'unique:posts,slug,' . $post->id
            : 'unique:posts,slug';

        return $request->validate(
            [
                'title' => [
                    'required',
                    'max:255',
                ],

                'slug' => [
                    'required',
                    'max:255',
                    $slugUnique,
                ],

                'excerpt' => [
                    'nullable',
                ],

                'content' => [
                    'required',
                    'string',
                ],

                'category_id' => [
                    'nullable',
                    'exists:categories,id',
                ],

                'tags' => [
                    'nullable',
                    'array',
                ],

                'tags.*' => [
                    'exists:tags,id',
                ],

                'image' => [
                    'nullable',
                    'image',
                    'mimes:jpg,jpeg,png,webp',
                    'max:2048',
                ],

                'status' => [
                    'required',
                    'in:draft,published,hidden,scheduled',
                ],

                'published_at' => [
                    'nullable',
                    'date',
                ],
            ],

            [
                'title.required' => 'Vui lòng nhập tiêu đề.',
                'title.max' => 'Tiêu đề tối đa 255 ký tự.',

                'slug.required' => 'Vui lòng nhập slug.',
                'slug.unique' => 'Slug này đã tồn tại.',
                'slug.max' => 'Slug tối đa 255 ký tự.',

                'content.required' => 'Vui lòng nhập nội dung.',

                'category_id.exists' => 'Danh mục không tồn tại.',

                'tags.array' => 'Tags không hợp lệ.',
                'tags.*.exists' => 'Tag không tồn tại.',

                'image.image' => 'File tải lên phải là hình ảnh.',
                'image.mimes' => 'Ảnh phải có định dạng jpg, jpeg, png hoặc webp.',
                'image.max' => 'Ảnh tối đa 2MB.',

                'status.required' => 'Vui lòng chọn trạng thái.',
                'status.in' => 'Trạng thái không hợp lệ.',

                'published_at.date' => 'Ngày xuất bản không hợp lệ.',
            ]
        );
    }
    //END private function validatePost

Laravel sẽ kiểm tra:

  • tiêu đề bắt buộc

  • slug không được trùng

  • category phải tồn tại

  • chỉ nhận ảnh

  • dung lượng tối đa

  • nội dung không được rỗng

Ghi chú: hàm xác thực này sẽ đặt trước thẻ đóng PostController


8. Upload hình ảnh

Form

enctype="multipart/form-data"

Controller

$image = null;

if($request->hasFile('image')){

    $image = $request->file('image')
        ->store('posts','public');

}

Kết quả

storage/app/public/posts/

abc123.jpg

xyz456.png

Laravel sẽ tự sinh tên file ngẫu nhiên để tránh trùng.

Ghi chú: xem phần xử lý upload ảnh + video trong editor tại đây.


9. Lưu dữ liệu

Post::create([

    'user_id'=>auth()->id(),

    'category_id'=>$request->category_id,

    'title'=>$request->title,

    'slug'=>$request->slug,

    'image'=>$image,

    'excerpt'=>$request->excerpt,

    'content'=>$request->content

]);

Tác giả sẽ được lấy từ

auth()->id()

Người dùng không thể giả mạo tác giả bài viết.


10. Hiển thị ảnh

Blade

<img src="{{ asset('storage/'.$post->image) }}">

Đừng quên tạo symbolic link

php artisan storage:link

Sau khi chạy lệnh này, Laravel sẽ tạo

public/storage

trỏ tới

storage/app/public

11. Cập nhật bài viết

Nếu người dùng chọn ảnh mới

Storage::disk('public')->delete($post->image);

sau đó upload lại.

Nếu không chọn ảnh

Laravel vẫn giữ ảnh cũ.

Đây là cách xử lý được sử dụng trong hầu hết các CMS hiện nay.

PostController
/**
     * Show the form for editing the specified resource.
     */
//chuẩn bị dữ liệu cho form edit (đã có $post)
public function edit(Post $post)
    {
        //dd($post);
        $post->load('tags')    ;//eager-load

        $categories = Category::orderBy('name')->get();
        $tags = Tag::orderBy('name')->get();

        return view('posts.edit', compact(
            'post',
            'categories',
            'tags'
        ));
    }
    /**
     * Update the specified resource in storage.
     */

    //Sau khi bấm nút Cập nhật form EDIT (đã có $post)
public function update(Request $request, Post $post)
    {
        /*
        |--------------------------------------------------------------------------
        | VALIDATE
        |--------------------------------------------------------------------------
        */

        $data = $this->validatePost($request, $post);


        /*
        |--------------------------------------------------------------------------
        | SANITIZE CONTENT
        |--------------------------------------------------------------------------
        */

        $content = sanitizeHtml($request->content);

        /*
        |--------------------------------------------------------------------------
        | KIỂM TRA NỘI DUNG THỰC
        |--------------------------------------------------------------------------
        */

        if (mb_strlen(trim(strip_tags($content))) < 20) {

            return back()
                ->withInput()
                ->withErrors([
                    'content' => 'Nội dung phải có ít nhất 20 ký tự.',
                ]);
        }

        $data['content'] = $content;


        /*
        |--------------------------------------------------------------------------
        | PUBLISHED AT
        |--------------------------------------------------------------------------
        */

        $publishedAt = $request->published_at;

        if (
            $request->status === 'published'
            && empty($publishedAt)
        ) {
            $publishedAt = now();
        }

        $data['published_at'] = $publishedAt;


        /*
        |--------------------------------------------------------------------------
        | UPLOAD IMAGE
        |--------------------------------------------------------------------------
        */

        if ($request->hasFile('image')) {

            if (
                $post->image &&
                Storage::disk('public')->exists($post->image)
            ) {
                Storage::disk('public')->delete($post->image);
            }

            $data['image'] = $request
                ->file('image')
                ->store('posts', 'public');
        }


        /*
        |--------------------------------------------------------------------------
        | UPDATE POST
        |--------------------------------------------------------------------------
        */

        $post->update($data);


        /*
        |--------------------------------------------------------------------------
        | UPDATE TAGS
        |--------------------------------------------------------------------------
        */

        $post->tags()->sync(
            $data['tags'] ?? []
        );


        /*
        |--------------------------------------------------------------------------
        | REDIRECT
        |--------------------------------------------------------------------------
        */

        return redirect()
            ->route('posts.show', $post)
            ->with(
                'success',
                'Bài viết đã được cập nhật thành công.'
            );
    }
    //END update function

posts/edit.blade.php

<x-app-layout>
    @php
        //dd($post->toArray());
    @endphp

    <div class="max-w-3xl mx-auto py-8">
        {{-- Alert --}}
        <x-alert />

        <x-ui.card
            title="Chỉnh sửa Bài Viết"
            description="Cập nhật thông tin bài viết"
        >

            <form
                action="{{ route('posts.update', $post) }}"
                method="POST"
                enctype="multipart/form-data"
            >

                @csrf
                @method('PUT')              
                

                {{-- TITLE --}}
                <x-ui.input
                    label="Tiêu đề"
                    name="title"
                    :value="old('title', $post->title)"
                />


                {{-- SLUG --}}
                <x-ui.input-slug
                    label="Slug"
                    name="slug"
                    source="title"
                    :value="old('slug', $post->slug)"
                />


                {{-- EXCERPT --}}
                <x-ui.textarea
                    label="Mô tả ngắn"
                    name="excerpt"
                    rows="3"
                    :value="old('excerpt', $post->excerpt)"
                />


                {{-- CONTENT --}}
                <x-ui.editor
                    label="Nội dung"
                    name="content"
                    height="500px"
                    :value="old('content', $post->content)"
                />


                {{-- CATEGORY --}}
                <x-ui.select
                    label="Danh mục"
                    name="category_id"
                    :options="$categories"
                    :value="old('category_id', $post->category_id)"
                />


                {{-- TAGS --}}
                <x-ui.select-multiple
                    label="Tags"
                    name="tags"
                    :options="$tags"
                    :value="old('tags', $post->tags->pluck('id')->toArray())"
                />


                {{-- IMAGE --}}
                <x-ui.file-input
                    label="Ảnh đại diện"
                    name="image"
                />

                @if ($post->image)

                    <div class="mt-3">
                        <p class="text-sm text-gray-500 mb-2">
                            Ảnh hiện tại
                        </p>

                        <img
                            src="{{ Storage::url($post->image) }}"
                            alt="{{ $post->title }}"
                            class="max-w-xs rounded-lg border"
                        >
                    </div>

                @endif


                {{-- STATUS --}}
                <x-ui.select
                    label="Trạng thái"
                    name="status"
                    :options="[
                        'draft' => 'Nháp',
                        'published' => 'Xuất bản',
                        'hidden' => 'Ẩn',
                        'scheduled' => 'Lên lịch',
                    ]"
                    :value="old('status', $post->status)"
                />


                {{-- PUBLISHED AT --}}
                <x-ui.input
                    label="Ngày xuất bản"
                    name="published_at"
                    type="datetime-local"
                    :value="old(
                        'published_at',
                        $post->published_at
                            ? $post->published_at->format('Y-m-d\TH:i')
                            : ''
                    )"
                />


                {{-- ACTIONS --}}
                <div class="mt-6 flex items-center gap-3">

                    {{-- UPDATE --}}
                    <x-ui.button type="submit">
                        Cập nhật bài viết
                    </x-ui.button>


                    {{-- CANCEL --}}
                    <a
                        href="{{ route('posts.index', $post) }}"
                        class="inline-flex items-center px-4 py-2 rounded-lg border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
                    >
                        Hủy
                    </a>

                </div>

            </form>

        </x-ui.card>

    </div>

</x-app-layout>
app/Policies/PostPolicy.php
public function update(User $user, Post $post): bool
    {
        //chỉ cho admin và user tạo ra post có quyền chỉnh sửa
        return $user->role === 'admin'
        || $user->id === $post->user_id;
    }

12. Xóa bài viết

Sửa hàm destroy trong PostController

public function destroy(string $id)
    {
        //
        $post = Post::findOrFail($id);

        $post->delete();

        return redirect()
            ->route('posts.index')
            ->with('success', 'Xóa bài viết thành công.');
    }

Khi xóa

if (!empty($post->image)) {
    Storage::disk('public')->delete($post->image);
}

$post->delete();

Không nên để file ảnh bị "mồ côi" trong thư mục Storage.


13. Giao diện quản trị

Danh sách nên hiển thị

  • Thumbnail

  • Tiêu đề

  • Danh mục

  • Tác giả

  • Ngày tạo

  • Trạng thái

  • Nút Edit

  • Nút Delete

Có thể kết hợp:

  • Tailwind CSS

  • DataTables.net

  • Badge màu

  • Icon Heroicons

  • Xác nhận trước khi xóa

Đây cũng là giao diện mà chúng ta sẽ sử dụng xuyên suốt các module còn lại.

Nếu sau khi chỉnh sửa giao diện mà k thay đổi gì phải chạy npm run build


14. Kết quả đạt được

Sau bài học này, Blog CMS đã có module quản lý bài viết hoàn chỉnh.

✔ Thêm bài viết

✔ Sửa bài viết

✔ Xóa bài viết

✔ Upload ảnh

✔ Relationship User

✔ Relationship Category

✔ Validation

✔ Hiển thị ảnh

✔ Phân trang

✔ Quản lý tác giả

Đây là một module CRUD thực tế mà bạn sẽ gặp trong hầu hết các hệ thống quản trị nội dung sử dụng Laravel.


Tổng kết

Trong bài học này chúng ta đã kết hợp rất nhiều kiến thức đã học trước đó:

  • Resource Controller để xây dựng CRUD nhanh chóng.

  • Eloquent Relationship để liên kết Post với Category và User.

  • Validation để đảm bảo dữ liệu hợp lệ.

  • Storage để quản lý hình ảnh.

  • Authentication để tự động gán tác giả cho bài viết.

  • Pagination giúp danh sách bài viết dễ theo dõi khi số lượng tăng lên.

Đến thời điểm này, dự án Blog CMS đã gần hoàn chỉnh và có cấu trúc tương tự nhiều hệ thống quản trị nội dung được sử dụng trong thực tế.


Bài tiếp theo

Bài 28 — CRUD Users, chúng ta sẽ xây dựng module quản lý người dùng dành cho quản trị viên:

  • Danh sách User

  • Chỉnh sửa thông tin

  • Thay đổi Role (Admin/User)

  • Khóa hoặc mở khóa tài khoản

  • Đặt lại mật khẩu

Sau bài này, Blog CMS sẽ có đầy đủ ba module quản trị cốt lõi: Categories, PostsUsers.

x1

quay về MỤC LỤC

Không có nhận xét nào:

Đăng nhận xét

Facebook Youtube RSS