في بعض الأحيان نحتاج إلى البحث في أكثر من نموذج Eloquent في الوقت نفسه — مثل posts و authors و books — انطلاقًا من حقل بحث واحد. الحل الأول الذي يتبادر إلى الذهن هو كتابة استعلام مستقل لكل نموذج، وهو حل صحيح لكنه يتضخم بسرعة.

في هذا المقال نمرّ على ثلاث طرق متدرّجة، ونوضّح ما تحلّه كل طريقة وما لا تحلّه — لأن أكثر الأخطاء شيوعًا هنا هو الظن أن تنظيم الكود يعني تحسين الأداء.

الجداول ونموذج العمل

لنفترض أن لدينا الجداول التالية:

بنية جدول posts: عمود id وعمود title وطوابع الوقت
جدول posts — حقل البحث فيه هو title.
بنية جدول authors: عمود id وعمود author_name وطوابع الوقت
جدول authors — حقل البحث فيه هو author_name.
بنية جدول books: عمود id وعمود book_name وطوابع الوقت
جدول books — حقل البحث فيه هو book_name.

ولدينا نموذج (form) واحد في ملف Blade يتم من خلاله البحث في الجداول الثلاثة معًا:

نموذج بحث بسيط يحتوي على حقل نصي واحد وزر بحث
نموذج بحث واحد يخدم الجداول الثلاثة.

الطريقة الأولى: استعلام مستقل لكل نموذج

هذه أبسط طريقة، ولا تحتاج أي حزمة إضافية:

public function index(Request $request): View
{
    $validated = $request->validate([
        'search' => ['required', 'string', 'min:2', 'max:100'],
    ]);

    $term = $this->escapeLike($validated['search']);

    $results = [
        'posts' => Post::where('title', 'like', "%{$term}%")->limit(10)->get(),
        'authors' => Author::where('author_name', 'like', "%{$term}%")->limit(10)->get(),
        'books' => Book::where('book_name', 'like', "%{$term}%")->limit(10)->get(),
    ];

    return view('search.index', compact('results'));
}

private function escapeLike(string $value): string
{
    return str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $value);
}

لاحظ ثلاثة أمور غائبة عادةً عن الأمثلة المنتشرة:

  • التحقق من المدخل. بدونه قد يصل null فيتحوّل الاستعلام إلى LIKE '%%' أي جلب الجدول كاملًا.
  • تهريب محارف % و _. هذه ليست ثغرة حقن — الربط (bindings) يحمي منها — لكنها تتيح للمستخدم كتابة % وإجبار قاعدة البيانات على مسح كامل.
  • الحد الأقصى للنتائج. استخدام get() بلا limit يعني تحميل كل المطابقات إلى الذاكرة.

وفي Laravel 11.32 فما فوق يمكنك استخدام صيغة أوضح:

Post::whereLike('title', "%{$term}%")->limit(10)->get();

عرض النتائج في Blade

الشائع هو تكرار الكتلة نفسها ثلاث مرات، وهو ما يجعل الملف طويلًا بلا داعٍ. الحل لا يحتاج حزمة أصلاً — يكفي جزء مشترك (partial):

{{-- resources/views/search/index.blade.php --}}
@foreach ($results as $type => $items)
    @include('search.partials.group', ['type' => $type, 'items' => $items])
@endforeach
{{-- resources/views/search/partials/group.blade.php --}}
<div class="font-bold mt-2 mb-2">{{ Str::headline($type) }}:</div>

@forelse ($items as $item)
    @if ($loop->first) <ul class="list-inside"> @endif
        <li class="list-disc">{{ $item->searchTitle }}</li>
    @if ($loop->last) </ul> @endif
@empty
    <span class="text-danger">No results.</span>
@endforelse

حيث searchTitle خاصية محسوبة (accessor) في كل نموذج تعيد الحقل المناسب، فيصبح العرض موحّدًا رغم اختلاف أسماء الأعمدة.

الخلاصة: الطريقة الأولى صحيحة تمامًا، وعيبها الوحيد هو التكرار — وهو عيب قابل للحل بأدوات Laravel نفسها.

الطريقة الثانية: حزمة Spatie Laravel Searchable

تمنحك هذه الحزمة واجهة واحدة موحّدة للبحث في عدة مصادر، مع تجميع تلقائي للنتائج حسب النوع.

التثبيت

composer require spatie/laravel-searchable

تجهيز النماذج

على كل نموذج أن ينفّذ واجهة Searchable عبر دالة واحدة تعيد كائن SearchResult. ولاحظ أننا نمرّر الرابط من البداية باستخدام مسار مسمّى، لا مسارًا مكتوبًا يدويًا:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Spatie\Searchable\Searchable;
use Spatie\Searchable\SearchResult;

