Kenapa duplikat data Laravel sering terjadi?

Masalah duplikat data Laravel sering muncul bukan karena relasi hasMany di Eloquent rusak, tetapi karena query yang dibangun tidak sesuai dengan bentuk datanya. Kasus yang sangat umum adalah ketika sebuah Order memiliki banyak OrderItem, lalu setiap item terhubung ke Product. Saat developer mencoba mengambil semuanya sekaligus dengan join manual, hasil di API atau Blade terlihat berulang: satu order bisa tampil beberapa kali, atau item dalam relasi tampak dobel.

Intinya sederhana: join akan memperluas baris hasil query. Jika satu order punya 3 item, maka hasil join antara orders dan order_items akan menghasilkan 3 baris untuk order yang sama. Ini adalah perilaku SQL yang benar, tetapi sering disalahartikan sebagai bug pada relasi Laravel.

Artikel ini membahas studi kasus nyata, gejala di hasil API/Blade, penyebab teknisnya, dan cara memperbaiki query secara aman tanpa menimbulkan data ganda.

Studi kasus: order, order_items, dan products

Misalkan kita punya struktur tabel seperti ini:

  • orders: menyimpan header pesanan
  • order_items: menyimpan item per order
  • products: data produk

Relasi Eloquent-nya kurang lebih seperti berikut:

class Order extends Model
{
    public function items()
    {
        return $this->hasMany(OrderItem::class);
    }
}

class OrderItem extends Model
{
    public function order()
    {
        return $this->belongsTo(Order::class);
    }

    public function product()
    {
        return $this->belongsTo(Product::class);
    }
}

Secara konsep, satu order punya banyak item. Jadi jika order ID 10 punya 2 item, maka:

  • Order tetap 1 data
  • Items berjumlah 2 data

Masalah mulai muncul ketika kita menulis query seperti ini:

$orders = Order::query()
    ->join('order_items', 'orders.id', '=', 'order_items.order_id')
    ->join('products', 'products.id', '=', 'order_items.product_id')
    ->select('orders.*', 'products.name as product_name', 'order_items.qty')
    ->get();

Secara SQL, query di atas valid. Namun hasilnya bukan lagi satu baris per order, melainkan satu baris per kombinasi order dan item. Jika order punya 5 item, order tersebut akan muncul 5 kali dalam hasil. Ketika data ini dikirim ke API atau ditampilkan di Blade, developer sering melihatnya sebagai duplikat data.

Gejala yang terlihat di API atau Blade

1. Order tampil berulang

Pada endpoint API, Anda mungkin mengharapkan hasil seperti ini:

[
  {
    "id": 10,
    "customer_name": "Budi"
  }
]

Tetapi yang keluar justru:

[
  {
    "id": 10,
    "customer_name": "Budi",
    "product_name": "Keyboard",
    "qty": 1
  },
  {
    "id": 10,
    "customer_name": "Budi",
    "product_name": "Mouse",
    "qty": 2
  }
]

Ini bukan karena relasi hasMany menduplikasi data, melainkan karena hasil query memang menjadi beberapa baris.

2. Blade menampilkan parent record berkali-kali

Jika data di-loop langsung di Blade:

@foreach ($orders as $order)
    <tr>
        <td>{{ $order->id }}</td>
        <td>{{ $order->customer_name }}</td>
        <td>{{ $order->product_name }}</td>
    </tr>
@endforeach

Maka order yang sama akan muncul berulang untuk setiap item. Dari sisi tampilan, ini tampak seperti data ganda. Padahal SQL memang menghasilkan banyak baris.

3. Eager loading terasa “aneh” setelah join

Kesalahan lain adalah menggabungkan join manual dengan with('items.product'), lalu bingung karena jumlah model utama tampak tidak sesuai. Eloquent tetap akan meng-hydrate model dari hasil query utama. Jika query utama sudah menggandakan baris parent, koleksi yang didapat bisa terlihat tidak wajar.

Akar masalah: join mengubah kardinalitas hasil

Relasi hasMany berarti satu parent punya banyak child. Dalam SQL, saat tabel parent di-join dengan tabel child, hasil akhirnya mengikuti jumlah child. Ini disebut perubahan kardinalitas hasil.

