API Relationship & Advanced Eloquent (One-to-Many, Many-to-Many, Polymorphic) - Perwira Learning Center


1. LATAR BELAKANG

    Memasuki minggu ketujuh PKL di Perwira Learning Center, saya mulai mempelajari aspek yang lebih dalam dari Eloquent ORM—jantung dari interaksi database di Laravel. Setelah berhasil membangun API dasar dengan autentikasi dan deployment di minggu-minggu sebelumnya, tantangan berikutnya adalah bagaimana mengelola relasi antar data yang kompleks.

Dalam aplikasi nyata, data tidak pernah berdiri sendiri. Sebuah post pasti memiliki penulis (user), bisa memiliki banyak komentar, dan mungkin juga memiliki banyak tag. Sebaliknya, sebuah user bisa memiliki banyak post, dan sebuah tag bisa digunakan di banyak post. Di sinilah konsep relasi database menjadi sangat krusial.

Eloquent menyediakan cara yang sangat elegan untuk mendefinisikan dan mengelola relasi ini. Dengan pendekatan object-oriented, kita bisa mengakses data relasi seolah-olah itu adalah properti dari model. Yang lebih hebat lagi, Laravel mendukung berbagai jenis relasi:

  1. One-to-One - Satu user memiliki satu profil
  2. One-to-Many - Satu user memiliki banyak post
  3. Many-to-Many - Post memiliki banyak tag, tag dimiliki banyak post
  4. Has-Many-Through - Mengakses data melalui relasi berantai
  5. Polymorphic Relations - Relasi yang bisa dimiliki oleh banyak model berbeda (komentar bisa untuk post DAN video)

Memahami dan mengimplementasikan relasi dengan benar akan membuat kode kita lebih bersih, query lebih efisien, dan API lebih powerful.


2. ALAT DAN BAHAN

2.1 Perangkat Lunak

  • Laravel 11 - Project API yang sudah berjalan (dari minggu sebelumnya)
  • MySQL/MariaDB - Database dengan struktur relasi
  • Postman/Thunder Client - Untuk testing endpoint API
  • Visual Studio Code - Editor kode
  • Git Bash/Terminal - Untuk menjalankan perintah Artisan
  • phpMyAdmin - Untuk melihat struktur database (opsional)

2.2 Perangkat Keras

  • Laptop dengan spesifikasi standar

3. PEMBAHASAN

3.1 Persiapan Database dan Model

Sebelum mempelajari relasi, mari kita siapkan struktur database yang akan digunakan:

3.1.1 Migration untuk Tabel Baru
bash
# Migration untuk tabel profiles (one-to-one dengan users)
php artisan make:migration create_profiles_table

# Migration untuk tabel tags (many-to-many dengan posts)
php artisan make:migration create_tags_table

# Migration untuk tabel pivot post_tag
php artisan make:migration create_post_tag_table

# Migration untuk tabel videos (untuk polymorphic)
php artisan make:migration create_videos_table

# Migration untuk tabel comments (polymorphic)
php artisan make:migration create_comments_table

Migration Profiles:

php
<?php
// database/migrations/xxxx_create_profiles_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::create('profiles', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('bio')->nullable();
$table->string('avatar')->nullable();
$table->string('website')->nullable();
$table->string('location')->nullable();
$table->date('birth_date')->nullable();
$table->enum('gender', ['male', 'female', 'other'])->nullable();
$table->json('social_links')->nullable(); // Simpan multiple links sebagai JSON
$table->timestamps();
});
}

public function down(): void
{
Schema::dropIfExists('profiles');
}
};

Migration Tags:

php
<?php
// database/migrations/xxxx_create_tags_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::create('tags', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->string('color')->default('#3b82f6'); // Warna untuk UI
$table->integer('usage_count')->default(0); // Counter berapa kali dipakai
$table->timestamps();
});
}

public function down(): void
{
Schema::dropIfExists('tags');
}
};

Migration Pivot Post-Tag:

php
<?php
// database/migrations/xxxx_create_post_tag_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::create('post_tag', function (Blueprint $table) {
$table->id();
$table->foreignId('post_id')->constrained()->onDelete('cascade');
$table->foreignId('tag_id')->constrained()->onDelete('cascade');
$table->timestamps(); // Menyimpan waktu kapan tag ditambahkan ke post
// Indeks untuk performa query
$table->index(['post_id', 'tag_id']);
});
}

public function down(): void
{
Schema::dropIfExists('post_tag');
}
};

Migration Videos:

