Fondasi database dari Bagian 2 kini kita angkat ke web. Bagian ini adalah jantung proyek: satu resource controller mengurus tujuh aksi CRUD artikel, dua form request menangani validasi, dan satu partial Blade menyatukan form tulis dengan form ubah. Autentikasi belum kita bangun — jadi sementara ini semua aksi tulis masih dilindungi middleware auth bawaan, dan Bagian 4 menyusul memberi cara loginnya.
Membagi Resource Route #
Peta route dari Bagian 1 diwujudkan di routes/web.php. Triknya: satu resource dibagi dua grup karena hak aksesnya berbeda:
// Menulis, mengubah, dan menghapus: khusus penulis yang login.
// Grup ini sengaja didaftarkan lebih dulu agar pola /posts/create
// tidak tertelan oleh pola /posts/{post} milik rute publik di bawah.
Route::middleware('auth')->group(function () {
Route::resource('posts', PostController::class)
->only(['create', 'store', 'edit', 'update', 'destroy']);
});
// Artikel: membaca terbuka untuk semua orang.
Route::resource('posts', PostController::class)
->only(['index', 'show']);Urutan pendaftaran tidak sekadar gaya. Laravel mencocokkan request dengan route sesuai urutan didaftarkan; bila grup publik lebih dulu, pola /posts/{post} akan menangkap /posts/create dan mencari artikel berjudul “create”. Grup yang menulis didaftarkan duluan supaya pola statis menang melawan pola parameter.
php artisan route:list memastikan hasilnya:
php artisan route:listOutput yang Dihasilkan:
GET|HEAD / .................................. home › routes/web.php:9
GET|HEAD kategori ....... categories.index › CategoryController@index
GET|HEAD kategori/{category} categories.show › CategoryController@sh…
GET|HEAD login ................. login › AuthController@showLoginForm
POST login ................. login.attempt › AuthController@login
POST logout ...................... logout › AuthController@logout
POST posts ................... posts.store › PostController@store
GET|HEAD posts ................... posts.index › PostController@index
GET|HEAD posts/create .......... posts.create › PostController@create
PUT|PATCH posts/{post} .......... posts.update › PostController@update
DELETE posts/{post} ........ posts.destroy › PostController@destroy
GET|HEAD posts/{post} .............. posts.show › PostController@show
GET|HEAD posts/{post}/edit ......... posts.edit › PostController@edit
GET|HEAD registrasi .. register › AuthController@showRegistrationForm
POST registrasi .. register.store › AuthController@registerController dengan Dua Wajah #
PostController@index memakai method when() untuk melayani dua jenis pengunjung sekaligus — tamu hanya melihat artikel terbit, penulis yang login juga melihat drafnya:
public function index(Request $request): View
{
$posts = Post::with(['user', 'category', 'tags'])
->when(
$request->user() === null,
fn ($query) => $query->published()
)
->latest('id')
->paginate(10);
return view('posts.index', ['posts' => $posts]);
}Eager loading with(['user', 'category', 'tags']) mencegah query N+1 saat daftar dirender. Method store() lalu menyimpan artikel baru — perhatikan baris slug:
public function store(StorePostRequest $request): RedirectResponse
{
$data = collect($request->validated())->except('tags')->all();
$data['slug'] = $data['slug'] ?? $this->uniqueSlug($data['title']);
$post = $request->user()->posts()->create($data);
$post->tags()->sync($request->validated('tags', []));
return redirect()
->route('posts.show', $post)
->with('status', 'Artikel berhasil disimpan.');
}Kunci tags dikeluarkan sebelum create() karena kolom itu bukan milik tabel posts — tag disimpan terpisah lewat sync() ke pivot. Slug diisi otomatis dari judul bila pengguna membiarkannya kosong; method privat uniqueSlug() menghindari tabrakan dengan menambahkan akhiran nomor (tulisan-pertama-2). Method update() hampir sama, hanya menambahkan $post->id sebagai pengecualian saat mengecek keunikan slug agar artikel tidak dianggap menabrak dirinya sendiri.
Sekilas tentang uniqueSlug() — loop while di dalamnya memeriksa kandidat slug satu per satu:
private function uniqueSlug(string $title, ?int $ignoreId = null): string
{
$slug = Str::slug($title);
$original = $slug;
$suffix = 2;
while (Post::query()
->when($ignoreId, fn ($query) => $query->where('id', '!=', $ignoreId))
->where('slug', $slug)
->exists()) {
$slug = "{$original}-{$suffix}";
$suffix++;
}
return $slug;
}Str::slug() membersihkan judul menjadi huruf kecil berpembatas tanda hubung, termasuk judul berbahasa Indonesia. Selama kandidat slug masih dipakai artikel lain, loop menaikkan akhiran dan mengulang pemeriksaan — pola yang sama dipakai banyak blog engine populer. Method show() lebih pendek: $this->authorize('view', $post) mengecek kebijakan dulu (detail di Bagian 6), lalu $post->load([...]) melakukan eager loading setelah model diterima dari route model binding. Terakhir, paginate(10) pada index() membagi daftar menjadi halaman sepuluh artikel dan menghasilkan instance LengthAwarePaginator yang dirender lewat {{ $posts->links() }} di view.
Form Request dan Partial Blade #
Validasi tidak menempel di controller, melainkan di StorePostRequest:
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'slug' => [
'nullable',
'string',
'max:255',
'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/',
Rule::unique('posts', 'slug'),
],
'body' => ['required', 'string'],
'category_id' => ['required', 'integer', Rule::exists('categories', 'id')],
'tags' => ['nullable', 'array'],
'tags.*' => ['integer', Rule::exists('tags', 'id')],
'published_at' => ['nullable', 'date'],
];
}Tipe pengembalian RedirectResponse di controller membuat Laravel otomatis mengalihkan kembali dengan error dan old input saat validasi gagal. Di sisi view, partial resources/views/posts/_form.blade.php dipakai bersama oleh create dan edit lewat @include('posts._form', ['aksi' => route('posts.store')]) — variabel $aksi menentukan tujuan form, sementara old('title', $post->title ?? '') mengisi nilai lama saat validasi gagal maupun saat form ubah dibuka. Setiap input didampingi @error untuk pesan galat per field, dan satu blok ringkasan error ditampilkan di puncak form.
Layout resources/views/layouts/app.blade.php menampung semuanya: satu berkas HTML dengan blok @yield('konten'), gaya ditulis sebagai CSS polos di dalam tag <style> sehingga proyek tidak butuh build step Vite sama sekali. Halaman daftar lalu me-loop $posts lewat @forelse — variannya @empty menampilkan pesan ramah ketika blog masih kosong, dan setiap kartu menampilkan lencana kategori serta tanda “Draf” untuk artikel yang belum terbit.
CRUD artikel kini utuh, hanya saja belum ada yang bisa login untuk mencobanya. Bagian 4 melengkapi kerangka autentikasinya.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
app/Http/Controllers/AuthController.php
Unduh berkas<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class AuthController extends Controller
{
/**
* Tampilkan form login.
*/
public function showLoginForm(): View
{
return view('auth.login');
}
/**
* Proses percobaan login penulis.
*/
public function login(Request $request): RedirectResponse
{
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
if (Auth::attempt($credentials)) {
$request->session()->regenerate();
return redirect()->intended(route('posts.index'));
}
return back()
->withErrors(['email' => 'Email atau kata sandi salah.'])
->onlyInput('email');
}
/**
* Keluar dari sesi penulis.
*/
public function logout(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('posts.index');
}
/**
* Tampilkan form registrasi penulis baru.
*/
public function showRegistrationForm(): View
{
return view('auth.register');
}
/**
* Simpan penulis baru lalu langsung login.
*/
public function register(Request $request): RedirectResponse
{
$credentials = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
'password' => ['required', 'string', 'min:8', 'confirmed'],
]);
$user = User::create($credentials);
Auth::login($user);
$request->session()->regenerate();
return redirect()
->route('posts.index')
->with('status', 'Registrasi berhasil. Selamat menulis!');
}
}
app/Http/Controllers/CategoryController.php
Unduh berkas<?php
namespace App\Http\Controllers;
use App\Models\Category;
use Illuminate\View\View;
class CategoryController extends Controller
{
/**
* Daftar seluruh kategori beserta jumlah artikelnya.
*/
public function index(): View
{
$categories = Category::query()
->withCount('posts')
->orderBy('name')
->get();
return view('categories.index', ['categories' => $categories]);
}
/**
* Daftar artikel terbit di dalam satu kategori.
*/
public function show(Category $category): View
{
$posts = $category->posts()
->with(['user', 'category', 'tags'])
->published()
->latest('id')
->paginate(10);
return view('categories.show', [
'category' => $category,
'posts' => $posts,
]);
}
}
app/Http/Controllers/Controller.php
Unduh berkas<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller
{
use AuthorizesRequests;
}
app/Http/Controllers/PostController.php
Unduh berkas<?php
namespace App\Http\Controllers;
use App\Http\Requests\StorePostRequest;
use App\Http\Requests\UpdatePostRequest;
use App\Models\Category;
use App\Models\Post;
use App\Models\Tag;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\View\View;
class PostController extends Controller
{
/**
* Daftar artikel: terbit untuk semua, draf hanya saat login.
*/
public function index(Request $request): View
{
$posts = Post::with(['user', 'category', 'tags'])
->when(
$request->user() === null,
fn ($query) => $query->published()
)
->latest('id')
->paginate(10);
return view('posts.index', ['posts' => $posts]);
}
/**
* Detail satu artikel.
*/
public function show(Post $post): View
{
$this->authorize('view', $post);
return view('posts.show', [
'post' => $post->load(['user', 'category', 'tags']),
]);
}
/**
* Form tulis artikel baru.
*/
public function create(): View
{
return view('posts.create', [
'post' => new Post(),
'categories' => Category::orderBy('name')->get(),
'tags' => Tag::orderBy('name')->get(),
]);
}
/**
* Simpan artikel baru.
*/
public function store(StorePostRequest $request): RedirectResponse
{
$data = collect($request->validated())->except('tags')->all();
$data['slug'] = $data['slug'] ?? $this->uniqueSlug($data['title']);
$post = $request->user()->posts()->create($data);
$post->tags()->sync($request->validated('tags', []));
return redirect()
->route('posts.show', $post)
->with('status', 'Artikel berhasil disimpan.');
}
/**
* Form ubah artikel milik penulis.
*/
public function edit(Post $post): View
{
$this->authorize('update', $post);
return view('posts.edit', [
'post' => $post,
'categories' => Category::orderBy('name')->get(),
'tags' => Tag::orderBy('name')->get(),
]);
}
/**
* Perbarui artikel milik penulis.
*/
public function update(UpdatePostRequest $request, Post $post): RedirectResponse
{
$this->authorize('update', $post);
$data = collect($request->validated())->except('tags')->all();
$data['slug'] = $data['slug'] ?? $this->uniqueSlug($data['title'], $post->id);
$post->update($data);
$post->tags()->sync($request->validated('tags', []));
return redirect()
->route('posts.show', $post)
->with('status', 'Artikel berhasil diperbarui.');
}
/**
* Hapus artikel milik penulis.
*/
public function destroy(Post $post): RedirectResponse
{
$this->authorize('delete', $post);
$post->delete();
return redirect()
->route('posts.index')
->with('status', 'Artikel berhasil dihapus.');
}
/**
* Buat slug unik dari judul, dengan akhiran nomor bila sudah dipakai.
*/
private function uniqueSlug(string $title, ?int $ignoreId = null): string
{
$slug = Str::slug($title);
$original = $slug;
$suffix = 2;
while (Post::query()
->when($ignoreId, fn ($query) => $query->where('id', '!=', $ignoreId))
->where('slug', $slug)
->exists()) {
$slug = "{$original}-{$suffix}";
$suffix++;
}
return $slug;
}
}
app/Http/Requests/StorePostRequest.php
Unduh berkas<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StorePostRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Validation\Rule|array|string>
*/
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'slug' => [
'nullable',
'string',
'max:255',
'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/',
Rule::unique('posts', 'slug'),
],
'body' => ['required', 'string'],
'category_id' => ['required', 'integer', Rule::exists('categories', 'id')],
'tags' => ['nullable', 'array'],
'tags.*' => ['integer', Rule::exists('tags', 'id')],
'published_at' => ['nullable', 'date'],
];
}
/**
* Get the error messages for the defined validation rules.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'slug.regex' => 'Slug hanya boleh berisi huruf kecil, angka, dan tanda hubung.',
'category_id.exists' => 'Kategori yang dipilih tidak tersedia.',
];
}
}
app/Http/Requests/UpdatePostRequest.php
Unduh berkas<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdatePostRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Validation\Rule|array|string>
*/
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'slug' => [
'nullable',
'string',
'max:255',
'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/',
Rule::unique('posts', 'slug')->ignore($this->route('post')),
],
'body' => ['required', 'string'],
'category_id' => ['required', 'integer', Rule::exists('categories', 'id')],
'tags' => ['nullable', 'array'],
'tags.*' => ['integer', Rule::exists('tags', 'id')],
'published_at' => ['nullable', 'date'],
];
}
/**
* Get the error messages for the defined validation rules.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'slug.regex' => 'Slug hanya boleh berisi huruf kecil, angka, dan tanda hubung.',
'category_id.exists' => 'Kategori yang dipilih tidak tersedia.',
];
}
}
app/Models/Category.php
Unduh berkas<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Category extends Model
{
/** @use HasFactory<\Database\Factories\CategoryFactory> */
use HasFactory;
protected $fillable = ['name', 'slug'];
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
public function getRouteKeyName(): string
{
return 'slug';
}
}
app/Models/Post.php
Unduh berkas<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Post extends Model
{
/** @use HasFactory<\Database\Factories\PostFactory> */
use HasFactory;
protected $fillable = [
'user_id',
'category_id',
'title',
'slug',
'body',
'published_at',
];
protected function casts(): array
{
return [
'published_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class);
}
public function scopePublished(Builder $query): Builder
{
return $query->whereNotNull('published_at')
->where('published_at', '<=', now());
}
public function getRouteKeyName(): string
{
return 'slug';
}
}
app/Models/Tag.php
Unduh berkas<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Tag extends Model
{
/** @use HasFactory<\Database\Factories\TagFactory> */
use HasFactory;
protected $fillable = ['name', 'slug'];
public function posts(): BelongsToMany
{
return $this->belongsToMany(Post::class);
}
public function getRouteKeyName(): string
{
return 'slug';
}
}
app/Models/User.php
Unduh berkas<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}
app/Policies/PostPolicy.php
Unduh berkas<?php
namespace App\Policies;
use App\Models\Post;
use App\Models\User;
class PostPolicy
{
/**
* Artikel terbit boleh dibaca siapa pun; draf hanya pemiliknya.
*/
public function view(?User $user, Post $post): bool
{
return $post->published_at !== null
|| ($user !== null && $user->is($post->user));
}
/**
* Penulis yang sudah login boleh membuat artikel.
*/
public function create(User $user): bool
{
return true;
}
/**
* Hanya pemilik artikel yang boleh memperbarui.
*/
public function update(User $user, Post $post): bool
{
return $user->is($post->user);
}
/**
* Hanya pemilik artikel yang boleh menghapus.
*/
public function delete(User $user, Post $post): bool
{
return $user->is($post->user);
}
}
app/Providers/AppServiceProvider.php
Unduh berkas<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}
database/factories/CategoryFactory.php
Unduh berkas<?php
namespace Database\Factories;
use App\Models\Category;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<Category>
*/
class CategoryFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$name = fake()->unique()->words(2, true);
return [
'name' => ucfirst($name),
'slug' => Str::slug($name),
];
}
}
database/factories/PostFactory.php
Unduh berkas<?php
namespace Database\Factories;
use App\Models\Category;
use App\Models\Post;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<Post>
*/
class PostFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$title = fake()->sentence(4, true);
return [
'user_id' => User::factory(),
'category_id' => Category::factory(),
'title' => $title,
'slug' => Str::slug($title),
'body' => fake()->paragraphs(3, true),
'published_at' => now(),
];
}
/**
* Artikel yang belum terbit.
*/
public function draft(): static
{
return $this->state(fn () => ['published_at' => null]);
}
}
database/factories/TagFactory.php
Unduh berkas<?php
namespace Database\Factories;
use App\Models\Tag;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<Tag>
*/
class TagFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$name = fake()->unique()->word();
return [
'name' => ucfirst($name),
'slug' => Str::slug($name),
];
}
}
database/factories/UserFactory.php
Unduh berkas<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}
database/migrations/0001_01_01_000000_create_users_table.php
Unduh berkas<?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('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};
database/migrations/0001_01_01_000001_create_cache_table.php
Unduh berkas<?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('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->bigInteger('expiration')->index();
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->bigInteger('expiration')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};
database/migrations/0001_01_01_000002_create_jobs_table.php
Unduh berkas<?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('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedSmallInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->string('connection');
$table->string('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
$table->index(['connection', 'queue', 'failed_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};
database/migrations/2026_10_13_153049_create_personal_access_tokens_table.php
Unduh berkas<?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('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
$table->text('name');
$table->string('token', 64)->unique();
$table->text('abilities')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('expires_at')->nullable()->index();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('personal_access_tokens');
}
};
database/migrations/2026_10_13_231411_create_categories_table.php
Unduh berkas<?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');
}
};
database/migrations/2026_10_13_231412_create_tags_table.php
Unduh berkas<?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('tags', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->string('slug')->unique();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('tags');
}
};
database/migrations/2026_10_13_231413_create_posts_table.php
Unduh berkas<?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()
->cascadeOnDelete();
$table->foreignId('category_id')
->constrained()
->restrictOnDelete();
$table->string('title');
$table->string('slug')->unique();
$table->text('body');
$table->timestamp('published_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('posts');
}
};
database/migrations/2026_10_13_231414_create_post_tag_table.php
Unduh berkas<?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('post_tag', function (Blueprint $table) {
$table->foreignId('post_id')
->constrained()
->cascadeOnDelete();
$table->foreignId('tag_id')
->constrained()
->cascadeOnDelete();
$table->primary(['post_id', 'tag_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('post_tag');
}
};
database/seeders/DatabaseSeeder.php
Unduh berkas<?php
namespace Database\Seeders;
use App\Models\Category;
use App\Models\Post;
use App\Models\Tag;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
use WithoutModelEvents;
/**
* Seed the application's database.
*/
public function run(): void
{
$budi = User::factory()->create([
'name' => 'Budi',
'email' => '[email protected]',
]);
$siti = User::factory()->create([
'name' => 'Siti',
'email' => '[email protected]',
]);
$laravel = Category::factory()->create([
'name' => 'Laravel',
'slug' => 'laravel',
]);
$php = Category::factory()->create([
'name' => 'PHP',
'slug' => 'php',
]);
$tutorial = Category::factory()->create([
'name' => 'Tutorial',
'slug' => 'tutorial',
]);
$eloquent = Tag::factory()->create(['name' => 'Eloquent', 'slug' => 'eloquent']);
$blade = Tag::factory()->create(['name' => 'Blade', 'slug' => 'blade']);
$testing = Tag::factory()->create(['name' => 'Testing', 'slug' => 'testing']);
$postPertama = Post::factory()->create([
'user_id' => $budi->id,
'category_id' => $laravel->id,
'title' => 'Belajar Eloquent dari Nol',
'slug' => 'belajar-eloquent-dari-nol',
'body' => 'Pada artikel ini kita membahas relasi belongsTo, hasMany, '
. 'dan belongsToMany dengan contoh aplikasi blog. Eloquent membuat '
. 'kode database terasa seperti bekerja dengan objek PHP biasa.',
'published_at' => '2026-10-01 09:00:00',
]);
$postPertama->tags()->sync([$eloquent->id]);
$postKedua = Post::factory()->create([
'user_id' => $siti->id,
'category_id' => $laravel->id,
'title' => 'Tips Mengorganisir Template Blade',
'slug' => 'tips-mengorganisir-template-blade',
'body' => 'Layout, partial, dan komponen adalah tiga lapisan yang '
. 'membuat view Laravel tetap rapi. Artikel ini merangkum pola '
. 'yang saya pakai di berbagai proyek nyata.',
'published_at' => '2026-10-05 10:00:00',
]);
$postKedua->tags()->sync([$blade->id, $tutorial->id]);
$postKetiga = Post::factory()->create([
'user_id' => $budi->id,
'category_id' => $php->id,
'title' => 'Menulis Feature Test Pertama',
'slug' => 'menulis-feature-test-pertama',
'body' => 'Feature test menguji aplikasi dari luar lewat HTTP. '
. 'Kita mulai dari status kode, lalu perlahan menambah assert '
. 'untuk isi database dan session.',
'published_at' => '2026-10-10 08:00:00',
]);
$postKetiga->tags()->sync([$testing->id]);
Post::factory()->draft()->create([
'user_id' => $budi->id,
'category_id' => $tutorial->id,
'title' => 'Draf: Rencana Artikel Berikutnya',
'slug' => 'draf-rencana-artikel-berikutnya',
'body' => 'Artikel ini masih dikerjakan dan belum boleh dibaca '
. 'pengunjung. Hanya Budi yang bisa melihatnya di daftar '
. 'artikel setelah login.',
'published_at' => null,
]);
}
}
routes/console.php
Unduh berkas<?php
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
routes/web.php
Unduh berkas<?php
use App\Http\Controllers\AuthController;
use App\Http\Controllers\CategoryController;
use App\Http\Controllers\PostController;
use Illuminate\Support\Facades\Route;
// Beranda diarahkan ke daftar artikel.
Route::get('/', fn () => redirect()->route('posts.index'))->name('home');
// Autentikasi penulis: hanya untuk tamu.
Route::middleware('guest')->group(function () {
Route::get('/registrasi', [AuthController::class, 'showRegistrationForm'])
->name('register');
Route::post('/registrasi', [AuthController::class, 'register'])
->name('register.store');
Route::get('/login', [AuthController::class, 'showLoginForm'])
->name('login');
Route::post('/login', [AuthController::class, 'login'])
->name('login.attempt');
});
// Keluar sesi: hanya untuk penulis yang login.
Route::post('/logout', [AuthController::class, 'logout'])
->middleware('auth')
->name('logout');
// Kategori bisa dijelajahi siapa pun.
Route::get('/kategori', [CategoryController::class, 'index'])
->name('categories.index');
Route::get('/kategori/{category}', [CategoryController::class, 'show'])
->name('categories.show');
// Menulis, mengubah, dan menghapus: khusus penulis yang login.
// Grup ini sengaja didaftarkan lebih dulu agar pola /posts/create
// tidak tertelan oleh pola /posts/{post} milik rute publik di bawah.
Route::middleware('auth')->group(function () {
Route::resource('posts', PostController::class)
->only(['create', 'store', 'edit', 'update', 'destroy']);
});
// Artikel: membaca terbuka untuk semua orang.
Route::resource('posts', PostController::class)
->only(['index', 'show']);
tests/Feature/AuthFlowTest.php
Unduh berkas<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AuthFlowTest extends TestCase
{
use RefreshDatabase;
public function test_pengunjung_bisa_mendaftar_sebagai_penulis(): void
{
$response = $this->post(route('register.store'), [
'name' => 'Budi',
'email' => '[email protected]',
'password' => 'rahasia-kuat',
'password_confirmation' => 'rahasia-kuat',
]);
$response->assertRedirect(route('posts.index'));
$this->assertAuthenticated();
$this->assertDatabaseHas('users', [
'name' => 'Budi',
'email' => '[email protected]',
]);
}
public function test_registrasi_dengan_email_duplikat_ditolak(): void
{
User::factory()->create(['email' => '[email protected]']);
$response = $this->post(route('register.store'), [
'name' => 'Budi Dua',
'email' => '[email protected]',
'password' => 'rahasia-kuat',
'password_confirmation' => 'rahasia-kuat',
]);
$response->assertSessionHasErrors('email');
$this->assertGuest();
}
public function test_penulis_bisa_login_dengan_kredensial_benar(): void
{
$user = User::factory()->create([
'email' => '[email protected]',
'password' => 'rahasia-kuat',
]);
$response = $this->post(route('login.attempt'), [
'email' => '[email protected]',
'password' => 'rahasia-kuat',
]);
$response->assertRedirect(route('posts.index'));
$this->assertAuthenticatedAs($user);
}
public function test_login_dengan_kata_sandi_salah_gagal(): void
{
User::factory()->create(['email' => '[email protected]']);
$response = $this->post(route('login.attempt'), [
'email' => '[email protected]',
'password' => 'kata-sandi-salah',
]);
$response->assertSessionHasErrors('email');
$this->assertGuest();
}
public function test_penulis_bisa_logout(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post(route('logout'));
$response->assertRedirect(route('posts.index'));
$this->assertGuest();
}
public function test_halaman_tulis_menolak_tamu(): void
{
$response = $this->get(route('posts.create'));
$response->assertRedirect(route('login'));
$this->assertGuest();
}
}
tests/Feature/ExampleTest.php
Unduh berkas<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
use RefreshDatabase;
public function test_beranda_mengarah_ke_daftar_artikel(): void
{
$response = $this->get('/');
$response->assertRedirect(route('posts.index'));
}
}
tests/Feature/PolicyTest.php
Unduh berkas<?php
namespace Tests\Feature;
use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PolicyTest extends TestCase
{
use RefreshDatabase;
public function test_artikel_terbit_bisa_dibaca_siapa_saja(): void
{
$post = Post::factory()->create(['slug' => 'artikel-terbit']);
$this->get(route('posts.show', $post))->assertOk();
}
public function test_draf_tidak_bisa_dibaca_tamu(): void
{
$post = Post::factory()->draft()->create();
$this->get(route('posts.show', $post))->assertForbidden();
}
public function test_draf_hanya_bisa_dibaca_pemiliknya(): void
{
$budi = User::factory()->create();
$post = Post::factory()->draft()->create(['user_id' => $budi->id]);
$siti = User::factory()->create();
$this->actingAs($budi)->get(route('posts.show', $post))->assertOk();
$this->actingAs($siti)->get(route('posts.show', $post))->assertForbidden();
}
public function test_penulis_tidak_bisa_mengubah_artikel_orang_lain(): void
{
$siti = User::factory()->create();
$post = Post::factory()->create(); // milik penulis lain.
$this->actingAs($siti)
->get(route('posts.edit', $post))
->assertForbidden();
}
public function test_penulis_tidak_bisa_menghapus_artikel_orang_lain(): void
{
$siti = User::factory()->create();
$post = Post::factory()->create(); // milik penulis lain.
$this->actingAs($siti)
->delete(route('posts.destroy', $post))
->assertForbidden();
$this->assertModelExists($post);
}
public function test_pemilik_bisa_membuka_form_ubah_artikel(): void
{
$budi = User::factory()->create();
$post = Post::factory()->create(['user_id' => $budi->id]);
$this->actingAs($budi)
->get(route('posts.edit', $post))
->assertOk()
->assertSeeText('Ubah Artikel');
}
public function test_tombol_ubah_hanya_tampil_bagi_pemilik(): void
{
$post = Post::factory()->create(['slug' => 'artikel-budi']);
$siti = User::factory()->create();
$this->get(route('posts.show', $post))
->assertDontSeeText('Ubah Artikel');
$this->actingAs($siti)
->get(route('posts.show', $post))
->assertDontSeeText('Ubah Artikel');
}
}
tests/Feature/PostCrudTest.php
Unduh berkas<?php
namespace Tests\Feature;
use App\Models\Category;
use App\Models\Post;
use App\Models\Tag;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PostCrudTest extends TestCase
{
use RefreshDatabase;
public function test_tamu_hanya_melihat_artikel_terbit(): void
{
$terbit = Post::factory()->create(['title' => 'Belajar Eloquent dari Nol']);
$draf = Post::factory()->draft()->create(['title' => 'Rencana Rahasia']);
$response = $this->get(route('posts.index'));
$response->assertOk()
->assertSeeText('Belajar Eloquent dari Nol')
->assertDontSeeText('Rencana Rahasia');
}
public function test_tamu_diarahkan_ke_login_saat_hendak_menulis(): void
{
$response = $this->get(route('posts.create'));
$response->assertRedirect(route('login'));
}
public function test_penulis_bisa_membuka_form_tulis(): void
{
$user = User::factory()->create();
Category::factory()->create(['name' => 'Laravel']);
Tag::factory()->create(['name' => 'Eloquent']);
$response = $this->actingAs($user)->get(route('posts.create'));
$response->assertOk()
->assertSeeText('Tulis Artikel Baru')
->assertSeeText('Laravel')
->assertSeeText('Eloquent');
}
public function test_penulis_bisa_menyimpan_artikel_baru(): void
{
$user = User::factory()->create();
$category = Category::factory()->create();
$tag = Tag::factory()->create();
$response = $this->actingAs($user)->post(route('posts.store'), [
'title' => 'Tulisan Pertama Saya',
'body' => 'Ini isi artikel pertama saya di aplikasi blog.',
'category_id' => $category->id,
'tags' => [$tag->id],
'published_at' => '2026-10-12 08:00:00',
]);
$response->assertRedirect(route('posts.show', 'tulisan-pertama-saya'));
$this->assertDatabaseHas('posts', [
'title' => 'Tulisan Pertama Saya',
'slug' => 'tulisan-pertama-saya',
'user_id' => $user->id,
]);
$this->assertDatabaseHas('post_tag', [
'post_id' => Post::where('slug', 'tulisan-pertama-saya')->value('id'),
'tag_id' => $tag->id,
]);
}
public function test_slug_duplikat_otomatis_diberi_akhiran(): void
{
$user = User::factory()->create();
$category = Category::factory()->create();
Post::factory()->create([
'title' => 'Tulisan Pertama',
'slug' => 'tulisan-pertama',
'user_id' => $user->id,
'category_id' => $category->id,
]);
$response = $this->actingAs($user)->post(route('posts.store'), [
'title' => 'Tulisan Pertama',
'body' => 'Judul sama, slug harus tetap unik.',
'category_id' => $category->id,
]);
$response->assertRedirect(route('posts.show', 'tulisan-pertama-2'));
$this->assertDatabaseHas('posts', ['slug' => 'tulisan-pertama-2']);
}
public function test_artikel_tidak_valid_ditolak(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post(route('posts.store'), [
'title' => '',
'body' => '',
'category_id' => 99999,
]);
$response->assertSessionHasErrors(['title', 'body', 'category_id']);
$this->assertDatabaseCount('posts', 0);
}
public function test_penulis_bisa_memperbarui_artikel_sendiri(): void
{
$user = User::factory()->create();
$post = Post::factory()->create([
'user_id' => $user->id,
'title' => 'Judul Lama',
'slug' => 'judul-lama',
]);
$category = $post->category;
$response = $this->actingAs($user)->put(route('posts.update', $post), [
'title' => 'Judul Baru',
'body' => 'Isi artikel yang sudah diperbarui.',
'category_id' => $category->id,
]);
$response->assertRedirect(route('posts.show', 'judul-baru'));
$this->assertDatabaseHas('posts', [
'id' => $post->id,
'title' => 'Judul Baru',
'slug' => 'judul-baru',
]);
}
public function test_penulis_bisa_menghapus_artikel_sendiri(): void
{
$user = User::factory()->create();
$post = Post::factory()->create(['user_id' => $user->id]);
$tag = Tag::factory()->create();
$post->tags()->sync([$tag->id]);
$response = $this->actingAs($user)
->delete(route('posts.destroy', $post));
$response->assertRedirect(route('posts.index'));
$this->assertModelMissing($post);
$this->assertDatabaseCount('post_tag', 0);
}
}
tests/TestCase.php
Unduh berkas<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
//
}