Contoh:

  • orders: 1 baris untuk order ID 10
  • order_items: 3 baris untuk order ID 10

Setelah di-join, hasil query akan menjadi 3 baris. Jadi jika tujuan Anda adalah mengambil daftar order, join ke tabel hasMany akan memperbanyak baris parent. Karena itu, join cocok untuk kasus tertentu, tetapi bukan default terbaik untuk mengambil struktur data bertingkat.

Catatan penting: jika tujuan utama Anda adalah menampilkan order beserta item-itemnya sebagai relasi, eager loading hampir selalu lebih aman dan lebih jelas dibanding join manual.

Perbaikan yang aman: gunakan eager loading untuk relasi

Untuk mengambil order beserta item dan product, gunakan Eloquent sesuai bentuk datanya:

$orders = Order::query()
    ->with(['items.product'])
    ->latest()
    ->get();

Dengan pendekatan ini:

  • Query utama tetap mengambil data orders tanpa menggandakan baris
  • Laravel mengambil order_items dan products melalui query terpisah yang terkontrol
  • Hasil akhirnya berbentuk nested object yang sesuai untuk API dan Blade

Contoh hasil JSON menjadi lebih rapi:

[
  {
    "id": 10,
    "customer_name": "Budi",
    "items": [
      {
        "id": 100,
        "qty": 1,
        "product": {
          "id": 1,
          "name": "Keyboard"
        }
      },
      {
        "id": 101,
        "qty": 2,
        "product": {
          "id": 2,
          "name": "Mouse"
        }
      }
    ]
  }
]

Inilah cara yang paling aman jika kebutuhan Anda adalah menampilkan relasi, bukan meratakan data menjadi satu tabel hasil.

Perbedaan join, eager loading, distinct, dan groupBy

1. Kapan memakai join?

Gunakan join jika Anda memang ingin:

  • Memfilter berdasarkan kolom tabel lain
  • Mengurutkan berdasarkan kolom relasi
  • Menghasilkan data flat untuk laporan
  • Menghitung agregasi langsung di SQL

Contoh yang tepat: ambil order yang memiliki produk tertentu.

$orders = Order::query()
    ->join('order_items', 'orders.id', '=', 'order_items.order_id')
    ->where('order_items.product_id', 5)
    ->select('orders.*')
    ->distinct()
    ->get();

Di sini distinct() penting karena satu order bisa punya lebih dari satu item yang cocok. Jika tujuan akhirnya hanya daftar order unik, maka distinct adalah pilihan yang relevan.

2. Kapan memakai eager loading?

Gunakan with() ketika Anda ingin memuat relasi sesuai struktur model. Ini paling tepat untuk:

  • Response API nested
  • Tampilan detail master-detail di Blade
  • Mencegah N+1 query tanpa merusak bentuk data parent-child

Contoh:

$orders = Order::with(['items.product'])->paginate(20);

Pendekatan ini lebih mudah dipelihara dan minim risiko duplikasi parent record.

3. Kapan memakai distinct?

distinct() dipakai saat hasil join menghasilkan baris ganda tetapi Anda hanya membutuhkan nilai unik. Namun, distinct bukan solusi universal.

Misalnya:

$orders = Order::query()
    ->join('order_items', 'orders.id', '=', 'order_items.order_id')
    ->select('orders.*')
    ->distinct()
    ->get();

Ini bisa menghilangkan order yang berulang jika kolom yang dipilih hanya orders.*. Tetapi jika Anda juga memilih product_name atau qty, setiap baris bisa tetap berbeda sehingga distinct tidak lagi menyatukan hasil seperti yang diharapkan.

Jadi, gunakan distinct hanya jika Anda benar-benar memahami kolom apa yang sedang dipilih.

4. Kapan memakai groupBy?

groupBy cocok jika Anda memang sedang membuat agregasi, misalnya total item per order atau total nilai order.

$orders = Order::query()
    ->join('order_items', 'orders.id', '=', 'order_items.order_id')
    ->selectRaw('orders.id, orders.customer_name, SUM(order_items.qty) as total_qty')
    ->groupBy('orders.id', 'orders.customer_name')
    ->get();