php
<?php
// database/migrations/xxxx_create_videos_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::create('videos', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('title');
$table->string('slug')->unique();
$table->text('description');
$table->string('url'); // URL video (YouTube/Vimeo)
$table->string('thumbnail')->nullable();
$table->integer('duration')->nullable(); // Durasi dalam detik
$table->integer('views')->default(0);
$table->boolean('is_published')->default(false);
$table->timestamp('published_at')->nullable();
$table->timestamps();
});
}

public function down(): void
{
Schema::dropIfExists('videos');
}
};

Migration Comments (Polymorphic):

php
<?php
// database/migrations/xxxx_create_comments_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->text('content');
// Polymorphic columns
$table->morphs('commentable'); // Membuat commentable_id dan commentable_type
$table->integer('likes_count')->default(0);
$table->boolean('is_approved')->default(true);
$table->timestamps();
// Indeks untuk polymorphic query
$table->index(['commentable_type', 'commentable_id']);
});
}

public function down(): void
{
Schema::dropIfExists('comments');
}
};
3.1.2 Membuat Model dengan Relasi
bash
# Buat model dengan migration (beberapa sudah ada)
php artisan make:model Profile -m
php artisan make:model Tag -m
php artisan make:model Video -m
php artisan make:model Comment -m

3.2 One-to-One Relation

Relasi one-to-one adalah relasi paling sederhana: satu baris di tabel A berhubungan dengan satu baris di tabel B.

Contoh: Setiap user memiliki satu profil.

3.2.1 Mendefinisikan Relasi di Model
php
<?php
// File: app/Models/User.php

namespace App\Models;

use Laravel\Sanctum\HasApiTokens;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

class User extends Authenticatable
{
use HasApiTokens, Notifiable;

protected $fillable = [
'name', 'email', 'password', 'role'
];

protected $hidden = [
'password', 'remember_token',
];

protected $casts = [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];

/**
* Relasi one-to-one ke Profile
*/
public function profile()
{
return $this->hasOne(Profile::class);
}
/**
* Relasi one-to-many ke Post
*/
public function posts()
{
return $this->hasMany(Post::class);
}
/**
* Relasi one-to-many ke Comment
*/
public function comments()
{
return $this->hasMany(Comment::class);
}
}
php
<?php
// File: app/Models/Profile.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;

class Profile extends Model
{
use HasFactory;

protected $fillable = [
'user_id', 'bio', 'avatar', 'website',
'location', 'birth_date', 'gender', 'social_links'
];

protected $casts = [
'birth_date' => 'date',
'social_links' => 'array', // Otomatis konversi JSON ke array
];

/**
* Relasi kebalikan dari one-to-one (inverse)
*/
public function user()
{
return $this->belongsTo(User::class);
}
/**
* Accessor untuk avatar URL
*/
public function getAvatarUrlAttribute()
{
return $this->avatar ? asset('storage/' . $this->avatar) : null;
}
/**
* Accessor untuk full bio
*/
public function getBioExcerptAttribute()
{
return strlen($this->bio) > 100
? substr($this->bio, 0, 100) . '...'
: $this->bio;
}
}
3.2.2 Menggunakan One-to-One di Controller
php
<?php
// File: app/Http/Controllers/Api/ProfileController.php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\ProfileRequest;
use App\Http\Resources\ProfileResource;
use App\Models\User;
use App\Traits\ApiResponseTrait;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class ProfileController extends Controller
{
use ApiResponseTrait;
/**
* GET /api/user/profile
* Mendapatkan profil user yang sedang login
*/
public function show(Request $request)
{
$user = $request->user();
// Load profil (jika ada)
$user->load('profile');
return $this->successResponse(
new ProfileResource($user->profile),
'Profil berhasil diambil'
);
}
/**
* POST /api/user/profile
* Membuat atau mengupdate profil
*/
public function update(ProfileRequest $request)
{
$user = $request->user();
// Cek apakah user sudah punya profil
if ($user->profile) {
// Update profil yang ada
$profile = $user->profile;
$profile->update($request->validated());
} else {
// Buat profil baru
$profile = $user->profile()->create($request->validated());
}
return $this->successResponse(
new ProfileResource($profile),
'Profil berhasil diperbarui'
);
}
/**
* POST /api/user/profile/avatar
* Upload avatar
*/
public function uploadAvatar(Request $request)
{
$request->validate([
'avatar' => 'required|image|max:2048' // Max 2MB
]);
$user = $request->user();
// Pastikan user punya profil
if (!$user->profile) {
$user->profile()->create([]);
}
$profile = $user->profile;
// Hapus avatar lama jika ada
if ($profile->avatar) {
Storage::disk('public')->delete($profile->avatar);
}
// Upload avatar baru
$path = $request->file('avatar')->store('avatars', 'public');
// Update path di database
$profile->update(['avatar' => $path]);
return $this->successResponse(
['avatar_url' => asset('storage/' . $path)],
'Avatar berhasil diupload'
);
}
}
3.2.3 Form Request untuk Profile
php
<?php
// File: app/Http/Requests/ProfileRequest.php

