Menulis kode begitu ide muncul memang menggoda, tetapi aplikasi sebesar blog multi-penulis akan cepat berantakan tanpa rancangan. Pada bagian ini kita tidak menulis satu baris kode pun — kita justru memutuskan tiga hal: fitur apa saja yang dibangun, data apa yang disimpan, dan URL apa yang dibuka untuk pengguna. Keputusan kecil di atas kertas ini yang nantinya menentukan bentuk migration di Bagian 2 dan controller di Bagian 3.

Daftar Fitur dan Entitas #

Dari ringkasan fitur proyek, kita menarik setiap kata benda menjadi kandidat entitas database:

FiturEntitas terkaitTabel
Penulis mendaftar dan loginpenulisusers
Penulis menulis artikelartikelposts
Artikel dikelompokkan per topikkategoricategories
Artikel diberi label bebastagtags
Artikel dipakai banyak tagrelasi many-to-manypost_tag (pivot)

Tabel users tidak kita buat sendiri karena Laravel sudah menyediakannya sejak instalasi — kita hanya menambahkan relasi posts() pada model User. Tabel post_tag murni berisi dua foreign key sebagai primary key gabungan, tanpa kolom tambahan, karena kita tidak menyimpan atribut apa pun pada hubungan artikel-tag.

Aturan Bisnis #

Supaya tidak ada perbedaan tafsir saat mulai menulis kode, kita tetapkan dulu empat aturan bisnis utama:

  • setiap artikel wajib dimiliki tepat satu penulis dan satu kategori,
  • slug artikel unik dan hanya boleh berisi huruf kecil, angka, serta tanda hubung,
  • artikel dengan published_at kosong adalah draf: hanya pemiliknya yang boleh melihatnya,
  • hanya pemilik artikel yang boleh mengubah dan menghapus artikelnya.

Aturan ketiga dan keempat kelak menjadi dua keputusan paling penting: yang pertama menentukan query scope published() pada Bagian 2, yang kedua menentukan isi PostPolicy di Bagian 6.

Mengapa aturan ditulis sebagai daftar pendek, bukan paragraf panjang? Karena setiap barisnya akan berubah menjadi satu artefak kode yang bisa diperiksa: aturan pertama menjadi foreign key user_id dan category_id di migration, aturan kedua menjadi unique index plus regex validasi, dan seterusnya. Daftar seperti ini juga berfungsi sebagai daftar periksa saat proyek berakhir — jika ada satu baris yang tidak punya padanan kode, berarti ada fitur yang luput.

Alur Pengguna #

Aplikasi memiliki dua peran pengguna dengan jalur berbeda. Pengunjung tanpa akun hanya bisa membaca: daftar artikel terbit, detail artikel, dan daftar kategori. Penulis yang sudah login membuka jalur tambahan untuk mengelola artikelnya sendiri.

Perhatikan bahwa tidak ada dashboard khusus: daftar artikel yang sama berfungsi ganda — pengunjung hanya melihat artikel terbit, sedangkan penulis yang login juga melihat draf miliknya sendiri ditandai lencana “Draf”. Keputusan ini menghemat satu halaman penuh sekaligus memberi latihan kondisi tampil di Blade.

Peta Route #

Terakhir, kita petakan URL yang akan dibuka. Nama route kita tentukan sejak sekarang agar controller dan view bisa memanggilnya dengan route() tanpa berubah-ubah:

MethodURINama routeAkses
GET/homepublik, alihkan ke posts.index
GET/postsposts.indexpublik
GET/posts/{post}posts.showpublik untuk artikel terbit
GET/POST/registrasiregister, register.storetamu
GET/POST/loginlogin, login.attempttamu
POST/logoutlogoutlogin
GET/posts/create dst.posts.create dst.login

Lima aksi terakhir (create, store, edit, update, destroy) kita serahkan pada satu Route::resource('posts', ...) yang dibagi dua grup: aksi baca untuk publik, aksi tulis untuk penulis yang login. URI kita pakai gaya Indonesia untuk halaman pengguna (/login, /registrasi, /kategori) — seleranya bebas, yang penting nama route konsisten.

Memetakan ke Struktur Laravel #

Terakhir, setiap keputusan di atas kita tempatkan ke rumahnya masing-masing dalam struktur aplikasi Laravel:

ArtefakLokasiMuncul di
Migration posts, post_tag, dll.database/migrations/Bagian 2
Model Post, Category, Tagapp/Models/Bagian 2
PostController, AuthController, CategoryControllerapp/Http/Controllers/Bagian 3–5
StorePostRequest, UpdatePostRequestapp/Http/Requests/Bagian 3 dan 6
PostPolicyapp/Policies/Bagian 6
Layout, form, halaman authresources/views/Bagian 3–5
Route definitionroutes/web.phpBagian 3
Feature testtests/Feature/Bagian 7

Pemisahan ini bukan formalitas. Form request terpisah menjaga controller ramping, policy terpisah membuat aturan kepemilikan bisa dipanggil ulang dari controller maupun Blade, dan partial form yang dipakai bersama mencegah duplikasi antara form tulis dan form ubah. Kita juga sengaja membatasi lingkup: komentar, upload gambar, dan pencarian artikel tidak masuk proyek ini — bukan karena tidak penting, melainkan agar tujuh bagian tetap fokus pada pola inti Laravel.

Dengan fitur, entitas, aturan, alur, dan route yang sudah dipaku, kita siap menuju Bagian 2 untuk menterjemahkan semuanya menjadi migration dan model pertama.

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