علاقة Many to Many Polymorphic في Laravel
مقدمة
تحدثنا في مقال سابق عن علاقة Polymorphic في Laravel، وكيفية بناء جدول واحد للتعليقات يخدم مجموعة من الجداول المختلفة. في هذا المقال سنتحدث عن نوع آخر من العلاقات متعددة الأشكال، وهي علاقة Polymorphic Many (أو Many to Many Polymorphic).
ما هي علاقة Polymorphic Many
لنفترض أن لدينا الجداول التالية:
Articles
id
name
Videos
id
name
Products
id
name
Tags
id
nameلدينا هنا حالة مختلفة عن Polymorphic العادية: كل (مقال، فيديو، منتج) يمكن أن يحتوي على مجموعة من الوسوم (Tags)، وفي نفس الوقت كل وسم (Tag) يمكن أن ينتمي إلى أكثر من مقال، وأكثر من فيديو، وأكثر من منتج.
بمعنى آخر، العلاقة هنا هي many to many بين كل نموذج والوسوم، لكنها في نفس الوقت polymorphic لأن جدول الوسوم واحد يخدم عدة نماذج مختلفة.
إنشاء جدول العلاقة (Pivot Table)
بما أننا نتعامل مع جدول tags، فإننا نحتاج لإنشاء جدول pivot جديد باسم tagables. لو كنا نتعامل مع جدول videos بدلاً من tags، لكان اسم الجدول الجديد videoables، وهكذا.
public function up()
{
Schema::create('tagables', function (Blueprint $table) {
$table->foreignId('tag_id')->constrained();
$table->morphs('tagable');
$table->timestamps();
});
}توضيح
النوع morphs('tagable') يقوم بإنشاء حقلين تلقائيًا:
- tagable_type: يحدد مسار (namespace) الموديل الذي ينتمي إليه الوسم، مثل App\Models\Article أو App\Models\Video.
- tagable_id: يحدد id السجل المرتبط، سواء كان id المقال، أو id الفيديو، أو id المنتج.
تم اختيار الاسم tagable كوسيط لـ morphs لأن اسم الجدول هو tagables. لو كان اسم الجدول videoables، لوجب أن يكون اسم النوع videoable. أي أن اسم العلاقة الممرر يجب أن يكون بصيغة singular (مفرد).
بناء العلاقات في الـ Models
نحتاج لبناء العلاقات في كل من Model الخاص بـ Article وVideo وProduct، وكذلك في Model الخاص بـ Tag.
الموديل Tag
في موديل Tag، نبني علاقة مستقلة لكل موديل آخر (Article، Video، Product):
class Tag extends Model
{
use HasFactory;
protected $fillable = ['tag_id'];
public function articles()
{
return $this->morphedByMany(Article::class, 'tagable');
}
public function videos()
{
return $this->morphedByMany(Video::class, 'tagable');
}
public function products()
{
return $this->morphedByMany(Product::class, 'tagable');
}
}توضيح
نوع العلاقة هنا يجب أن يكون morphedByMany، يليه اسم الموديل، ثم اسم العلاقة tagable (لأننا نتعامل مع جدول tags). لو كنا نتعامل مع جدول videos بدلاً من ذلك، لكان يجب أن تكون videoable.
موديلات Article وVideo وProduct
class Article extends Model
{
use HasFactory;
public function tags()
{
return $this->morphToMany(Tag::class, 'tagable');
}
}
class Video extends Model
{
use HasFactory;
public function tags()
{
return $this->morphToMany(Tag::class, 'tagable');
}
}
class Product extends Model
{
use HasFactory;
public function tags()
{
return $this->morphToMany(Tag::class, 'tagable');
}
}توضيح
في موديلات (Article، Video، Product)، اسم الدالة هو tags في كل الحالات، ونوع العلاقة هو morphToMany، يليه اسم العلاقة tagable.
إدخال البيانات في علاقة Polymorphic Many
لنفترض أن لدينا فورم لإدخال المقالات، يتيح كتابة عنوان المقال واختيار مجموعة من الوسوم المرتبطة به:
<form method="POST" action="{{ route('articles.store') }}">
@csrf
<input type="text" name="name" class="form-control">
<select name="tag_id[]" multiple class="form-control">
@foreach($tags as $tag)
<option value="{{ $tag->id }}">{{ $tag->tag_name }}</option>
@endforeach
</select>
<button type="submit" class="btn btn-primary">Submit</button>
</form>ولإدخال البيانات ضمن ArticleController، في دالة store:
public function store(Request $request)
{
$article = new Article();
$article->name = $request->name;
$article->save();
$article->tags()->attach($request->tag_id);
}توضيح
- يتم أولًا حفظ المقال بشكل طبيعي.
- ثم يتم استخدام العلاقة tags واستدعاء الدالة attach لحفظ tag_id في جدول tagables، حيث يتم تخزين الحقول التالية تلقائيًا:
- tag_id: وهو id الوسم من جدول tags.
- tagable_type: يُخزَّن فيه مسار الموديل، وفي حالتنا هذه (بما أننا أدخلنا مقالًا) ستكون القيمة App\Models\Article.
- tagable_id: يُخزَّن فيه id المقال.
عرض البيانات في علاقة Polymorphic Many
لنفترض أننا نريد عرض مقال معين مع الوسوم التابعة له:
public function show($id)
{
$article = Article::with('tags')->findOrFail($id);
return view('article_details', compact('article'));
}كما نلاحظ، كل ما علينا فعله هو استخدام with مع اسم الدالة التي تحتوي العلاقة، وهي tags، وسيقوم Laravel تلقائيًا بجلب جميع الوسوم المرتبطة بهذا المقال عبر جدول tagables.
الاستعلام العكسي: جلب كل السجلات المرتبطة بوسم معيّن
تمامًا كما جلبنا الوسوم التابعة لمقال معين، يمكننا القيام بالعكس: جلب جميع المقالات (أو الفيديوهات، أو المنتجات) المرتبطة بوسم معين، وذلك باستخدام العلاقات التي بنيناها في موديل Tag:
public function show($id)
{
$tag = Tag::with(['articles', 'videos', 'products'])->findOrFail($id);
return view('tag_details', compact('tag'));
}بعد ذلك يمكن الوصول للنتائج مباشرة:
$tag->articles; // كل المقالات المرتبطة بهذا الوسم $tag->videos; // كل الفيديوهات المرتبطة بهذا الوسم $tag->products; // كل المنتجات المرتبطة بهذا الوسم
هذا مفيد جدًا مثلاً في صفحة تعرض "كل المحتوى المرتبط بوسم Laravel" بغض النظر عن نوعه (مقال، فيديو، منتج).
فك الربط وتحديث الوسوم: detach() و sync()
فك ربط وسم واحد (detach)
لإزالة وسم معين من مقال دون حذف الوسم نفسه من جدول tags:
$article->tags()->detach($tagId);
ولفك ربط جميع الوسوم المرتبطة بالمقال دفعة واحدة:
$article->tags()->detach();
تحديث الوسوم بالكامل (sync)
عند تعديل مقال موجود مسبقًا (مثل صفحة Edit)، غالبًا ما نريد استبدال الوسوم القديمة بالوسوم الجديدة المختارة من الفورم دفعة واحدة، بدلاً من التعامل مع attach/detach يدويًا. هنا تفيد دالة sync:
public function update(Request $request, Article $article)
{
$article->name = $request->name;
$article->save();
$article->tags()->sync($request->tag_id);
}تقوم sync بمقارنة القائمة الممرّرة مع الوسوم الحالية، فتضيف الوسوم الجديدة، وتحذف أي وسم غير موجود في القائمة الجديدة، وتترك الوسوم المشتركة كما هي. هذا يجعلها الخيار الأنسب لفورمات التعديل مقارنة بـ attach التي تكرر الإضافة دون إزالة القديم.
إضافة حقول إضافية لجدول Pivot: withPivot
أحيانًا نحتاج لتخزين بيانات إضافية داخل جدول tagables نفسه، مثل معرفة من أضاف الوسم (created_by) أو ترتيب عرضه (order). في هذه الحالة نضيف الحقل في الـ migration:
Schema::create('tagables', function (Blueprint $table) {
$table->foreignId('tag_id')->constrained();
$table->morphs('tagable');
$table->foreignId('created_by')->nullable();
$table->timestamps();
});ثم نُعلم Eloquent بوجود هذا الحقل الإضافي عبر withPivot في تعريف العلاقة:
public function tags()
{
return $this->morphToMany(Tag::class, 'tagable')
->withPivot('created_by')
->withTimestamps();
}وعند الإدخال، يمكن تمرير قيم هذا الحقل مباشرة مع attach:
$article->tags()->attach($request->tag_id, ['created_by' => auth()->id()]);
ولقراءة قيمة الحقل لاحقًا من كل وسم مرتبط:
foreach ($article->tags as $tag) {
echo $tag->pivot->created_by;
}استخدام morphMap لتخزين اسم مختصر بدل الـ Namespace الكامل
افتراضيًا، يقوم Laravel بتخزين المسار الكامل للموديل (App\Models\Article) داخل حقل tagable_type. هذا يعمل بشكل صحيح، لكنه يحمل مشكلتين في مشاريع الإنتاج:
- لو تم نقل الموديل لاحقًا إلى namespace مختلف أو تمت إعادة تسميته، ستصبح كل السجلات القديمة في قاعدة البيانات غير مرتبطة بشكل صحيح (لأن القيمة المخزنة namespace قديم لم يعد موجودًا).
- كشف بنية الكود الداخلية (namespace الكامل) داخل قاعدة البيانات ليس ممارسة مثالية من ناحية الأمان وتنظيم البيانات.
الحل هو استخدام morphMap لتعريف اسم مختصر وثابت لكل موديل، بحيث يتم تخزين هذا الاسم بدلاً من الـ namespace الكامل. يتم ذلك عادة داخل AppServiceProvider في دالة boot:
use Illuminate\Database\Eloquent\Relations\Relation;
public function boot()
{
Relation::morphMap([
'article' => \App\Models\Article::class,
'video' => \App\Models\Video::class,
'product' => \App\Models\Product::class,
]);
}بعد إضافة هذا التعريف، سيتم تخزين القيمة article بدلاً من App\Models\Article داخل حقل tagable_type لأي سجل جديد. أما السجلات القديمة المخزنة بالـ namespace الكامل، فيجب تحديثها يدويًا عبر migration لضمان توافقها مع morphMap الجديد.
ملاحظة: يُفضّل تعريف morphMap منذ بداية المشروع قبل إدخال أي بيانات، لتفادي الحاجة لعمل migration تصحيحي لاحقًا.
ملاحظة حول الأداء (Indexes)
عند استخدام morphs('tagable') في الـ migration، يقوم Laravel تلقائيًا بإنشاء index مركّب (composite index) على الحقلين tagable_type وtagable_id معًا. هذا الأمر مهم جدًا من ناحية الأداء، خصوصًا مع زيادة حجم البيانات، لأن أغلب الاستعلامات على جدول pivot تعتمد على هذين الحقلين معًا في شرط WHERE.
لا حاجة لإضافة index يدويًا في الحالة الاعتيادية، لكن يجدر الانتباه لهذه النقطة عند مراجعة أداء الاستعلامات على جداول polymorphic كبيرة، والتأكد من عدم إزالة هذا الـ index عن طريق الخطأ عند تعديل الـ migration لاحقًا.
مثال متكامل: صفحة عرض كل المحتوى المرتبط بوسم واحد
لتجميع كل ما سبق في سيناريو عملي واحد، إليك مثال لصفحة تعرض كل المقالات والفيديوهات والمنتجات المرتبطة بوسم معين، بالإضافة إلى تاريخ إضافة كل ربط:
// Route
Route::get('/tags/{tag}', [TagController::class, 'show']);
// TagController
public function show(Tag $tag)
{
$tag->load(['articles', 'videos', 'products']);
return view('tags.show', compact('tag'));
}{{-- tags/show.blade.php --}}
<h1>الوسم: {{ $tag->name }}</h1>
<h3>المقالات</h3>
@foreach($tag->articles as $article)
<p>{{ $article->name }} - أُضيف بتاريخ {{ $article->pivot->created_at }}</p>
@endforeach
<h3>الفيديوهات</h3>
@foreach($tag->videos as $video)
<p>{{ $video->name }}</p>
@endforeach
<h3>المنتجات</h3>
@foreach($tag->products as $product)
<p>{{ $product->name }}</p>
@endforeachهذا المثال يوضح كيف يمكن لجدول pivot واحد (tagables) أن يخدم عرض بيانات من ثلاثة نماذج مختلفة تمامًا، دون الحاجة لثلاثة جداول pivot منفصلة أو ثلاثة استعلامات متفرقة.
جدول ملخّص للمفاهيم
| العنصر | الوصف |
|---|---|
| morphs('tagable') | ينشئ حقلي tagable_type وtagable_id في جدول pivot |
| morphToMany | يُستخدم في الموديلات الأخرى (Article, Video, Product) للوصول إلى Tags |
| morphedByMany | يُستخدم في موديل Tag للوصول إلى كل موديل مرتبط به |
| attach() | لربط سجل بوسم أو أكثر عبر جدول pivot |
| with('tags') | لجلب الوسوم المرتبطة بسجل معين (Eager Loading) |
| detach() | فك ربط وسم واحد أو أكثر دون حذفه من جدول tags |
| sync() | استبدال كل الوسوم المرتبطة بقائمة جديدة دفعة واحدة (مثالي لصفحات التعديل) |
| withPivot() | تعريف حقول إضافية في جدول pivot (مثل created_by) والوصول إليها لاحقًا |
| morphMap() | تخزين اسم مختصر بدل الـ namespace الكامل داخل tagable_type |
الخلاصة
تتيح علاقة Many to Many Polymorphic في Laravel بناء نظام وسوم (Tags) أو أي علاقة مشابهة تخدم عدة نماذج مختلفة (مقالات، فيديوهات، منتجات...) باستخدام جدول pivot واحد فقط، بدلاً من إنشاء جدول pivot منفصل لكل نموذج. وذلك عبر:
- استخدام morphs() في الـ migration لإنشاء الحقول اللازمة.
- استخدام morphedByMany() في الموديل المركزي (Tag).
- استخدام morphToMany() في باقي الموديلات (Article، Video، Product).
وفي الاستخدام العملي والإنتاجي، يُنصح أيضًا بمراعاة:
- استخدام sync() بدلاً من attach() في فورمات التعديل لتفادي تكرار العلاقات القديمة.
- الاستفادة من withPivot() عند الحاجة لتخزين بيانات وصفية إضافية داخل جدول pivot.
- تعريف morphMap() منذ بداية المشروع لتفادي تخزين الـ namespace الكامل للموديلات، وضمان استقرار العلاقات حتى لو تغيّرت بنية الكود لاحقًا.
هذا النمط من العلاقات يقلل التكرار في قاعدة البيانات، ويجعل إضافة نماذج جديدة تدعم الوسوم أمرًا سهلًا ومباشرًا مستقبلًا.
أيضا بالشرح أدخلنا الـ tag_id طيب وين حقل النص الخاص بال tag مثل الـ tag_name ممكن التوضيح ومراجعة الشرح لو تكرمت
سؤال ممتاز، وأعتذر إن لم يكن واضحًا بما فيه الكفاية في الشرح. للتوضيح:
حقل النص الخاص بالوسم (مثل tag_name أو name) لا علاقة له بجدول tagables، بل هو حقل موجود أصلًا في جدول tags نفسه، ويُنشأ ويُملأ بشكل منفصل تمامًا قبل عملية الربط.
بمعنى آخر، العملية تمر بمرحلتين منفصلتين:
لهذا السبب كانت القائمة في الفورم (select) تعرض $tag->tag_name كنص ظاهر للمستخدم، بينما القيمة الفعلية المُرسلة هي $tag->id فقط. جدول tagables مسؤول فقط عن تخزين العلاقة (من هو الوسم، ومع أي سجل ارتبط)، وليس عن تخزين نص الوسم نفسه — فهذا يبقى دائمًا في جدول tags الأصلي.
شكرًا لتنبيهك، سأحرص على توضيح هذه النقطة بشكل أوضح في المقال.
أخي الكريم هنا أنت كنت تتحدث عن التعليقات بينما الشرح يتكلم عن التاجات في السطر التالي في أول الموضوع : ((((( ولكن لدينا كل ( مقال، فيديو، منتج) يحتوي على مجموعة من التعليقات، وكل تعليق ينتمي لأكثر من ( فيديو، مقال، منتج). أي أن العلاقة many to many polymorphic. )))) أيضا سؤال هل يوجد فرق بين able و ables لأن في المقال السابق انت ذكرت بأن يجب وضع إسم الجدول singular ومن ثم إضافة able https://etharshrouf.com/ar/article/151 بينما في المقال هذا أنت ذكرت التالي : ((((( هنا نحتاج لإنشاء جدول جديد باسم tagables، ولو كنا نتعامل مع جدول videos فإن إسم الجدول الجديد سيكون videoables... وهكذا )))) فأيهم أمشي عليه ؟ وشكرا لك
شكرًا جزيلًا على الملاحظتين الدقيقتين، وكلاهما في محله تمامًا.
أولًا، بخصوص خلط "التعليقات" مع "الوسوم":
أنت محق تمامًا، هذا خطأ متبقٍّ من صياغة المقال السابق (الذي كان فعلًا عن جدول تعليقات واحد يخدم عدة جداول). عند كتابة هذا المقال عن الوسوم (Tags)، بقيت جملة "التعليقات" بالخطأ في مقدمة الشرح رغم أن كل الأمثلة والأكواد التالية تتحدث عن Tags حصرًا. سأقوم بتصحيح هذه الجملة لتصبح متسقة مع باقي المقال.
ثانيًا، بخصوص able مقابل ables:
لا يوجد تعارض فعليًا بين المقالين، لكن الالتباس مفهوم لأن كلا الاسمين يبدوان متشابهين. الفرق هو بين شيئين مختلفين تمامًا:
فالقاعدة الثابتة في المقالين هي نفسها: اسم الجدول جمع، واسم العلاقة الممرر لـ morphs مفرد. المقال السابق تحدث عن اسم العلاقة (singular + able)، وهذا المقال ذكر اسم الجدول (ables بصيغة الجمع) — وكلاهما صحيح، لكنهما يتحدثان عن موضعين مختلفين في نفس البنية.
شكرًا لك على المتابعة الدقيقة والأسئلة المفيدة، هذا النوع من الملاحظات يساعد فعلًا على تحسين وضوح الشرح.