Kita telah menempuh seluruh section inti Course Laravel, mulai dari Routing dan Request sampai Testing Laravel. Proyek ini menjadi tempat semua pengetahuan tersebut dipakai membangun satu aplikasi utuh: aplikasi blog multi-penulis dengan artikel, kategori, tag, autentikasi, proteksi kepemilikan artikel, dan rangkaian feature test yang membuktikan semuanya bekerja. Tidak ada materi baru yang diperkenalkan — proyek ini murni latihan konsolidasi, jadi siapkan chapter favorit Anda untuk dirujuk kembali.
Fitur Aplikasi #
Aplikasi yang kita bangun memiliki kemampuan berikut:
- pengunjung membaca daftar artikel terbit dan membuka detailnya lewat URL slug,
- pengunjung menjelajahi artikel per kategori dan melihat jumlah artikel tiap kategori,
- penulis baru mendaftar sendiri lewat form registrasi, lalu login dan logout,
- penulis menulis, mengubah, dan menghapus artikelnya sendiri lewat CRUD lengkap,
- artikel boleh disimpan sebagai draf yang hanya terlihat oleh pemiliknya,
- tiap artikel terhubung ke satu kategori dan banyak tag lewat relasi Eloquent,
- form divalidasi menyeluruh: judul wajib, slug unik, kategori harus tersedia,
- hanya pemilik artikel yang boleh mengubah atau menghapus — penegakan lewat policy,
- seluruh alur utama terlindungi 23 feature test yang selalu hijau.
Tujuan Pembelajaran #
Setelah menyelesaikan proyek ini, kita mampu:
- menerjemahkan daftar fitur menjadi tabel database, route, dan controller yang rapi,
- merancang relasi
belongsTo,hasMany, danbelongsToManypada skema nyata, - memakai resource controller dengan form request terpisah untuk store dan update,
- mengimplementasikan autentikasi manual (registrasi, login, logout) tanpa starter kit,
- menegakkan aturan kepemilikan lewat policy dan authorization di controller serta Blade,
- menulis feature test untuk alur utama aplikasi termasuk kasus penolakan akses.
Prasyarat #
Proyek ini memakai materi yang sudah dibahas pada section berikut:
- Routing dan Request — resource route, route groups, named routes,
- Controller — resource controller dan separation of concerns,
- View dan Blade
— layout, partial,
@error,old(),@can, - Database dan Migrations — kolom, foreign key, seeder, factory,
- Eloquent ORM — mass assignment, query scope, pagination,
- Eloquent Relationships — relasi dan pivot table,
- Validasi dan Form
— form request dan rule
unique/exists, - Authentication dan Authorization — sesi login dan policy,
- Testing Laravel — feature test dan database testing.
Skema Database #
Empat tabel baru kita rancang di sekitar tabel users bawaan Laravel. Diagram berikut merangkum relasinya:
Satu artikel (posts) dimiliki satu penulis (users) dan satu categories. Hubungan artikel dengan tags adalah many-to-many melalui tabel pivot post_tag. Kolom published_at yang kosong menandai draf.
Tahapan Pengerjaan #
Proyek dikerjakan dalam tujuh bagian berurutan. Setiap bagian berdiri di atas hasil bagian sebelumnya:
- Bagian 1: Perancangan Aplikasi — memetakan fitur, entitas, alur pengguna, dan daftar route sebelum menulis kode.
- Bagian 2: Struktur Database — membuat migration, model beserta relasinya, factory, dan seeder.
- Bagian 3: Manajemen Artikel — membangun CRUD artikel dengan resource controller, form request, dan Blade.
- Bagian 4: Autentikasi Pengguna — mengimplementasikan registrasi, login, logout, dan proteksi halaman penulis.
- Bagian 5: Kategori dan Tag
— menghubungkan artikel dengan kategori dan tag lewat relasi, select, checkbox, dan
sync(). - Bagian 6: Validasi dan Keamanan — memperkuat form dengan validasi menyeluruh, policy kepemilikan, dan CSRF.
- Bagian 7: Pengujian Aplikasi — menutup proyek dengan feature test untuk seluruh alur utama.
Menjalankan Aplikasi #
Seluruh kode sumber proyek tersedia pada direktori app/, database/, resources/, routes/, dan tests/ di folder proyek ini — struktur foldernya mengikuti aplikasi Laravel sungguhan sehingga mudah dibandingkan dengan hasil kerja Anda. Aplikasi memakai SQLite dan konfigurasi bawaan .env hasil instalasi Laravel; tidak ada layanan eksternal yang dibutuhkan.
Setelah struktur proyek siap, jalankan migration beserta seeder:
php artisan migrate --seedSeeder menyediakan dua akun contoh: [email protected] dan [email protected], keduanya dengan kata sandi password. Terakhir, jalankan development server lalu buka alamat yang tertera di terminal:
php artisan serveUntuk memastikan seluruh alur utama sehat, jalankan rangkaian test yang kita bangun di Bagian 7:
php artisan testMulailah dari Bagian 1 dan kerjakan berurutan — di bagian itulah kita memutuskan bentuk aplikasi sebelum satu baris kode pun ditulis.
Tetapkan fitur, entitas, dan alur aplikasi blog sebelum menulis kode.
Buka → # Bagian 2: Struktur DatabaseRancang migration tabel users, posts, categories, tags, dan pivot-nya.
Buka → # Bagian 3: Manajemen ArtikelBangun CRUD artikel dengan resource controller, form request, dan Blade.
Buka → # Bagian 4: Autentikasi PenggunaImplementasikan registrasi, login, logout, dan proteksi halaman penulis.
Buka → # Bagian 5: Kategori dan TagHubungkan artikel dengan kategori dan tag lewat relasi dan form bersarang.
Buka → # Bagian 6: Validasi dan KeamananPerkuat form dengan validasi menyeluruh, authorization ownership, dan CSRF.
Buka → # Bagian 7: Pengujian AplikasiTutup proyek dengan feature test untuk alur utama aplikasi blog.
Buka →Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
app/Http/Controllers/AuthController.php
Unduh berkas<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class AuthController extends Controller
{
/**
* Tampilkan form login.
*/
public function showLoginForm(): View
{
return view('auth.login');
}
/**
* Proses percobaan login penulis.
*/
public function login(Request $request): RedirectResponse
{
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
if (Auth::attempt($credentials)) {
$request->session()->regenerate();
return redirect()->intended(route('posts.index'));
}
return back()
->withErrors(['email' => 'Email atau kata sandi salah.'])
->onlyInput('email');
}
/**
* Keluar dari sesi penulis.
*/
public function logout(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('posts.index');
}
/**
* Tampilkan form registrasi penulis baru.
*/
public function showRegistrationForm(): View
{
return view('auth.register');
}
/**
* Simpan penulis baru lalu langsung login.
*/
public function register(Request $request): RedirectResponse
{
$credentials = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
'password' => ['required', 'string', 'min:8', 'confirmed'],
]);
$user = User::create($credentials);
Auth::login($user);
$request->session()->regenerate();
return redirect()
->route('posts.index')
->with('status', 'Registrasi berhasil. Selamat menulis!');
}
}
app/Http/Controllers/CategoryController.php
Unduh berkas<?php
namespace App\Http\Controllers;
use App\Models\Category;
use Illuminate\View\View;
class CategoryController extends Controller
{
/**
* Daftar seluruh kategori beserta jumlah artikelnya.
*/
public function index(): View
{
$categories = Category::query()
->withCount('posts')
->orderBy('name')
->get();
return view('categories.index', ['categories' => $categories]);
}
/**
* Daftar artikel terbit di dalam satu kategori.
*/
public function show(Category $category): View
{
$posts = $category->posts()
->with(['user', 'category', 'tags'])
->published()
->latest('id')
->paginate(10);
return view('categories.show', [
'category' => $category,
'posts' => $posts,
]);
}
}
app/Http/Controllers/Controller.php
Unduh berkas<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller
{
use AuthorizesRequests;
}
app/Http/Controllers/PostController.php
Unduh berkas<?php
namespace App\Http\Controllers;
use App\Http\Requests\StorePostRequest;
use App\Http\Requests\UpdatePostRequest;
use App\Models\Category;
use App\Models\Post;
use App\Models\Tag;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\View\View;
class PostController extends Controller
{
/**
* Daftar artikel: terbit untuk semua, draf hanya saat login.
*/
public function index(Request $request): View
{
$posts = Post::with(['user', 'category', 'tags'])
->when(
$request->user() === null,
fn ($query) => $query->published()
)
->latest('id')
->paginate(10);
return view('posts.index', ['posts' => $posts]);
}
/**
* Detail satu artikel.
*/
public function show(Post $post): View
{
$this->authorize('view', $post);
return view('posts.show', [
'post' => $post->load(['user', 'category', 'tags']),
]);
}
/**
* Form tulis artikel baru.
*/
public function create(): View
{
return view('posts.create', [
'post' => new Post(),
'categories' => Category::orderBy('name')->get(),
'tags' => Tag::orderBy('name')->get(),
]);
}
/**
* Simpan artikel baru.
*/
public function store(StorePostRequest $request): RedirectResponse
{
$data = collect($request->validated())->except('tags')->all();
$data['slug'] = $data['slug'] ?? $this->uniqueSlug($data['title']);
$post = $request->user()->posts()->create($data);
$post->tags()->sync($request->validated('tags', []));
return redirect()
->route('posts.show', $post)
->with('status', 'Artikel berhasil disimpan.');
}
/**
* Form ubah artikel milik penulis.
*/
public function edit(Post $post): View
{
$this->authorize('update', $post);
return view('posts.edit', [
'post' => $post,
'categories' => Category::orderBy('name')->get(),
'tags' => Tag::orderBy('name')->get(),
]);
}
/**
* Perbarui artikel milik penulis.
*/
public function update(UpdatePostRequest $request, Post $post): RedirectResponse
{
$this->authorize('update', $post);
$data = collect($request->validated())->except('tags')->all();
$data['slug'] = $data['slug'] ?? $this->uniqueSlug($data['title'], $post->id);
$post->update($data);
$post->tags()->sync($request->validated('tags', []));
return redirect()
->route('posts.show', $post)
->with('status', 'Artikel berhasil diperbarui.');
}
/**
* Hapus artikel milik penulis.
*/
public function destroy(Post $post): RedirectResponse
{
$this->authorize('delete', $post);
$post->delete();
return redirect()
->route('posts.index')
->with('status', 'Artikel berhasil dihapus.');
}
/**
* Buat slug unik dari judul, dengan akhiran nomor bila sudah dipakai.
*/
private function uniqueSlug(string $title, ?int $ignoreId = null): string
{
$slug = Str::slug($title);
$original = $slug;
$suffix = 2;
while (Post::query()
->when($ignoreId, fn ($query) => $query->where('id', '!=', $ignoreId))
->where('slug', $slug)
->exists()) {
$slug = "{$original}-{$suffix}";
$suffix++;
}
return $slug;
}
}
app/Http/Requests/StorePostRequest.php
Unduh berkas<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StorePostRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Validation\Rule|array|string>
*/
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'slug' => [
'nullable',
'string',
'max:255',
'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/',
Rule::unique('posts', 'slug'),
],
'body' => ['required', 'string'],
'category_id' => ['required', 'integer', Rule::exists('categories', 'id')],
'tags' => ['nullable', 'array'],
'tags.*' => ['integer', Rule::exists('tags', 'id')],
'published_at' => ['nullable', 'date'],
];
}
/**
* Get the error messages for the defined validation rules.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'slug.regex' => 'Slug hanya boleh berisi huruf kecil, angka, dan tanda hubung.',
'category_id.exists' => 'Kategori yang dipilih tidak tersedia.',
];
}
}
app/Http/Requests/UpdatePostRequest.php
Unduh berkas<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdatePostRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Validation\Rule|array|string>
*/
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'slug' => [
'nullable',
'string',
'max:255',
'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/',
Rule::unique('posts', 'slug')->ignore($this->route('post')),
],
'body' => ['required', 'string'],
'category_id' => ['required', 'integer', Rule::exists('categories', 'id')],
'tags' => ['nullable', 'array'],
'tags.*' => ['integer', Rule::exists('tags', 'id')],
'published_at' => ['nullable', 'date'],
];
}
/**
* Get the error messages for the defined validation rules.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'slug.regex' => 'Slug hanya boleh berisi huruf kecil, angka, dan tanda hubung.',
'category_id.exists' => 'Kategori yang dipilih tidak tersedia.',
];
}
}
app/Models/Category.php
Unduh berkas<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Category extends Model
{
/** @use HasFactory<\Database\Factories\CategoryFactory> */
use HasFactory;
protected $fillable = ['name', 'slug'];
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
public function getRouteKeyName(): string
{
return 'slug';
}
}
app/Models/Post.php
Unduh berkas<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Post extends Model
{
/** @use HasFactory<\Database\Factories\PostFactory> */
use HasFactory;
protected $fillable = [
'user_id',
'category_id',
'title',
'slug',
'body',
'published_at',
];
protected function casts(): array
{
return [
'published_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class);
}
public function scopePublished(Builder $query): Builder
{
return $query->whereNotNull('published_at')
->where('published_at', '<=', now());
}
public function getRouteKeyName(): string
{
return 'slug';
}
}
app/Models/Tag.php
Unduh berkas<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Tag extends Model
{
/** @use HasFactory<\Database\Factories\TagFactory> */
use HasFactory;
protected $fillable = ['name', 'slug'];
public function posts(): BelongsToMany
{
return $this->belongsToMany(Post::class);
}
public function getRouteKeyName(): string
{
return 'slug';
}
}
app/Models/User.php
Unduh berkas<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}
app/Policies/PostPolicy.php
Unduh berkas<?php
namespace App\Policies;
use App\Models\Post;
use App\Models\User;
class PostPolicy
{
/**
* Artikel terbit boleh dibaca siapa pun; draf hanya pemiliknya.
*/
public function view(?User $user, Post $post): bool
{
return $post->published_at !== null
|| ($user !== null && $user->is($post->user));
}
/**
* Penulis yang sudah login boleh membuat artikel.
*/
public function create(User $user): bool
{
return true;
}
/**
* Hanya pemilik artikel yang boleh memperbarui.
*/
public function update(User $user, Post $post): bool
{
return $user->is($post->user);
}
/**
* Hanya pemilik artikel yang boleh menghapus.
*/
public function delete(User $user, Post $post): bool
{
return $user->is($post->user);
}
}
app/Providers/AppServiceProvider.php
Unduh berkas<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}
database/factories/CategoryFactory.php
Unduh berkas<?php
namespace Database\Factories;
use App\Models\Category;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<Category>
*/
class CategoryFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$name = fake()->unique()->words(2, true);
return [
'name' => ucfirst($name),
'slug' => Str::slug($name),
];
}
}
database/factories/PostFactory.php
Unduh berkas<?php
namespace Database\Factories;
use App\Models\Category;
use App\Models\Post;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<Post>
*/
class PostFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$title = fake()->sentence(4, true);
return [
'user_id' => User::factory(),
'category_id' => Category::factory(),
'title' => $title,
'slug' => Str::slug($title),
'body' => fake()->paragraphs(3, true),
'published_at' => now(),
];
}
/**
* Artikel yang belum terbit.
*/
public function draft(): static
{
return $this->state(fn () => ['published_at' => null]);
}
}
database/factories/TagFactory.php
Unduh berkas<?php
namespace Database\Factories;
use App\Models\Tag;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<Tag>
*/
class TagFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$name = fake()->unique()->word();
return [
'name' => ucfirst($name),
'slug' => Str::slug($name),
];
}
}
database/factories/UserFactory.php
Unduh berkas<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}
database/migrations/0001_01_01_000000_create_users_table.php
Unduh berkas<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};
database/migrations/0001_01_01_000001_create_cache_table.php
Unduh berkas<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->bigInteger('expiration')->index();
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->bigInteger('expiration')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};
database/migrations/0001_01_01_000002_create_jobs_table.php
Unduh berkas<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedSmallInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->string('connection');
$table->string('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
$table->index(['connection', 'queue', 'failed_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};
database/migrations/2026_10_13_153049_create_personal_access_tokens_table.php
Unduh berkas<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
$table->text('name');
$table->string('token', 64)->unique();
$table->text('abilities')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('expires_at')->nullable()->index();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('personal_access_tokens');
}
};
database/migrations/2026_10_13_231411_create_categories_table.php
Unduh berkas<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->string('slug')->unique();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('categories');
}
};
database/migrations/2026_10_13_231412_create_tags_table.php
Unduh berkas<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('tags', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->string('slug')->unique();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('tags');
}
};
database/migrations/2026_10_13_231413_create_posts_table.php
Unduh berkas<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')
->constrained()
->cascadeOnDelete();
$table->foreignId('category_id')
->constrained()
->restrictOnDelete();
$table->string('title');
$table->string('slug')->unique();
$table->text('body');
$table->timestamp('published_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('posts');
}
};
database/migrations/2026_10_13_231414_create_post_tag_table.php
Unduh berkas<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('post_tag', function (Blueprint $table) {
$table->foreignId('post_id')
->constrained()
->cascadeOnDelete();
$table->foreignId('tag_id')
->constrained()
->cascadeOnDelete();
$table->primary(['post_id', 'tag_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('post_tag');
}
};
database/seeders/DatabaseSeeder.php
Unduh berkas<?php
namespace Database\Seeders;
use App\Models\Category;
use App\Models\Post;
use App\Models\Tag;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
use WithoutModelEvents;
/**
* Seed the application's database.
*/
public function run(): void
{
$budi = User::factory()->create([
'name' => 'Budi',
'email' => '[email protected]',
]);
$siti = User::factory()->create([
'name' => 'Siti',
'email' => '[email protected]',
]);
$laravel = Category::factory()->create([
'name' => 'Laravel',
'slug' => 'laravel',
]);
$php = Category::factory()->create([
'name' => 'PHP',
'slug' => 'php',
]);
$tutorial = Category::factory()->create([
'name' => 'Tutorial',
'slug' => 'tutorial',
]);
$eloquent = Tag::factory()->create(['name' => 'Eloquent', 'slug' => 'eloquent']);
$blade = Tag::factory()->create(['name' => 'Blade', 'slug' => 'blade']);
$testing = Tag::factory()->create(['name' => 'Testing', 'slug' => 'testing']);
$postPertama = Post::factory()->create([
'user_id' => $budi->id,
'category_id' => $laravel->id,
'title' => 'Belajar Eloquent dari Nol',
'slug' => 'belajar-eloquent-dari-nol',
'body' => 'Pada artikel ini kita membahas relasi belongsTo, hasMany, '
. 'dan belongsToMany dengan contoh aplikasi blog. Eloquent membuat '
. 'kode database terasa seperti bekerja dengan objek PHP biasa.',
'published_at' => '2026-10-01 09:00:00',
]);
$postPertama->tags()->sync([$eloquent->id]);
$postKedua = Post::factory()->create([
'user_id' => $siti->id,
'category_id' => $laravel->id,
'title' => 'Tips Mengorganisir Template Blade',
'slug' => 'tips-mengorganisir-template-blade',
'body' => 'Layout, partial, dan komponen adalah tiga lapisan yang '
. 'membuat view Laravel tetap rapi. Artikel ini merangkum pola '
. 'yang saya pakai di berbagai proyek nyata.',
'published_at' => '2026-10-05 10:00:00',
]);
$postKedua->tags()->sync([$blade->id, $tutorial->id]);
$postKetiga = Post::factory()->create([
'user_id' => $budi->id,
'category_id' => $php->id,
'title' => 'Menulis Feature Test Pertama',
'slug' => 'menulis-feature-test-pertama',
'body' => 'Feature test menguji aplikasi dari luar lewat HTTP. '
. 'Kita mulai dari status kode, lalu perlahan menambah assert '
. 'untuk isi database dan session.',
'published_at' => '2026-10-10 08:00:00',
]);
$postKetiga->tags()->sync([$testing->id]);
Post::factory()->draft()->create([
'user_id' => $budi->id,
'category_id' => $tutorial->id,
'title' => 'Draf: Rencana Artikel Berikutnya',
'slug' => 'draf-rencana-artikel-berikutnya',
'body' => 'Artikel ini masih dikerjakan dan belum boleh dibaca '
. 'pengunjung. Hanya Budi yang bisa melihatnya di daftar '
. 'artikel setelah login.',
'published_at' => null,
]);
}
}
routes/console.php
Unduh berkas<?php
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
routes/web.php
Unduh berkas<?php
use App\Http\Controllers\AuthController;
use App\Http\Controllers\CategoryController;
use App\Http\Controllers\PostController;
use Illuminate\Support\Facades\Route;
// Beranda diarahkan ke daftar artikel.
Route::get('/', fn () => redirect()->route('posts.index'))->name('home');
// Autentikasi penulis: hanya untuk tamu.
Route::middleware('guest')->group(function () {
Route::get('/registrasi', [AuthController::class, 'showRegistrationForm'])
->name('register');
Route::post('/registrasi', [AuthController::class, 'register'])
->name('register.store');
Route::get('/login', [AuthController::class, 'showLoginForm'])
->name('login');
Route::post('/login', [AuthController::class, 'login'])
->name('login.attempt');
});
// Keluar sesi: hanya untuk penulis yang login.
Route::post('/logout', [AuthController::class, 'logout'])
->middleware('auth')
->name('logout');
// Kategori bisa dijelajahi siapa pun.
Route::get('/kategori', [CategoryController::class, 'index'])
->name('categories.index');
Route::get('/kategori/{category}', [CategoryController::class, 'show'])
->name('categories.show');
// Menulis, mengubah, dan menghapus: khusus penulis yang login.
// Grup ini sengaja didaftarkan lebih dulu agar pola /posts/create
// tidak tertelan oleh pola /posts/{post} milik rute publik di bawah.
Route::middleware('auth')->group(function () {
Route::resource('posts', PostController::class)
->only(['create', 'store', 'edit', 'update', 'destroy']);
});
// Artikel: membaca terbuka untuk semua orang.
Route::resource('posts', PostController::class)
->only(['index', 'show']);
tests/Feature/AuthFlowTest.php
Unduh berkas<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AuthFlowTest extends TestCase
{
use RefreshDatabase;
public function test_pengunjung_bisa_mendaftar_sebagai_penulis(): void
{
$response = $this->post(route('register.store'), [
'name' => 'Budi',
'email' => '[email protected]',
'password' => 'rahasia-kuat',
'password_confirmation' => 'rahasia-kuat',
]);
$response->assertRedirect(route('posts.index'));
$this->assertAuthenticated();
$this->assertDatabaseHas('users', [
'name' => 'Budi',
'email' => '[email protected]',
]);
}
public function test_registrasi_dengan_email_duplikat_ditolak(): void
{
User::factory()->create(['email' => '[email protected]']);
$response = $this->post(route('register.store'), [
'name' => 'Budi Dua',
'email' => '[email protected]',
'password' => 'rahasia-kuat',
'password_confirmation' => 'rahasia-kuat',
]);
$response->assertSessionHasErrors('email');
$this->assertGuest();
}
public function test_penulis_bisa_login_dengan_kredensial_benar(): void
{
$user = User::factory()->create([
'email' => '[email protected]',
'password' => 'rahasia-kuat',
]);
$response = $this->post(route('login.attempt'), [
'email' => '[email protected]',
'password' => 'rahasia-kuat',
]);
$response->assertRedirect(route('posts.index'));
$this->assertAuthenticatedAs($user);
}
public function test_login_dengan_kata_sandi_salah_gagal(): void
{
User::factory()->create(['email' => '[email protected]']);
$response = $this->post(route('login.attempt'), [
'email' => '[email protected]',
'password' => 'kata-sandi-salah',
]);
$response->assertSessionHasErrors('email');
$this->assertGuest();
}
public function test_penulis_bisa_logout(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post(route('logout'));
$response->assertRedirect(route('posts.index'));
$this->assertGuest();
}
public function test_halaman_tulis_menolak_tamu(): void
{
$response = $this->get(route('posts.create'));
$response->assertRedirect(route('login'));
$this->assertGuest();
}
}
tests/Feature/ExampleTest.php
Unduh berkas<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
use RefreshDatabase;
public function test_beranda_mengarah_ke_daftar_artikel(): void
{
$response = $this->get('/');
$response->assertRedirect(route('posts.index'));
}
}
tests/Feature/PolicyTest.php
Unduh berkas<?php
namespace Tests\Feature;
use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PolicyTest extends TestCase
{
use RefreshDatabase;
public function test_artikel_terbit_bisa_dibaca_siapa_saja(): void
{
$post = Post::factory()->create(['slug' => 'artikel-terbit']);
$this->get(route('posts.show', $post))->assertOk();
}
public function test_draf_tidak_bisa_dibaca_tamu(): void
{
$post = Post::factory()->draft()->create();
$this->get(route('posts.show', $post))->assertForbidden();
}
public function test_draf_hanya_bisa_dibaca_pemiliknya(): void
{
$budi = User::factory()->create();
$post = Post::factory()->draft()->create(['user_id' => $budi->id]);
$siti = User::factory()->create();
$this->actingAs($budi)->get(route('posts.show', $post))->assertOk();
$this->actingAs($siti)->get(route('posts.show', $post))->assertForbidden();
}
public function test_penulis_tidak_bisa_mengubah_artikel_orang_lain(): void
{
$siti = User::factory()->create();
$post = Post::factory()->create(); // milik penulis lain.
$this->actingAs($siti)
->get(route('posts.edit', $post))
->assertForbidden();
}
public function test_penulis_tidak_bisa_menghapus_artikel_orang_lain(): void
{
$siti = User::factory()->create();
$post = Post::factory()->create(); // milik penulis lain.
$this->actingAs($siti)
->delete(route('posts.destroy', $post))
->assertForbidden();
$this->assertModelExists($post);
}
public function test_pemilik_bisa_membuka_form_ubah_artikel(): void
{
$budi = User::factory()->create();
$post = Post::factory()->create(['user_id' => $budi->id]);
$this->actingAs($budi)
->get(route('posts.edit', $post))
->assertOk()
->assertSeeText('Ubah Artikel');
}
public function test_tombol_ubah_hanya_tampil_bagi_pemilik(): void
{
$post = Post::factory()->create(['slug' => 'artikel-budi']);
$siti = User::factory()->create();
$this->get(route('posts.show', $post))
->assertDontSeeText('Ubah Artikel');
$this->actingAs($siti)
->get(route('posts.show', $post))
->assertDontSeeText('Ubah Artikel');
}
}
tests/Feature/PostCrudTest.php
Unduh berkas<?php
namespace Tests\Feature;
use App\Models\Category;
use App\Models\Post;
use App\Models\Tag;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PostCrudTest extends TestCase
{
use RefreshDatabase;
public function test_tamu_hanya_melihat_artikel_terbit(): void
{
$terbit = Post::factory()->create(['title' => 'Belajar Eloquent dari Nol']);
$draf = Post::factory()->draft()->create(['title' => 'Rencana Rahasia']);
$response = $this->get(route('posts.index'));
$response->assertOk()
->assertSeeText('Belajar Eloquent dari Nol')
->assertDontSeeText('Rencana Rahasia');
}
public function test_tamu_diarahkan_ke_login_saat_hendak_menulis(): void
{
$response = $this->get(route('posts.create'));
$response->assertRedirect(route('login'));
}
public function test_penulis_bisa_membuka_form_tulis(): void
{
$user = User::factory()->create();
Category::factory()->create(['name' => 'Laravel']);
Tag::factory()->create(['name' => 'Eloquent']);
$response = $this->actingAs($user)->get(route('posts.create'));
$response->assertOk()
->assertSeeText('Tulis Artikel Baru')
->assertSeeText('Laravel')
->assertSeeText('Eloquent');
}
public function test_penulis_bisa_menyimpan_artikel_baru(): void
{
$user = User::factory()->create();
$category = Category::factory()->create();
$tag = Tag::factory()->create();
$response = $this->actingAs($user)->post(route('posts.store'), [
'title' => 'Tulisan Pertama Saya',
'body' => 'Ini isi artikel pertama saya di aplikasi blog.',
'category_id' => $category->id,
'tags' => [$tag->id],
'published_at' => '2026-10-12 08:00:00',
]);
$response->assertRedirect(route('posts.show', 'tulisan-pertama-saya'));
$this->assertDatabaseHas('posts', [
'title' => 'Tulisan Pertama Saya',
'slug' => 'tulisan-pertama-saya',
'user_id' => $user->id,
]);
$this->assertDatabaseHas('post_tag', [
'post_id' => Post::where('slug', 'tulisan-pertama-saya')->value('id'),
'tag_id' => $tag->id,
]);
}
public function test_slug_duplikat_otomatis_diberi_akhiran(): void
{
$user = User::factory()->create();
$category = Category::factory()->create();
Post::factory()->create([
'title' => 'Tulisan Pertama',
'slug' => 'tulisan-pertama',
'user_id' => $user->id,
'category_id' => $category->id,
]);
$response = $this->actingAs($user)->post(route('posts.store'), [
'title' => 'Tulisan Pertama',
'body' => 'Judul sama, slug harus tetap unik.',
'category_id' => $category->id,
]);
$response->assertRedirect(route('posts.show', 'tulisan-pertama-2'));
$this->assertDatabaseHas('posts', ['slug' => 'tulisan-pertama-2']);
}
public function test_artikel_tidak_valid_ditolak(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post(route('posts.store'), [
'title' => '',
'body' => '',
'category_id' => 99999,
]);
$response->assertSessionHasErrors(['title', 'body', 'category_id']);
$this->assertDatabaseCount('posts', 0);
}
public function test_penulis_bisa_memperbarui_artikel_sendiri(): void
{
$user = User::factory()->create();
$post = Post::factory()->create([
'user_id' => $user->id,
'title' => 'Judul Lama',
'slug' => 'judul-lama',
]);
$category = $post->category;
$response = $this->actingAs($user)->put(route('posts.update', $post), [
'title' => 'Judul Baru',
'body' => 'Isi artikel yang sudah diperbarui.',
'category_id' => $category->id,
]);
$response->assertRedirect(route('posts.show', 'judul-baru'));
$this->assertDatabaseHas('posts', [
'id' => $post->id,
'title' => 'Judul Baru',
'slug' => 'judul-baru',
]);
}
public function test_penulis_bisa_menghapus_artikel_sendiri(): void
{
$user = User::factory()->create();
$post = Post::factory()->create(['user_id' => $user->id]);
$tag = Tag::factory()->create();
$post->tags()->sync([$tag->id]);
$response = $this->actingAs($user)
->delete(route('posts.destroy', $post));
$response->assertRedirect(route('posts.index'));
$this->assertModelMissing($post);
$this->assertDatabaseCount('post_tag', 0);
}
}
tests/TestCase.php
Unduh berkas<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
//
}