Mass assignment di Laravel bukan hanya soal lupa mengatur $fillable. Masalah yang lebih umum adalah over-posting: klien mengirim field tambahan yang tidak Anda harapkan, lalu data itu ikut masuk ke model karena controller terlalu permisif, validasi terlalu longgar, atau input langsung diteruskan ke create() dan update().

Strategi yang aman adalah membuat validasi input berlapis: batasi field yang boleh diterima, validasi dengan ketat, normalisasi nilai sebelum disimpan, dan pastikan model hanya menerima atribut yang memang diizinkan. Di Laravel, kombinasi Form Request + allowlist field + $fillable + test fitur biasanya sudah cukup kuat untuk menutup celah mass assignment dan data tak terduga.

Mengapa mass assignment masih terjadi meski sudah ada $fillable?

Laravel menyediakan proteksi mass assignment melalui $fillable dan $guarded. Namun, proteksi ini sering dianggap sebagai satu-satunya lapisan keamanan, padahal ada beberapa masalah praktis:

  • Controller meneruskan seluruh request ke model, misalnya $request->all().
  • Validasi terlalu longgar, sehingga field yang tidak relevan tidak tersaring dengan jelas.
  • Nested array tidak dibatasi, sehingga klien bisa mengirim struktur tambahan.
  • forceFill() atau pola serupa dipakai tanpa kontrol yang ketat.
  • $guarded = [] dipakai demi kenyamanan, lalu proteksi mass assignment praktis hilang.

Intinya, $fillable adalah garis pertahanan terakhir di level model, bukan pengganti validasi dan filtrasi input di level request.

Prinsip hardening: hanya terima field yang benar-benar dibutuhkan

Pola yang paling aman untuk endpoint create dan update adalah:

  1. Definisikan field yang diizinkan di Form Request.
  2. Gunakan aturan validasi yang eksplisit dan ketat.
  3. Ambil hanya data tervalidasi melalui validated() atau hasil transformasi yang lebih sempit.
  4. Simpan hanya ke atribut model yang ada di $fillable.
  5. Tambahkan test fitur untuk membuktikan field liar tidak ikut tersimpan.

Prinsip ini lebih aman daripada pola umum seperti Model::create($request->all()), meskipun model sudah memiliki $fillable.

Contoh kasus: endpoint create/update untuk produk

Misalkan kita punya model Product dengan atribut yang memang boleh diisi dari request: name, slug, price, is_active, metadata, dan image_path. Sementara field seperti is_admin_only, approved_at, atau internal_notes tidak boleh berasal dari klien publik.

Model: gunakan $fillable, hindari $guarded = []

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Product extends Model
{
    protected $fillable = [
        'name',
        'slug',
        'price',
        'is_active',
        'metadata',
        'image_path',
        'category_id',
    ];

    protected $casts = [
        'is_active' => 'boolean',
        'price' => 'integer',
        'metadata' => 'array',
    ];
}

Mengapa $fillable lebih aman? Karena Anda membuat allowlist eksplisit atas atribut yang boleh diisi secara massal. Jika ada field baru di tabel database dan Anda lupa meninjaunya, field itu tidak otomatis bisa diisi dari request.

Catatan: $guarded = [] berarti semua atribut boleh diisi secara massal. Ini nyaman saat prototyping, tetapi berisiko tinggi untuk aplikasi nyata karena memudahkan over-posting masuk ke model.

Form Request untuk create: validasi ketat + normalisasi input

Gunakan Form Request agar aturan validasi, otorisasi request, dan normalisasi input tidak tercecer di controller. Fokus artikel ini pada validasi dan pembatasan data, bukan auth.

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Support\Str;

class StoreProductRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    protected function prepareForValidation(): void
    {
        $name = $this->input('name');
        $slug = $this->input('slug');
        $metadata = $this->input('metadata', []);

        $normalizedMetadata = is_array($metadata)
            ? [
                'color' => isset($metadata['color']) ? trim((string) $metadata['color']) : null,
                'size' => isset($metadata['size']) ? trim((string) $metadata['size']) : null,
            ]
            : [];

        $this->merge([
            'name' => is_string($name) ? trim($name) : $name,
            'slug' => is_string($slug) && $slug !== '' ? Str::slug($slug) : ($name ? Str::slug($name) : null),
            'metadata' => array_filter($normalizedMetadata, fn ($value) => !is_null($value) && $value !== ''),
        ]);
    }

    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:120'],
            'slug' => ['required', 'string', 'max:140', 'alpha_dash', Rule::unique('products', 'slug')],
            'price' => ['required', 'integer', 'min:0'],
            'is_active' => ['sometimes', 'boolean'],
            'category_id' => ['required', 'integer', 'exists:categories,id'],

            'metadata' => ['sometimes', 'array'],
            'metadata.color' => ['sometimes', 'string', 'max:30'],
            'metadata.size' => ['sometimes', 'string', 'max:10'],
        ];
    }

    public function productData(): array
    {
        $data = $this->validated();

        return [
            'name' => $data['name'],
            'slug' => $data['slug'],
            'price' => $data['price'],
            'is_active' => $data['is_active'] ?? false,
            'category_id' => $data['category_id'],
            'metadata' => $data['metadata'] ?? [],
        ];
    }
}

Ada beberapa hal penting di sini:

  • prepareForValidation() dipakai untuk normalisasi awal: trim string, membentuk slug, dan menyaring metadata yang diizinkan.
  • productData() membuat allowlist kedua. Bahkan setelah lolos validasi, hanya field yang memang dibutuhkan yang dikembalikan ke controller.
  • Nested array seperti metadata dibatasi sampai level key yang diharapkan. Ini penting untuk mencegah struktur liar ikut tersimpan.

Form Request untuk update: beda aturan, tetap sempit

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Support\Str;

class UpdateProductRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    protected function prepareForValidation(): void
    {
        $merged = [];

        if ($this->has('name') && is_string($this->input('name'))) {
            $merged['name'] = trim($this->input('name'));
        }

        if ($this->has('slug') && is_string($this->input('slug'))) {
            $merged['slug'] = Str::slug($this->input('slug'));
        }

        if ($this->has('metadata') && is_array($this->input('metadata'))) {
            $metadata = $this->input('metadata');
            $merged['metadata'] = array_filter([
                'color' => isset($metadata['color']) ? trim((string) $metadata['color']) : null,
                'size' => isset($metadata['size']) ? trim((string) $metadata['size']) : null,
            ], fn ($value) => !is_null($value) && $value !== '');
        }

        if ($merged !== []) {
            $this->merge($merged);
        }
    }

    public function rules(): array
    {
        $product = $this->route('product');
        $productId = is_object($product) ? $product->getKey() : $product;

        return [
            'name' => ['sometimes', 'string', 'max:120'],
            'slug' => [
                'sometimes',
                'string',
                'max:140',
                'alpha_dash',
                Rule::unique('products', 'slug')->ignore($productId),
            ],
            'price' => ['sometimes', 'integer', 'min:0'],
            'is_active' => ['sometimes', 'boolean'],
            'category_id' => ['sometimes', 'integer', 'exists:categories,id'],

            'metadata' => ['sometimes', 'array'],
            'metadata.color' => ['sometimes', 'string', 'max:30'],
            'metadata.size' => ['sometimes', 'string', 'max:10'],
        ];
    }

    public function productData(): array
    {
        $data = $this->validated();

        return array_intersect_key($data, array_flip([
            'name',
            'slug',
            'price',
            'is_active',
            'category_id',
            'metadata',
        ]));
    }
}

Untuk update, gunakan sometimes agar field opsional benar-benar hanya divalidasi saat dikirim. Ini lebih cocok untuk endpoint PATCH atau PUT yang tidak selalu mengubah semua atribut.

Controller: jangan gunakan $request->all()

Setelah memakai Form Request, controller seharusnya menjadi tipis dan eksplisit.

<?php

namespace App\Http\Controllers;

use App\Http\Requests\StoreProductRequest;
use App\Http\Requests\UpdateProductRequest;
use App\Models\Product;
use Illuminate\Http\JsonResponse;

class ProductController extends Controller
{
    public function store(StoreProductRequest $request): JsonResponse
    {
        $product = Product::create($request->productData());

        return response()->json([
            'id' => $product->id,
            'slug' => $product->slug,
        ], 201);
    }

