Relasi satu-ke-satu membatasi pasangan tunggal, padahal pola paling umum di aplikasi justru sebaliknya: satu post dikomentari banyak orang, satu user menulis banyak post, satu kategori menampung banyak artikel. Relasi seperti ini adalah one to many — satu baris di sisi induk dipetakan ke banyak baris di sisi anak. Kuncinya tetap satu foreign key di tabel anak: comments.post_id menyimpan siapa induk setiap comment, dan banyak comment boleh memakai nilai yang sama. Chapter ini membangun relasi Post dan Comment tersebut lalu mengujinya dengan operasi lengkap: membaca collection, menulis lewat relasi, dan memindahkan kepemilikan.

Migration dan Model #

Dua tabel kita susun seperti biasa — induk dulu, baru anak dengan foreign key-nya:

php
Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('judul', 120);
    $table->text('isi');
    $table->timestamps();
});

Schema::create('comments', function (Blueprint $table) {
    $table->id();
    $table->foreignId('post_id')->constrained()->cascadeOnDelete();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->text('isi');
    $table->timestamps();
});

Perhatikan bahwa comments memegang dua foreign key sekaligus: post_id menunjuk post yang dikomentari, user_id menunjuk user penulisnya — relasi yang berbeda boleh berbagi satu tabel. Model-nya pun mendefinisikan dua relasi dari sisi yang berbeda:

php
// app/Models/Post.php
public function comments(): HasMany
{
    return $this->hasMany(Comment::class);
}

public function latestComment(): HasOne
{
    return $this->hasOne(Comment::class)->latestOfMany();
}

// app/Models/Comment.php
public function post(): BelongsTo
{
    return $this->belongsTo(Post::class);
}

public function user(): BelongsTo
{
    return $this->belongsTo(User::class);
}

hasMany(Comment::class) menebak kolom comments.post_id sebagai penghubung — sama seperti hasOne, hanya tanpa limit 1. Sementara belongsTo di sisi comment adalah jalan baliknya masing-masing.

Mengakses $post->comments #

Berbeda dengan satu-ke-satu, property relasi hasMany mengembalikan Illuminate\Database\Eloquent\Collection — nol atau banyak model sekaligus:

php
$post1 = Post::where('judul', 'Tips Produktif Belajar Laravel')->first();

echo 'class: ', get_class($post1->comments), "\n";
echo 'jumlah: ', $post1->comments->count(), "\n";
foreach ($post1->comments as $comment) {
    echo '- ', $comment->isi, "\n";
}
echo 'inverse: ', $post1->comments->first()->post->judul, "\n";

Output yang Dihasilkan:

text
class: Illuminate\Database\Eloquent\Collection
jumlah: 3
- Materinya sangat membantu!
- Terima kasih, bang.
- Saya tunggu bagian relasi.
inverse: Tips Produktif Belajar Laravel

Collection ini bisa diiterasi seperti array biasa, dan method bawaannya — count(), first(), pluck(), contains() — bekerja pada data yang sudah berada di memori. Baris terakhir memperlihatkan bahwa setiap anggota collection adalah model utuh yang membawa relasinya sendiri: $comment->post menjalankan satu query balik ke tabel posts lewat belongsTo yang kita definisikan di model Comment. Bila kita hanya butuh jumlah di database, $post1->comments()->count() lebih murah karena diterjemahkan menjadi select count(*) tanpa memuat baris mana pun.

Ada dua cara utama menambah anak lewat sisi induk. Yang pertama, create() pada relasi — sama seperti di chapter One to One , foreign key diisi otomatis:

php
$siti = User::where('email', '[email protected]')->first();

$baru = $post1->comments()->create([
    'user_id' => $siti->id,
    'isi' => 'Terima kasih, materinya jelas!',
]);
echo 'tersimpan id ', $baru->id, ' dengan post_id ', $baru->post_id, "\n";