namespace App\Http\Requests;

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

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

public function rules(): array
{
return [
'bio' => 'nullable|string|max:1000',
'website' => 'nullable|url|max:255',
'location' => 'nullable|string|max:255',
'birth_date' => 'nullable|date|before:today',
'gender' => 'nullable|in:male,female,other',
'social_links' => 'nullable|array',
'social_links.*' => 'url'
];
}

public function messages(): array
{
return [
'website.url' => 'Format website tidak valid',
'birth_date.before' => 'Tanggal lahir harus sebelum hari ini',
'gender.in' => 'Gender harus male, female, atau other'
];
}
}
3.2.4 Resource untuk Profile
php
<?php
// File: app/Http/Resources/ProfileResource.php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class ProfileResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'bio' => $this->bio,
'bio_excerpt' => $this->bio_excerpt,
'avatar' => $this->avatar_url,
'website' => $this->website,
'location' => $this->location,
'birth_date' => $this->birth_date?->format('Y-m-d'),
'age' => $this->birth_date?->age,
'gender' => $this->gender,
'social_links' => $this->social_links,
'member_since' => $this->created_at->format('d M Y'),
'user' => [
'id' => $this->user->id,
'name' => $this->user->name,
'email' => $this->user->email
]
];
}
}

3.3 One-to-Many Relation

Relasi one-to-many adalah relasi paling umum: satu baris di tabel A bisa memiliki banyak baris di tabel B.

Contoh: Satu user bisa memiliki banyak post.

3.3.1 Mendefinisikan Relasi di Model
php
<?php
// File: app/Models/Post.php (tambahkan relasi)

class Post extends Model
{
use HasFactory, SoftDeletes;

protected $fillable = [
'title', 'content', 'slug', 'image', 'category_id',
'user_id', 'views', 'is_published', 'published_at'
];

protected $casts = [
'is_published' => 'boolean',
'published_at' => 'datetime',
'views' => 'integer'
];

/**
* Relasi kebalikan one-to-many ke User
*/
public function author()
{
return $this->belongsTo(User::class, 'user_id');
}

/**
* Relasi one-to-many ke Comment
*/
public function comments()
{
return $this->hasMany(Comment::class);
}

/**
* Relasi many-to-many ke Tag
*/
public function tags()
{
return $this->belongsToMany(Tag::class)
->withTimestamps()
->withPivot('created_at');
}

/**
* Relasi ke Category
*/
public function category()
{
return $this->belongsTo(Category::class);
}
}
3.3.2 Mengakses Relasi One-to-Many
php
<?php
// File: app/Http/Controllers/Api/UserPostController.php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Resources\PostResource;
use App\Models\User;
use App\Traits\ApiResponseTrait;
use Illuminate\Http\Request;

class UserPostController extends Controller
{
use ApiResponseTrait;
/**
* GET /api/users/{user}/posts
* Mendapatkan semua post milik user tertentu
*/
public function index(User $user)
{
// Mengakses relasi hasMany
$posts = $user->posts()
->with(['category', 'tags'])
->latest()
->paginate(15);
return PostResource::collection($posts);
}
/**
* GET /api/users/{user}/posts/stats
* Statistik post user
*/
public function stats(User $user)
{
$stats = [
'total_posts' => $user->posts()->count(),
'published_posts' => $user->posts()->where('is_published', true)->count(),
'draft_posts' => $user->posts()->where('is_published', false)->count(),
'total_views' => $user->posts()->sum('views'),
'avg_views' => $user->posts()->avg('views'),
'most_viewed' => $user->posts()->orderBy('views', 'desc')->first()?->title,
'last_post' => $user->posts()->latest()->first()?->created_at?->diffForHumans()
];
return $this->successResponse($stats, 'Statistik post user');
}
/**
* GET /api/user/posts/recent
* 5 post terbaru dari user yang login
*/
public function recent(Request $request)
{
$posts = $request->user()
->posts()
->with(['category'])
->latest()
->limit(5)
->get();
return PostResource::collection($posts);
}
}

3.4 Many-to-Many Relation

Relasi many-to-many adalah relasi di mana banyak baris di tabel A berhubungan dengan banyak baris di tabel B, membutuhkan tabel pivot.