    public function update(UpdateProductRequest $request, Product $product): JsonResponse
    {
        $product->update($request->productData());

        return response()->json([
            'id' => $product->id,
            'slug' => $product->slug,
        ]);
    }
}

Pola ini aman karena controller tidak pernah menyentuh seluruh payload mentah. Sumber data untuk model hanya berasal dari method yang sudah terkurasi di Form Request.

Perbedaan fill(), create(), update(), dan forceFill()

Memahami perilaku method Eloquent penting agar Anda tidak tanpa sadar membuka celah.

fill()

Mengisi atribut model secara massal di memori dan tetap tunduk pada $fillable/$guarded. Perlu save() untuk menulis ke database.

$product->fill($request->productData());
$product->save();

create()

Membuat instance baru dan langsung menyimpannya. Tetap tunduk pada proteksi mass assignment.

Product::create($request->productData());

update()

Pada instance model, perilakunya mirip fill() lalu save(), dan tetap menghormati $fillable.

$product->update($request->productData());

forceFill()

Melewati proteksi mass assignment. Method ini berguna hanya untuk kasus internal yang sangat terkontrol, misalnya saat sistem sendiri menghitung atribut tertentu yang tidak pernah berasal dari pengguna.

$product->forceFill([
    'approved_at' => now(),
    'internal_notes' => 'generated by system',
])->save();

Jangan pernah meneruskan payload request mentah ke forceFill(). Jika dilakukan, semua lapisan $fillable praktis tidak berarti lagi.

$fillable vs $guarded: kapan memakai yang mana?

Gunakan $fillable untuk endpoint yang menerima input eksternal

Ini pilihan paling aman karena daftar atribut yang bisa diisi jelas dan sempit.

Hati-hati dengan $guarded

$guarded bisa berguna untuk model internal atau ketika Anda benar-benar mengontrol semua assignment. Tetapi pola seperti ini berisiko:

protected $guarded = [];

Konsekuensinya, setiap atribut model dapat diisi secara massal. Jika ada kolom sensitif di database, field itu bisa ikut terset dari request ketika developer lupa menyaring input.

Rekomendasi praktis: untuk model yang dipakai di endpoint API/web form, default-kan ke $fillable yang eksplisit. Gunakan assignment manual atau forceFill() hanya untuk atribut internal yang tidak berasal dari klien.

Validasi nested array: sumber over-posting yang sering terlewat

Banyak bug mass assignment tidak muncul di field top-level, tetapi di payload bersarang seperti:

{
  "name": "Laptop X",
  "metadata": {
    "color": "black",
    "size": "15",
    "is_admin_only": true,
    "internal_notes": "should never be stored"
  }
}

Jika Anda hanya menulis rule 'metadata' => ['array'] lalu langsung menyimpan seluruh array itu, key liar tetap ikut masuk ke kolom JSON atau cast array.

Pendekatan yang lebih aman:

  • Validasi parent array: metadata harus array.
  • Validasi child key yang memang diizinkan.
  • Bangun ulang array hasil akhir secara eksplisit, jangan langsung percaya pada seluruh payload nested.

Karena itu contoh sebelumnya memakai:

'metadata' => ['sometimes', 'array'],
'metadata.color' => ['sometimes', 'string', 'max:30'],
'metadata.size' => ['sometimes', 'string', 'max:10'],

lalu di prepareForValidation() dan productData(), hanya key yang di-allowlist yang diteruskan.

Normalisasi input: cegah variasi liar sebelum validasi dan simpan

Normalisasi membantu dua hal: membuat validasi lebih konsisten dan mencegah data “aneh tapi lolos” masuk ke model. Beberapa contoh yang relevan:

  • Trim string agar " Produk A " menjadi "Produk A".
  • Slugify input slug agar formatnya seragam.
  • Cast boolean dengan hati-hati, terutama jika request datang dari form HTML atau JSON.
  • Bangun ulang nested array agar key liar dibuang.

Jangan campurkan normalisasi yang mengubah makna bisnis tanpa sadar. Contoh: mengubah semua huruf menjadi lowercase mungkin tepat untuk email, tetapi belum tentu tepat untuk nama produk.

Validasi route parameter: jangan percaya ID di URL begitu saja

