Sebuah proyek belum selesai sebelum perilakunya terkunci oleh test. Bagian penutup ini merangkum tiga berkas feature test yang sebenarnya sudah kita buat dan jalankan sepanjang proyek — sekarang kita bedah polanya secara menyeluruh. Totalnya 23 test yang menguji CRUD, autentikasi, dan otorisasi dari luar lewat HTTP, persis seperti pengguna sungguhan.
Bekal Dasar Setiap Test #
Ketiga class test berbagi kerangka yang sama:
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');
}Blok di atas adalah bagian pembuka tests/Feature/PostCrudTest.php — class dan test pertamanya, persis seperti berkas aslinya; enam test lain menyusul di bawahnya dengan pola yang sama.
Trait RefreshDatabase menjalankan ulang migration di awal setiap test, sehingga setiap method mulai dari database bersih — seeder tidak ikut dipanggil di sini, jadi setiap test membangun datanya sendiri lewat factory. Konfigurasi phpunit.xml mengarahkan database test ke SQLite in-memory, membuat seluruh rangkaian berjalan cepat tanpa menyentuh database.sqlite utama. Factory state draft() buatan Bagian 2 terbukti berguna: dua baris Post::factory() menghasilkan satu artikel terbit dan satu draf untuk diadu di halaman daftar.
Menguji Perjalanan Data #
Test penulisan artikel menyoroti kekhasan proyek ini — slug otomatis dan relasi pivot:
$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,
]);actingAs() menggantikan proses login sungguhan, cocok untuk fokus ke satu aksi. Pengalihan kita asertasi langsung ke slug yang diharapkan — cara ampuh memastikan uniqueSlug() bekerja. Test slug duplikat otomatis diberi akhiran menguji cabang lainnya: judul yang sama dua kali menghasilkan slug tulisan-pertama-2, bukan pelanggaran constraint unik. Sementara itu assertDatabaseHas('post_tag', ...) membuktikan sync() benar-benar menulis baris pivot.
Test AuthFlowTest memilih jalur berbeda: assertAuthenticated() dan assertGuest() memeriksa status sesi pengujian, bukan isi tabel. Kombinasi keduanya di satu class terlihat pada test login — request dikirim dengan kredensial, lalu status sesi dan pengalihan diperiksa sekaligus. Test login dengan kata sandi salah membuktikan jalur galatnya: assertSessionHasErrors('email') menangkap pesan “Email atau kata sandi salah.” sementara assertGuest() memastikan sesi tetap tertutup, dan onlyInput('email') membuat alamat yang diketik tetap terisi ulang di form.
Satu kebiasaan kecil yang membuat test mudah dirawat: satu method menguji satu perilaku. Test “menyimpan artikel” tidak sekaligus menguji validasi, dan test “artikel tidak valid” tidak mencampur kasus penolakan akses. Saat sebuah test merah, kegagalan langsung menunjuk satu perilaku — tidak perlu menebak mana dari lima asersi yang saling menutupi.
Menguji Penolakan #
Test yang paling berharga justru yang mengharapkan kegagalan. PolicyTest memastikan penegakan kepemilikan tidak bocor:
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);
}assertForbidden() menuntut respons 403 dari policy, lalu assertModelExists() memastikan artikel masih ada — penolakan tidak boleh berdampak samping. Dua test draf melengkapi skenario akses: tamu dan penulis lain ditolak, pemilik lolos.
Rangkaian Lengkap #
Seluruh suite dijalankan dengan satu perintah:
php artisan testSelama pengembangan, --filter mempersempit fokus ke satu class atau satu method — misalnya php artisan test --filter=PolicyTest seperti yang kita pakai di Bagian 6 — sehingga siklus sunting-ujung hanya berdetik-detik. Menjalankan seluruh suite tetap dilakukan sebelum menganggap pekerjaan selesai, karena perubahan di satu bagian bisa diam-diam merusak bagian lain.
Output yang Dihasilkan:
PASS Tests\Unit\ExampleTest
✓ that true is true
PASS Tests\Feature\AuthFlowTest
✓ pengunjung bisa mendaftar sebagai penulis
✓ registrasi dengan email duplikat ditolak
✓ penulis bisa login dengan kredensial benar
✓ login dengan kata sandi salah gagal
✓ penulis bisa logout
✓ halaman tulis menolak tamu
PASS Tests\Feature\ExampleTest
✓ beranda mengarah ke daftar artikel
PASS Tests\Feature\PolicyTest
✓ artikel terbit bisa dibaca siapa saja
✓ draf tidak bisa dibaca tamu
✓ draf hanya bisa dibaca pemiliknya
✓ penulis tidak bisa mengubah artikel orang lain
✓ penulis tidak bisa menghapus artikel orang lain
✓ pemilik bisa membuka form ubah artikel
✓ tombol ubah hanya tampil bagi pemilik
PASS Tests\Feature\PostCrudTest
✓ tamu hanya melihat artikel terbit
✓ tamu diarahkan ke login saat hendak menulis
✓ penulis bisa membuka form tulis
✓ penulis bisa menyimpan artikel baru
✓ slug duplikat otomatis diberi akhiran
✓ artikel tidak valid ditolak
✓ penulis bisa memperbarui artikel sendiri
✓ penulis bisa menghapus artikel sendiri
Tests: 23 passed (63 assertions)
Duration: 0.69sDengan 23 test hijau, proyek aplikasi blog resmi tuntas: dari perancangan, database, CRUD, autentikasi, relasi kategori-tag, hingga pertahanan keamanan — semuanya teruji. Langkah alami berikutnya adalah memperluas aplikasi ini: pencarian artikel, komentar, upload gambar, atau mengubah front-end ke Tailwind sembari memakai rangkaian test yang sama sebagai jaring pengaman.
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
{
//
}