تخطَّ إلى المحتوى

العلاقات بين الجداول في Laravel: hasMany و belongsTo

الدرس 8 من 11 · Laravel

البيانات في الحياة مترابطة: المقال له تعليقات، والطلب له منتجات، والطالب مسجّل في كورسات. حتى الآن تعاملت مع جدول واحد معزول. في هذا الدرس ستتعلم العلاقات (Relationships) في Eloquent، وسترى كيف تكتب $post->comments فيجلب لك Laravel تعليقات المقال دون أن تكتب سطر SQL واحداً.

الفكرة: المفتاح الأجنبي

Section titled “الفكرة: المفتاح الأجنبي”

كيف نعرف أن تعليقاً ما يخص مقالاً معيناً؟ نضيف في جدول التعليقات عموداً اسمه post_id نكتب فيه رقم المقال. هذا العمود يسمى المفتاح الأجنبي (Foreign Key).

تخيّل ملصق الأمتعة في المطار: كل حقيبة عليها رقم تذكرة صاحبها. الحقائب كثيرة، لكن الرقم يدلّ على صاحب كل واحدة. المسافر الواحد له حقائب كثيرة، والحقيبة لها مسافر واحد. هذه بالضبط علاقة واحد إلى كثير (One to Many)، وهي أشهر علاقة.

ننشئ الـ Model ومعه ملف الـ Migration:

نافذة طرفيّة
php artisan make:model Comment -m
database/migrations/..._create_comments_table.php
public function up(): void
{
Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
$table->string('author');
$table->text('body');
$table->timestamps();
});
}

السطر المهم هو سطر foreignId، وفيه ثلاثة أجزاء:

  • foreignId('post_id'): ينشئ عمود المفتاح الأجنبي.
  • constrained(): يفهم من الاسم post_id أن العمود يشير إلى id في جدول posts، ويمنع قاعدة البيانات من قبول رقم مقال غير موجود.
  • cascadeOnDelete(): إذا حُذف المقال، تُحذف تعليقاته معه تلقائياً.

ثم نفّذ php artisan migrate.

تعريف العلاقة في الـ Models

Section titled “تعريف العلاقة في الـ Models”

العلاقة تُعرَّف من الجهتين، كل جهة بتابع صغير. في المقال: «لي تعليقات كثيرة»:

app/Models/Post.php
use Illuminate\Database\Eloquent\Relations\HasMany;
class Post extends Model
{
protected $fillable = ['title', 'body', 'published'];
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
}

وفي التعليق: «أنا أنتمي إلى مقال»:

app/Models/Comment.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Comment extends Model
{
protected $fillable = ['author', 'body'];
public function post(): BelongsTo
{
return $this->belongsTo(Post::class);
}
}

انتبه للأسماء: comments جمع لأنها كثيرة، و post مفرد لأنه واحد. والقاعدة لتتذكر: الـ Model الذي في جدوله المفتاح الأجنبي هو الذي يكتب belongsTo.

الآن يبدأ السحر. لإضافة تعليق على مقال، نستخدم العلاقة نفسها، فيملأ Laravel العمود post_id وحده:

$post = Post::create(['title' => 'أول مقال', 'body' => 'مرحباً بكم في مدونتي.']);
$post->comments()->create(['author' => 'عمر', 'body' => 'مقال رائع!']);
$post->comments()->create(['author' => 'ليلى', 'body' => 'شكراً على الشرح.']);
echo $post->comments->count() . "\n";
foreach ($post->comments as $comment) {
echo $comment->author . ': ' . $comment->body . "\n";
}
الناتج
2
عمر: مقال رائع!
ليلى: شكراً على الشرح.

وفي الاتجاه العكسي، من التعليق إلى مقاله:

$comment = Comment::first();
echo $comment->post_id . "\n";
echo $comment->post->title . "\n";
الناتج
1
أول مقال

في المتحكم ترسل المقال كالمعتاد، وفي القالب تمر على تعليقاته:

