علاقة 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 الكامل للموديلات، وضمان استقرار العلاقات حتى لو تغيّرت بنية الكود لاحقًا.

هذا النمط من العلاقات يقلل التكرار في قاعدة البيانات، ويجعل إضافة نماذج جديدة تدعم الوسوم أمرًا سهلًا ومباشرًا مستقبلًا.