CRUD artikel dari Bagian 3 terlindung middleware auth, tetapi belum ada satu pun cara untuk membuat akun. Bagian ini menutup celah itu dengan AuthController: satu controller kecil yang menangani registrasi, login, dan logout secara manual — tanpa starter kit — supaya kita memahami setiap baris yang berjalan. Kita juga memakai middleware guest agar penulis yang sudah login tidak melihat form login lagi.

Route Autentikasi #

Empat route autentikasi kita daftarkan dalam grup guest; logout sendirian dalam grup auth:

php
// 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');

Nama route login sengaja dipertahankan dalam bahasa Inggris karena middleware auth bawaan Laravel mengalihkan tamu ke route('login') — mengubah nama ini membuat pengalihan gagal. Hasil route:list untuk path login:

bash
php artisan route:list --path=login

Output yang Dihasilkan:

text
  GET|HEAD   login ...................... login › AuthController@showLoginForm
  POST       login ................. login.attempt › AuthController@login

Registrasi dan Login #

Method register() menerima data, memvalidasi, lalu langsung mengangkat user baru sebagai penulis yang login:

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

Rule confirmed membaca input password_confirmation dari form dan menolak bila tidak sama. Password tidak perlu di-hash manual — cast 'password' => 'hashed' pada model User bawaan Laravel mengurusnya saat mass assignment. Method login() lalu memeriksa kredensial dan mengurus sesi:

php
if (Auth::attempt($credentials)) {
    $request->session()->regenerate();

    return redirect()->intended(route('posts.index'));
}

return back()
    ->withErrors(['email' => 'Email atau kata sandi salah.'])
    ->onlyInput('email');

Dua hal layak diperhatikan. Auth::attempt() mengembalikan true atau false, jadi kegagalan cukup ditangani if — kita tidak perlu membandingkan hash sendiri. Setelah berhasil, session()->regenerate() mengganti ID sesi guna mencegah session fixation; redirect()->intended() mengantar penulis kembali ke halaman yang tadinya hendak dibuka sebelum dihalau ke login. Pesan galat sengaja digabur menjadi satu (“Email atau kata sandi salah.”) supaya tidak membocorkkan alamat email mana yang terdaftar.

Logout adalah kebalikannya — keluar, matikan sesi, ganti token CSRF:

php
public function logout(Request $request): RedirectResponse
{
    Auth::logout();

    $request->session()->invalidate();
    $request->session()->regenerateToken();

    return redirect()->route('posts.index');
}

invalidate() menghapus seluruh data sesi, sedangkan regenerateToken() mengganti token CSRF lama. Layout resources/views/layouts/app.blade.php merespons status login lewat direktif @auth/@guest: menu kanan berganti antara tautan Masuk–Registrasi dan tombol Tulis Artikel–Keluar.

Satu pertanyaan yang sering muncul: mengapa form login dan aksi login berada dalam grup guest, padahal proses login justru dilakukan tamu? Middleware berjalan sebelum controller — saat request POST /login tiba, pengguna memang masih tamu, jadi grup guest meloloskannya; pengecekan kredensial baru terjadi di dalam controller. Sebaliknya bila penulis yang sudah login mencoba membuka /login lagi, middleware guest mengalihkannya ke rute bernama home (beranda kita) sebelum form sempat dirender. Kombinasi auth pada grup aksi tulis dan guest pada grup form login inilah yang membuat kedua dunia itu tidak pernah bertabrakan.

Di sisi tampilan, kedua view auth memakai pola yang sama dengan form artikel: @csrf wajib ada, old('email') mengembalikan alamat email yang sudah diketik saat password salah, dan onlyInput('email') pada pengalihan galat memastikan kata sandi tidak pernah dikirim kembali ke browser. Kedua halaman juga saling menautkan satu sama lain — “Belum punya akun?” dan “Sudah punya akun?” — supaya pengunjung baru tidak menemui jalan buntu.

Membuktikan Alurnya #

Feature test AuthFlowTest menguji enam skenario. Contohnya, pendaftaran penulis baru:

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

Aserasi assertAuthenticated() membuktikan user baru langsung login tanpa perlu mengisi form login lagi. Alur autentikasi selesai — penulis kini bisa masuk dan keluar. Bagian 5 melengkapi form tulis dengan pemilihan kategori dan tag.

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