Hardening input bukan hanya body request. Route parameter juga bagian dari input eksternal. Ada dua pendekatan umum:

Gunakan route model binding

Jika route menerima Product $product, Laravel akan mengambil model yang sesuai. Ini mengurangi parsing manual ID dan membuat controller lebih rapi.

Route::patch('/products/{product}', [ProductController::class, 'update']);

Jika perlu, validasi konsistensi route dan body

Masalah umum: klien mengirim category_id atau foreign key yang valid secara sintaks, tetapi tidak sesuai dengan konteks route. Misalnya endpoint berada di bawah /categories/{category}/products, maka jangan hanya validasi exists; pastikan relasinya memang cocok dengan resource pada route.

Untuk update dengan unique rule, gunakan parameter route sebagai acuan saat mengabaikan record saat ini, seperti pada contoh Rule::unique()->ignore($productId).

Sanitasi file metadata bila Anda menerima upload

Jika endpoint create/update juga menerima file, fokus hardening bukan hanya tipe MIME dan ukuran file. Metadata file juga bisa menjadi sumber data tak terduga.

Praktik yang aman:

  • Validasi file dengan rule yang ketat, misalnya gambar dan ukuran maksimal.
  • Gunakan nama file yang dihasilkan server, jangan percaya nama file asli dari klien sebagai identifier utama.
  • Jangan menyimpan metadata file mentah kecuali memang dibutuhkan.
  • Jika Anda menyimpan informasi file ke model, simpan hanya field yang diperlukan seperti path, disk, dan ukuran hasil perhitungan server.

Contoh tambahan di Form Request:

'image' => ['sometimes', 'file', 'image', 'max:2048'],

Lalu di controller:

if ($request->hasFile('image')) {
    $path = $request->file('image')->store('products', 'public');
    $product->update(['image_path' => $path]);
}

Jangan pernah mengisi atribut sensitif dari metadata file klien seperti nama asli atau path buatan pengguna tanpa sanitasi dan pembatasan yang jelas.

Pola DTO atau Action untuk membatasi data masuk

Untuk aplikasi yang mulai kompleks, Anda bisa menambah lapisan eksplisit antara request dan model menggunakan DTO atau Action. Tujuannya bukan sekadar gaya arsitektur, tetapi membatasi bentuk data yang boleh lewat.

Contoh DTO sederhana

<?php

namespace App\Data;

class ProductData
{
    public function __construct(
        public readonly string $name,
        public readonly string $slug,
        public readonly int $price,
        public readonly bool $isActive,
        public readonly int $categoryId,
        public readonly array $metadata,
    ) {}

    public static function fromArray(array $data): self
    {
        return new self(
            name: $data['name'],
            slug: $data['slug'],
            price: $data['price'],
            isActive: $data['is_active'] ?? false,
            categoryId: $data['category_id'],
            metadata: $data['metadata'] ?? [],
        );
    }

    public function toModelAttributes(): array
    {
        return [
            'name' => $this->name,
            'slug' => $this->slug,
            'price' => $this->price,
            'is_active' => $this->isActive,
            'category_id' => $this->categoryId,
            'metadata' => $this->metadata,
        ];
    }
}

Lalu di controller:

use App\Data\ProductData;

$data = ProductData::fromArray($request->productData());
$product = Product::create($data->toModelAttributes());

Kapan DTO membantu?

  • Saat field input banyak dan dipakai ulang di beberapa endpoint.
  • Saat ada transformasi data non-trivial.
  • Saat Anda ingin memisahkan kontrak input dari model Eloquent.

Trade-off: kode bertambah. Untuk CRUD kecil, Form Request dengan method productData() sering sudah cukup. DTO lebih terasa manfaatnya saat domain mulai kompleks.

Kesalahan umum yang sering membuka celah

  • Model::create($request->all()) atau $model->update($request->all()).
  • Mengandalkan $fillable saja tanpa validasi ketat.
  • Memakai $guarded = [] di model yang menerima input pengguna.
  • Menyimpan nested JSON langsung dari request tanpa rebuild key yang diizinkan.
  • Memakai forceFill() untuk payload dari klien.
  • Melakukan normalisasi setelah simpan, bukan sebelum validasi/simpan.
  • Tidak menulis test untuk memastikan field liar diabaikan.