resources/views/posts/show.blade.php
<h1>{{ $post->title }}</h1>
<p>{{ $post->body }}</p>
<h2>التعليقات ({{ $post->comments->count() }})</h2>
@forelse ($post->comments as $comment)
<p><strong>{{ $comment->author }}:</strong> {{ $comment->body }}</p>
@empty
<p>لا توجد تعليقات بعد. كن أول من يعلّق!</p>
@endforelse

هذا أشهر خطأ أداء في Laravel، ويجب أن تعرفه. لنفترض أن عندنا 3 مقالات، ونريد عرض عدد تعليقات كل مقال:

foreach (Post::all() as $post) {
echo $post->comments->count();
}

الكود يبدو بريئاً، لكنه ينفّذ 4 استعلامات: واحد لجلب المقالات، ثم استعلام لتعليقات كل مقال. مع 100 مقال تصبح 101 استعلام! لذلك تسمى المشكلة «N+1».

تخيّل أنك ذهبت إلى السوق لتشتري عشرة أشياء، وعدت إلى البيت بعد كل شيء تشتريه. الحل أن تأخذ قائمة مشتريات وتجلب كل شيء في رحلة واحدة. وهذا ما تفعله with:

foreach (Post::with('comments')->get() as $post) {
echo $post->comments->count();
}

الآن استعلامان فقط مهما كان عدد المقالات:

select * from "posts"
select * from "comments" where "comments"."post_id" in (1, 2, 3)

هذا يسمى التحميل المسبق (Eager Loading). القاعدة: إذا كنت ستستخدم علاقة داخل حلقة، فحمّلها مسبقاً بـ with.

العدّ والتصفية بالعلاقة

Section titled “العدّ والتصفية بالعلاقة”

إذا كنت تريد عدد التعليقات فقط، فلا تحمّلها كلها. withCount تضيف لكل مقال خاصية اسمها comments_count:

foreach (Post::withCount('comments')->get() as $post) {
echo $post->title . ': ' . $post->comments_count . "\n";
}
echo Post::has('comments')->count() . "\n";
الناتج
أول مقال: 2
ثاني مقال: 0
ثالث مقال: 1
2

و has('comments') تختار المقالات التي لها تعليق واحد على الأقل، وهي هنا مقالان.

  1. أضف في صفحة عرض المقال نموذجاً لكتابة تعليق: حقل للاسم وحقل للنص.
  2. أنشئ مساراً POST /posts/{id}/comments يتحقق من البيانات، ثم يحفظ التعليق بـ $post->comments()->create(...).
  3. في صفحة قائمة المقالات، اعرض بجانب كل عنوان عدد تعليقاته باستخدام withCount.
  4. احذف مقالاً له تعليقات، وتأكد أن تعليقاته حُذفت معه.
  • نسيان return في تابع العلاقة: يعيد التابع null، وتحصل على خطأ غامض عند استخدام العلاقة.
  • العلاقات داخل الحلقات بدون with: مشكلة N+1 التي تجعل الصفحة بطيئة كلما زادت البيانات.
  • عكس الاتجاه: hasMany للجهة التي «تملك»، و belongsTo للجهة التي فيها المفتاح الأجنبي.
  • اسم مفتاح غير متوقع: Laravel يتوقع post_id. إذا سمّيت العمود article_id مثلاً، فأخبره: $this->belongsTo(Post::class, 'article_id').

سؤال

في علاقة «المقال له تعليقات كثيرة»، في أي جدول يوضع المفتاح الأجنبي؟

سؤال

كيف تتجنب مشكلة N+1 عند عرض المقالات مع تعليقاتها؟

  • العلاقة تُبنى على مفتاح أجنبي مثل post_id، وننشئه بـ foreignId()->constrained().
  • hasMany في الجهة المالكة، و belongsTo في الجهة التي فيها المفتاح.
  • $post->comments تعطي النتيجة، و $post->comments() تعطي استعلاماً قابلاً للتكملة.
  • with تحل مشكلة N+1 بالتحميل المسبق.
  • withCount تعدّ السجلات المرتبطة، و has تصفّي بوجودها.