Contoh: Post bisa memiliki banyak tag, dan tag bisa dimiliki banyak post.

3.4.1 Mendefinisikan Relasi Many-to-Many
php
<?php
// File: app/Models/Post.php (tambahkan)

public function tags()
{
return $this->belongsToMany(Tag::class)
->withTimestamps() // Menyimpan timestamps di pivot
->withPivot('created_at') // Kolom tambahan di pivot
->using(PostTag::class); // Menggunakan model pivot custom
}
php
<?php
// File: app/Models/Tag.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Support\Str;

class Tag extends Model
{
use HasFactory;

protected $fillable = [
'name', 'slug', 'description', 'color', 'usage_count'
];

protected static function boot()
{
parent::boot();
// Auto-generate slug saat creating
static::creating(function ($tag) {
$tag->slug = Str::slug($tag->name);
});
}

/**
* Relasi many-to-many ke Post
*/
public function posts()
{
return $this->belongsToMany(Post::class)
->withTimestamps()
->withPivot('created_at');
}
/**
* Scope untuk tag populer
*/
public function scopePopular($query, $minUsage = 10)
{
return $query->where('usage_count', '>=', $minUsage);
}
/**
* Increment usage count
*/
public function incrementUsage()
{
$this->increment('usage_count');
}
/**
* Decrement usage count
*/
public function decrementUsage()
{
if ($this->usage_count > 0) {
$this->decrement('usage_count');
}
}
}
3.4.2 Model Pivot Kustom

Untuk fungsionalitas lebih, kita bisa membuat model pivot:

bash
php artisan make:model PostTag -p
php
<?php
// File: app/Models/PostTag.php

namespace App\Models;

use Illuminate\Database\Eloquent\Relations\Pivot;

class PostTag extends Pivot
{
protected $table = 'post_tag';
protected $casts = [
'created_at' => 'datetime'
];
// Relasi tambahan jika diperlukan
public function tag()
{
return $this->belongsTo(Tag::class);
}
public function post()
{
return $this->belongsTo(Post::class);
}
}
3.4.3 Controller untuk Tag Management
php
<?php
// File: app/Http/Controllers/Api/TagController.php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\TagRequest;
use App\Http\Resources\TagResource;
use App\Models\Post;
use App\Models\Tag;
use App\Traits\ApiResponseTrait;
use Illuminate\Http\Request;

class TagController extends Controller
{
use ApiResponseTrait;
/**
* GET /api/tags
* Daftar semua tag
*/
public function index(Request $request)
{
$query = Tag::query();
// Filter popular tags
if ($request->has('popular')) {
$query->popular();
}
// Search
if ($request->has('search')) {
$query->where('name', 'LIKE', '%' . $request->search . '%');
}
$tags = $query->orderBy('usage_count', 'desc')->paginate(20);
return TagResource::collection($tags);
}
/**
* POST /api/tags
* Membuat tag baru
*/
public function store(TagRequest $request)
{
$tag = Tag::create($request->validated());
return $this->successResponse(
new TagResource($tag),
'Tag berhasil dibuat',
201
);
}
/**
* GET /api/tags/{tag}
* Detail tag
*/
public function show(Tag $tag)
{
$tag->load('posts' => function($query) {
$query->latest()->limit(10);
});
return new TagResource($tag);
}
/**
* POST /api/posts/{post}/tags
* Menambahkan tag ke post
*/
public function attachToPost(Request $request, Post $post)
{
$request->validate([
'tag_ids' => 'required|array',
'tag_ids.*' => 'exists:tags,id'
]);
// Sync tanpa menghapus tag yang sudah ada
$post->tags()->syncWithoutDetaching($request->tag_ids);
// Increment usage count untuk setiap tag
Tag::whereIn('id', $request->tag_ids)->increment('usage_count');
return $this->successResponse(
TagResource::collection($post->tags),
'Tag berhasil ditambahkan ke post'
);
}
/**
* DELETE /api/posts/{post}/tags/{tag}
* Menghapus tag dari post
*/
public function detachFromPost(Post $post, Tag $tag)
{
// Cek apakah tag ada di post
if (!$post->tags()->where('tag_id', $tag->id)->exists()) {
return $this->notFoundResponse('Tag tidak ditemukan di post ini');
}
// Hapus relasi
$post->tags()->detach($tag->id);
// Decrement usage count
$tag->decrementUsage();
return $this->successResponse(null, 'Tag berhasil dihapus dari post');
}
/**
* PUT /api/tags/{tag}
* Update tag
*/
public function update(TagRequest $request, Tag $tag)
{
$tag->update($request->validated());
return $this->successResponse(
new TagResource($tag),
'Tag berhasil diupdate'
);
}
/**
* DELETE /api/tags/{tag}
* Hapus tag
*/
public function destroy(Tag $tag)
{
// Detach dari semua post
$tag->posts()->detach();
// Hapus tag
$tag->delete();
return $this->deletedResponse('Tag berhasil dihapus');
}
}
3.4.4 Resource untuk Tag
php
<?php
// File: app/Http/Resources/TagResource.php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class TagResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'description' => $this->description,
'color' => $this->color,
'usage_count' => $this->usage_count,
'created_at' => $this->created_at->format('d M Y'),
// Jika relasi posts di-load
'posts' => PostResource::collection($this->whenLoaded('posts')),
'posts_count' => $this->when(
$this->relationLoaded('posts'),
fn() => $this->posts->count()
)
];
}
}

