Пошук по відношеннях і Filament-таблицях з mozex/laravel-searchable

У матеріалі розбирається підхід автора статті з Laravel News до пакету mozex/laravel-searchable від Mozex. Пакет додає trait для Eloquent-моделей, який дозволяє єдиним скоупом шукати по локальних полях, звичайних відношеннях і поліморфних зв'язках, а також інтегрувати цей пошук з Filament.

1. Коли має сенс саме mozex/laravel-searchable

Автор порівнює цей пакет зі Spatie's Laravel Searchable та іншими рішеннями й фокусується на сценаріях, де потрібно фільтрувати вже існуючий Eloquent-запит, а не збирати спільний список результатів з різних моделей.

  • Spatie's Laravel Searchable створює окремий search-запит, який комбінує результати різних моделей і джерел. Це зручно для сторінок, де треба показувати разом, наприклад, пости й відео.
  • mozex/laravel-searchable працює як Eloquent-scope search(), який фільтрує поточний query builder. Тобто після Model::search() ви можете продовжувати ланцюжок: додавати where, with, paginate тощо.

Саме тому авторка статті (через приклади) відносить mozex/laravel-searchable до задач типу:

  • модераційні екрани (списки коментарів, замовлень, заявок), де вже є фільтри й сортування;
  • адмін-панелі на Filament, де один і той самий Eloquent-запит живе і в ресурсі, і в таблиці, і в панельному глобальному пошуку;
  • складні реляційні схеми з поліморфією й різними підключеннями до БД, де не хочеться дублювати whereHas і whereHasMorph у кожному місці.

2. Пошук по полях моделі: як працює скоуп search ()

Щоб модель можна було шукати через пакет, потрібно підключити trait і описати стовпці, які беруть участь у пошуку.

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Mozex\Searchable\Searchable;

class Post extends Model
{
    use Searchable;
    public function searchableColumns(): array
    {
        return ['title', 'body'];
    }
}

Після цього можна викликати скоуп search() на Eloquent-запиті.

use App\Models\Post;
$posts = Post::search('laravel')->paginate(20);

