Питання на співбесіді: Пошук
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
6 питань
Найпростіший пошук - фільтр за кількома колонками, і для більшості сайтів цього достатньо надовго.
Запит:
public function index(Request $request)
{
$term = $request->string('q')->trim()->toString();
$vacancies = Vacancy::query()
->when($term !== '', function ($query) use ($term) {
$query->where(function ($query) use ($term) {
$query->where('title', 'like', "%{$term}%")
->orWhere('description', 'like', "%{$term}%");
});
})
->paginate(15)
->withQueryString();
return view('vacancies.index', compact('vacancies'));
}
Дві деталі, на яких зазвичай помиляються:
1. Дужки навколо orWhere. Без вкладеного замикання умова «розклеїться»: where(A)->orWhere(B) разом з іншими фільтрами дасть фільтр AND A OR B, і пошук почне повертати чуже. Вкладене замикання групує умови правильно.
2. Порожній запит. Без when() пошук за порожнім рядком перетвориться на like '%%' - формально працює, але виконує зайву роботу.
Про безпеку: підставляти значення в where() безпечно - Eloquent параметризує запит. Небезпечним є лише whereRaw("title like '%{$term}%'") зі вставкою рядка напряму.
Коли цього перестане вистачати: like '%...%' не використовує індекс і не знає словоформ, тож на десятках тисяч записів пошук стає повільним, а «розробники» не знаходять «розробник». Тоді переходять на повнотекстовий індекс бази або Laravel Scout.
Scout дає єдиний API пошуку для моделей (Post::search('laravel')->paginate()), а сам пошук виконує рушій: Meilisearch, Typesense, Algolia - або власна база даних.
Два рушії не потребують жодного зовнішнього сервісу.
Рушій database:
SCOUT_DRIVER=database
- шукає прямо в таблицях через повнотекстові індекси MySQL/PostgreSQL та умови
LIKE; - окремого індексування немає - дані завжди актуальні,
scout:importне потрібен; - стратегію пошуку для колонок можна задати атрибутами:
use Laravel\Scout\Attributes\{SearchUsingFullText, SearchUsingPrefix};
#[SearchUsingPrefix(['id', 'email'])]
#[SearchUsingFullText(['title', 'body'])]
public function toSearchableArray(): array
{
return ['id' => $this->id, 'title' => $this->title, 'body' => $this->body];
}
SearchUsingPrefix шукає текст% (може використати звичайний індекс), SearchUsingFullText - через повнотекстовий індекс, решта колонок - %текст%.
Рушій collection:
SCOUT_DRIVER=collection
- завантажує всі записи моделі з бази й фільтрує їх у PHP;
- працює з будь-якою базою, включно з SQLite, - зручно для тестів і прототипів;
- для кількох сотень записів - нормально, для таблиці на сотні тисяч - пам'ять і час ростуть лінійно.
Порівняння:
| collection | database | Meilisearch/Typesense | |
|---|---|---|---|
| інфраструктура | нічого | нічого | окремий сервер |
| обсяг даних | сотні записів | десятки-сотні тисяч | мільйони |
| стійкість до опечаток | ні | ні | так |
| ранжування, фасети, підсвічування | ні | обмежено | так |
| актуальність | миттєва | миттєва | після індексування |
Практичний підхід: почати з database - код пошуку однаковий для всіх рушіїв, тож коли знадобиться стійкість до опечаток чи релевантність, достатньо змінити драйвер і проіндексувати дані. А collection - у тестах, щоб не піднімати пошуковий сервер.
Докладніше в документації: Scout: рушії database і collection
Scout - це загальний інтерфейс до пошукових рушіїв: модель позначається трейтом, а рушій (Meilisearch, Algolia, Typesense, база) підмінюється конфігурацією.
class Vacancy extends Model
{
use Searchable;
/**
* @return array<string, mixed>
*/
public function toSearchableArray(): array
{
return [
'title' => $this->title,
'description' => $this->description,
'company' => $this->company?->name,
];
}
}
Vacancy::search('laravel middle')->paginate(15);
Індексація відбувається автоматично на збереженні моделі, а масово - через php artisan scout:import.
Коли вистачить LIKE:
- Пошук по одній-двох колонках, записів - тисячі.
- Достатньо точного входження, без урахування словоформ.
- Не хочеться ще одного сервісу в інфраструктурі.
Vacancy::where('title', 'like', "%{$term}%")->get();
Де LIKE перестає працювати:
- Не використовує індекс із
%на початку - на великій таблиці це повний перебір. - Не знає словоформ. «розробник» не знайдеться за запитом «розробники», і жодних синонімів.
- Не ранжує. Збіг у заголовку та згадка в кінці опису однакові за вагою.
- Не прощає помилок - один зайвий символ, і результат порожній.
- Не шукає по кількох сутностях одразу.
Проміжний варіант - повнотекстовий індекс самої бази: whereFullText() у MySQL, tsvector у PostgreSQL. Він знімає перші три пункти без окремого сервісу, хоча за якістю ранжування поступається спеціалізованим рушіям.
Практичне правило: починайте з LIKE, переходьте на повнотекстовий індекс, коли обсяг виріс, і на Scout - коли потрібні релевантність, помилки в запиті й фасети.
Трейт Searchable підписується на події моделі: після saved модель надсилається в індекс, після deleted - видаляється з нього. Окремо нічого викликати не потрібно.
Що потрапляє в індекс - визначає toSearchableArray:
public function toSearchableArray(): array
{
return [
'id' => (int) $this->id,
'title' => $this->title,
'body' => strip_tags($this->body),
'author' => $this->author->name,
'tags' => $this->tags->pluck('name')->all(),
'published_at' => $this->published_at?->timestamp,
];
}
Правила:
- лише поля для пошуку й фільтрів, а не вся модель - менший індекс, швидший пошук, менше витоків даних;
- дати - числом (timestamp), щоб по них можна було фільтрувати й сортувати;
- дані зі зв'язків - денормалізуються в документ. Але тоді зміна імені автора не оновить його статті в індексі - про це треба подбати окремо.
Що індексувати взагалі:
public function shouldBeSearchable(): bool
{
return $this->isPublished();
}
Чернетки не потраплять у пошук, а при знятті з публікації запис видалиться з індексу.
Коли оновлювати:
public function searchIndexShouldBeUpdated(): bool
{
return $this->wasRecentlyCreated || $this->wasChanged(['title', 'body']);
}
Без цього кожне оновлення лічильника переглядів відправляло б документ в індекс.
Черга - обов'язкова для зовнішніх рушіїв:
// config/scout.php
'queue' => ['connection' => 'redis', 'queue' => 'scout'],
Інакше кожне збереження моделі чекає HTTP-запиту до Meilisearch, а недоступний пошуковий сервер ламає збереження. Завдання Scout унікальні - дублікати для тієї самої моделі не ставляться, поки попереднє в черзі.
Масові операції:
Post::where(...)->searchable()/->unsearchable()- додати чи прибрати набір;Post::withoutSyncingToSearch(fn () => ...)- імпорт чи міграція без тисяч завдань індексування, з однимscout:importпотім;- масовий
update()через query builder не викликає подій моделі - індекс не оновиться, його треба синхронізувати вручну.
Початкове наповнення: php artisan scout:import "App\Models\Post" або scout:queue-import --chunk=500 для великих таблиць, а makeAllSearchableUsing - для жадібного завантаження зв'язків під час імпорту.
Докладніше в документації: Scout: налаштування даних для пошуку
Laravel Scout - драйверна абстракція повнотекстового пошуку. Додаєте трейт до моделі - і вона автоматично синхронізується з пошуковим індексом (Meilisearch, Algolia, Elasticsearch, навіть database).
class Post extends Model
{
use Searchable;
public function toSearchableArray(): array
{
return ['title' => $this->title, 'body' => $this->body];
}
}
Post::search('laravel queues')->paginate(15);
- Індекс оновлюється на події моделі (краще - через чергу).
- Початкове наповнення:
php artisan scout:import "App\Models\Post". - Meilisearch дає швидкий typo-tolerant пошук «з коробки»; Elasticsearch - складніші аналітичні запити.
Перебудова потрібна, коли змінилася структура документа (toSearchableArray), налаштування ранжування, мовна обробка чи версія пошукового рушія. Наївний шлях - scout:flush і scout:import - залишає сайт без пошуку на весь час імпорту, а на мільйонах записів це години.
Налаштування індексу як код. Фільтровані, сортовані поля й правила ранжування описують у config/scout.php:
'meilisearch' => [
'index-settings' => [
Post::class => [
'filterableAttributes' => ['author_id', 'tags', 'published_at'],
'sortableAttributes' => ['published_at'],
'searchableAttributes' => ['title', 'body', 'tags'],
],
],
],
php artisan scout:sync-index-settings
Команду додають у деплой. Зміна налаштувань у Meilisearch запускає перебудову індексу на боці рушія - він продовжує відповідати, але на великих індексах це навантаження.
Перебудова без простою - через новий індекс:
- версія в імені індексу:
public function searchableAs(): string
{
return config('scout.prefix') . 'posts_' . config('search.posts_version');
}
- наповнити новий індекс, поки старий обслуговує пошук: окремий процес з новою версією в конфігурації виконує
scout:queue-import; - дочекатися завершення індексації на боці рушія (Meilisearch обробляє задачі асинхронно - успішний імпорт у Laravel ще не означає готовий індекс);
- перемкнути версію в конфігурації застосунку (або атомарно поміняти індекси місцями - Meilisearch має операцію swap indexes);
- досинхронізувати зміни, що відбулися під час імпорту: записи, оновлені після старту, - повторно (
Post::where('updated_at', '>=', $startedAt)->searchable()); - видалити старий індекс після перевірки.
Чому крок 5 важливий: поки йде імпорт, звичайні збереження моделей пишуть у поточний індекс (той, що повертає searchableAs). Без досинхронізації новий індекс міститиме застарілі дані для змінених за цей час записів.
Інші пастки:
scout:importу черзі не відновлює зв'язки зmakeAllSearchableUsing- денормалізовані дані зі зв'язків варто завантажувати вtoSearchableArray, усвідомлюючи N+1, або імпортувати синхронно порціями;- видалення: записи, видалені під час перебудови, можуть «воскреснути» в новому індексі, якщо їх прочитали до видалення - перевіряйте
shouldBeSearchableі результати пошуку фільтруйте по базі; - тест релевантності: набір контрольних запитів з очікуваними результатами, який порівнює старий і новий індекс перед перемиканням.
Докладніше в документації: Scout: налаштування індексів Meilisearch