3.5 Polymorphic Relations

Polymorphic relations memungkinkan sebuah model menjadi milik lebih dari satu model lain dalam satu relasi.

Contoh: Komentar bisa dimiliki oleh Post ATAU Video.

3.5.1 Mendefinisikan Polymorphic Relations
php
<?php
// File: app/Models/Comment.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;

class Comment extends Model
{
use HasFactory;

protected $fillable = [
'user_id', 'content', 'commentable_id', 'commentable_type',
'likes_count', 'is_approved'
];

protected $casts = [
'is_approved' => 'boolean',
'likes_count' => 'integer'
];

/**
* Get the parent commentable model (post or video).
*/
public function commentable()
{
return $this->morphTo();
}

/**
* Relasi ke user (pembuat komentar)
*/
public function user()
{
return $this->belongsTo(User::class);
}
/**
* Scope untuk komentar yang disetujui
*/
public function scopeApproved($query)
{
return $query->where('is_approved', true);
}
}
php
<?php
// File: app/Models/Post.php (tambahkan)

/**
* Relasi polymorphic ke Comment
*/
public function comments()
{
return $this->morphMany(Comment::class, 'commentable');
}

/**
* Mendapatkan semua komentar yang sudah disetujui
*/
public function approvedComments()
{
return $this->morphMany(Comment::class, 'commentable')
->where('is_approved', true);
}
php
<?php
// File: app/Models/Video.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Support\Str;

class Video extends Model
{
use HasFactory;

protected $fillable = [
'user_id', 'title', 'slug', 'description', 'url',
'thumbnail', 'duration', 'views', 'is_published', 'published_at'
];

protected $casts = [
'is_published' => 'boolean',
'published_at' => 'datetime',
'duration' => 'integer',
'views' => 'integer'
];

protected static function boot()
{
parent::boot();
static::creating(function ($video) {
$video->slug = Str::slug($video->title);
});
}

/**
* Relasi ke user
*/
public function user()
{
return $this->belongsTo(User::class);
}

/**
* Relasi polymorphic ke Comment
*/
public function comments()
{
return $this->morphMany(Comment::class, 'commentable');
}

/**
* Mendapatkan thumbnail URL dari YouTube/Vimeo
*/
public function getThumbnailUrlAttribute()
{
if ($this->thumbnail) {
return asset('storage/' . $this->thumbnail);
}
// Extract YouTube thumbnail
if (str_contains($this->url, 'youtube.com') || str_contains($this->url, 'youtu.be')) {
return $this->getYouTubeThumbnail();
}
return null;
}
private function getYouTubeThumbnail()
{
// Logic extract YouTube video ID
preg_match('/(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/', $this->url, $matches);
if (isset($matches[1])) {
return "https://img.youtube.com/vi/{$matches[1]}/hqdefault.jpg";
}
return null;
}
/**
* Increment views
*/
public function incrementViews()
{
$this->increment('views');
}
}
3.5.2 Controller untuk Comment (Polymorphic)
php
<?php
// File: app/Http/Controllers/Api/CommentController.php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\CommentRequest;
use App\Http\Resources\CommentResource;
use App\Models\Comment;
use App\Models\Post;
use App\Models\Video;
use App\Traits\ApiResponseTrait;
use Illuminate\Http\Request;

