Laravel で Algolia を利用していくつかのモデルのデータを混ぜて検索する方法
オンライン家庭教師マナリンク 開発の Technote です。
今回は Laravel で Algolia を利用していくつかのモデルのデータを混ぜて検索する方法を紹介します。

Laravel Scout
Algolia の導入自体は Laravel Scout を利用すると、モデルの更新・削除の際に検索に必要なデータの同期を自動でやってくれるのでとても簡単です。
ただ、今回はいくつかのモデルのデータを混ぜて index を作成したいので Laravel Scout だけでは難しいです。
Scout Extended
自分で実装すると大変そうですが、Algolia のドキュメントにもやり方があるように Scout Extended というパッケージを利用すると簡単に実装できます。
導入方法
1. パッケージの追加
composer require algolia/scout-extended
2. Aggregator の生成
php artisan scout:make-aggregator [Aggregator名]
このコマンドで app/Search に Aggregator を継承したクラスが生成されます。
ここでは AutocompleteItem で生成したとして話を進めます。
3. models に検索対象にしたいモデルを追加
namespace App\Search;
use Algolia\ScoutExtended\Searchable\Aggregator;
use App\Models\CourseFeature; // コースの特徴
use App\Models\Subject; // 科目
use App\Models\Teacher; // 先生
class AutocompleteItem extends Aggregator
{
protected $models = [
CourseFeature::class,
Subject::class,
Teacher::class,
];
}
4. Aggregator の bootSearchable を呼び出し
Model で use された Searchable などの Trait に関しては boot[Trait名] が Laravel によって勝手に呼ばれますが、Aggregator は AppServiceProvider などで bootSearchable を明示的に呼び出す必要があります。
class AppServiceProvider extends ServiceProvider
{
public function boot()
{
AutocompleteItem::bootSearchable();
}
}
$models に追加するモデルは Searchable を use すべきかどうか
ドキュメント内では特に言及はないですが、shouldBeSearchable の実装例を見ると Searchable を use しているかどうかで分岐させています。
public function shouldBeSearchable()
{
// Check if the class uses the Searchable trait before calling shouldBeSearchable
if (array_key_exists(Searchable::class, class_uses($this->model))) {
return $this->model->shouldBeSearchable();
}
}
shouldBeSearchable は同期するかどうかの判定を実装するメソッドです。
例えば先生が一つもコースを作成していない場合は同期しないなどの条件を記述します。
これは Aggregator からそれぞれのモデルの shouldBeSearchable を呼ぶための実装ですが、これだけを見ると Searchable の use が必要なように思えます。
ただ、実装を追うとわかりますがそれぞれのモデルでの Searchable の use は必要ありません。
逆に Searchable を use すると Searchable での Observer (ModelObserver) と Aggregator での Observer (AggregatorObserver) をそれぞれ observe することになり、 Aggregator での index とは別にそのモデルの index も作成されてしまいます。
saved => searchable (
Model) =>$this->newCollection([$this])->searchable()=> macro 登録されたsearchableが起動 =>queueMakeSearchableで同期
saved => searchable (
Aggregator) =>AggregatorCollection::make([$this])->searchable()=> macro 登録されたsearchableが起動 =>queueMakeSearchableで同期
disableSyncingFor を使用するとそれも防ぐことができますが、そもそもそのモデルだけで検索しない場合は use しなければいいだけです。
それぞれのモデルでは Searchable を use せず、Aggregator の shouldBeSearchable の実装を以下のように method_exists による判定にすれば色々とうまくいきました。
public function shouldBeSearchable()
{
if (method_exists($this->model, 'shouldBeSearchable')) {
return $this->model->shouldBeSearchable();
}
return false;
}
それぞれのモデルで Searchable を use していなくても toSearchableArray で同期するデータを整形することもできます。
関連データの同期について
今回の AutocompleteItem の例での CourseFeature のコースの特徴は、紐づく公開されたコースがない場合は同期させない要件でした。
よりイメージしやすいように例えば「記事」とそれに紐づく「タグ」を考えます。
公開された記事が一つでも紐付いているタグだけを同期したい場合、タグの操作だけではなく、記事の作成、削除や公開フラグの更新などでもタグの同期処理が実行される必要があります。
ここで紹介されているように $touches を使用したり saved イベントを購読することである程度実装できます。
ただし $touches は更新日時カラムが存在しなければいけない点に注意が必要です。
また saved イベントを購読する場合にもモデルの save とタグの sync の実行順序に注意が必要です。
以下のように更新と作成を共通化している場合はうまくいきません。
function save(Article $article, $entity) {
$article->title = $entity->getTitle();
// ...
$article->save(); // saved イベント発火(まだ tags は同期されていない)
$article->tags()->sync($entity->getTags());
}
function create($entity) {
save(new Article(), $entity);
}
function update($entity) {
save(Article::findOrFail($entity->getId()), $entity);
}
またこの例ではタグが外される場合(≠タグの削除)も考慮する必要があるので Pivot化して中間テーブルの deleted イベントを購読するなどの工夫も必要です。
まとめ
Scout Extended を利用すると複数のモデルを混ぜた検索も簡単に導入できますが、関連データの同期なども考えると注意すべきことも少なくないです。
もっと Algolia の技術記事が増えてくれると嬉しいです。
オンライン家庭教師マナリンクを運営するNoSchool社のテックブログです。 manalink.jp/ 実際に検証・開発した内容をベースに、ただのマニュアルや告知に留まらない具体的な知見を公開します! カジュアル面談はこちら! forms.gle/fGAk3vDqKv4Dg2MN7
Discussion