class Post extends Model implements Searchable
{
    public function getSearchResult(): SearchResult
    {
        return new SearchResult(
            $this,
            $this->title,
            route('posts.show', $this),
        );
    }
}
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Spatie\Searchable\Searchable;
use Spatie\Searchable\SearchResult;

class Author extends Model implements Searchable
{
    public function getSearchResult(): SearchResult
    {
        return new SearchResult(
            $this,
            $this->author_name,
            route('authors.show', $this),
        );
    }
}
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Spatie\Searchable\Searchable;
use Spatie\Searchable\SearchResult;

class Book extends Model implements Searchable
{
    public function getSearchResult(): SearchResult
    {
        return new SearchResult(
            $this,
            $this->book_name,
            route('books.show', $this),
        );
    }
}

لماذا route() لا '/post/'.$this->id؟ لأن المسار المكتوب يدويًا ينكسر عند أي تغيير في ملف المسارات، ويتجاهل بادئة اللغة أو النطاق الفرعي إن وُجدت، ولا يستفيد من ربط النماذج بالمسار (route model binding) الذي يسمح لك لاحقًا بالانتقال من id إلى slug دون تعديل النماذج.

تنفيذ البحث

use Spatie\Searchable\Search;

public function index(Request $request): View
{
    $validated = $request->validate([
        'search' => ['required', 'string', 'min:2', 'max:100'],
    ]);

    $results = (new Search())
        ->registerModel(Post::class, 'title')
        ->registerModel(Author::class, 'author_name')
        ->registerModel(Book::class, 'book_name')
        ->limitAspectResults(10)
        ->search($validated['search']);

    return view('search.index', compact('results'));
}

استدعاء limitAspectResults(10) ضروري ولا يُذكر كثيرًا: بدونه ستعيد كل جهة بحث جميع مطابقاتها. البحث نفسه يتم بطريقة غير حساسة لحالة الأحرف.

عرض النتائج

هنا تظهر الفائدة الأوضح — حلقة واحدة بدل ثلاث، مع تجميع تلقائي حسب النوع:

<p>عدد النتائج: {{ $results->count() }}</p>

@forelse ($results->groupByType() as $type => $typeResults)
    <div class="font-bold mt-2 mb-2">{{ Str::headline($type) }}</div>

    <ul class="list-inside">
        @foreach ($typeResults as $result)
            <li class="list-disc">
                <a href="{{ $result->url }}">{{ $result->title }}</a>
            </li>
        @endforeach
    </ul>
@empty
    <p>لا توجد نتائج مطابقة.</p>
@endforelse

ولأن كل نتيجة تحمل رابطها معها، فإن الضغط على نتيجة من نوع Post يوجّه إلى صفحة المنشور، ومن نوع Book إلى صفحة الكتاب — دون أي شرط if في العرض.

تخصيص اسم النوع

الاسم الظاهر في groupByType() يُشتق تلقائيًا من النموذج. لتغييره أضف خاصية عامة:

class Post extends Model implements Searchable
{
    public $searchableType = 'المقالات';
}

البحث في أكثر من حقل

(new Search())
    ->registerModel(User::class, 'first_name', 'last_name')
    ->search('john');

// أو بصيغة المصفوفة
(new Search())
    ->registerModel(User::class, ['first_name', 'last_name'])
    ->search('john');

تحكّم دقيق عبر closure

هذه أقوى ميزة في الحزمة وأقلها استخدامًا. تتيح لك المطابقة التامة، وتطبيق النطاقات (scopes)، والتحميل المسبق للعلاقات:

use Spatie\Searchable\ModelSearchAspect;

(new Search())
    ->registerModel(Author::class, function (ModelSearchAspect $aspect) {
        $aspect
            ->addSearchableAttribute('author_name')   // مطابقة جزئية
            ->addExactSearchableAttribute('email')    // مطابقة تامة فقط
            ->where('is_active', true)
            ->has('books')
            ->with('books');                          // يمنع مشكلة N+1
    })
    ->search($term);

استدعاء with() هنا مهم عمليًا: إن كانت دالة getSearchResult() تصل إلى علاقة (مثل اسم المؤلف داخل عنوان الكتاب)، فستُنفَّذ استعلامات إضافية بعدد النتائج.

مصادر خارج قاعدة البيانات

لست مقيدًا بنماذج Eloquent. يمكنك إنشاء جهة بحث مخصّصة لواجهة API خارجية أو مصفوفة أو ملفات:

use Illuminate\Support\Collection;
use Spatie\Searchable\SearchAspect;

class OrderSearchAspect extends SearchAspect
{
    public function getResults(string $term): Collection
    {
        return OrderApi::searchOrders($term);
    }
}
(new Search())
    ->registerAspect(OrderSearchAspect::class)
    ->search($term);

ما لا تفعله هذه الحزمة