class CommentController extends Controller
{
use ApiResponseTrait;
/**
* GET /api/posts/{post}/comments
* Mendapatkan komentar dari post
*/
public function postComments(Post $post)
{
$comments = $post->comments()
->with('user')
->approved()
->latest()
->paginate(20);
return CommentResource::collection($comments);
}
/**
* GET /api/videos/{video}/comments
* Mendapatkan komentar dari video
*/
public function videoComments(Video $video)
{
$comments = $video->comments()
->with('user')
->approved()
->latest()
->paginate(20);
return CommentResource::collection($comments);
}
/**
* POST /api/posts/{post}/comments
* Menambahkan komentar ke post
*/
public function storePostComment(CommentRequest $request, Post $post)
{
$comment = $post->comments()->create([
'user_id' => $request->user()->id,
'content' => $request->content
]);
$comment->load('user');
return $this->successResponse(
new CommentResource($comment),
'Komentar berhasil ditambahkan',
201
);
}
/**
* POST /api/videos/{video}/comments
* Menambahkan komentar ke video
*/
public function storeVideoComment(CommentRequest $request, Video $video)
{
$comment = $video->comments()->create([
'user_id' => $request->user()->id,
'content' => $request->content
]);
$comment->load('user');
return $this->successResponse(
new CommentResource($comment),
'Komentar berhasil ditambahkan',
201
);
}
/**
* PUT /api/comments/{comment}
* Update komentar (hanya pemilik)
*/
public function update(CommentRequest $request, Comment $comment)
{
// Cek kepemilikan
if ($comment->user_id !== $request->user()->id) {
return $this->errorResponse('Anda tidak berhak mengupdate komentar ini', 403);
}
$comment->update([
'content' => $request->content
]);
return $this->successResponse(
new CommentResource($comment),
'Komentar berhasil diupdate'
);
}
/**
* DELETE /api/comments/{comment}
* Hapus komentar (hanya pemilik atau admin)
*/
public function destroy(Request $request, Comment $comment)
{
// Cek kepemilikan atau admin
if ($comment->user_id !== $request->user()->id && $request->user()->role !== 'admin') {
return $this->errorResponse('Anda tidak berhak menghapus komentar ini', 403);
}
$comment->delete();
return $this->deletedResponse('Komentar berhasil dihapus');
}
/**
* POST /api/comments/{comment}/approve
* Menyetujui komentar (untuk moderator/admin)
*/
public function approve(Request $request, Comment $comment)
{
if ($request->user()->role !== 'admin' && $request->user()->role !== 'moderator') {
return $this->errorResponse('Anda tidak berhak menyetujui komentar', 403);
}
$comment->update(['is_approved' => true]);
return $this->successResponse(
new CommentResource($comment),
'Komentar disetujui'
);
}
}
3.5.3 Resource untuk Comment
php
<?php
// File: app/Http/Resources/CommentResource.php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class CommentResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'content' => $this->content,
'likes_count' => $this->likes_count,
'is_approved' => $this->is_approved,
'created_at' => $this->created_at->format('d M Y H:i'),
'created_ago' => $this->created_at->diffForHumans(),
'user' => [
'id' => $this->user->id,
'name' => $this->user->name,
'avatar' => $this->user->profile?->avatar_url
],
// Informasi tentang commentable (post/video)
'commentable' => [
'type' => class_basename($this->commentable_type),
'id' => $this->commentable_id,
'title' => $this->commentable?->title ?? $this->commentable?->name
]
];
}
}
3.5.4 Form Request untuk Comment
php
<?php
// File: app/Http/Requests/CommentRequest.php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

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

public function rules(): array
{
return [
'content' => 'required|string|min:3|max:1000'
];
}

public function messages(): array
{
return [
'content.required' => 'Komentar wajib diisi',
'content.min' => 'Komentar minimal 3 karakter',
'content.max' => 'Komentar maksimal 1000 karakter'
];
}
}

3.6 Advanced Eloquent: Has-Many-Through

Relasi "has-many-through" memberikan akses ke relasi jauh melalui relasi perantara.

Contoh: Mendapatkan semua komentar dari semua post milik user tertentu.

php
<?php
// File: app/Models/User.php (tambahkan)

/**
* Mendapatkan semua komentar dari semua post user
*/
public function postComments()
{
return $this->hasManyThrough(
Comment::class, // Model tujuan
Post::class, // Model perantara
'user_id', // Foreign key di posts
'commentable_id', // Foreign key di comments
'id', // Local key di users
'id' // Local key di posts
)->where('commentable_type', Post::class); // Hanya komentar untuk post
}

/**
* Mendapatkan semua komentar dari semua video user
*/
public function videoComments()
{
return $this->hasManyThrough(
Comment::class,
Video::class,
'user_id',
'commentable_id',
'id',
'id'
)->where('commentable_type', Video::class);
}

/**
* Mendapatkan semua komentar (dari post dan video)
*/
public function allComments()
{
$postComments = $this->postComments();
$videoComments = $this->videoComments();
// Union kedua query
return $postComments->union($videoComments->toBase());
}

Penggunaan di Controller:

