Membangun sistem Content Management System (CMS) untuk blog adalah salah satu proyek awal yang sering dilakukan oleh para developer, terutama mereka yang ingin mendalami framework web. Di antara banyak pilihan, Laravel menjadi favorit banyak pengembang karena kemudahannya, ekosistemnya yang luas, dan fitur-fitur yang developer-friendly. Kenapa harus repot-repot membuat CMS sendiri padahal sudah ada WordPress? Jawabannya sederhana: fleksibilitas, kontrol penuh, dan pengalaman belajar yang tak ternilai.
Dengan Laravel, Anda tidak hanya membuat blog, tetapi juga memahami arsitektur di baliknya, cara kerja autentikasi, manajemen database dengan Eloquent, hingga menampilkan konten menggunakan Blade. Ini adalah fondasi kuat untuk proyek-proyek web yang lebih kompleks di masa depan. Artikel ini akan memandu Anda langkah demi langkah untuk membangun CMS blog sederhana namun fungsional menggunakan Laravel, mulai dari setup proyek hingga fitur CRUD (Create, Read, Update, Delete) untuk postingan dan kategori.
Persiapan Lingkungan Pengembangan
Sebelum memulai coding, pastikan Anda memiliki semua alat yang dibutuhkan. Ini adalah langkah krusial agar proses pengembangan berjalan mulus. Mengabaikan satu bagian saja bisa menyebabkan berbagai error yang membuang waktu.
Prasyarat Software
- PHP: Versi 8.1 atau yang lebih baru. Laravel sangat bergantung pada fitur-fitur PHP terbaru.
- Composer: Package manager untuk PHP. Gunakan untuk menginstal Laravel dan dependensi lainnya.
- Node.js & npm/Yarn: Untuk mengelola frontend aset seperti CSS dan JavaScript (misalnya, untuk Laravel Breeze atau Vite).
- Database Server: MySQL atau MariaDB direkomendasikan. Anda bisa menggunakan XAMPP, Laragon, Valet, Docker, atau instal terpisah.
- Terminal/CMD: Untuk menjalankan perintah Composer, Artisan, dan lainnya.
- Code Editor: Visual Studio Code (VS Code) adalah pilihan populer dengan banyak ekstensi Laravel.
Instalasi Proyek Laravel Baru
Buka terminal Anda dan jalankan perintah berikut untuk membuat proyek Laravel baru. Ganti nama-blog-anda dengan nama proyek yang Anda inginkan.
composer create-project laravel/laravel nama-blog-anda
Setelah proyek selesai diunduh, masuk ke direktori proyek:
cd nama-blog-anda
Konfigurasi Database
Laravel menggunakan file .env di root proyek untuk konfigurasi lingkungan. Buka file ini dan sesuaikan bagian database:
DB_CONNECTION=mysqlDB_HOST=127.0.0.1DB_PORT=3306DB_DATABASE=nama_database_blog_anda(Buat database ini secara manual di phpMyAdmin atau tool database Anda)DB_USERNAME=root(atau username database Anda)DB_PASSWORD=(atau password database Anda)
Pastikan Anda telah membuat database dengan nama yang sama di server database Anda.
Menginstal Autentikasi Laravel
Kita akan menggunakan Laravel Breeze untuk sistem autentikasi dasar. Breeze menyediakan scaffolding untuk login, register, reset password, dan dashboard. Instal Breeze dengan Composer:
composer require laravel/breeze --dev
Setelah itu, instal Breeze dengan stack yang Anda inginkan (misal, Blade dengan Livewire):
php artisan breeze:install blade
Pilih “Yes” saat ditanya apakah ingin menginstal Livewire. Kemudian, jalankan migrasi database dan instal dependensi frontend:
php artisan migrate
npm install && npm run dev
Sekarang, jalankan server pengembangan Laravel:
php artisan serve
Buka browser Anda dan akses http://127.0.0.1:8000. Anda akan melihat halaman selamat datang Laravel dan tautan “Login” serta “Register”. Cobalah mendaftar akun baru untuk memastikan autentikasi berfungsi.
Membangun Struktur Database untuk Blog (Migrations)
Sebuah blog membutuhkan tabel untuk postingan (Post) dan kategori (Category). Kita akan membuat migrasi untuk tabel-tabel ini dan mendefinisikan relasi antar mereka.
Membuat Model dan Migrasi untuk Post
Jalankan perintah Artisan berikut untuk membuat model Post dan file migrasinya secara bersamaan:
php artisan make:model Post -m
Buka file migrasi yang baru dibuat di database/migrations/xxxx_create_posts_table.php dan definisikan skema tabelnya:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/
* Run the migrations.
*/
public function up(): void
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->foreignId('category_id')->nullable()->constrained()->onDelete('set null');
$table->string('title');
$table->string('slug')->unique();
$table->text('content');
$table->string('thumbnail')->nullable();
$table->enum('status', ['draft', 'published'])->default('draft');
$table->timestamp('published_at')->nullable();
$table->timestamps();
});
}
/
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('posts');
}
};
Membuat Model dan Migrasi untuk Category
Sama seperti Post, buat model Category dan migrasinya:
php artisan make:model Category -m
Buka file migrasi database/migrations/xxxx_create_categories_table.php dan definisikan skema tabelnya:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/
* Run the migrations.
*/
public function up(): void
{
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->string('slug')->unique();
$table->timestamps();
});
}
/
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('categories');
}
};
Mendefinisikan Relasi Antar Model
Buka file app/Models/User.php dan tambahkan relasi hasMany ke Post:
<?php
namespace App\Models;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
use HasFactory, Notifiable;
// ...
public function posts()
{
return $this->hasMany(Post::class);
}
}
Buka file app/Models/Category.php dan tambahkan relasi hasMany ke Post:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Category extends Model
{
use HasFactory;
protected $fillable = ['name', 'slug'];
public function posts()
{
return $this->hasMany(Post::class);
}
}
Buka file app/Models/Post.php dan tambahkan relasi belongsTo ke User dan Category:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Post extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'category_id',
'title',
'slug',
'content',
'thumbnail',
'status',
'published_at',
];
protected $casts = [
'published_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
}
Jalankan Migrasi Database
Setelah semua migrasi dan relasi model didefinisikan, jalankan migrasi:
php artisan migrate
Pastikan urutan migrasi tabel users, categories, lalu posts agar tidak ada error foreign key.
Menyiapkan Dashboard Admin untuk Postingan dan Kategori
Laravel Breeze sudah menyediakan dashboard dasar. Kita akan mengembangkannya untuk mengelola postingan dan kategori blog.
Membuat Controller untuk Postingan
Buat controller untuk mengelola postingan:
php artisan make:controller Admin/PostController
Isi app/Http/Controllers/Admin/PostController.php dengan metode CRUD dasar. Untuk memudahkan, kita akan menggunakan Request $request untuk validasi dan Storage untuk upload gambar.
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class PostController extends Controller
{
public function index()
{
$posts = Post::with(['user', 'category'])->latest()->paginate(10);
return view('admin.posts.index', compact('posts'));
}
public function create()
{
$categories = Category::all();
return view('admin.posts.create', compact('categories'));
}
public function store(Request $request)
{
$request->validate([
'title' => 'required|string|max:255',
'category_id' => 'nullable|exists:categories,id',
'content' => 'required|string',
'thumbnail' => 'nullable|image|max:2048', // Max 2MB
'status' => 'required|in:draft,published',
'published_at' => 'nullable|date',
]);
$slug = Str::slug($request->title);
$originalSlug = $slug;
$count = 1;
while (Post::where('slug', $slug)->exists()) {
$slug = $originalSlug . '-' . $count++;
}
$thumbnailPath = null;
if ($request->hasFile('thumbnail')) {
$thumbnailPath = $request->file('thumbnail')->store('thumbnails', 'public');
}
Post::create([
'user_id' => auth()->id(),
'category_id' => $request->category_id,
'title' => $request->title,
'slug' => $slug,
'content' => $request->content,
'thumbnail' => $thumbnailPath,
'status' => $request->status,
'published_at' => $request->status == 'published' ? ($request->published_at ?? now()) : null,
]);
return redirect()->route('admin.posts.index')->with('success', 'Postingan berhasil dibuat.');
}
public function edit(Post $post)
{
$categories = Category::all();
return view('admin.posts.edit', compact('post', 'categories'));
}
public function update(Request $request, Post $post)
{
$request->validate([
'title' => 'required|string|max:255',
'category_id' => 'nullable|exists:categories,id',
'content' => 'required|string',
'thumbnail' => 'nullable|image|max:2048',
'status' => 'required|in:draft,published',
'published_at' => 'nullable|date',
]);
// Cek jika title berubah, maka slug juga harus diupdate
if ($request->title !== $post->title) {
$slug = Str::slug($request->title);
$originalSlug = $slug;
$count = 1;
while (Post::where('slug', $slug)->where('id', '!=', $post->id)->exists()) {
$slug = $originalSlug . '-' . $count++;
}
$post->slug = $slug;
}
if ($request->hasFile('thumbnail')) {
// Hapus thumbnail lama jika ada
if ($post->thumbnail) {
Storage::disk('public')->delete($post->thumbnail);
}
$thumbnailPath = $request->file('thumbnail')->store('thumbnails', 'public');
$post->thumbnail = $thumbnailPath;
} elseif ($request->input('remove_thumbnail')) {
if ($post->thumbnail) {
Storage::disk('public')->delete($post->thumbnail);
$post->thumbnail = null;
}
}
$post->fill([
'category_id' => $request->category_id,
'title' => $request->title,
'content' => $request->content,
'status' => $request->status,
'published_at' => $request->status == 'published' ? ($request->published_at ?? now()) : null,
])->save();
return redirect()->route('admin.posts.index')->with('success', 'Postingan berhasil diperbarui.');
}
public function destroy(Post $post)
{
if ($post->thumbnail) {
Storage::disk('public')->delete($post->thumbnail);
}
$post->delete();
return redirect()->route('admin.posts.index')->with('success', 'Postingan berhasil dihapus.');
}
}
Membuat Controller untuk Kategori
Buat controller untuk mengelola kategori:
php artisan make:controller Admin/CategoryController
Isi app/Http/Controllers/Admin/CategoryController.php dengan metode CRUD dasar:
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Category;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class CategoryController extends Controller
{
public function index()
{
$categories = Category::latest()->paginate(10);
return view('admin.categories.index', compact('categories'));
}
public function create()
{
return view('admin.categories.create');
}
public function store(Request $request)
{
$request->validate([
'name' => 'required|string|max:255|unique:categories,name',
]);
Category::create([
'name' => $request->name,
'slug' => Str::slug($request->name),
]);
return redirect()->route('admin.categories.index')->with('success', 'Kategori berhasil dibuat.');
}
public function edit(Category $category)
{
return view('admin.categories.edit', compact('category'));
}
public function update(Request $request, Category $category)
{
$request->validate([
'name' => 'required|string|max:255|unique:categories,name,' . $category->id,
]);
$category->update([
'name' => $request->name,
'slug' => Str::slug($request->name),
]);
return redirect()->route('admin.categories.index')->with('success', 'Kategori berhasil diperbarui.');
}
public function destroy(Category $category)
{
// Pastikan tidak ada postingan yang terkait dengan kategori ini atau set null
if ($category->posts()->count() > 0) {
return redirect()->route('admin.categories.index')->with('error', 'Tidak bisa menghapus kategori yang masih memiliki postingan.');
}
$category->delete();
return redirect()->route('admin.categories.index')->with('success', 'Kategori berhasil dihapus.');
}
}
Definisikan Rute Admin
Buka routes/web.php dan tambahkan rute untuk admin. Kita akan menggabungkan rute ini dalam middleware auth agar hanya user terautentikasi yang bisa mengaksesnya.
<?php
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\Admin\PostController;
use App\Http\Controllers\Admin\CategoryController;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider and all of them will
| be assigned to the "web" middleware group. Make something great!
|
*/
Route::get('/', function () {
return view('welcome');
});
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/dashboard', function () {
return view('dashboard');
})->name('dashboard');
Route::prefix('admin')->name('admin.')->group(function () {
Route::resource('posts', PostController::class);
Route::resource('categories', CategoryController::class);
});
});
Route::middleware('auth')->group(function () {
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
});
require __DIR__.'/auth.php';
Catatan: Anda bisa menambahkan middleware kustom untuk role admin jika diperlukan, tapi untuk tutorial ini, semua user terautentikasi dianggap sebagai admin.
Membuat Views Admin
Laravel Breeze menggunakan layout di resources/views/layouts/app.blade.php. Kita bisa membuat tampilan khusus untuk admin atau menggunakan layout yang sudah ada.
Buat folder resources/views/admin/posts dan resources/views/admin/categories.
resources/views/admin/posts/index.blade.php
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Manajemen Postingan') }}
</h2>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-900">
<div class="flex justify-end mb-4">
<a href="{{ route('admin.posts.create') }}" class="bg-indigo-500 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded">
Tambah Postingan
</a>
</div>
<x-auth-session-status class="mb-4" :status="session('success') ?? session('error')" />
<div class="overflow-x-auto">
<table class="min-w-full bg-white">
<thead class="bg-gray-800 text-white">
<tr>
<th class="w-1/4 py-3 px-4 uppercase font-semibold text-sm">Judul</th>
<th class="w-1/4 py-3 px-4 uppercase font-semibold text-sm">Kategori</th>
<th class="w-1/4 py-3 px-4 uppercase font-semibold text-sm">Status</th>
<th class="w-1/4 py-3 px-4 uppercase font-semibold text-sm">Penulis</th>
<th class="w-1/4 py-3 px-4 uppercase font-semibold text-sm">Aksi</th>
</tr>
</thead>
<tbody class="text-gray-700">
<?php foreach ($posts as $post): ?>
<tr>
<td class="py-3 px-4">{{ $post->title }}</td>
<td class="py-3 px-4">{{ $post->category->name ?? 'Tidak ada' }}</td>
<td class="py-3 px-4">{{ ucfirst($post->status) }}</td>
<td class="py-3 px-4">{{ $post->user->name }}</td>
<td class="py-3 px-4 flex space-x-2">
<a href="{{ route('admin.posts.edit', $post) }}" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-3 rounded text-xs">Edit</a>
<form action="{{ route('admin.posts.destroy', $post) }}" method="POST" onsubmit="return confirm('Anda yakin ingin menghapus postingan ini?');">
@csrf
@method('DELETE')
<button type="submit" class="bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-3 rounded text-xs">Hapus</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="mt-4">
{{ $posts->links() }}
</div>
</div>
</div>
</div>
</div>
</x-app-layout>
resources/views/admin/posts/create.blade.php dan edit.blade.php
Ini akan menjadi form untuk menambah/mengedit postingan. Anda bisa menggunakan komponen form dari Laravel Breeze atau membuat sendiri. Untuk tutorial ini, saya berikan contoh sederhana.
File create.blade.php:
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Buat Postingan Baru') }}
</h2>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-900">
<form method="POST" action="{{ route('admin.posts.store') }}" enctype="multipart/form-data">
@csrf
<div class="mb-4">
<x-input-label for="title" :value="__('Judul Postingan')" />
<x-text-input id="title" class="block mt-1 w-full" type="text" name="title" :value="old('title')" required autofocus />
<x-input-error :messages="$errors->get('title')" class="mt-2" />
</div>
<div class="mb-4">
<x-input-label for="category_id" :value="__('Kategori')" />
<select id="category_id" name="category_id" class="border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm block mt-1 w-full">
<option value="">Pilih Kategori</option>
<?php foreach ($categories as $category): ?>
<option value="{{ $category->id }}" {{ old('category_id') == $category->id ? 'selected' : '' }}>{{ $category->name }}</option>
<?php endforeach; ?>
</select>
<x-input-error :messages="$errors->get('category_id')" class="mt-2" />
</div>
<div class="mb-4">
<x-input-label for="content" :value="__('Konten Postingan')" />
<textarea id="content" name="content" rows="10" class="border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm block mt-1 w-full" required>{{ old('content') }}</textarea>
<x-input-error :messages="$errors->get('content')" class="mt-2" />
</div>
<div class="mb-4">
<x-input-label for="thumbnail" :value="__('Thumbnail')" />
<input id="thumbnail" type="file" name="thumbnail" class="block mt-1 w-full border-gray-300 rounded-md shadow-sm focus:border-indigo-500 focus:ring-indigo-500" />
<x-input-error :messages="$errors->get('thumbnail')" class="mt-2" />
</div>
<div class="mb-4">
<x-input-label for="status" :value="__('Status')" />
<select id="status" name="status" class="border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm block mt-1 w-full">
<option value="draft" {{ old('status') == 'draft' ? 'selected' : '' }}>Draft</option>
<option value="published" {{ old('status') == 'published' ? 'selected' : '' }}>Published</option>
</select>
<x-input-error :messages="$errors->get('status')" class="mt-2" />
</div>
<div class="mb-4">
<x-input-label for="published_at" :value="__('Tanggal Publikasi (Opsional)')" />
<x-text-input id="published_at" class="block mt-1 w-full" type="datetime-local" name="published_at" :value="old('published_at')" />
<x-input-error :messages="$errors->get('published_at')" class="mt-2" />
</div>
<div class="flex items-center justify-end mt-4">
<x-primary-button>
{{ __('Simpan Postingan') }}
</x-primary-button>
</div>
</form>
</div>
</div>
</div>
</div>
</x-app-layout>
File edit.blade.php (modifikasi dari create.blade.php untuk edit):
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Edit Postingan') }}
</h2>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-900">
<form method="POST" action="{{ route('admin.posts.update', $post) }}" enctype="multipart/form-data">
@csrf
@method('PUT')
<div class="mb-4">
<x-input-label for="title" :value="__('Judul Postingan')" />
<x-text-input id="title" class="block mt-1 w-full" type="text" name="title" :value="old('title', $post->title)" required autofocus />
<x-input-error :messages="$errors->get('title')" class="mt-2" />
</div>
<div class="mb-4">
<x-input-label for="category_id" :value="__('Kategori')" />
<select id="category_id" name="category_id" class="border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm block mt-1 w-full">
<option value="">Pilih Kategori</option>
<?php foreach ($categories as $category): ?>
<option value="{{ $category->id }}" {{ old('category_id', $post->category_id) == $category->id ? 'selected' : '' }}>{{ $category->name }}</option>
<?php endforeach; ?>
</select>
<x-input-error :messages="$errors->get('category_id')" class="mt-2" />
</div>
<div class="mb-4">
<x-input-label for="content" :value="__('Konten Postingan')" />
<textarea id="content" name="content" rows="10" class="border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm block mt-1 w-full" required>{{ old('content', $post->content) }}</textarea>
<x-input-error :messages="$errors->get('content')" class="mt-2" />
</div>
<div class="mb-4">
<x-input-label for="thumbnail" :value="__('Thumbnail')" />
<?php if ($post->thumbnail): ?>
<div class="mt-2 mb-2">
<img src="{{ Storage::url($post->thumbnail) }}" alt="{{ $post->title }}" class="max-w-xs h-auto" />
<label for="remove_thumbnail" class="inline-flex items-center mt-2">
<input id="remove_thumbnail" type="checkbox" name="remove_thumbnail" class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500" value="1" />
<span class="ms-2 text-sm text-gray-600">{{ __('Hapus thumbnail') }}</span>
</label>
</div>
<?php endif; ?>
<input id="thumbnail" type="file" name="thumbnail" class="block mt-1 w-full border-gray-300 rounded-md shadow-sm focus:border-indigo-500 focus:ring-indigo-500" />
<x-input-error :messages="$errors->get('thumbnail')" class="mt-2" />
</div>
<div class="mb-4">
<x-input-label for="status" :value="__('Status')" />
<select id="status" name="status" class="border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm block mt-1 w-full">
<option value="draft" {{ old('status', $post->status) == 'draft' ? 'selected' : '' }}>Draft</option>
<option value="published" {{ old('status', $post->status) == 'published' ? 'selected' : '' }}>Published</option>
</select>
<x-input-error :messages="$errors->get('status')" class="mt-2" />
</div>
<div class="mb-4">
<x-input-label for="published_at" :value="__('Tanggal Publikasi (Opsional)')" />
<x-text-input id="published_at" class="block mt-1 w-full" type="datetime-local" name="published_at" :value="old('published_at', $post->published_at ? $post->published_at->format('Y-m-d\TH:i') : '')" />
<x-input-error :messages="$errors->get('published_at')" class="mt-2" />
</div>
<div class="flex items-center justify-end mt-4">
<x-primary-button>
{{ __('Update Postingan') }}
</x-primary-button>
</div>
</form>
</div>
</div>
</div>
</div>
</x-app-layout>
Untuk kategori, buat views serupa di resources/views/admin/categories/. Misalnya, index.blade.php, create.blade.php, dan edit.blade.php.
Penting: Untuk dapat menampilkan gambar yang di-upload, Anda perlu membuat symbolic link dari storage ke public folder:
php artisan storage:link
Ini akan membuat folder public/storage yang mengarah ke storage/app/public.
Menampilkan Blog di Tampilan Publik
Sekarang, kita akan membuat tampilan blog untuk pengunjung.
Membuat Controller untuk Tampilan Publik
Buat controller terpisah untuk tampilan publik agar logikanya tidak tercampur dengan admin:
php artisan make:controller BlogController
Isi app/Http/Controllers/BlogController.php:
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\Post;
use Illuminate\Http\Request;
class BlogController extends Controller
{
public function index()
{
$posts = Post::with('category', 'user')
->where('status', 'published')
->whereNotNull('published_at')
->where('published_at', 'latest('published_at')
->paginate(10);
$categories = Category::has('posts')->get(); // Hanya kategori yang memiliki postingan
return view('blog.index', compact('posts', 'categories'));
}
public function show(string $slug)
{
$post = Post::with('category', 'user')
->where('slug', $slug)
->where('status', 'published')
->whereNotNull('published_at')
->where('published_at', 'firstOrFail();
return view('blog.show', compact('post'));
}
public function category(Category $category)
{
$posts = $category->posts()
->with('user')
->where('status', 'published')
->whereNotNull('published_at')
->where('published_at', 'latest('published_at')
->paginate(10);
$categories = Category::has('posts')->get();
return view('blog.category', compact('posts', 'category', 'categories'));
}
}
Definisikan Rute Publik
Tambahkan rute ini di routes/web.php, di luar grup middleware auth:
// ... existing routes ...
Route::get('/', [BlogController::class, 'index'])->name('blog.index');
Route::get('/blog/{slug}', [BlogController::class, 'show'])->name('blog.show');
Route::get('/category/{category:slug}', [BlogController::class, 'category'])->name('blog.category');
Membuat Views Publik
Buat folder resources/views/blog/.
resources/views/blog/index.blade.php (Halaman Utama Blog)
Ini adalah tampilan daftar postingan blog. Anda bisa mendesainnya sesuai keinginan.
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tubianto.com - Blog</title>
<!-- Tailwind CSS CDN for simplicity in tutorial. Use npm for production -->
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="antialiased bg-gray-100">
<nav class="bg-white shadow p-4">
<div class="container mx-auto flex justify-between items-center">
<a href="{{ route('blog.index') }}" class="text-xl font-bold text-gray-800">Tubianto.com Blog</a>
<div>
<a href="{{ route('dashboard') }}" class="text-gray-600 hover:text-gray-900 mx-2">Dashboard Admin</a>
</div>
</div>
</nav>
<div class="container mx-auto px-4 py-8">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<div class="md:col-span-3">
<h1 class="text-3xl font-bold text-gray-800 mb-6">Latest Posts</h1>
<?php if ($posts->isEmpty()): ?>
<p class="text-gray-600">Belum ada postingan yang diterbitkan.</p>
<?php else: ?>
<?php foreach ($posts as $post): ?>
<div class="bg-white rounded-lg shadow p-6 mb-6">
<?php if ($post->thumbnail): ?>
<img src="{{ Storage::url($post->thumbnail) }}" alt="{{ $post->title }}" class="w-full h-48 object-cover rounded-md mb-4" />
<?php endif; ?>
<h2 class="text-2xl font-bold text-gray-800 mb-2">
<a href="{{ route('blog.show', $post->slug) }}" class="hover:text-indigo-600">{{ $post->title }}</a>
</h2>
<p class="text-gray-600 text-sm mb-3">
Oleh <strong>{{ $post->user->name }}</strong> pada {{ $post->published_at->format('d M Y') }}
<?php if ($post->category): ?>
di <a href="{{ route('blog.category', $post->category->slug) }}" class="text-indigo-500 hover:underline">{{ $post->category->name }}</a>
<?php endif; ?>
</p>
<p class="text-gray-700 mb-4">{{ Str::limit(strip_tags($post->content), 200) }}</p>
<a href="{{ route('blog.show', $post->slug) }}" class="text-indigo-500 hover:underline">Baca Selengkapnya →</a>
</div>
<?php endforeach; ?>
<div class="mt-8">
{{ $posts->links() }}
</div>
<?php endif; ?>
</div>
<div class="md:col-span-1">
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-xl font-bold text-gray-800 mb-4">Kategori</h3>
<ul>
<?php foreach ($categories as $category): ?>
<li class="mb-2">
<a href="{{ route('blog.category', $category->slug) }}" class="text-gray-600 hover:text-indigo-600">{{ $category->name }} ({{ $category->posts()->where('status', 'published')->count() }})</a>
</li>
<?php endforeach; ?>
</ul>
</div>
</div>
</div>
</div>
</body>
</html>
resources/views/blog/show.blade.php (Detail Postingan)
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tubianto.com - {{ $post->title }}</title>
<!-- Tailwind CSS CDN for simplicity in tutorial. Use npm for production -->
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="antialiased bg-gray-100">
<nav class="bg-white shadow p-4">
<div class="container mx-auto flex justify-between items-center">
<a href="{{ route('blog.index') }}" class="text-xl font-bold text-gray-800">Tubianto.com Blog</a>
<div>
<a href="{{ route('dashboard') }}" class="text-gray-600 hover:text-gray-900 mx-2">Dashboard Admin</a>
</div>
</div>
</nav>
<div class="container mx-auto px-4 py-8">
<div class="bg-white rounded-lg shadow p-8">
<h1 class="text-4xl font-bold text-gray-800 mb-4">{{ $post->title }}</h1>
<p class="text-gray-600 text-sm mb-6">
Oleh <strong>{{ $post->user->name }}</strong> pada {{ $post->published_at->format('d M Y') }}
<?php if ($post->category): ?>
di <a href="{{ route('blog.category', $post->category->slug) }}" class="text-indigo-500 hover:underline">{{ $post->category->name }}</a>
<?php endif; ?>
</p>
<?php if ($post->thumbnail): ?>
<img src="{{ Storage::url($post->thumbnail) }}" alt="{{ $post->title }}" class="w-full h-64 object-cover rounded-md mb-8" />
<?php endif; ?>
<div class="prose max-w-none text-gray-800 leading-relaxed">
{{-- Ini akan otomatis menginterpretasikan HTML dalam konten --}}
{!! $post->content !!}
</div>
<div class="mt-8">
<a href="{{ route('blog.index') }}" class="text-indigo-500 hover:underline">← Kembali ke Blog</a>
</div>
</div>
</div>
</body>
</html>
Buat juga resources/views/blog/category.blade.php dengan struktur mirip index.blade.php, tetapi hanya menampilkan postingan dari kategori yang dipilih.
Masalah yang Sering Terjadi
Dalam proses pengembangan, wajar jika menemui beberapa kendala. Berikut adalah masalah umum dan solusinya:
1. Error Database Connection
- Gejala: Pesan error “SQLSTATE[HY000] [1049] Unknown database ‘nama_database_blog_anda'” atau sejenisnya.
- Penyebab: Database belum dibuat, atau kredensial di
.envsalah. - Solusi: Pastikan Anda sudah membuat database di server MySQL/MariaDB Anda dengan nama yang sesuai di
.env, danDB_USERNAMEsertaDB_PASSWORDsudah benar.
2. `php artisan migrate` Gagal
- Gejala: Error seperti “Table ‘users’ already exists” atau masalah foreign key.
- Penyebab: Migrasi sudah pernah dijalankan sebagian, atau urutan migrasi salah (misalnya, membuat tabel
postssebelumusers). - Solusi: Hapus database Anda, lalu jalankan
php artisan migrate:fresh(ini akan menghapus semua tabel dan menjalankan ulang migrasi). Jika data penting, gunakanphp artisan migrate:rollbacklalu perbaiki masalah danphp artisan migrate.
3. File Upload (Thumbnail) Tidak Muncul
- Gejala: Gambar tidak terlihat di tampilan publik, atau path gambar rusak.
- Penyebab:
php artisan storage:linkbelum dijalankan, atau permission folderstorage/app/publicsalah. - Solusi: Pastikan Anda sudah menjalankan
php artisan storage:link. Periksa permission folderstorage/appdanstorage/app/public, berikan hak tulis yang sesuai (misal,chmod -R 775 storage).
4. `404 Not Found` untuk Rute Admin atau Blog
- Gejala: Mengakses URL tertentu (misal
/admin/postsatau/blog/judul-post) menghasilkan 404. - Penyebab: Rute belum didefinisikan dengan benar di
routes/web.php, atau ada typo pada nama rute atau slug. - Solusi: Periksa kembali
routes/web.php. Pastikan nama rute diroute()helper sesuai dengan yang didefinisikan. Gunakanphp artisan route:listuntuk melihat semua rute yang terdaftar.
5. Masalah dengan CSS/JavaScript (Setelah `npm run dev`)
- Gejala: Tampilan berantakan, fitur JavaScript (misal, dropdown) tidak berfungsi.
- Penyebab: Kompilasi aset frontend gagal, atau browser masih menggunakan cache lama.
- Solusi: Pastikan
npm installdannpm run devberjalan tanpa error. Coba hapus cache browser (Ctrl+Shift+R atau Incognito mode). Untuk production, gunakannpm run build.
Pengalaman dan Pertimbangan Praktis
Membangun CMS blog dasar dengan Laravel memang menarik, tetapi ada beberapa hal praktis yang perlu Anda pertimbangkan jika ingin menjadikannya proyek yang lebih serius atau siap produksi.
Validasi Input yang Ketat
Pada contoh di atas, kita sudah menggunakan validasi sederhana, namun dalam praktiknya, Anda mungkin memerlukan validasi yang lebih kuat. Misalnya, untuk konten, pastikan tidak ada kode berbahaya (XSS). Untuk upload gambar, batasi tipe file dan ukuran secara lebih spesifik. Selalu asumsikan input dari pengguna tidak aman.
Optimasi Gambar dan Media
Gambar adalah komponen penting dalam blog. Namun, gambar yang tidak dioptimalkan bisa memperlambat loading blog secara signifikan. Pertimbangkan untuk:
- Kompresi Gambar: Gunakan library seperti Intervention Image untuk mengompres gambar saat diunggah.
- Responsive Images: Hasilkan beberapa ukuran gambar (thumbnail, medium, large) dan gunakan atribut
srcsetdi HTML. - Lazy Loading: Implementasikan lazy loading untuk gambar di luar viewport.
Integrasi WYSIWYG Editor
Menulis konten di <textarea> polos kurang nyaman. Integrasikan WYSIWYG (What You See Is What You Get) editor seperti TinyMCE, CKEditor, atau Tiptap. Ini akan meningkatkan pengalaman menulis bagi pengguna admin dan memungkinkan formatting teks yang kaya (bold, italic, daftar, link, dll.).
SEO Friendly URLs (Slugs)
Kita sudah mengimplementasikan slug otomatis dari judul. Pastikan slug selalu unik dan bersih (tidak ada karakter aneh). Untuk SEO yang lebih advance, Anda mungkin ingin menambahkan fitur untuk mengedit slug secara manual oleh admin.
Keamanan
Laravel memiliki banyak fitur keamanan bawaan (CSRF protection, hashing password, SQL injection prevention). Namun, tetap pastikan Anda:
- Menggunakan validasi yang tepat untuk semua input.
- Menghindari raw SQL queries, selalu gunakan Eloquent.
- Menjaga dependencies tetap up-to-date.
- Menerapkan otorisasi (misal, hanya admin yang bisa mengedit/menghapus postingan orang lain).
Skalabilitas dan Performa
Untuk blog dengan traffic tinggi, pertimbangkan:
- Caching: Cache query database yang sering diakses (misal, daftar postingan terbaru) atau cache seluruh halaman.
- Queues: Gunakan queue untuk tugas-tugas berat di background (misal, memproses gambar setelah diunggah, mengirim notifikasi email).
- Database Indexing: Pastikan kolom yang sering digunakan untuk pencarian atau filter memiliki indeks.
Deployment ke Lingkungan Produksi
Saat akan deploy ke VPS atau hosting, jangan lupa:
- Mengatur
APP_ENV=productiondi.env. - Menjalankan
php artisan config:cache,php artisan route:cache,php artisan view:cache. - Menggunakan Nginx/Apache untuk serving file Laravel.
- Mengamankan server database.
- Menggunakan SSL/TLS untuk koneksi HTTPS.
FAQ
Mengapa tidak pakai WordPress saja?
WordPress adalah solusi CMS yang sangat matang dan powerful. Namun, membuat CMS dengan Laravel memberikan Anda kontrol penuh atas setiap aspek aplikasi, fleksibilitas tanpa batas, dan pengalaman belajar yang mendalam tentang pengembangan web modern. Ini ideal jika Anda punya kebutuhan yang sangat spesifik atau ingin membangun fondasi untuk platform yang lebih besar di masa depan.
Apakah Laravel CMS ini cocok untuk pemula?
Tentu saja! Laravel dirancang dengan prinsip kemudahan penggunaan dan produktivitas developer. Dengan panduan yang tepat, pemula bisa dengan cepat memahami dan membangun aplikasi. Ini adalah cara yang sangat baik untuk belajar konsep-konsep seperti MVC, ORM, routing, dan autentikasi dalam konteks proyek nyata.
Bagaimana cara menambahkan fitur komentar?
Anda bisa menambahkan fitur komentar dengan membuat model Comment baru yang berelasi belongsTo dengan Post dan User. Kemudian, buat controller dan view untuk mengelola komentar. Anda juga bisa mengintegrasikan layanan komentar pihak ketiga seperti Disqus atau Commento.io.
Apakah sulit mengelola SEO di CMS Laravel?
Tidak sulit. Dengan Laravel, Anda memiliki kontrol penuh atas struktur URL (slug), meta title, meta description, dan Open Graph tags. Anda bisa menambahkan kolom-kolom ini ke tabel posts dan memungkinkan admin untuk mengisinya. Banyak package SEO yang tersedia untuk Laravel untuk membantu otomasi.
Kesimpulan
Selamat! Anda telah berhasil membangun CMS blog dasar menggunakan Laravel. Dari persiapan lingkungan, struktur database dengan migrasi dan model Eloquent, sistem autentikasi dengan Laravel Breeze, hingga fitur CRUD untuk postingan dan kategori, Anda kini memiliki fondasi yang kuat. Tampilan publik blog juga sudah dapat diakses oleh pengunjung.
Proyek ini adalah langkah awal yang sangat baik untuk memahami ekosistem Laravel secara mendalam. Dari sini, Anda bisa terus mengembangkan CMS ini dengan fitur-fitur lanjutan seperti tag, komentar, fitur pencarian, kategori berlapis, penjadwalan postingan, hingga peran pengguna yang lebih kompleks (admin, editor, kontributor). Kunci sukses pengembangan adalah terus mencoba, membaca dokumentasi, dan memecahkan masalah. Happy coding!
TAGS: Laravel, CMS, Blog, Web Development, PHP, Fullstack, Tutorial, Coding, Database, CRUD


