Pada aplikasi Laravel, Form Request sering dianggap sebagai pagar utama agar data yang masuk sudah aman diproses. Masalahnya, ada kasus yang cukup sering terjadi: validasi dinyatakan lolos, tetapi data nested seperti array varian produk, harga, atribut, atau media masih memiliki struktur yang tidak sesuai saat disimpan ke database. Akibatnya, bug baru muncul di layer berikutnya: model, service, repository, atau proses sinkronisasi ke sistem lain.
Masalah ini biasanya bukan karena validator Laravel buruk, melainkan karena aturan validasi yang ditulis belum cukup ketat untuk mengunci bentuk data. Di payload nested, ada perbedaan penting antara "field ini ada", "field ini array", dan "array ini hanya boleh punya key tertentu dengan isi tertentu". Jika bagian ini longgar, request bisa lolos, tetapi hasil akhirnya tetap rusak.
Artikel ini membahas pola praktis untuk mencegah masalah tersebut, dengan fokus pada wildcard rules, array keys yang wajib, prepareForValidation, DTO atau normalisasi data, dan perbedaan validated() vs all().
Kenapa validasi bisa lolos tetapi struktur data tetap salah?
Validator Laravel bekerja berdasarkan rule yang kita tulis. Jika rule hanya memeriksa sebagian struktur, validator tidak otomatis menebak bentuk ideal data aplikasi kita. Misalnya, kita menulis bahwa variants harus array, dan variants.*.sku harus string. Secara kasat mata terlihat cukup. Namun, validator belum tentu memastikan setiap item benar-benar punya kombinasi key yang dibutuhkan, urutan tipe data yang konsisten, atau nama key yang sesuai dengan kontrak API internal.
Contoh payload produk yang rawan salah:
{
"name": "Sepatu Running",
"price": "250000",
"variants": [
{
"sku": "RUN-RED-42",
"color": "red",
"size": 42,
"price": "275000"
},
{
"sku": "RUN-BLUE-43",
"attributes": {
"color": "blue",
"size": "43"
}
},
{
"sku": "",
"extra_field": "tidak dipakai"
}
],
"images": {
"0": { "url": "https://cdn.example.com/a.jpg" },
"cover": "https://cdn.example.com/cover.jpg"
}
}Payload di atas bisa menimbulkan banyak masalah:
pricedi level root bertipe string, padahal aplikasi mungkin ingin integer.- Struktur
variantstidak konsisten: item pertama memakai key langsung, item kedua memakai nestedattributes, item ketiga malah punyaextra_field. imagesberbentuk object dengan key campuran, bukan list item yang konsisten.- Field penting seperti
skubisa kosong pada salah satu item.
Jika rule tidak eksplisit, sebagian besar ketidakkonsistenan ini bisa lolos.
Gunakan wildcard rule, tetapi pahami batasannya
Rule dasar yang sering belum cukup
Contoh rule yang umum ditulis:
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'price' => ['required', 'numeric'],
'variants' => ['nullable', 'array'],
'variants.*.sku' => ['required', 'string'],
'variants.*.price' => ['nullable', 'numeric'],
];
}Rule ini sudah lebih baik daripada tidak ada validasi nested sama sekali, tetapi masih punya celah:
- Tidak memastikan setiap item di
variantsmemang object/array yang bentuknya konsisten. - Tidak memastikan key wajib lain seperti
colordansizeselalu ada. - Tidak membatasi key liar seperti
extra_field. - Tidak membantu jika client mengirim struktur alternatif seperti
attributes.color.
Perketat struktur item nested
Untuk payload produk, tulis rule yang lebih eksplisit:
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'price' => ['required', 'integer', 'min:0'],
'variants' => ['required', 'array', 'min:1'],
'variants.*' => ['required', 'array'],
'variants.*.sku' => ['required', 'string', 'max:100'],
'variants.*.color' => ['required', 'string', 'max:50'],
'variants.*.size' => ['required', 'string', 'max:20'],
'variants.*.price' => ['nullable', 'integer', 'min:0'],
'images' => ['nullable', 'array'],
'images.*' => ['required', 'array'],
'images.*.url' => ['required', 'url'],
'images.*.is_cover' => ['nullable', 'boolean'],
];
}Wildcard seperti variants.*.sku berarti setiap elemen di dalam variants harus mengikuti aturan tersebut. Ini penting untuk data list. Namun, wildcard hanya memeriksa jalur field yang kita definisikan. Ia tidak otomatis melarang struktur lain di luar jalur itu.
Intinya: wildcard bagus untuk memvalidasi pola berulang, tetapi bukan alat tunggal untuk menjamin bentuk data akhir. Kita tetap perlu strategi untuk memastikan key wajib, normalisasi input, dan sanitasi data hasil validasi.
Array keys wajib: validasi isi saja tidak cukup
Masalah paling umum: item array tidak seragam
Dalam banyak kasus, aplikasi mengharapkan setiap varian produk memiliki set key yang sama, misalnya sku, color, size, dan price. Jika sebagian item punya struktur berbeda, proses mapping ke database akan menjadi rapuh.
Contoh payload yang sering lolos sebagian validasi tetapi merusak proses simpan:
{
"name": "Kaos Basic",
"price": 99000,
"variants": [
{ "sku": "TS-RED-M", "color": "red", "size": "M", "price": 99000 },
{ "sku": "TS-BLU-L", "attributes": { "color": "blue", "size": "L" } },
{ "sku": "TS-GRN-XL", "color": "green" }
]
}Item kedua dan ketiga tidak punya struktur yang sama. Saat disimpan, kode seperti ini bisa gagal atau menghasilkan data setengah kosong:
foreach ($request->validated('variants', []) as $variant) {
ProductVariant::create([
'sku' => $variant['sku'],
'color' => $variant['color'],
'size' => $variant['size'],
'price' => $variant['price'] ?? null,
]);
}Untuk mencegahnya, pastikan semua key yang memang wajib diberi rule required. Jangan hanya memvalidasi key yang "mungkin ada" jika di level bisnis field itu sebenarnya wajib.
Batasi bentuk array sedini mungkin
Secara praktis, ada dua pendekatan:
- Ketat di request: tolak payload yang tidak sesuai kontrak.
- Longgar di request, ketat di normalisasi: terima beberapa variasi bentuk lalu ubah ke bentuk standar sebelum diproses lebih lanjut.
Jika API Anda dipakai banyak client yang belum konsisten, pendekatan kedua sering lebih realistis. Namun, kontraknya tetap harus jelas.
prepareForValidation: normalisasi sebelum rule dijalankan
prepareForValidation() berguna saat payload dari client tidak selalu konsisten, tetapi masih bisa diubah ke bentuk standar. Metode ini dijalankan sebelum proses validasi, sehingga rule bekerja terhadap data yang sudah dinormalisasi.
Contoh: sebagian client mengirim price sebagai string numerik, sebagian mengirim variants[*].attributes.color alih-alih variants[*].color. Kita bisa rapikan dulu.
protected function prepareForValidation(): void
{
$variants = collect($this->input('variants', []))
->map(function ($variant) {
if (!is_array($variant)) {
return $variant;
}
$attributes = $variant['attributes'] ?? [];
return [
'sku' => $variant['sku'] ?? null,
'color' => $variant['color'] ?? ($attributes['color'] ?? null),
'size' => isset($variant['size'])
? (string) $variant['size']
: (isset($attributes['size']) ? (string) $attributes['size'] : null),
'price' => isset($variant['price']) ? (int) $variant['price'] : null,
];
})
->values()
->all();
$images = collect($this->input('images', []))
->map(function ($image) {
if (is_string($image)) {
return ['url' => $image, 'is_cover' => false];
}
if (is_array($image)) {
return [
'url' => $image['url'] ?? null,
'is_cover' => (bool) ($image['is_cover'] ?? false),
];
}
return ['url' => null, 'is_cover' => false];
})
->values()
->all();
$this->merge([
'price' => (int) $this->input('price', 0),
'variants' => $variants,
'images' => $images,
]);
}Kenapa pendekatan ini efektif?
- Rule validasi menjadi lebih sederhana karena beroperasi pada struktur yang sudah seragam.
- Layer di bawah request tidak perlu tahu berbagai variasi bentuk input dari client.
- Anda bisa menghapus ketergantungan pada key alternatif seperti
attributes.color.
Trade-off-nya, jangan sampai prepareForValidation() terlalu kompleks hingga berubah menjadi tempat logika bisnis utama. Gunakan untuk normalisasi input, bukan untuk keputusan domain yang besar.
DTO atau normalisasi data setelah validasi
Walaupun request sudah memvalidasi dan menormalisasi data, masih ada manfaat besar dari DTO (Data Transfer Object) atau setidaknya satu lapisan transformasi eksplisit sebelum menyimpan ke model. Tujuannya adalah memastikan service atau action menerima kontrak data yang benar-benar jelas.
Contoh DTO sederhana tanpa package tambahan:
final class ProductVariantData
{
public function __construct(
public string $sku,
public string $color,
public string $size,
public ?int $price,
) {}
public static function fromArray(array $data): self
{
return new self(
sku: $data['sku'],
color: $data['color'],
size: $data['size'],
price: $data['price'] ?? null,
);
}
}Lalu di controller atau service:
$payload = $request->validated();
$variants = collect($payload['variants'])
->map(fn (array $item) => ProductVariantData::fromArray($item));Keuntungan DTO:
- Tipe data lebih eksplisit.
- Lebih mudah dites karena ada satu titik transformasi.
- Mengurangi risiko akses array mentah di banyak tempat.
- Memudahkan refactor ketika struktur request berubah.
Jika proyek Anda kecil, DTO formal mungkin terasa berlebihan. Sebagai kompromi, buat saja metode normalisasi di service yang mengembalikan struktur array final yang konsisten. Prinsipnya sama: jangan biarkan data request mentah mengalir ke semua lapisan aplikasi.
validated() vs all(): perbedaan yang sering menyebabkan bug
Ini salah satu sumber masalah terbesar. Setelah request lolos validasi, banyak developer tanpa sadar memakai $request->all() saat menyimpan data. Padahal, all() mengembalikan seluruh input mentah, termasuk field yang tidak tervalidasi, field liar, dan struktur asli sebelum kita memilih subset yang aman diproses.
Sebaliknya, validated() hanya mengembalikan data yang lolos rule validasi.
public function store(ProductRequest $request)
{
$data = $request->validated();
$product = Product::create([
'name' => $data['name'],
'price' => $data['price'],
]);
foreach ($data['variants'] as $variant) {
$product->variants()->create($variant);
}
}Bandingkan dengan pola yang berisiko:
public function store(ProductRequest $request)
{
$product = Product::create($request->all());
}Masalah pada all():
- Field ekstra dari client bisa ikut terbawa.
- Struktur nested yang tidak diharapkan bisa lolos ke layer model.
- Jika digabung dengan mass assignment yang longgar, risiko bug dan keamanan meningkat.
Aturan praktis: setelah memakai Form Request, default-kan diri Anda ke validated(), bukan all().
Pola implementasi yang aman untuk payload produk
Contoh request yang lebih siap produksi
class StoreProductRequest extends FormRequest
{
public function prepareForValidation(): void
{
$variants = collect($this->input('variants', []))
->map(function ($variant) {
$attributes = is_array($variant['attributes'] ?? null)
? $variant['attributes']
: [];
return [
'sku' => $variant['sku'] ?? null,
'color' => $variant['color'] ?? ($attributes['color'] ?? null),
'size' => isset($variant['size'])
? (string) $variant['size']
: (isset($attributes['size']) ? (string) $attributes['size'] : null),
'price' => isset($variant['price']) ? (int) $variant['price'] : null,
];
})
->values()
->all();
$this->merge([
'price' => (int) $this->input('price', 0),
'variants' => $variants,
]);
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'price' => ['required', 'integer', 'min:0'],
'variants' => ['required', 'array', 'min:1'],
'variants.*' => ['required', 'array'],
'variants.*.sku' => ['required', 'string', 'max:100'],
'variants.*.color' => ['required', 'string', 'max:50'],
'variants.*.size' => ['required', 'string', 'max:20'],
'variants.*.price' => ['nullable', 'integer', 'min:0'],
];
}
}Controller yang tidak memproses input mentah
public function store(StoreProductRequest $request)
{
$data = $request->validated();
DB::transaction(function () use ($data) {
$product = Product::create([
'name' => $data['name'],
'price' => $data['price'],
]);
foreach ($data['variants'] as $variant) {
$product->variants()->create($variant);
}
});
return response()->json(['message' => 'Produk berhasil disimpan'], 201);
}Pola ini memisahkan tanggung jawab dengan cukup rapi:
- Request menangani normalisasi dan validasi.
- Controller memakai data yang sudah tervalidasi.
- Penyimpanan dibungkus transaksi agar konsisten.
Debugging tips saat nested payload terasa “aneh”
- Dump hasil
validated(), bukan hanyaall(). Banyak kebingungan muncul karena yang diperiksa justru input mentah. - Periksa payload JSON asli dari client, terutama jika ada perbedaan antara object dan array.
- Tes beberapa variasi payload buruk dengan feature test, bukan hanya payload ideal.
- Pastikan normalisasi tidak menutupi error penting. Jika input terlalu rusak, lebih baik tolak daripada dipaksa dibenahi.
- Waspadai key opsional yang sebenarnya diwajibkan oleh proses bisnis.
Contoh skenario test yang layak dibuat:
- Varian tanpa
sku. - Varian memakai
attributes.colorlalu dinormalisasi menjadicolor. imagesdikirim sebagai string, object, dan array campuran.- Field liar seperti
extra_fieldmuncul pada item nested.
Kesimpulan
Form Request yang lolos validasi belum otomatis berarti struktur data nested sudah aman dipakai. Masalah biasanya muncul karena rule wildcard hanya memeriksa jalur tertentu, sementara bentuk keseluruhan payload masih longgar. Untuk mencegah data produk atau varian tersimpan dalam keadaan rusak, gunakan kombinasi berikut:
- Wildcard rule untuk memvalidasi item berulang.
- Key wajib yang eksplisit agar setiap item nested seragam.
- prepareForValidation() untuk normalisasi payload yang datang dari client dengan format tidak konsisten.
- DTO atau lapisan transformasi agar data yang masuk ke service/model lebih tegas kontraknya.
- validated() alih-alih all() agar hanya data yang lolos validasi yang diproses.
Jika kontrak request Anda jelas dan transformasi dilakukan di tempat yang tepat, bug “validasi lolos tapi data masih rusak” biasanya bisa dihentikan jauh sebelum menyentuh database.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!