Ini tepat untuk laporan ringkas. Namun groupBy bukan solusi untuk memuat relasi detail. Jika Anda butuh daftar item lengkap, groupBy justru akan mengubah bentuk data menjadi agregat.

Contoh perbaikan query yang aman

Kasus salah: daftar order beserta item, tetapi pakai join flat

$orders = Order::query()
    ->join('order_items', 'orders.id', '=', 'order_items.order_id')
    ->join('products', 'products.id', '=', 'order_items.product_id')
    ->select('orders.*', 'products.name as product_name', 'order_items.qty')
    ->get();

Risiko:

  • Order tampil berulang
  • Pagination bisa menyesatkan karena yang dihitung adalah baris join, bukan order unik
  • Sulit dipakai untuk response nested

Perbaikan 1: gunakan eager loading

$orders = Order::query()
    ->with(['items.product'])
    ->select('id', 'customer_name', 'created_at')
    ->latest()
    ->get();

Ini solusi terbaik jika tujuan Anda adalah menampilkan order dan relasinya.

Perbaikan 2: jika butuh filter dari child, kombinasikan whereHas dan with

$orders = Order::query()
    ->whereHas('items', function ($query) {
        $query->where('product_id', 5);
    })
    ->with(['items.product'])
    ->get();

Pendekatan ini lebih aman dibanding join manual untuk kebutuhan bisnis umum. Anda tetap bisa memfilter berdasarkan child tanpa menggandakan parent di hasil utama.

Perbaikan 3: jika memang harus join, ambil parent unik

$orders = Order::query()
    ->join('order_items', 'orders.id', '=', 'order_items.order_id')
    ->where('order_items.product_id', 5)
    ->select('orders.*')
    ->distinct()
    ->get();

Ini aman jika output yang diinginkan hanya daftar order unik, bukan struktur relasi detail.

Kesalahan umum yang sering terjadi

  • Menganggap join sama dengan eager loading. Keduanya punya tujuan berbeda.
  • Memilih terlalu banyak kolom saat distinct. Akibatnya baris tetap dianggap unik dan duplikasi tidak hilang.
  • Memakai groupBy untuk “menghapus duplikat”. Padahal groupBy semestinya untuk agregasi.
  • Melakukan pagination pada query join hasMany. Hasil jumlah data sering tidak sesuai ekspektasi karena yang dihitung adalah baris hasil join.
  • Mencampur kebutuhan data flat dan nested dalam satu query. Ini membuat query sulit dipahami dan rawan bug.

Tips debugging saat data relasi terlihat dobel

  1. Cek SQL mentahnya. Gunakan toSql(), dd(), atau query log untuk melihat apakah Anda sedang melakukan join ke tabel hasMany.

  2. Hitung jumlah child di database. Bisa jadi “duplikat” sebenarnya adalah data child yang memang lebih dari satu.

  3. Periksa kolom select. Jika Anda hanya butuh parent, jangan pilih kolom child tanpa alasan.

  4. Bandingkan hasil join vs with. Jika dengan eager loading hasil normal, masalah biasanya ada pada bentuk query join.

  5. Uji dengan satu order spesifik. Ambil satu order yang punya beberapa item, lalu lihat berapa baris yang keluar dari join.

Kesimpulan

Kasus duplikat data Laravel pada relasi hasMany umumnya berasal dari join yang salah atau dipakai pada konteks yang keliru. Saat Anda meng-join orders dengan order_items, satu order akan muncul sebanyak jumlah item yang dimilikinya. Itu perilaku SQL yang normal.

Jika tujuan Anda adalah menampilkan relasi, gunakan eager loading dengan with(). Jika tujuan Anda adalah memfilter parent berdasarkan child, pertimbangkan whereHas(). Gunakan distinct hanya saat memang butuh hasil unik dari query join, dan pakai groupBy bila Anda sedang membuat agregasi.

Dengan memahami perbedaan ini, Anda bisa menghindari bug tampilan, response API yang membingungkan, dan query yang sulit dirawat. Singkatnya: jangan jadikan join sebagai solusi default untuk semua relasi hasMany di Laravel.