php
public function userComments(User $user)
{
$comments = $user->allComments()
->with('user')
->latest()
->paginate(20);
return CommentResource::collection($comments);
}

3.7 Polymorphic Many-to-Many

Laravel juga mendukung polymorphic many-to-many, di mana sebuah model bisa memiliki banyak relasi many-to-many dengan model lain.

Contoh: Sistem "likes" dimana user bisa menyukai post ATAU video.

bash
# Migration untuk likes
php artisan make:migration create_likes_table
php
// database/migrations/xxxx_create_likes_table.php
Schema::create('likes', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->morphs('likeable'); // likeable_id, likeable_type
$table->timestamps();
$table->unique(['user_id', 'likeable_id', 'likeable_type']);
});
php
// app/Models/User.php
public function likes()
{
return $this->hasMany(Like::class);
}

// app/Models/Post.php
public function likes()
{
return $this->morphMany(Like::class, 'likeable');
}

public function isLikedBy(User $user)
{
return $this->likes()->where('user_id', $user->id)->exists();
}

// app/Models/Video.php (sama seperti Post)
public function likes()
{
return $this->morphMany(Like::class, 'likeable');
}

3.8 Query Optimization dengan Eager Loading

Salah satu masalah paling umum saat bekerja dengan relasi adalah N+1 query problem.

3.8.1 Masalah N+1
php
// Controller - INI BURUK (N+1 queries)
public function index()
{
$posts = Post::all(); // 1 query
foreach ($posts as $post) {
echo $post->author->name; // N query tambahan!
}
// Total: 1 + N queries
}
3.8.2 Solusi Eager Loading
php
// Controller - BAIK (eager loading)
public function index()
{
// 1 query posts + 1 query authors = 2 queries
$posts = Post::with('author', 'category', 'tags')->get();
// Tidak ada query tambahan saat mengakses relasi
foreach ($posts as $post) {
echo $post->author->name;
echo $post->category->name;
foreach ($post->tags as $tag) {
echo $tag->name;
}
}
}
3.8.3 Nested Eager Loading
php
// Load relasi bertingkat
$posts = Post::with([
'author',
'comments.user', // Relasi comments, lalu user dari comment
'tags'
])->get();

// Conditional eager loading
$posts = Post::with([
'author',
'comments' => function ($query) {
$query->where('is_approved', true)
->latest()
->limit(10);
}
])->get();
3.8.4 Eager Loading dengan Constraint
php
public function index(Request $request)
{
$query = Post::query();
// Load relasi hanya jika kondisi terpenuhi
if ($request->has('with_comments')) {
$query->with(['comments' => function ($q) {
$q->latest()->limit(5);
}]);
}
if ($request->has('with_author')) {
$query->with('author');
}
$posts = $query->paginate(15);
return PostResource::collection($posts);
}
3.8.5 Lazy Eager Loading

Kadang kita perlu memuat relasi setelah query utama dijalankan:

php
$posts = Post::all();

if ($request->has('load_tags')) {
$posts->load('tags'); // Lazy eager loading
}

if ($request->has('load_comments')) {
$posts->load(['comments' => function ($q) {
$q->latest()->limit(10);
}]);
}
3.8.6 Counting Related Models
php
// Menghitung jumlah relasi tanpa load data
$posts = Post::withCount('comments', 'likes', 'tags')->get();

foreach ($posts as $post) {
echo $post->comments_count;
echo $post->likes_count;
echo $post->tags_count;
}

// Dengan kondisi
$posts = Post::withCount(['comments' => function ($q) {
$q->where('is_approved', true);
}])->get();

3.9 Advanced Query Scopes untuk Relasi

php
<?php
// File: app/Models/Post.php

class Post extends Model
{
// ...
/**
* Scope untuk post dengan komentar terbanyak
*/
public function scopeMostCommented($query, $minComments = 5)
{
return $query->withCount('comments')
->having('comments_count', '>=', $minComments)
->orderBy('comments_count', 'desc');
}
/**
* Scope untuk post dengan tag tertentu
*/
public function scopeWithAnyTag($query, array $tagNames)
{
return $query->whereHas('tags', function ($q) use ($tagNames) {
$q->whereIn('name', $tagNames);
});
}
/**
* Scope untuk post dari author dengan role tertentu
*/
public function scopeFromAuthors($query, array $roles = ['author', 'editor'])
{
return $query->whereHas('author', function ($q) use ($roles) {
$q->whereIn('role', $roles);
});
}
/**
* Scope untuk post populer (views > 1000 atau komentar > 10)
*/
public function scopePopular($query)
{
return $query->where(function ($q) {
$q->where('views', '>', 1000)
->orWhereHas('comments', function ($cq) {
$cq->havingRaw('COUNT(*) > 10');
});
});
}
}