Test fitur: buktikan over-posting ditolak

Hardening yang baik harus bisa diuji. Fokus utama test adalah memastikan field yang tidak diizinkan tidak ikut tersimpan, walaupun dikirim oleh klien.

<?php

namespace Tests\Feature;

use App\Models\Category;
use App\Models\Product;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ProductControllerTest extends TestCase
{
    use RefreshDatabase;

    public function test_store_product_ignores_unexpected_fields(): void
    {
        $category = Category::factory()->create();

        $payload = [
            'name' => 'Laptop Pro',
            'slug' => 'laptop-pro',
            'price' => 15000000,
            'is_active' => true,
            'category_id' => $category->id,
            'metadata' => [
                'color' => 'black',
                'size' => '15',
                'internal_notes' => 'must be ignored',
            ],
            'approved_at' => now()->toDateTimeString(),
            'internal_notes' => 'must not be saved',
        ];

        $response = $this->postJson('/products', $payload);

        $response->assertCreated();

        $product = Product::firstOrFail();

        $this->assertSame('Laptop Pro', $product->name);
        $this->assertSame('laptop-pro', $product->slug);
        $this->assertSame(['color' => 'black', 'size' => '15'], $product->metadata);
        $this->assertNull($product->getAttribute('approved_at'));
        $this->assertNull($product->getAttribute('internal_notes'));
    }

    public function test_update_product_ignores_unexpected_fields(): void
    {
        $category = Category::factory()->create();
        $product = Product::factory()->create([
            'category_id' => $category->id,
            'metadata' => ['color' => 'silver'],
        ]);

        $payload = [
            'price' => 20000000,
            'metadata' => [
                'color' => 'gray',
                'is_admin_only' => true,
            ],
            'approved_at' => now()->toDateTimeString(),
        ];

        $response = $this->patchJson('/products/' . $product->id, $payload);

        $response->assertOk();

        $product->refresh();

        $this->assertSame(20000000, $product->price);
        $this->assertSame(['color' => 'gray'], $product->metadata);
        $this->assertNull($product->getAttribute('approved_at'));
    }
}

Test seperti ini sangat berguna saat ada refactor. Jika suatu hari seseorang mengubah controller menjadi memakai $request->all() atau mengosongkan $guarded, test akan cepat memberi sinyal.

Tips debugging saat data tak terduga masih lolos

  • Periksa sumber data ke model: apakah berasal dari validated(), safe(), method allowlist kustom, atau justru all()?
  • Cek model: apakah $fillable benar? Apakah ada $guarded = []?
  • Audit nested array: apakah Anda menyimpan array mentah ke kolom JSON?
  • Cari penggunaan forceFill() atau assignment manual yang terlalu luas.
  • Tinjau mutator/cast: pastikan tidak ada transformasi yang membuat field tampak “hilang” padahal sebenarnya tersimpan.
  • Tambahkan assertion di test untuk field yang seharusnya tidak pernah berubah.

Checklist audit code review untuk endpoint create/update

  1. Apakah endpoint memakai Form Request, bukan validasi inline yang tercecer?
  2. Apakah controller menghindari $request->all() dan hanya memakai data tervalidasi?
  3. Apakah ada allowlist field eksplisit seperti productData() atau DTO?
  4. Apakah model memakai $fillable yang sempit?
  5. Apakah ada $guarded = [] yang berbahaya?
  6. Apakah ada penggunaan forceFill()? Jika ya, apakah hanya untuk data internal yang tidak berasal dari klien?
  7. Apakah nested array/JSON divalidasi sampai child key dan dibangun ulang secara eksplisit?
  8. Apakah input dinormalisasi sebelum validasi/simpan, misalnya trim, slugify, atau sanitasi struktur array?
  9. Apakah route parameter dan body request divalidasi konsistensinya jika ada relasi resource?
  10. Jika ada upload file, apakah metadata file yang disimpan benar-benar minimum dan berasal dari nilai yang dikontrol server?
  11. Apakah ada test fitur yang membuktikan field liar diabaikan saat create dan update?

Jika semua poin di atas lolos, endpoint Laravel Anda biasanya sudah jauh lebih kuat terhadap mass assignment, over-posting, dan masuknya data tak terduga ke model.