Полнотекстовый поиск в Laravel: как делать правильно
LIKE '%слово%' не масштабируется и не находит опечаток. Разбираем четыре подхода к поиску в Laravel: от MySQL FULLTEXT до Scout с Meilisearch — что выбрать под ваш объём данных и почему.

Запрос where('title', 'like', '%ноутбук%') работает на тысяче строк и умирает на миллионе: индекс по колонке он не использует, MySQL читает всю таблицу целиком. Плюс он не найдёт «наутбук» с опечаткой, не проранжирует результаты по релевантности и споткнётся на морфологии — «ноутбуки» и «ноутбук» для него разные строки. Ниже — четыре рабочих подхода для Laravel 11.x с ценой каждого: что выигрываете, чем платите и когда какой брать.
Задача и почему LIKE — тупик
Допустим, каталог товаров или база статей: пользователь вводит слово, вы должны вернуть релевантные записи быстро. Наивное решение через LIKE с ведущим процентом (%слово%) не может опереться на B-tree индекс — база вынуждена делать полный перебор. На 50 000 строк это ещё терпимо, на миллионе запрос уходит в секунды.
Дальше начинаются вопросы, на которые LIKE не отвечает вовсе: ранжирование (что показать первым), опечатки, синонимы, стемминг. Поэтому для поиска берут либо специализированные возможности самой СУБД, либо отдельный поисковый движок через пакет Laravel Scout.
Подход 1. MySQL FULLTEXT через миграцию и whereFullText
Если вы на MySQL 5.7+ или MariaDB и не хотите поднимать отдельный сервис — начните отсюда. С Laravel 8 в query builder есть метод whereFullText, который под капотом генерирует MATCH ... AGAINST.
Сначала добавьте FULLTEXT-индекс в миграции:
// database/migrations/..._add_fulltext_to_articles.php
// Laravel 11.x, MySQL 8.x
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('articles', function (Blueprint $table) {
// индекс сразу по двум колонкам
$table->fullText(['title', 'body']);
});
}
public function down(): void
{
Schema::table('articles', function (Blueprint $table) {
$table->dropFullText(['title', 'body']);
});
}
};
Запрос в модели:
// поиск по FULLTEXT-индексу
$articles = Article::query()
->whereFullText(['title', 'body'], $request->input('q'))
->limit(20)
->get();
По умолчанию MySQL работает в natural language mode. Если нужны операторы +обязательное -исключить, передайте boolean mode:
$articles = Article::query()
->whereFullText(['title', 'body'], '+laravel -symfony', ['mode' => 'boolean'])
->get();
Цена. Ноль внешних зависимостей, индекс живёт в той же базе, транзакционно консистентен. Но: стандартный парсер MySQL режет текст по пробелам и знакам препинания и плохо работает с русской морфологью «из коробки» — «ноутбуки» и «ноутбук» не схлопнутся. Есть параметр innodb_ft_min_token_size (по умолчанию 3), из-за которого короткие слова вроде «ПК» в индекс не попадают. Ранжирование по релевантности базовое.
Подход 2. PostgreSQL tsvector и полноценный стемминг
Если вы на PostgreSQL 12+, у вас на руках честный полнотекстовый движок с стеммингом1 и русским словарём. Ключевой тип — tsvector (разобранный на леммы документ), запрос — tsquery.
Laravel умеет искать через whereFullText и для Postgres, но чтобы задействовать русскую конфигурацию и ранжирование, чаще пишут через whereRaw или generated-колонку. Добавьте материализованный tsvector в миграции:
// PostgreSQL 16, generated-колонка + GIN-индекс
DB::statement("
ALTER TABLE articles
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('russian', coalesce(title,'') || ' ' || coalesce(body,''))
) STORED
");
DB::statement('CREATE INDEX articles_search_idx ON articles USING GIN (search_vector)');
Запрос с ранжированием по релевантности:
$q = $request->input('q');
$articles = Article::query()
->whereRaw("search_vector @@ plainto_tsquery('russian', ?)", [$q])
->orderByRaw("ts_rank(search_vector, plainto_tsquery('russian', ?)) DESC", [$q])
->limit(20)
->get();
Цена. Стемминг и морфология работают: «ноутбуки» найдут «ноутбук». GIN-индекс быстрый на чтение. Всё внутри базы. Но привязка к PostgreSQL, а plainto_tsquery не даёт нечёткого поиска по опечаткам — для этого нужны расширения вроде pg_trgm, и это отдельная тема. Generated-колонка занимает место и пересчитывается при апдейте строки.
Подход 3. Laravel Scout + драйвер database
Scout — официальный пакет Laravel для полнотекстового поиска. Он даёт единый API поверх разных движков и синхронизирует индекс через модельные события. Драйвер database не требует внешних сервисов и подходит как ступенька перед переездом на настоящий движок.
composer require laravel/scout
php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
В .env укажите драйвер, добавьте трейт и опишите индексируемые поля:
// .env
SCOUT_DRIVER=database
use Laravel\Scout\Searchable;
class Article extends Model
{
use Searchable;
// какие поля индексировать
public function toSearchableArray(): array
{
return [
'title' => $this->title,
'body' => $this->body,
];
}
}
Поиск становится однострочником:
$articles = Article::search($request->input('q'))->paginate(20);
Цена. Единый API — сменить движок потом можно правкой одной переменной окружения. Но драйвер database под капотом использует те же LIKE/FULLTEXT-механизмы и не выиграет у прямого SQL по скорости на больших объёмах. Это способ начать, а не финальное решение для миллионов записей.
Подход 4. Scout + Meilisearch: релевантность и опечатки
Когда нужен человеческий поиск — с опечатками, мгновенным ранжированием и фасетами — берут внешний движок. Meilisearch дружит со Scout официально, ставится одним контейнером и по умолчанию терпим к опечаткам (typo tolerance).
composer require laravel/scout meilisearch/meilisearch-php http-interop/http-factory-guzzle
// .env
SCOUT_DRIVER=meilisearch
MEILISEARCH_HOST=http://127.0.0.1:7700
MEILISEARCH_KEY=YOUR_MASTER_KEY
После настройки залейте существующие записи в индекс командой:
php artisan scout:import "App\Models\Article"
Дальше код поиска не меняется — тот же Article::search(...). Настройки релевантности (какие поля важнее, какие фильтруемые) задаются в конфиге config/scout.php в секции индекса; актуальный формат сверяйте с документацией Scout и Meilisearch, он менялся между версиями.
Главный сдвиг мышления при переходе на внешний движок: поисковый индекс — это отдельное хранилище, а не колонка в таблице. Оно может отстать от базы, если очередь синхронизации встала. Мониторьте её так же, как саму БД.
Цена. Сильная сторона — качество поиска, опечатки и скорость на любых объёмах. Но появляется внешний сервис: его нужно поднять, бэкапить, синхронизировать. Индекс живёт отдельно от базы и обновляется через очередь — включите SCOUT_QUEUE=true, иначе синхронизация будет тормозить каждый апдейт модели в веб-запросе.
Что выбирать
| Подход | Объём | Опечатки/морфология | Внешний сервис | Когда брать |
|---|---|---|---|---|
| MySQL FULLTEXT | до сотен тысяч строк | морфологии нет | нет | вы на MySQL, поиск вторичен |
| PostgreSQL tsvector | сотни тысяч — миллионы | стемминг есть, опечаток нет | нет | вы на Postgres, нужен русский стемминг |
| Scout + database | до десятков тысяч | нет | нет | MVP, начать быстро |
| Scout + Meilisearch | любой | опечатки и релевантность | да | поиск — часть продукта |
Критерий простой. Поиск — не центральная фича и вы на реляционной базе: используйте её встроенные возможности (FULLTEXT для MySQL, tsvector для Postgres) и не тащите лишний сервис. Поиск — то, ради чего пользователь пришёл, и качество результата напрямую влияет на конверсию: Meilisearch через Scout.
Что ломается на практике
- Синхронный индекс на записи. Без
SCOUT_QUEUE=trueкаждое сохранение модели ходит в поисковый движок синхронно и замедляет запись. Включайте очередь и держите воркер живым. - Забыли переиндексировать. После смены драйвера или изменения
toSearchableArrayстарые записи остаются со старой структурой — гоняйтеscout:flushиscout:import. - Короткие слова в MySQL. Слова короче
innodb_ft_min_token_size(по умолчанию 3) в FULLTEXT-индекс не попадают — «ПК», «iOS» найти не выйдет без правки конфига и переиндексации. - Пустой tsquery.
plainto_tsqueryна строке из одних стоп-слов вернёт пустой запрос и ноль результатов — обрабатывайте случай пустого ввода отдельно.
Что дальше
Определите объём и роль поиска в проекте и выберите один подход из таблицы — не начинайте с Meilisearch, если у вас 5 000 записей и поиск нужен «чтобы был». Если остаётесь на СУБД, замерьте план запроса через EXPLAIN: индекс должен использоваться, а не полное сканирование. Если переходите на Scout — сразу настройте очередь и мониторинг её длины, потому что расхождение индекса с базой проявляется не сразу, а в виде «поиск не находит то, что точно есть».
1 Стемминг — приведение слов к основе, чтобы «ноутбук», «ноутбука», «ноутбуки» считались одним словом. В PostgreSQL за это отвечает языковая конфигурация словаря (например, 'russian').
Редактор изображений: Убрать лишнее, заменить фон, добавить объект, расширить кадр — на вашем изображении.
Спросить за 20 ₽Источники: Laravel Scout — официальная документация, PostgreSQL: Full Text Search
Частые вопросы
У меня старая версия Laravel, есть ли whereFullText?
Scout с драйвером database — это то же самое, что просто LIKE?
Поиск не находит записи, которые точно есть в базе — что проверить?
Нужен ли отдельный сервер под Meilisearch в продакшене?
Как сделать нечёткий поиск по опечаткам на PostgreSQL без внешнего движка?
Замедлит ли FULLTEXT-индекс вставку и обновление записей?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.