Kita telah menempuh seluruh section inti Course Laravel, mulai dari Routing dan Request sampai Testing Laravel. Proyek ini menjadi tempat semua pengetahuan tersebut dipakai membangun satu aplikasi utuh: aplikasi blog multi-penulis dengan artikel, kategori, tag, autentikasi, proteksi kepemilikan artikel, dan rangkaian feature test yang membuktikan semuanya bekerja. Tidak ada materi baru yang diperkenalkan — proyek ini murni latihan konsolidasi, jadi siapkan chapter favorit Anda untuk dirujuk kembali.

Fitur Aplikasi #

Aplikasi yang kita bangun memiliki kemampuan berikut:

  • pengunjung membaca daftar artikel terbit dan membuka detailnya lewat URL slug,
  • pengunjung menjelajahi artikel per kategori dan melihat jumlah artikel tiap kategori,
  • penulis baru mendaftar sendiri lewat form registrasi, lalu login dan logout,
  • penulis menulis, mengubah, dan menghapus artikelnya sendiri lewat CRUD lengkap,
  • artikel boleh disimpan sebagai draf yang hanya terlihat oleh pemiliknya,
  • tiap artikel terhubung ke satu kategori dan banyak tag lewat relasi Eloquent,
  • form divalidasi menyeluruh: judul wajib, slug unik, kategori harus tersedia,
  • hanya pemilik artikel yang boleh mengubah atau menghapus — penegakan lewat policy,
  • seluruh alur utama terlindungi 23 feature test yang selalu hijau.

Tujuan Pembelajaran #

Setelah menyelesaikan proyek ini, kita mampu:

  • menerjemahkan daftar fitur menjadi tabel database, route, dan controller yang rapi,
  • merancang relasi belongsTo, hasMany, dan belongsToMany pada skema nyata,
  • memakai resource controller dengan form request terpisah untuk store dan update,
  • mengimplementasikan autentikasi manual (registrasi, login, logout) tanpa starter kit,
  • menegakkan aturan kepemilikan lewat policy dan authorization di controller serta Blade,
  • menulis feature test untuk alur utama aplikasi termasuk kasus penolakan akses.

Prasyarat #

Proyek ini memakai materi yang sudah dibahas pada section berikut:

Skema Database #

Empat tabel baru kita rancang di sekitar tabel users bawaan Laravel. Diagram berikut merangkum relasinya:

Satu artikel (posts) dimiliki satu penulis (users) dan satu categories. Hubungan artikel dengan tags adalah many-to-many melalui tabel pivot post_tag. Kolom published_at yang kosong menandai draf.

Tahapan Pengerjaan #

Proyek dikerjakan dalam tujuh bagian berurutan. Setiap bagian berdiri di atas hasil bagian sebelumnya:

  1. Bagian 1: Perancangan Aplikasi — memetakan fitur, entitas, alur pengguna, dan daftar route sebelum menulis kode.
  2. Bagian 2: Struktur Database — membuat migration, model beserta relasinya, factory, dan seeder.
  3. Bagian 3: Manajemen Artikel — membangun CRUD artikel dengan resource controller, form request, dan Blade.
  4. Bagian 4: Autentikasi Pengguna — mengimplementasikan registrasi, login, logout, dan proteksi halaman penulis.
  5. Bagian 5: Kategori dan Tag — menghubungkan artikel dengan kategori dan tag lewat relasi, select, checkbox, dan sync().
  6. Bagian 6: Validasi dan Keamanan — memperkuat form dengan validasi menyeluruh, policy kepemilikan, dan CSRF.
  7. Bagian 7: Pengujian Aplikasi — menutup proyek dengan feature test untuk seluruh alur utama.

Menjalankan Aplikasi #

Seluruh kode sumber proyek tersedia pada direktori app/, database/, resources/, routes/, dan tests/ di folder proyek ini — struktur foldernya mengikuti aplikasi Laravel sungguhan sehingga mudah dibandingkan dengan hasil kerja Anda. Aplikasi memakai SQLite dan konfigurasi bawaan .env hasil instalasi Laravel; tidak ada layanan eksternal yang dibutuhkan.

Setelah struktur proyek siap, jalankan migration beserta seeder:

bash
php artisan migrate --seed

Seeder menyediakan dua akun contoh: [email protected] dan [email protected], keduanya dengan kata sandi password. Terakhir, jalankan development server lalu buka alamat yang tertera di terminal:

bash
php artisan serve

Untuk memastikan seluruh alur utama sehat, jalankan rangkaian test yang kita bangun di Bagian 7:

bash
php artisan test

Mulailah dari Bagian 1 dan kerjakan berurutan — di bagian itulah kita memutuskan bentuk aplikasi sebelum satu baris kode pun ditulis.

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
{
    //
}