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

posts — حقل البحث فيه هو title.
authors — حقل البحث فيه هو author_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 Searchable | Scout + محرك |
|---|---|---|---|
| تنظيم الكود | متكرر | موحّد | موحّد |
| الأداء على بيانات كبيرة | ضعيف | ضعيف (نفس الآلية) | ممتاز |
| ترتيب حسب الصلة | لا | لا | نعم |
| دعم العربية | ضعيف | ضعيف | جيد |
| بنية تحتية إضافية | لا | لا | نعم |
ملاحظات أمنية أخيرة
- تحقّق من المدخل دائمًا واشترط حدًا أدنى للطول (حرفان مثلًا)، لأن البحث بحرف واحد يطابق كل شيء تقريبًا.
- حدّد معدل الطلبات على مسار البحث عبر
throttle:30,1، فمسار بحث مفتوح على استعلامات ثقيلة هدف سهل لإرهاق الخادم. - لا تعرض ما لا يملك المستخدم صلاحية رؤيته. جهات البحث لا تطبّق سياسات الصلاحيات تلقائيًا؛ استخدم
where()داخل الـ closure لتصفية النتائج حسب المستخدم الحالي. - Blade يهرّب المخرجات افتراضيًا، فلا تستخدم
{!! !!}مع نص البحث أو النتائج.
الخلاصة
ابدأ بالاستعلامات المنفصلة إن كان البحث ثانويًا وبياناتك محدودة. انتقل إلى Spatie Searchable حين يصبح تكرار الكود مزعجًا وتحتاج بنية نتائج موحّدة مع روابط — وهي ممتازة في هذا الدور تحديدًا. وانتقل إلى Scout حين يصبح البحث ميزة يعتمد عليها مستخدمك فعلًا.
المهم أن تعرف أي مشكلة تحلّ في كل مرحلة: الأولى مشكلة تكرار، والثانية مشكلة تنظيم، والثالثة مشكلة أداء وجودة نتائج. الخلط بينها هو ما يجعل مشروعًا يعمل بسلاسة على ألف صف ثم يتعثّر على مئة ألف.
شكراً لك مقالة مفيدة
شكرًا لك، سعيد أنها أفادتك. إن جرّبتها على مشروع فعلي ووجدت حالة لم يغطّها المقال، أخبرني بها وسأضيفها.
كلام جميل ومختصر و كليين كوود بس انت هنا في البلييد قرات ال title الذي هوو attribute في post model {{ $searchResult->title }} كيف ممكن اقراء داتا من الموديلات الاخري مع العلم ان ال foreach واحدة
شكرًا لك، وسؤالك في محله وهو أكثر نقطة تلتبس على من يجرّب الحزمة لأول مرة.
المفتاح هو أن
$searchResultليس النموذج نفسه، بل كائنSearchResultتُنشئه الحزمة. وخاصيةtitleفيه ليست عمودtitleفي جدولposts، بل هي ببساطة القيمة الثانية التي مرّرتها أنت في دالةgetSearchResult().انظر إلى الترتيب:
فأنت في نموذج
Authorمرّرتauthor_name، وفيBookمرّرتbook_name، وفيPostمرّرتtitle. الحزمة تحوّلها كلها إلى شكل موحّد (titleوurlوtype)، ولهذا تكفي حلقةforeachواحدة رغم اختلاف أسماء الأعمدة. التوحيد يحدث في النماذج لا في ملف Blade.وإن احتجت بيانات إضافية من النموذج الأصلي، فهي متاحة عبر خاصية
searchable:ويمكنك التفريع حسب النوع دون كسر الحلقة الواحدة، بأن تجعل لكل نوع ملف عرض جزئي:
تنبيه أخير: الوصول إلى علاقة عبر
$result->searchableداخل الحلقة يسبب مشكلة N+1. عالِجها بالتحميل المسبق في الـ closure:Can it used for api
Yes, it works fine for APIs — nothing in the package is tied to Blade. The controller stays almost identical; you just serialize the results instead of passing them to a view:
Three things worth knowing before you ship this:
SearchResultholds the full Eloquent model, so a naivejson()can leak columns you didn't intend to expose.limitAspectResults()is not optional here. An API endpoint with no cap is an easy way to exhaust your server, since these areLIKE '%term%'queries that scan the whole table.throttle:30,1on the route.One caveat: the package returns an in-memory collection, not a paginator, so you can't paginate the combined results. If your API needs real pagination, relevance ranking, or typo tolerance, that's the point where Laravel Scout with Meilisearch or Typesense becomes the better fit.