Di bagian sebelumnya kita sudah menguasai relasi antar Model. Sekarang saatnya membahas salah satu aspek paling krusial dalam aplikasi web: menangani input dari pengguna dengan aman dan benar. Validasi yang buruk adalah pintu masuk utama kerentanan keamanan dan data yang kotor di database. Laravel menyediakan sistem validasi yang sangat lengkap, ekspresif, dan mudah digunakan — dari validasi sederhana di Controller hingga Form Request class yang terpisah dan reusable.
Membuat Form di Blade
Sebelum membahas validasi, mari pastikan form HTML di Blade sudah dibuat dengan benar. Ada beberapa hal penting yang wajib ada di setiap form Laravel — terutama CSRF token dan method spoofing untuk HTTP method selain GET dan POST — yang jika terlewat akan menyebabkan error yang membingungkan.
{{-- resources/views/posts/create.blade.php --}}
@extends('layouts.app')
@section('content')
<div class="max-w-2xl mx-auto py-8 px-4">
<h1 class="text-2xl font-bold mb-6">Tulis Artikel Baru</h1>
{{-- Form untuk CREATE (POST) --}}
<form action="{{ route('posts.store') }}" method="POST" enctype="multipart/form-data">
{{-- WAJIB: CSRF Token untuk keamanan --}}
@csrf
{{-- Judul --}}
<div class="mb-4">
<label for="title" class="block text-sm font-medium text-gray-700 mb-1">
Judul <span class="text-red-500">*</span>
</label>
<input
type="text"
id="title"
name="title"
value="{{ old('title') }}"
placeholder="Masukkan judul artikel..."
class="w-full border rounded-lg px-3 py-2 @error('title') border-red-500 @enderror"
>
@error('title')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
{{-- Kategori (dropdown) --}}
<div class="mb-4">
<label for="category_id" class="block text-sm font-medium text-gray-700 mb-1">
Kategori <span class="text-red-500">*</span>
</label>
<select name="category_id" id="category_id"
class="w-full border rounded-lg px-3 py-2 @error('category_id') border-red-500 @enderror">
<option value="">-- Pilih Kategori --</option>
@foreach ($categories as $category)
<option value="{{ $category->id }}"
@selected(old('category_id') == $category->id)>
{{ $category->name }}
</option>
@endforeach
</select>
@error('category_id')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
{{-- Tags (checkbox multiple) --}}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">Tags</label>
<div class="flex flex-wrap gap-3">
@foreach ($tags as $tag)
<label class="flex items-center gap-2">
<input
type="checkbox"
name="tags[]"
value="{{ $tag->id }}"
@checked(in_array($tag->id, old('tags', [])))
>
{{ $tag->name }}
</label>
@endforeach
</div>
@error('tags')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
{{-- Konten --}}
<div class="mb-4">
<label for="content" class="block text-sm font-medium text-gray-700 mb-1">
Konten <span class="text-red-500">*</span>
</label>
<textarea
id="content"
name="content"
rows="10"
class="w-full border rounded-lg px-3 py-2 @error('content') border-red-500 @enderror"
>{{ old('content') }}</textarea>
@error('content')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
{{-- Thumbnail (file upload) --}}
<div class="mb-4">
<label for="thumbnail" class="block text-sm font-medium text-gray-700 mb-1">
Thumbnail
</label>
<input
type="file"
id="thumbnail"
name="thumbnail"
accept="image/*"
class="w-full @error('thumbnail') text-red-500 @enderror"
>
@error('thumbnail')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
{{-- Status (radio button) --}}
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">Status</label>
<div class="flex gap-4">
<label class="flex items-center gap-2">
<input type="radio" name="status" value="draft"
@checked(old('status', 'draft') === 'draft')>
Draft
</label>
<label class="flex items-center gap-2">
<input type="radio" name="status" value="published"
@checked(old('status') === 'published')>
Publikasikan
</label>
</div>
</div>
{{-- Submit --}}
<div class="flex gap-3">
<button type="submit" class="btn-primary">
Simpan Artikel
</button>
<a href="{{ route('posts.index') }}" class="btn-outline">
Batal
</a>
</div>
</form>
</div>
@endsection
{{-- Form untuk UPDATE (PUT/PATCH) --}}
{{-- Perbedaan utama: action ke route update, tambah @method('PUT') --}}
<form action="{{ route('posts.update', $post) }}" method="POST" enctype="multipart/form-data">
@csrf
@method('PUT') {{-- Method spoofing: HTML hanya support GET & POST --}}
<div class="mb-4">
<label for="title">Judul</label>
<input
type="text"
name="title"
{{-- old() untuk repopulate, fallback ke nilai saat ini --}}
value="{{ old('title', $post->title) }}"
>
@error('title')
<p class="text-red-500 text-sm">{{ $message }}</p>
@enderror
</div>
{{-- Thumbnail saat ini (tampilkan preview) --}}
@if ($post->thumbnail)
<div class="mb-4">
<p class="text-sm text-gray-600 mb-2">Thumbnail saat ini:</p>
<img src="{{ Storage::url($post->thumbnail) }}"
alt="Thumbnail"
class="h-32 w-auto rounded-lg object-cover">
<label class="flex items-center gap-2 mt-2 text-sm">
<input type="checkbox" name="remove_thumbnail" value="1">
Hapus thumbnail ini
</label>
</div>
@endif
<input type="file" name="thumbnail" accept="image/*">
<button type="submit">Update Artikel</button>
</form>
{{-- Form untuk DELETE --}}
<form action="{{ route('posts.destroy', $post) }}" method="POST"
onsubmit="return confirm('Yakin ingin menghapus artikel ini?')">
@csrf
@method('DELETE')
<button type="submit" class="btn-danger">Hapus Artikel</button>
</form>
Validasi di Controller: $request->validate()
Cara paling sederhana melakukan validasi di Laravel adalah menggunakan method validate() langsung di dalam Controller. Jika validasi gagal, Laravel otomatis redirect kembali ke halaman sebelumnya dengan error messages dan input lama tersimpan di session — tanpa perlu satu baris kode tambahan.
Method validate() menerima array aturan validasi di mana key adalah nama field dan value adalah aturan yang dipisahkan pipe (|) atau dalam bentuk array. Jika semua aturan lolos, method mengembalikan array data yang sudah tervalidasi — gunakan ini untuk menyimpan ke database, bukan $request->all().
Secara default Laravel menggunakan pesan error dalam Bahasa Inggris. Kamu bisa mengkustomisasi pesan untuk aturan tertentu atau field tertentu dengan menambahkan parameter kedua dan ketiga ke validate().
Pesan error default menggunakan nama field yang sering tidak ramah pengguna — misalnya "category_id" bukan "Kategori". Gunakan parameter keempat untuk mendefinisikan nama yang lebih ramah untuk setiap field.
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class PostController extends Controller
{
public function store(Request $request)
{
// ── VALIDASI DASAR ────────────────────────────
$validated = $request->validate([
// Aturan sebagai string dengan pipe
'title' => 'required|string|min:5|max:255',
'slug' => 'required|string|unique:posts,slug',
'content' => 'required|string|min:100',
'category_id' => 'required|integer|exists:categories,id',
'status' => 'required|in:draft,published',
'thumbnail' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:2048',
// Aturan sebagai array (lebih mudah dibaca untuk aturan panjang)
'excerpt' => ['nullable', 'string', 'max:500'],
'tags' => ['nullable', 'array'],
'tags.*' => ['integer', 'exists:tags,id'], // Validasi setiap elemen array
// Field boolean
'is_featured' => ['nullable', 'boolean'],
]);
// $validated HANYA berisi field yang lolos validasi
// Aman untuk digunakan langsung
Post::create([
...$validated,
'user_id' => auth()->id(),
'published_at' => $validated['status'] === 'published' ? now() : null,
]);
return redirect()->route('posts.index')
->with('success', 'Artikel berhasil disimpan!');
}
public function update(Request $request, Post $post)
{
// ── KUSTOMISASI PESAN ERROR ───────────────────
$validated = $request->validate(
// Rules (parameter pertama)
[
'title' => 'required|string|max:255',
'content' => 'required|string|min:100',
'category_id' => 'required|exists:categories,id',
// unique dengan pengecualian ID saat ini (untuk update)
'slug' => 'required|string|unique:posts,slug,' . $post->id,
],
// Pesan kustom (parameter kedua)
[
'title.required' => 'Judul artikel wajib diisi.',
'title.max' => 'Judul tidak boleh lebih dari 255 karakter.',
'content.required' => 'Konten artikel wajib diisi.',
'content.min' => 'Konten minimal :min karakter.',
'category_id.required' => 'Pilih kategori untuk artikel ini.',
'category_id.exists' => 'Kategori yang dipilih tidak valid.',
'slug.unique' => 'Slug sudah digunakan artikel lain.',
],
// Nama field kustom (parameter ketiga)
[
'title' => 'Judul',
'content' => 'Konten',
'category_id' => 'Kategori',
'slug' => 'Slug',
]
);
$post->update($validated);
return redirect()->route('posts.show', $post)
->with('success', 'Artikel berhasil diperbarui!');
}
}
Aturan Validasi yang Paling Sering Dipakai
Laravel menyediakan lebih dari 90 aturan validasi bawaan. Berikut adalah yang paling sering digunakan dalam pengembangan aplikasi web sehari-hari — dari validasi string sederhana hingga validasi kondisional yang kompleks.
<?php
$rules = [
// ── KEBERADAAN DATA ────────────────────────────────
'nama' => 'required', // Wajib ada dan tidak kosong
'keterangan' => 'nullable', // Boleh tidak ada / null
'foto' => 'sometimes|image', // Validasi HANYA jika field ada di request
'catatan' => 'present', // Field harus ada (boleh kosong)
// ── STRING ─────────────────────────────────────────
'nama' => 'required|string|min:2|max:100',
'username' => 'required|string|alpha_dash|min:3|max:30',
// alpha_dash: hanya huruf, angka, strip, underscore
'kode' => 'required|alpha_num', // Hanya huruf dan angka
'nama' => 'required|alpha', // Hanya huruf
'deskripsi'=> 'nullable|string|max:1000',
// ── ANGKA ──────────────────────────────────────────
'umur' => 'required|integer|min:17|max:100',
'harga' => 'required|numeric|min:0',
'rating' => 'required|decimal:0,1|between:1,5',
'stok' => 'required|integer|min:0',
// ── EMAIL & URL ────────────────────────────────────
'email' => 'required|email|unique:users,email',
'website' => 'nullable|url',
'no_hp' => 'nullable|regex:/^[0-9]{10,15}$/',
// ── PASSWORD ───────────────────────────────────────
'password' => 'required|string|min:8|confirmed',
// 'confirmed' → butuh field 'password_confirmation' yang sama
// Password dengan aturan kompleks (Laravel 9+)
'password' => ['required', \Illuminate\Validation\Rules\Password::defaults()
->min(8)
->mixedCase() // Harus ada huruf besar dan kecil
->numbers() // Harus ada angka
->symbols() // Harus ada simbol
->uncompromised() // Cek apakah password pernah bocor di data breach
],
// ── DATE ───────────────────────────────────────────
'tanggal_lahir' => 'required|date|before:today',
'start_date' => 'required|date',
'end_date' => 'required|date|after:start_date',
'expired_at' => 'nullable|date|after_or_equal:today',
// ── FILE & GAMBAR ──────────────────────────────────
'foto' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:2048',
// max: dalam kilobyte (2048 = 2MB)
'dokumen' => 'nullable|file|mimes:pdf,doc,docx|max:5120',
'avatar' => 'nullable|image|dimensions:min_width=100,min_height=100',
// ── DATABASE ───────────────────────────────────────
'email' => 'required|email|unique:users,email',
// Unique dengan pengecualian ID saat ini (untuk update):
'email' => 'required|email|unique:users,email,' . $user->id,
'category_id' => 'required|exists:categories,id',
'user_id' => 'required|exists:users,id',
// ── ARRAY ──────────────────────────────────────────
'tags' => 'nullable|array|max:5', // Maks 5 elemen
'tags.*' => 'integer|exists:tags,id', // Setiap elemen
'foto' => 'nullable|array',
'foto.*' => 'image|max:2048',
// ── KONDISIONAL ────────────────────────────────────
// required_if: wajib jika field lain bernilai tertentu
'alasan' => 'required_if:status,rejected',
// required_unless: wajib kecuali field lain bernilai tertentu
'no_hp' => 'required_unless:contact_method,email',
// required_with: wajib jika salah satu field lain ada
'nama_perusahaan' => 'required_with:npwp',
// required_without: wajib jika field lain tidak ada
'email' => 'required_without:no_hp',
// ── ENUM (Laravel 10+) ─────────────────────────────
'status' => ['required', \Illuminate\Validation\Rule::in(['draft', 'published', 'archived'])],
// ── EXCLUDE ────────────────────────────────────────
// exclude_if: hapus field dari validated data jika kondisi terpenuhi
'company' => 'exclude_if:is_personal,true|required|string',
];
Form Request Class: Validasi yang Terpisah dan Reusable
Saat aturan validasi semakin kompleks, menaruhnya langsung di Controller membuat method menjadi sangat panjang dan sulit dibaca. Form Request adalah solusi elegan Laravel — kelas PHP terpisah yang bertugas khusus menangani validasi. Selain membuat Controller lebih ringkas, Form Request juga bisa digunakan ulang di beberapa tempat dan jauh lebih mudah di-test secara independen.
Artisan membuat file Form Request di folder app/Http/Requests/ dengan dua method yang perlu diisi: authorize() untuk menentukan siapa yang boleh membuat request ini, dan rules() untuk mendefinisikan aturan validasinya.
Method authorize() mengembalikan boolean — true berarti request diizinkan, false berarti Laravel mengembalikan 403 Forbidden. Ini tempat yang tepat untuk mengecek apakah user yang login berhak melakukan aksi ini, misalnya hanya pemilik post yang bisa mengeditnya.
Ganti parameter Request $request di Controller dengan Form Request class yang sudah dibuat. Laravel Service Container otomatis meng-inject dan menjalankan validasi sebelum method Controller dipanggil — jika validasi gagal, redirect otomatis terjadi sebelum kode Controller dieksekusi.
# Buat Form Request
php artisan make:request StorePostRequest
php artisan make:request UpdatePostRequest
# File dibuat di: app/Http/Requests/
<?php
// app/Http/Requests/StorePostRequest.php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
class StorePostRequest extends FormRequest
{
/**
* Tentukan siapa yang boleh membuat request ini.
* Kembalikan true untuk mengizinkan, false untuk 403.
*/
public function authorize(): bool
{
// Hanya user yang sudah login
return auth()->check();
// Atau cek role tertentu:
// return auth()->check() && auth()->user()->hasRole('author');
}
/**
* Aturan validasi untuk request ini.
*/
public function rules(): array
{
return [
'title' => ['required', 'string', 'min:5', 'max:255'],
'slug' => ['required', 'string', 'unique:posts,slug'],
'excerpt' => ['nullable', 'string', 'max:500'],
'content' => ['required', 'string', 'min:100'],
'category_id' => ['required', 'integer', 'exists:categories,id'],
'tags' => ['nullable', 'array', 'max:10'],
'tags.*' => ['integer', 'exists:tags,id'],
'thumbnail' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
'status' => ['required', 'in:draft,published'],
'is_featured' => ['nullable', 'boolean'],
'meta_title' => ['nullable', 'string', 'max:60'],
'meta_description' => ['nullable', 'string', 'max:160'],
];
}
/**
* Pesan error kustom.
*/
public function messages(): array
{
return [
'title.required' => 'Judul artikel wajib diisi.',
'title.min' => 'Judul minimal :min karakter.',
'content.required' => 'Konten artikel wajib diisi.',
'content.min' => 'Konten minimal :min karakter.',
'category_id.required' => 'Pilih kategori untuk artikel ini.',
'category_id.exists' => 'Kategori yang dipilih tidak valid.',
'thumbnail.image' => 'File harus berupa gambar.',
'thumbnail.max' => 'Ukuran gambar maksimal 2MB.',
];
}
/**
* Nama field yang lebih ramah untuk pesan error.
*/
public function attributes(): array
{
return [
'title' => 'Judul',
'slug' => 'Slug',
'excerpt' => 'Ringkasan',
'content' => 'Konten',
'category_id' => 'Kategori',
'tags' => 'Tag',
'thumbnail' => 'Thumbnail',
'status' => 'Status',
'meta_title' => 'Meta Title',
'meta_description' => 'Meta Description',
];
}
/**
* Persiapkan data sebelum validasi dijalankan.
* Berguna untuk normalisasi input.
*/
protected function prepareForValidation(): void
{
$this->merge([
// Auto-generate slug dari title jika tidak diisi
'slug' => $this->slug ?: Str::slug($this->title),
// Pastikan is_featured selalu boolean
'is_featured' => $this->boolean('is_featured'),
]);
}
/**
* Data yang sudah divalidasi dan siap digunakan.
* Override untuk mengeksklusikan field tertentu.
*/
public function validated($key = null, $default = null): array
{
// Hapus field yang tidak perlu disimpan ke database
return collect(parent::validated())
->except(['tags']) // Tags dihandle terpisah
->toArray();
}
}
<?php
// app/Http/Requests/UpdatePostRequest.php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdatePostRequest extends FormRequest
{
public function authorize(): bool
{
// Hanya pemilik post yang bisa update
$post = $this->route('post'); // Ambil model dari route binding
return auth()->id() === $post->user_id;
}
public function rules(): array
{
$post = $this->route('post');
return [
'title' => ['required', 'string', 'min:5', 'max:255'],
// Unique dengan pengecualian ID post saat ini
'slug' => [
'required',
'string',
Rule::unique('posts', 'slug')->ignore($post->id),
],
'content' => ['required', 'string', 'min:100'],
'category_id' => ['required', 'integer', 'exists:categories,id'],
'tags' => ['nullable', 'array'],
'tags.*' => ['integer', 'exists:tags,id'],
// Thumbnail opsional saat update
'thumbnail' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
'remove_thumbnail' => ['nullable', 'boolean'],
'status' => ['required', Rule::in(['draft', 'published', 'archived'])],
'is_featured' => ['nullable', 'boolean'],
];
}
public function messages(): array
{
return [
'title.required' => 'Judul artikel wajib diisi.',
'slug.unique' => 'Slug sudah digunakan artikel lain.',
'content.min' => 'Konten minimal :min karakter.',
];
}
}
<?php
// app/Http/Controllers/PostController.php
// Controller menjadi jauh lebih ringkas dengan Form Request
use App\Http\Requests\StorePostRequest;
use App\Http\Requests\UpdatePostRequest;
class PostController extends Controller
{
// Ganti Request dengan StorePostRequest
// Validasi otomatis dijalankan sebelum method ini dipanggil
public function store(StorePostRequest $request): RedirectResponse
{
// $request->validated() — hanya data yang sudah lolos validasi
$post = Post::create([
...$request->validated(),
'user_id' => auth()->id(),
'published_at' => $request->status === 'published' ? now() : null,
]);
// Handle tags secara terpisah (many-to-many)
if ($request->has('tags')) {
$post->tags()->sync($request->tags);
}
// Handle thumbnail upload
if ($request->hasFile('thumbnail')) {
$path = $request->file('thumbnail')->store('thumbnails', 'public');
$post->update(['thumbnail' => $path]);
}
return redirect()->route('posts.show', $post)
->with('success', 'Artikel berhasil dipublikasikan!');
}
public function update(UpdatePostRequest $request, Post $post): RedirectResponse
{
// authorize() di Form Request sudah mengecek kepemilikan
$data = $request->validated();
// Handle remove thumbnail
if ($request->boolean('remove_thumbnail') && $post->thumbnail) {
Storage::disk('public')->delete($post->thumbnail);
$data['thumbnail'] = null;
}
// Handle thumbnail baru
if ($request->hasFile('thumbnail')) {
if ($post->thumbnail) {
Storage::disk('public')->delete($post->thumbnail);
}
$data['thumbnail'] = $request->file('thumbnail')
->store('thumbnails', 'public');
}
$post->update($data);
// Sync tags
$post->tags()->sync($request->tags ?? []);
return redirect()->route('posts.show', $post)
->with('success', 'Artikel berhasil diperbarui!');
}
}
Custom Validation Rules
Meski Laravel menyediakan lebih dari 90 aturan validasi bawaan, terkadang kamu membutuhkan aturan yang spesifik untuk logika bisnismu — misalnya memastikan username tidak mengandung kata terlarang, memvalidasi format nomor KTP, atau mengecek apakah slot waktu masih tersedia. Laravel menyediakan dua cara membuat custom validation rule.
Buat kelas Rule terpisah menggunakan Artisan jika aturan validasi kompleks dan akan digunakan di banyak tempat. Kelas ini memiliki method passes() yang berisi logika validasi dan message() yang berisi pesan error.
Gunakan closure langsung di dalam array rules jika aturan hanya dipakai satu kali dan logikanya sederhana. Lebih cepat ditulis tapi tidak bisa digunakan ulang.
# Buat Rule class
php artisan make:rule NoProhibitedWords
php artisan make:rule ValidIndonesianPhone
# File dibuat di: app/Rules/
<?php
// app/Rules/NoProhibitedWords.php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class NoProhibitedWords implements ValidationRule
{
private array $prohibited = ['spam', 'scam', 'judi', 'togel'];
public function validate(string $attribute, mixed $value, Closure $fail): void
{
foreach ($this->prohibited as $word) {
if (str_contains(strtolower($value), $word)) {
$fail("Field :attribute mengandung kata yang tidak diizinkan: '{$word}'.");
return;
}
}
}
}
// ── PENGGUNAAN DI FORM REQUEST ────────────────────────
use App\Rules\NoProhibitedWords;
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255', new NoProhibitedWords],
'content' => ['required', 'string', new NoProhibitedWords],
];
}
// ── CLOSURE RULE: Untuk aturan sekali pakai ───────────
public function rules(): array
{
return [
'username' => [
'required',
'string',
'min:3',
// Closure rule langsung di sini
function (string $attribute, mixed $value, Closure $fail) {
$reserved = ['admin', 'root', 'system', 'support'];
if (in_array(strtolower($value), $reserved)) {
$fail("Username '{$value}' tidak tersedia.");
}
},
],
'jam_mulai' => [
'required',
'date_format:H:i',
function ($attribute, $value, $fail) {
$hour = (int) explode(':', $value)[0];
if ($hour < 8 || $hour >= 17) {
$fail('Jam mulai harus antara 08:00 dan 17:00.');
}
},
],
];
}
Menangani File Upload dengan Benar
File upload adalah fitur yang sangat umum tapi juga penuh dengan potensi masalah jika tidak ditangani dengan benar — dari masalah keamanan (eksekusi file berbahaya), performa (file terlalu besar), hingga manajemen storage yang berantakan. Laravel menyediakan abstraksi filesystem yang membuat pengelolaan file upload menjadi jauh lebih mudah dan aman.
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class PostController extends Controller
{
public function store(StorePostRequest $request)
{
$data = $request->validated();
// ── UPLOAD FILE SEDERHANA ──────────────────────
if ($request->hasFile('thumbnail')) {
$file = $request->file('thumbnail');
// store() — nama file di-generate otomatis (random hash)
// Parameter: (path/folder, disk_name)
$path = $file->store('thumbnails', 'public');
// Tersimpan: storage/app/public/thumbnails/abc123.jpg
// URL publik: /storage/thumbnails/abc123.jpg
$data['thumbnail'] = $path;
}
// ── UPLOAD DENGAN NAMA KUSTOM ──────────────────
if ($request->hasFile('thumbnail')) {
$file = $request->file('thumbnail');
$extension = $file->getClientOriginalExtension();
$filename = Str::slug($request->title) . '-' . time() . '.' . $extension;
// storeAs() — tentukan nama file sendiri
$path = $file->storeAs('thumbnails', $filename, 'public');
$data['thumbnail'] = $path;
}
// ── INFORMASI FILE ─────────────────────────────
if ($request->hasFile('thumbnail')) {
$file = $request->file('thumbnail');
$originalName = $file->getClientOriginalName(); // nama asli dari client
$extension = $file->getClientOriginalExtension(); // jpg, png, dll
$mimeType = $file->getMimeType(); // image/jpeg, dll
$size = $file->getSize(); // bytes
$isValid = $file->isValid(); // apakah upload sukses
}
$post = Post::create($data);
// ── MULTIPLE FILE UPLOAD ───────────────────────
if ($request->hasFile('images')) {
foreach ($request->file('images') as $image) {
$path = $image->store('post-images', 'public');
$post->images()->create([
'path' => $path,
'filename' => $image->getClientOriginalName(),
'mime_type' => $image->getMimeType(),
'size' => $image->getSize(),
]);
}
}
return redirect()->route('posts.show', $post)
->with('success', 'Artikel berhasil disimpan!');
}
public function update(UpdatePostRequest $request, Post $post)
{
$data = $request->validated();
// ── HAPUS FILE LAMA SAAT UPDATE ────────────────
if ($request->hasFile('thumbnail')) {
// Hapus thumbnail lama dari storage
if ($post->thumbnail && Storage::disk('public')->exists($post->thumbnail)) {
Storage::disk('public')->delete($post->thumbnail);
}
// Simpan yang baru
$data['thumbnail'] = $request->file('thumbnail')
->store('thumbnails', 'public');
}
// Handle tombol "hapus thumbnail"
if ($request->boolean('remove_thumbnail') && $post->thumbnail) {
Storage::disk('public')->delete($post->thumbnail);
$data['thumbnail'] = null;
}
$post->update($data);
return redirect()->route('posts.show', $post)
->with('success', 'Artikel berhasil diperbarui!');
}
public function destroy(Post $post)
{
// ── HAPUS FILE SAAT RECORD DIHAPUS ─────────────
if ($post->thumbnail) {
Storage::disk('public')->delete($post->thumbnail);
}
// Hapus semua gambar terkait
foreach ($post->images as $image) {
Storage::disk('public')->delete($image->path);
$image->delete();
}
$post->delete();
return redirect()->route('posts.index')
->with('success', 'Artikel berhasil dihapus.');
}
}
⚠️ Setelah menggunakan storage dengan disk 'public', pastikan kamu sudah menjalankan php artisan storage:link untuk membuat symbolic link dari public/storage ke storage/app/public. Tanpa perintah ini, file yang sudah di-upload tidak bisa diakses via URL publik. Di production, perintah ini harus dijalankan sekali saat deploy pertama kali.
Menampilkan Error Validasi yang Informatif
User experience yang baik membutuhkan pesan error yang jelas, spesifik, dan ditampilkan dekat dengan field yang bermasalah. Blade sudah menyediakan variabel $errors yang otomatis tersedia di semua view setelah validasi gagal — berikut pola-pola terbaik untuk menampilkannya.
{{-- ── KOMPONEN REUSABLE UNTUK INPUT DENGAN ERROR ─── --}}
{{-- resources/views/components/form/input.blade.php --}}
@props([
'name',
'label',
'type' => 'text',
'placeholder' => '',
'required' => false,
])
<div class="mb-4">
<label for="{{ $name }}"
class="block text-sm font-medium text-gray-700 mb-1">
{{ $label }}
@if ($required)
<span class="text-red-500">*</span>
@endif
</label>
<input
type="{{ $type }}"
id="{{ $name }}"
name="{{ $name }}"
value="{{ old($name) }}"
placeholder="{{ $placeholder }}"
{{ $attributes->merge(['class' => 'w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-indigo-500 ' . ($errors->has($name) ? 'border-red-500 bg-red-50' : 'border-gray-300')]) }}
@if ($required) required @endif
>
@error($name)
<p class="text-red-500 text-sm mt-1 flex items-center gap-1">
<span>⚠</span> {{ $message }}
</p>
@enderror
</div>
{{-- ── PENGGUNAAN KOMPONEN ─────────────────────────── --}}
<x-form.input
name="title"
label="Judul Artikel"
placeholder="Masukkan judul yang menarik..."
:required="true"
/>
<x-form.input
name="email"
label="Email"
type="email"
placeholder="nama@email.com"
:required="true"
/>
{{-- ── SUMMARY ERROR DI ATAS FORM ─────────────────── --}}
@if ($errors->any())
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<div class="flex items-center gap-2 mb-2">
<span class="text-red-500">⚠</span>
<p class="font-semibold text-red-700">
Terdapat {{ $errors->count() }} kesalahan yang perlu diperbaiki:
</p>
</div>
<ul class="list-disc list-inside space-y-1">
@foreach ($errors->all() as $error)
<li class="text-red-600 text-sm">{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
Praktik: Form Lengkap Create dan Edit Post
Mari satukan semua konsep yang sudah dipelajari — Form Request, validasi, file upload, dan penampilan error — dalam satu alur lengkap yang mencerminkan implementasi di aplikasi production. Kita akan membuat alur create dan edit post yang robust dan user-friendly.
<?php
// app/Http/Controllers/PostController.php — Versi final
namespace App\Http\Controllers;
use App\Models\{Post, Category, Tag};
use App\Http\Requests\{StorePostRequest, UpdatePostRequest};
use Illuminate\Http\{RedirectResponse};
use Illuminate\Support\Facades\{Storage, DB};
use Illuminate\View\View;
class PostController extends Controller
{
public function create(): View
{
$categories = Category::orderBy('name')->get(['id', 'name']);
$tags = Tag::orderBy('name')->get(['id', 'name']);
return view('posts.create', compact('categories', 'tags'));
}
public function store(StorePostRequest $request): RedirectResponse
{
// Jalankan dalam database transaction untuk keamanan
$post = DB::transaction(function () use ($request) {
// Simpan data utama
$post = Post::create([
...$request->safe()->except(['tags', 'thumbnail']),
'user_id' => auth()->id(),
'published_at' => $request->status === 'published' ? now() : null,
]);
// Handle thumbnail
if ($request->hasFile('thumbnail')) {
$post->update([
'thumbnail' => $request->file('thumbnail')
->store('thumbnails', 'public'),
]);
}
// Sync tags (many-to-many)
$post->tags()->sync($request->validated()['tags'] ?? []);
return $post;
});
return redirect()->route('posts.show', $post)
->with('success', 'Artikel "' . $post->title . '" berhasil dipublikasikan!');
}
public function edit(Post $post): View
{
// Pastikan hanya pemilik yang bisa edit
abort_if(auth()->id() !== $post->user_id, 403);
$categories = Category::orderBy('name')->get(['id', 'name']);
$tags = Tag::orderBy('name')->get(['id', 'name']);
$selectedTags = $post->tags->pluck('id')->toArray();
return view('posts.edit', compact('post', 'categories', 'tags', 'selectedTags'));
}
public function update(UpdatePostRequest $request, Post $post): RedirectResponse
{
DB::transaction(function () use ($request, $post) {
$data = $request->safe()->except(['tags', 'thumbnail', 'remove_thumbnail']);
// Handle thumbnail
if ($request->boolean('remove_thumbnail')) {
Storage::disk('public')->delete($post->thumbnail);
$data['thumbnail'] = null;
} elseif ($request->hasFile('thumbnail')) {
Storage::disk('public')->delete($post->thumbnail ?? '');
$data['thumbnail'] = $request->file('thumbnail')
->store('thumbnails', 'public');
}
$data['published_at'] = $request->status === 'published'
? ($post->published_at ?? now())
: null;
$post->update($data);
$post->tags()->sync($request->validated()['tags'] ?? []);
});
return redirect()->route('posts.show', $post)
->with('success', 'Artikel berhasil diperbarui!');
}
}
💡 Perhatikan penggunaan DB::transaction() yang membungkus operasi create/update beserta relasi tags dan file upload. Ini memastikan jika salah satu operasi gagal (misalnya sync tags error setelah thumbnail sudah tersimpan), semua operasi database akan di-rollback ke kondisi semula. File yang sudah ter-upload memang tidak ikut rollback, tapi minimal data di database tetap konsisten.
Langkah Selanjutnya
Luar biasa — kamu sudah menguasai Form, Validasi, dan Request Handling di Laravel secara menyeluruh! Mulai dari membuat form Blade yang benar, validasi di Controller, Form Request class yang terpisah dan reusable, custom validation rules, penanganan file upload yang aman, hingga menampilkan error yang informatif kepada pengguna. Di bagian kesembilan seri ini, kita akan membahas Authentication dengan Laravel Breeze — cara termudah untuk menambahkan sistem login, register, dan reset password yang lengkap ke aplikasi Laravel.
🚀 Sebelum lanjut ke Bagian 9, pastikan kamu sudah berlatih: (1) membuat Form Request terpisah untuk create dan update dengan aturan validasi yang lengkap, (2) menggunakan old() untuk repopulate form saat validasi gagal, (3) mengimplementasikan file upload dengan penghapusan file lama yang benar, dan (4) membuat custom Rule class untuk kebutuhan validasi yang spesifik. Form handling yang robust adalah fondasi dari aplikasi web yang andal dan user-friendly.