Relasi paling sederhana antar tabel adalah satu-ke-satu: satu baris di tabel ini berpasangan dengan paling banyak satu baris di tabel itu. Contoh klasiknya di aplikasi nyata adalah user dan nomor telepon — satu user mendaftarkan satu nomor, dan satu nomor hanya terdaftar atas nama satu user. Relasi seperti ini biasanya lahir karena kolomnya jarang dipakai atau berukuran besar sehingga dipisah ke tabel sendiri agar tabel utama tetap ramping. Chapter ini membangun relasi tersebut dari migration sampai query, menguji setiap klaimnya di database sungguhan.

Migration dan Model #

Kedua tabel kita beri kunci penghubung user_id pada tabel phones. Batasan unique() pada kolom itu penting: secara konvensi Eloquent, hasOne() mengambil baris pertama yang cocok, dan unique memastikan tidak akan pernah ada baris kedua — satu-ke-satu dijaga di level database, bukan hanya di level kode:

php
Schema::create('phones', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete();
    $table->string('nomor', 20);
    $table->timestamps();
});

Di sisi model, relasi didefinisikan sebagai method hasOne() di pemilik data, dan belongsTo() sebagai jalan baliknya di sisi pemegang foreign key:

php
// app/Models/User.php
public function phone(): HasOne
{
    return $this->hasOne(Phone::class);
}

// app/Models/Phone.php
use Illuminate\Database\Eloquent\Attributes\Fillable;

#[Fillable(['nomor'])]
class Phone extends Model
{
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

Definisi satu baris ini bekerja karena konvensi kunci dari chapter sebelumnya : hasOne(Phone::class) otomatis mencocokkan phones.user_id dengan users.id, dan belongsTo(User::class) mencocokkan users.id dengan kolom user_id milik phones sendiri.

Mengambil Data: Satu Query Saat Disentuh #

Mengakses $user->phone menjalankan tepat satu query, dan hasilnya adalah instance Phone — bukan collection, karena satu-ke-satu:

php
use Illuminate\Support\Facades\DB;

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

DB::enableQueryLog();
echo 'nomor Budi: ', $budi->phone->nomor, "\n";

foreach (DB::getQueryLog() as $q) {
    echo 'query: [', $q['query'], "]\n";
}

Output yang Dihasilkan:

text
nomor Budi: 0812-3456-7890
query: [select * from "phones" where "phones"."user_id" = ? and "phones"."user_id" is not null limit 1]

Query itu dijalankan secara lazy: baru saat baris echo menyentuh property phone. Setelah dijalankan sekali, hasilnya disimpan di memori sehingga akses $budi->phone berikutnya pada objek yang sama tidak mengulang query. Bila tidak ada baris yang cocok, property ini bernilai null — kita buktikan segera lewat user Anto yang belum punya nomor.

Inverse: belongsTo dari Sisi Phone #

Dari sisi Phone, method user() mengembalikan pemilik nomor. Pola ini disebut inverse relationship — relasi balik yang menuntun kembali ke pemiliknya:

php
$phoneBudi = Phone::first();
echo 'pemilik nomor ', $phoneBudi->nomor, ': ', $phoneBudi->user->name, "\n";

Output yang Dihasilkan:

text
pemilik nomor 0812-3456-7890: Budi

Tidak ada yang baru secara mekanisme — tetap lazy loading, tetap satu query SELECT ke tabel users dengan where "users"."id" = ?. Yang berbeda hanya arah pandangnya: satu model berangkat dari user, satu model berangkat dari phone, keduanya sampai ke pasangannya masing-masing. Nama kolom kustom dan opsi lain kedua relasi ini dirinci di dokumentasi Eloquent Relationships .

Membuat Data lewat Relasi #

Relasi tidak hanya untuk membaca. Method phone() dipanggil dengan kurung untuk membuat model baru dengan foreign key yang diisi otomatis:

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

$phoneSiti = $siti->phone()->create(['nomor' => '0898-7654-3210']);

echo 'tersimpan untuk ', $siti->name, ' | nomor: ', $phoneSiti->nomor, ' | user_id: ', $phoneSiti->user_id, "\n";
echo 'balik arah: ', $phoneSiti->user->email, "\n";

Output yang Dihasilkan:

text
tersimpan untuk Siti | nomor: 0898-7654-3210 | user_id: 2
balik arah: [email protected]

Perhatikan bahwa attribute #[Fillable(['nomor'])] pada model Phone sengaja tidak memuat user_id — dan justru karena itu contoh di atas tetap aman. Method create() milik relasi menulis foreign key secara langsung lewat setAttribute() setelah model dibuat, sehingga kolom penghubung tidak pernah bergantung pada mass assignment.

Model Default dengan withDefault() #

Sekarang kita uji user Anto yang belum memiliki nomor. Tanpa penanganan khusus, $anto->phone->nomor memicu error karena membaca property dari null. Method withDefault() pada hasOne() mengubah situasi ini — bila tidak ada baris yang cocok, relasi mengembalikan model kosong alih-alih null:

php
public function phone(): HasOne
{
    return $this->hasOne(Phone::class)->withDefault();
}
php
$anto = User::where('email', '[email protected]')->first();

echo 'exists: ', var_export($anto->phone->exists, true), "\n";
echo 'nomor: ', var_export($anto->phone->nomor, true), "\n";

Output yang Dihasilkan:

text
exists: false
nomor: NULL

Model kosong ini tidak pernah tersimpan ke database — attribute exists bernilai false dan seluruh kolomnya null. Gunanya murni di sisi kode: $anto->phone->nomor ?? '(belum ada nomor)' atau template Blade yang tidak perlu berulang kali memeriksa null. Versi withDefault(['nomor' => 'belum terdaftar']) bahkan mengisi attribute bawaan pada model kosong tersebut.

Memuat Banyak User Sekaligus #

Perhatikan apa yang terjadi bila kita menampilkan daftar user beserta nomornya. Dengan lazy loading, tiga user berarti empat query: satu untuk daftar user, satu per user untuk phone. Method with() memuat relasi di muka — eager loading — sehingga semuanya selesai dalam dua query:

php
$users = User::orderBy('id')->with('phone')->get();

foreach ($users as $u) {
    echo $u->name, ' => ', $u->phone->nomor ?? '(belum ada nomor)', "\n";
}

Output yang Dihasilkan:

text
Budi => 0812-3456-7890
Siti => 0898-7654-3210
Anto => (belum ada nomor)

Dua query itu adalah select * from "users" order by "id" asc lalu select * from "phones" where "phones"."user_id" in (1, 2, 3) — nomor ketiga user diambil sekaligus dengan satu klausa IN. Perhatikan pula baris Anto: kombinasi withDefault() dan operator ?? membuat tampilan tetap rapi tanpa pemeriksaan tambahan. Kita baru mencicipi with() di sini; seluruh strategi pemuatan relasi dibahas tuntas di chapter Eager Loading .

Satu user, satu phone — batasan unique() di migration dan limit 1 di query saling menguatkan. Namun tidak semua relasi sesempit itu: satu post bisa menampung ratusan comment, dan One to Many membahas pola yang tepat untuk situasi tersebut.