Aplikasi sudah bisa dipakai, tetapi “bisa dipakai” belum berarti “aman dipakai”. Bagian ini merapatkan tiga lapisan pertahanan yang selama ini bekerja di belakang layar: validasi menyeluruh pada semua input, kebijakan kepemilikan lewat PostPolicy, dan perlindungan CSRF pada setiap form. Kita bedah satu per satu bagaimana ketiganya bekerja dalam kode yang sudah tertanam.

Validasi Unik yang Mengenal Dirinya Sendiri #

Aturan bisnis Bagian 1 menuntut slug unik. StorePostRequest menolak slug yang sudah dipakai artikel lain lewat Rule::unique('posts', 'slug'). Masalahnya muncul di form ubah: artikel yang menyimpan slug yang sama dengan slug miliknya sendiri harus dianggap sah, bukan duplikat. Untuk itu UpdatePostRequest memakai ignore():

php
'slug' => [
    'nullable',
    'string',
    'max:255',
    'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/',
    Rule::unique('posts', 'slug')->ignore($this->route('post')),
],

$this->route('post') mengambil model hasil route model binding — instance artikel yang sedang diubah — sehingga query keunikan mengecualikan baris artikel itu sendiri. Rule exists untuk category_id dan tags.* juga ikut menjaga database: nilai category_id: 99999 yang tidak ada di tabel categories ditolak sebelum sempat menjadi foreign key rusak. Feature test membuktikannya lewat asersi:

php
$response->assertSessionHasErrors(['title', 'body', 'category_id']);

$this->assertDatabaseCount('posts', 0);

Test artikel tidak valid ditolak di Bagian 7 mengirim form kosong plus kategori fiktif — semua error tercatat di session dan tabel posts tetap kosong.

Kedua form request juga membawa method messages() untuk mengganti teks galat bawaan:

php
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.',
    ];
}

Tanpa method ini, pesan regex bawaan berbahasa teknis dan membingungkan pengguna awam. Kuncinya ada pada penulisan kunci pesan: nama field, titik, lalu nama rule — persis seperti slug.regex. Bagian atas form juga merangkum seluruh galat dalam satu blok ringkasan lewat $errors->any(), sehingga pengguna langsung tahu ada berapa kesalahan sebelum menelusuri field satu per satu.

Kepemilikan Lewat PostPolicy #

Validasi menjaga bentuk data; policy menjaga siapa yang boleh berbuat. App\Policies\PostPolicy berisi empat keputusan:

php
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));
    }

    /**
     * Hanya pemilik artikel yang boleh memperbarui.
     */
    public function update(User $user, Post $post): bool
    {
        return $user->is($post->user);
    }
}

Dua method tersisa dipangkas dari cuplikan: create() selalu mengembalikan true untuk penulis yang login, dan delete() isinya sama persis dengan update().

Parameter pertamanya bisa ?User (nullable) — Laravel otomatis meneruskan pengguna yang sedang login, atau null untuk tamu. Method update() dan delete() memakai $user->is($post->user) yang membandingkan kedua model berdasarkan primary key; cukup satu baris, tanpa membandingkan kolom manual. Karena nama class mengikuti konvensi App\Models\Post → App\Policies\PostPolicy, Laravel menemukan policy ini tanpa registrasi apa pun.

Controller memanggilnya lewat $this->authorize('update', $post) — bila policy mengembalikan false, request berhenti dengan status 403. Di sisi Blade, view posts/show.blade.php menyembunyikan tombol dengan @can:

blade
@can('update', $post)
    <p>
        <a href="{{ route('posts.edit', $post) }}">Ubah Artikel</a>
    </p>
@endcan

@can('delete', $post)
    <form method="POST" action="{{ route('posts.destroy', $post) }}"
          onsubmit="return confirm('Hapus artikel ini?');">
        @csrf
        @method('DELETE')
        <button type="submit" class="tombol tombol-bahaya">Hapus Artikel</button>
    </form>
@endcan

@can hanya menyembunyikan tombol — keamanan sebenarnya tetap dipatok di controller, karena pengguna jahat bisa saja mengirim request PUT tanpa melalui tombol.

Perhatikan pula dua jenis penolakan yang berbeda di aplikasi ini. Tamu yang membuka /posts/create ditangani middleware auth: ia belum diidentifikasi, jadi pengalihan ke halaman login masuk akal — siapa tahu ia penulis yang lupa masuk. Penulis yang sudah login lalu menyentuh artikel orang lain ditangani policy: identitas sudah jelas dan aksesnya ditolak, jadi respons 403 Forbidden yang tepat, bukan pengalihan. Membedakan keduanya membuat pesan sistem konsisten: “silakan masuk dulu” untuk yang belum dikenal, “ini bukan milik Anda” untuk yang menyalahi aturan.

CSRF pada Setiap Form #

Semua form di aplikasi — login, registrasi, simpan artikel, ubah, hapus — memuat directive @csrf yang menyisipkan token rahasia sesi ke dalam field tersembunyi. Middleware VerifyCsrfToken menolak request POST/PUT/DELETE tanpa token yang cocok. Dua langkah tambahan menutup celah: session()->regenerate() setelah login (Bagian 4) dan session()->regenerateToken() saat logout mengganti token lama.

Form hapus memakai satu trik khusus: HTML hanya mengenal GET dan POST, sementara RESTful delete butuh verb DELETE. Directive @method('DELETE') mengatasinya dengan field tersembunyi _method yang dipahami Laravel — request dikirim sebagai POST, lalu framework menerjemahkannya menjadi DELETE sebelum mencocokkan route. Ditambah dialog konfirmasi onsubmit, tombol hapus tidak bisa terpicu karena salah klik.

php artisan test --filter=PolicyTest merangkum hasil lapisan pertahanan ini:

bash
php artisan test --filter=PolicyTest

Output yang Dihasilkan:

text
  PASS  Tests\Feature\PolicyTest
  ✓ artikel terbit bisa dibaca siapa saja
  ✓ draf tidak bisa dibaca tamu
  ✓ draf hanya bisa dibaca pemiliknya
  ✓ penulis tidak bisa mengubah artikel orang lain
  ✓ penulis tidak bisa menghapus artikel orang lain
  ✓ pemilik bisa membuka form ubah artikel
  ✓ tombol ubah hanya tampil bagi pemilik

  Tests:    7 passed (11 assertions)
  Duration:  0.34s

Validasi, kepemilikan, dan CSRF kini bekerja sebagai satu sistem. Tugas terakhir proyek: memastikan seluruh sistem hijau dalam satu rangkaian test — itu pekerjaan Bagian 7 .

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

app/Http/Controllers/AuthController.php

Unduh berkas
php
<?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
<?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
<?php

namespace App\Http\Controllers;

use Illuminate\Foundation\Auth\Access\AuthorizesRequests;

abstract class Controller
{
    use AuthorizesRequests;
}

app/Http/Controllers/PostController.php

Unduh berkas
php
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?php

namespace Tests;

use Illuminate\Foundation\Testing\TestCase as BaseTestCase;

abstract class TestCase extends BaseTestCase
{
    //
}