العلاقات بين الجداول في Laravel: hasMany و belongsTo
البيانات في الحياة مترابطة: المقال له تعليقات، والطلب له منتجات، والطالب مسجّل في كورسات. حتى الآن تعاملت مع جدول واحد معزول. في هذا الدرس ستتعلم العلاقات (Relationships) في Eloquent، وسترى كيف تكتب $post->comments فيجلب لك Laravel تعليقات المقال دون أن تكتب سطر SQL واحداً.
الفكرة: المفتاح الأجنبي
Section titled “الفكرة: المفتاح الأجنبي”كيف نعرف أن تعليقاً ما يخص مقالاً معيناً؟ نضيف في جدول التعليقات عموداً اسمه post_id نكتب فيه رقم المقال. هذا العمود يسمى المفتاح الأجنبي (Foreign Key).
تخيّل ملصق الأمتعة في المطار: كل حقيبة عليها رقم تذكرة صاحبها. الحقائب كثيرة، لكن الرقم يدلّ على صاحب كل واحدة. المسافر الواحد له حقائب كثيرة، والحقيبة لها مسافر واحد. هذه بالضبط علاقة واحد إلى كثير (One to Many)، وهي أشهر علاقة.
تجهيز جدول التعليقات
Section titled “تجهيز جدول التعليقات”ننشئ الـ Model ومعه ملف الـ Migration:
php artisan make:model Comment -mpublic 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”العلاقة تُعرَّف من الجهتين، كل جهة بتابع صغير. في المقال: «لي تعليقات كثيرة»:
use Illuminate\Database\Eloquent\Relations\HasMany;
class Post extends Model{ protected $fillable = ['title', 'body', 'published'];
public function comments(): HasMany { return $this->hasMany(Comment::class); }}وفي التعليق: «أنا أنتمي إلى مقال»:
<?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.
استخدام العلاقة
Section titled “استخدام العلاقة”الآن يبدأ السحر. لإضافة تعليق على مقال، نستخدم العلاقة نفسها، فيملأ 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أول مقالالعرض في صفحة Blade
Section titled “العرض في صفحة Blade”في المتحكم ترسل المقال كالمعتاد، وفي القالب تمر على تعليقاته:
<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مشكلة N+1 وحلها: with
Section titled “مشكلة N+1 وحلها: with”هذا أشهر خطأ أداء في 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ثالث مقال: 12و has('comments') تختار المقالات التي لها تعليق واحد على الأقل، وهي هنا مقالان.
جرّب بنفسك
Section titled “جرّب بنفسك”- أضف في صفحة عرض المقال نموذجاً لكتابة تعليق: حقل للاسم وحقل للنص.
- أنشئ مساراً
POST /posts/{id}/commentsيتحقق من البيانات، ثم يحفظ التعليق بـ$post->comments()->create(...). - في صفحة قائمة المقالات، اعرض بجانب كل عنوان عدد تعليقاته باستخدام
withCount. - احذف مقالاً له تعليقات، وتأكد أن تعليقاته حُذفت معه.
أخطاء شائعة
Section titled “أخطاء شائعة”- نسيان
returnفي تابع العلاقة: يعيد التابعnull، وتحصل على خطأ غامض عند استخدام العلاقة. - العلاقات داخل الحلقات بدون
with: مشكلة N+1 التي تجعل الصفحة بطيئة كلما زادت البيانات. - عكس الاتجاه:
hasManyللجهة التي «تملك»، وbelongsToللجهة التي فيها المفتاح الأجنبي. - اسم مفتاح غير متوقع: Laravel يتوقع
post_id. إذا سمّيت العمودarticle_idمثلاً، فأخبره:$this->belongsTo(Post::class, 'article_id').
اختبر نفسك
Section titled “اختبر نفسك”سؤال
في علاقة «المقال له تعليقات كثيرة»، في أي جدول يوضع المفتاح الأجنبي؟
المفتاح الأجنبي post_id يوضع في جهة «الكثير»: كل تعليق يحمل رقم مقاله.
سؤال
كيف تتجنب مشكلة N+1 عند عرض المقالات مع تعليقاتها؟
with تحمّل تعليقات كل المقالات في استعلام واحد إضافي، بدلاً من استعلام لكل مقال.
الخلاصة
Section titled “الخلاصة”- العلاقة تُبنى على مفتاح أجنبي مثل
post_id، وننشئه بـforeignId()->constrained(). hasManyفي الجهة المالكة، وbelongsToفي الجهة التي فيها المفتاح.$post->commentsتعطي النتيجة، و$post->comments()تعطي استعلاماً قابلاً للتكملة.withتحل مشكلة N+1 بالتحميل المسبق.withCountتعدّ السجلات المرتبطة، وhasتصفّي بوجودها.