Cara kedua memakai save() yang menerima instance model yang sudah terisi — berguna saat proses pembentukan modelnya lebih panjang dari sekadar array:

php
$anto = User::where('email', '[email protected]')->first();

$c = new Comment(['user_id' => $anto->id, 'isi' => 'Saya juga sedang belajar relasi.']);
$post1->comments()->save($c);
echo 'tersimpan id ', $c->id, ' dengan post_id ', $c->post_id, "\n";

Output yang Dihasilkan:

text
tersimpan id 5 dengan post_id 1
tersimpan id 6 dengan post_id 1

Kedua jalur berakhir di tempat yang sama: baris baru di comments dengan post_id = 1 yang diambil dari model $post1, tanpa kita menyentuh kolom itu secara manual.

Property yang Di-cache #

Kini ada jebakan yang wajib kita buktikan. Property $post1->comments sudah termuat di memori sejak demo pertama, dan Eloquent tidak otomatis menyegarkannya:

php
echo 'property lama: ', $post1->comments->count(), "\n";
$post1->load('comments');
echo 'setelah load(): ', $post1->comments->count(), "\n";

Output yang Dihasilkan:

text
property lama: 3
setelah load(): 5

Associate: Menautkan dari Sisi Anak #

Kadang kita justru memegang si anak, bukan si induk — misalnya form komentar yang memilih post tujuan. Untuk situasi seperti ini, associate() pada relasi belongsTo menautkan induk ke model yang belum tersimpan:

php
$post2 = Post::where('judul', 'Memahami Migration di Laravel')->first();
$budi = User::where('email', '[email protected]')->first();

$pindah = new Comment(['user_id' => $budi->id, 'isi' => 'Mirip fitur di framework lain.']);
$pindah->post()->associate($post2);
$pindah->save();

echo 'tersimpan id ', $pindah->id, ' dengan post_id ', $pindah->post_id, "\n";
foreach ($post2->comments as $comment) {
    echo '- ', $comment->isi, "\n";
}

Output yang Dihasilkan:

text
tersimpan id 7 dengan post_id 2
- Penjelasannya jelas sekali.
- Mirip fitur di framework lain.

Method associate($post2) hanya mengisi attribute post_id pada model di memori — tidak menyentuh database — sehingga save() tetap diperlukan untuk menuliskannya. Lawannya, dissociate(), mengosongkan foreign key. Perbedaannya dengan save() milik hasMany murni soal sisi mana yang kita pegang: induk yang menarik anak masuk, atau anak yang menautkan dirinya ke induk.

Method sebagai Query Builder #

Terakhir, panggil methodnya dengan kurung dan kita dapat query builder penuh untuk memfilter anak-anak di level database:

php
foreach ($post1->comments()->where('id', '>=', 5)->orderBy('id')->get() as $comment) {
    echo '- ', $comment->isi, "\n";
}
echo 'jumlah comment post 1 di database: ', $post1->comments()->count(), "\n";

Output yang Dihasilkan:

text
- Terima kasih, materinya jelas!
- Saya juga sedang belajar relasi.
jumlah comment post 1 di database: 5

Method latestComment() di model Post dibangun dari ide yang sama: hasOne(Comment::class) dirantai latestOfMany() sehingga relasi memilih satu comment dengan MAX(id) per post lewat subquery agregat — $post1->latestComment->isi mengembalikan comment terbaru tanpa memuat sisanya. Pola ofMany() seperti ini akan kita dalami lagi di Relationship Queries karena berguna untuk relasi satu-ke-banyak versi ringkas — comment terbaru, harga termurah, atau transaksi terbesar; variasinya tercantum di dokumentasi Eloquent Relationships .

Satu comment kini dengan rapi milik satu post. Namun coba bayangkan relasi user dan role: satu user memegang banyak role, satu role dipegang banyak user — tidak ada yang bisa menjadi pemilik tunggal. Solusinya adalah tabel perantara, dan Many to Many membangunnya.