هذه أهم فقرة في المقال. الحزمة تنظّم الكود، ولا تحسّن الأداء، لأنها تنفّذ داخليًا نفس استعلامات LIKE '%term%' التي كتبتها يدويًا في الطريقة الأولى. النتيجة:

  • لا استفادة من الفهارس. النمط الذي يبدأ بـ % لا يمكن لفهرس B-tree خدمته، فيتحوّل الاستعلام إلى مسح كامل للجدول. على آلاف الصفوف لن تلاحظ شيئًا؛ على مئات الآلاف ستلاحظ كثيرًا.
  • لا ترتيب حسب الصلة. النتائج مجمّعة حسب النوع لا مرتّبة حسب قوة المطابقة، فالنتيجة الأدق قد تظهر أخيرًا.
  • لا تسامح مع الأخطاء الإملائية ولا تحليل صرفي للكلمات.
  • لا ترقيم صفحات. ما تعيده الحزمة مجموعة في الذاكرة، لا Paginator.
  • مشكلة خاصة بالعربية: عامل LIKE يقارن نصًا بنص، فلا يوحّد صور الهمزة ولا التاء المربوطة. البحث عن «احمد» لن يجد «أحمد»، والبحث عن «مكتبه» لن يجد «مكتبة». الحل الجزئي هو تخزين عمود مُطبّع (normalized) بجانب الأصلي والبحث فيه، والحل الكامل هو محرك بحث حقيقي.

الطريقة الثالثة: متى تحتاج محرك بحث فعليًا

إذا كبرت البيانات أو صار البحث ميزة أساسية في المنتج لا وظيفة ثانوية، فالانتقال ضروري. أمامك مساران:

الفهرسة النصية الكاملة في قاعدة البيانات

Post::whereFullText('title', $term)->limit(10)->get();

يتطلب فهرس FULLTEXT على العمود. حل خفيف بلا بنية تحتية إضافية، لكن دعمه للعربية في MySQL متواضع ويحتاج ضبط محلّل ngram، بينما PostgreSQL أفضل في هذا الجانب.

Laravel Scout مع محرك خارجي

الحل الأنسب للمشاريع الجادة: يفهرس بياناتك في محرك مثل Meilisearch أو Typesense أو Algolia، فتحصل على ترتيب حسب الصلة، وتسامح مع الأخطاء الإملائية، وتطبيع للحروف العربية، ونتائج بأجزاء من الثانية.

تنبيه على تعارض الأسماء: Scout يوفّر سمة اسمها Searchable، وحزمة Spatie توفّر واجهة بالاسم نفسه. إن استخدمتهما معًا في نموذج واحد فاستعمل الاسم المستعار:

use Laravel\Scout\Searchable as ScoutSearchable;
use Spatie\Searchable\Searchable as SpatieSearchable;

مقارنة سريعة

مقارنة بين الطرق الثلاث
المعياراستعلامات منفصلةSpatie SearchableScout + محرك
تنظيم الكودمتكررموحّدموحّد
الأداء على بيانات كبيرةضعيفضعيف (نفس الآلية)ممتاز
ترتيب حسب الصلةلالانعم
دعم العربيةضعيفضعيفجيد
بنية تحتية إضافيةلالانعم

ملاحظات أمنية أخيرة

  • تحقّق من المدخل دائمًا واشترط حدًا أدنى للطول (حرفان مثلًا)، لأن البحث بحرف واحد يطابق كل شيء تقريبًا.
  • حدّد معدل الطلبات على مسار البحث عبر throttle:30,1، فمسار بحث مفتوح على استعلامات ثقيلة هدف سهل لإرهاق الخادم.
  • لا تعرض ما لا يملك المستخدم صلاحية رؤيته. جهات البحث لا تطبّق سياسات الصلاحيات تلقائيًا؛ استخدم where() داخل الـ closure لتصفية النتائج حسب المستخدم الحالي.
  • Blade يهرّب المخرجات افتراضيًا، فلا تستخدم {!! !!} مع نص البحث أو النتائج.

الخلاصة

ابدأ بالاستعلامات المنفصلة إن كان البحث ثانويًا وبياناتك محدودة. انتقل إلى Spatie Searchable حين يصبح تكرار الكود مزعجًا وتحتاج بنية نتائج موحّدة مع روابط — وهي ممتازة في هذا الدور تحديدًا. وانتقل إلى Scout حين يصبح البحث ميزة يعتمد عليها مستخدمك فعلًا.

المهم أن تعرف أي مشكلة تحلّ في كل مرحلة: الأولى مشكلة تكرار، والثانية مشكلة تنظيم، والثالثة مشكلة أداء وجودة نتائج. الخلط بينها هو ما يجعل مشروعًا يعمل بسلاسة على ألف صف ثم يتعثّر على مئة ألف.