Ключові деталі, на які звертає увагу автор:

  • Пріоритет полів задається порядком у масиві searchableColumns(). Наприклад, ['title', 'body'] означає, що збіг у title важить більше, ніж збіг лише в body (це впливає на релевантність, особливо у зв'язці з Filament).
  • Розбиття рядка пошуку: багатослівний запит розбивається на терми, і кожен з них має зустрічатися принаймні десь у налаштованих стовпцях. Тобто це логіка «AND по термах».
  • Фразовий пошук: подвійні лапки фіксують фразу. Наприклад, "laravel scout" queue шукає одну фразу й додаткові окремі терми.
  • Обмеження кількості термів: за замовчуванням враховуються перші 10 термів, решта відкидається. Це захищає від надто довгих або зловмисних рядків.
  • Символи % і _ із введення користувача сприймаються буквально, а не як шаблони для LIKE. Це зменшує «магичність» поведінки й робить запит безпечнішим.

У цій частині автор фактично показує, як Searchable замінює ручне конструювання набору where/orWhere для пошуку по кількох полях, але з уже вбудованою логікою розбору рядка й сортування за релевантністю.

3. Пошук по поліморфних відношеннях з типізованим синтаксисом

Окремий практичний блок присвячений модераційному екрану коментарів, які можуть належати різним сутностям, наприклад постам і відео. Потрібно шукати одночасно:

  • по самому коментарю (поле body);
  • по заголовку поста, якщо коментар до поста;
  • по назві відео, якщо коментар до відео.

Спочатку задається morphMap для типів:

use App\Models\Post;
use App\Models\Video;
use Illuminate\Database\Eloquent\Relations\Relation;
Relation::morphMap([
    'post' => Post::class,
    'video' => Video::class,
]);

Далі в моделі коментаря задаються «шляхи» пошуку, включно з поліморфними цільовими моделями.

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Mozex\Searchable\Searchable;

class Comment extends Model
{
    use Searchable;

    public function searchableColumns(): array
    {
        return [
            'body',
            'commentable:post.title',
            'commentable:video.name',
        ];
    }
    public function commentable(): MorphTo
    {
        return $this->morphTo();
    }
}

Ключі на кшталт commentable:post.title мають структуру:

  • ліворуч від двокрапки — ім'я поліморфного відношення (commentable);
  • праворуч — конкретний morph-тип (post або video) та шлях до колонки (title, name);
  • можливі вкладені шляхи, наприклад commentable:post.author.name, якщо в моделі Post є відношення author.

Звичайні (неполіморфні) відношення задаються крапковою нотацією, наприклад author.company.name.

Після налаштування колонок пошук виглядає як звичний Eloquent-запит:

Приклад коду в оригіналі

Автор робить кілька важливих уточнень:

  • Пакет групує свої умови пошуку в підзапит, тому попередні обмеження (where('approved', false)) зберігаються й не «перекриваються» пошуком.
  • Аналогічний запит можна було б написати вручну через whereHasMorph(), але тоді всі поля типу commentable:post.title довелося б дублювати в кожному місці використання. Тут же оголошення централізоване в searchableColumns().

Такий підхід добре масштабується для поліморфних схем, де одна «дитяча» модель (коментар, файл, медіа-тег) може належати різним «батьківським» моделям, кожна зі своїми полями для пошуку.

4. Відношення на іншому підключенні до БД і обмеження external lookup

Окремий сценарій, на якому зупиняється автор, — моделі, пов'язані через BelongsTo, але зберігаються в іншій базі. Наприклад, локальна таблиця постів і загальна таблиця користувачів на іншому підключенні.

Якщо в searchableColumns() зазначити щось на кшталт author.name, пакет:

  • визначає, що цільова модель у відношенні author живе на іншому connection;
  • виконує окремий запит до пов'язаної моделі й отримує список її первинних ключів (ID);
  • фільтрує головну модель по цих ID.

Те ж саме може застосовуватися до підтримуваних morph-цілей.

Щоб запобігти вибуху кількості ID, пакет вводить ліміт: за замовчуванням не більше 50 збігів для однієї колонки й одного терма. Це налаштовується прямо в пошуковому виклику:

Приклад коду в оригіналі

Автор підкреслює наслідки такого ліміту:

  • якщо користувачів з ім'ям «Jane» більше за ліміт, частина потенційно релевантних постів буде відфільтрована ще на етапі зовнішнього пошуку;
  • збільшення ліміту дасть повніший результат, але призведе до більшої кількості ID і довшого IN (...) у головному запиті;
  • оптимальне значення залежить від ваших даних і профілю навантаження.

У версії 1.3.0, за словами автора, є ще два важливі обмеження для таких external lookup:

  • фільтрація йде тільки по первинному ключу цільової моделі;
  • додаткові where-умови, визначені всередині методу відношення (наприклад, фільтр по активних користувачах), не враховуються.

Звідси випливають практичні наслідки:

  • якщо BelongsTo налаштований на нестандартний owner key, або логіка сильно покладається на додаткові where у відношенні, автор рекомендує для таких випадків окремий, вручну написаний запит;
  • пакет не намагається проходити довгі ланцюжки відношень через різні підключення — «шорткат» працює для прямого BelongsTo і підтриманих morph-мішеней, але не для довільних глибоких шляхів.

5. Інтеграція з Filament: relevance-сортування і global search

Для моделей, де використовується trait, пакет додає Filament-макрос advancedSearchable() для колонок таблиць. Автор показує це на прикладі текстової колонки:

Приклад коду в оригіналі

Цей підхід дає одразу кілька ефектів:

  • Перевикористання однієї схеми пошуку: колонка не описує сама, як саме й по яких полях шукати — вона покладається на searchableColumns() в моделі, включно з typed morph-шляхами.
  • Автоматичне ранжування по релевантності: під час пошуку в таблиці Filament спочатку сортує по оціненій релевантності збігів, а вже потім застосовує дефолтне сортування таблиці для розв'язання «нічиєї».
  • Пріоритет користувацького сортування: якщо користувач клікає на заголовок колонки й задає явний order by, це сортування має вищий пріоритет, ніж релевантність.

Щодо самої моделі релевантності автор фіксує такі моменти:

  • порядок у searchableColumns() задає пріоритет полів (перші — важливіші за наступні);
  • для «прямих» колонок (які належать самій моделі) градація така:
    • повний збіг — найвищий рейтинг;
    • префікс (початок рядка збігається) — нижчий;
    • підрядок (деінде в середині значення) — ще нижчий.
  • для однострибкових відношень (simple relation, morph) також є градації;
  • для глибших шляхів і external lookup використовується спрощена схема «збіг/нема збігу» без тонкого ранжування.

Якщо автоматичне relevance-сортування в таблицях не підходить, його можна вимкнути через конфіг-ключ searchable.filament.relevance_sort.

Окремо згадується SearchableGlobalSearchProvider для панельного глобального пошуку Filament. Його можна зареєструвати в панелі, а для кожного ресурсу використати стандартний метод getGloballySearchableAttributes(), щоб задати, які атрибути ресурс експонує в глобальному пошуку. При цьому діють звичайні Filament-правила доступу й формування URL, а результати сортуються всередині категорії ресурсу за релевантністю.

6. Вибір між mozex/laravel-searchable та іншими пакетами

У фінальному блоці автор розкладає по полицях відмінності між різними підходами до пошуку в Laravel-проєктах.

Cross Eloquent Search від Protone Media автор згадує як альтернативу, яка:

  • уміє шукати по вкладених відношеннях;
  • може використовувати нативний full-text-пошук MySQL/PostgreSQL;
  • вміє відразу пагінувати об'єднаний список результатів з кількох моделей.

Його зручно застосовувати там, де потрібен спільний результат на кшталт «пости + відео в одному списку результатів». При цьому в документації Cross Eloquent Search зазначено, що його relevance-сортування не підтримує ранжування результатів, отриманих через відношення.

На противагу цьому, mozex/laravel-searchable:

  • заточений під фільтрацію однієї моделі (наприклад, Comment), яка шукає через свої відношення до інших таблиць;
  • має оцінку релевантності для однострибкових відношень (у тому числі morph) і бінарну для глибших шляхів;
  • не потребує окремого search-індексу — запити будуються на LIKE '%term%' та підзапитах.

Автор окремо згадує, що альтернативою «без індексу» можуть бути й можливості Scout Database Engine, який підтримує різні стратегії пошуку (префіксну, full-text залежно від БД). Але якщо потрібні:

  • толерантність до опечаток;
  • зовнішній пошуковий індекс;

автор рекомендує дивитися в бік інтеграції Laravel Scout з Meilisearch (посилання на відповідний гайд є в оригінальному тексті).

Важливе технічне попередження від автора стосується продуктивності mozex/laravel-searchable:

  • substring-пошук реалізований через LIKE '%term%', що, як правило, «ламає» можливість B-tree-індексів прискорювати збіг;
  • додавання реляційних пошуків і ранжування означає наявність додаткових підзапитів;
  • для завантажених endpoint-ів такий підхід потрібно обов'язково тестувати саме на своїх даних і expected-навантаженні.

Тому, за підсумком автора, основний аргумент на користь mozex/laravel-searchable — це адмінський пошук, насичений відношеннями (включно з поліморфними й міжконекційними), який інакше вимагав би складного й дубльованого коду запитів. Особливо якщо ті самі моделі використовуються в Filament-таблицях і глобальному пошуку панелі.

7. Установка та налаштування пакета

Автор зазначає, що на момент написання статті (версія 1.3.0) пакет вимагає:

  • PHP 8.2+;
  • Laravel версій ^12.69 або ^13.30;
  • для опціональної інтеграції з Filament — підтримуються Filament 4 і 5.

Установка виглядає стандартно через Composer:

Приклад коду в оригіналі

Пакет не містить власних міграцій. Щоб опублікувати конфіг config/searchable.php і налаштувати дефолтні значення (ліміт термів, externalLimit та ін.), автор пропонує використати команду php artisan searchable:install.

Подальші приклади використання й вихідний код доступні в репозиторії Mozex Laravel Searchable на GitHub, який автор і радить як основне джерело деталей реалізації.


Джерело: Yannick Lyn Fatt — Laravel News

Пошук

Закрити