// Penggunaan di controller
public function popularPosts()
{
$posts = Post::popular()
->with(['author', 'tags'])
->withCount('comments')
->paginate(15);
return PostResource::collection($posts);
}

3.10 Testing Relasi dengan Postman

3.10.1 Membuat Post dengan Tags

Request:

text
POST /api/posts
Authorization: Bearer {token}
Content-Type: application/json

{ "title": "Belajar Relasi Eloquent", "content": "Artikel tentang relasi database di Laravel...", "category_id": 1, "tags": [1, 2, 3]
}

Response:

json
{
"success": true,
"message": "Post berhasil dibuat",
"data": {
"id": 51,
"title": "Belajar Relasi Eloquent",
"content": "Artikel tentang relasi database di Laravel...",
"author": {
"id": 1,
"name": "John Doe"
},
"tags": [
{"id": 1, "name": "Laravel"},
{"id": 2, "name": "PHP"},
{"id": 3, "name": "Database"}
],
"comments_count": 0
}
}
3.10.2 Menambahkan Komentar ke Post

Request:

text
POST /api/posts/51/comments
Authorization: Bearer {token}
Content-Type: application/json

{ "content": "Artikelnya sangat membantu!"
}

Response:

json
{
"success": true,
"message": "Komentar berhasil ditambahkan",
"data": {
"id": 101,
"content": "Artikelnya sangat membantu!",
"user": {
"id": 2,
"name": "Jane Smith"
},
"created_ago": "1 minute ago"
}
}
3.10.3 Melihat Post dengan Relasi

Request:

text
GET /api/posts/51?with=author,tags,comments.user

Response:

json
{
"success": true,
"data": {
"id": 51,
"title": "Belajar Relasi Eloquent",
"content": "Artikel tentang relasi database di Laravel...",
"author": {
"id": 1,
"name": "John Doe",
"email": "john@example.com"
},
"tags": [
{"id": 1, "name": "Laravel", "color": "#3b82f6"},
{"id": 2, "name": "PHP", "color": "#8b5cf6"}
],
"comments": [
{
"id": 101,
"content": "Artikelnya sangat membantu!",
"user": {"name": "Jane Smith"},
"created_ago": "5 minutes ago"
}
],
"comments_count": 1,
"likes_count": 5
}
}

3.11 Kendala dan Solusi

KendalaSolusi
N+1 Query ProblemGunakan with() untuk eager loading
Lupa mendefinisikan relasiSelalu cek model apakah relasi sudah didefinisikan
Error "Column not found"Cek nama foreign key, pastikan migration sudah benar
Data relasi tidak munculLoad relasi dengan load() atau with() sebelum dikirim
Performance lambatGunakan withCount() daripada load semua data, tambahkan index di database
Mass assignment errorTambahkan kolom relasi ke $fillable
Relasi many-to-many errorPastikan tabel pivot dibuat dengan urutan kolom yang benar
Polymorphic type errorPastikan commentable_type terisi dengan class name yang benar

4. KESIMPULAN

Eloquent ORM adalah salah satu fitur terkuat Laravel yang memungkinkan kita bekerja dengan relasi database secara intuitif dan efisien. Pada hari ini, kita telah mempelajari:

  1. One-to-One Relation
    • Setiap user memiliki satu profil
    • Menggunakan hasOne() dan belongsTo()
  2. One-to-Many Relation
    • User memiliki banyak post
    • Menggunakan hasMany() dan belongsTo()
  3. Many-to-Many Relation
    • Post memiliki banyak tag, tag dimiliki banyak post
    • Menggunakan belongsToMany() dengan tabel pivot
    • Fitur sync(), attach(), detach() untuk mengelola relasi
  4. Polymorphic Relations
    • Komentar bisa untuk post ATAU video
    • Menggunakan morphMany() dan morphTo()
    • Sangat fleksibel untuk konten yang berbeda
  5. Advanced Eloquent
    • Has-Many-Through untuk relasi berantai
    • Polymorphic Many-to-Many untuk likes
    • Eager loading untuk mengatasi N+1 problem
    • Query scopes untuk query yang sering digunakan

Dengan pemahaman ini, kita bisa membangun API yang kompleks dengan relasi database yang rumit sekalipun. Pada hari kedua minggu ini, kita akan mempelajari Pagination, Filtering, dan API Query Optimization untuk membuat API kita lebih efisien dan user-friendly.


5. DAFTAR PUSTAKA

Posting Komentar

0 Komentar