Laravel Scout memudahkan integrasi full-text search dengan mesin pencarian seperti Algolia, Meilisearch, atau driver database tertentu. Namun, di aplikasi production, masalah utamanya bukan sekadar membuat data bisa dicari, melainkan menjaga index tetap konsisten terhadap data utama di database. Saat produk berubah cepat, stok sering diupdate, status aktif/nonaktif berubah, atau relasi seperti kategori ikut berubah, index search dapat tertinggal jika arsitekturnya tidak dirancang dengan benar.
Pada artikel ini kita akan membahas pendekatan yang aman untuk sinkronisasi Laravel Scout menggunakan queue dan observer, termasuk kapan indexing berjalan sinkron, kapan sebaiknya melalui queue, bagaimana melakukan reimport massal, dan bagaimana menangani perubahan relasi agar hasil pencarian tetap akurat.
Memahami Kapan Laravel Scout Melakukan Indexing
Secara umum, model yang menggunakan trait Searchable akan diindeks saat model dibuat, diupdate, atau dihapus. Perilaku ini dapat berjalan:
- Sinkron: request aplikasi akan menunggu proses indexing selesai.
- Melalui queue: request hanya mendorong job ke queue, lalu worker memproses indexing di background.
Pendekatan sinkron lebih sederhana dan cocok untuk data kecil atau kebutuhan admin panel yang tidak sensitif terhadap latency. Namun di production, terutama jika write throughput cukup tinggi, indexing sinkron memiliki beberapa kelemahan:
- menambah waktu respons request,
- meningkatkan risiko timeout,
- menyulitkan retry jika mesin pencarian sedang bermasalah,
- membuat operasi write dan indexing terlalu terikat.
Karena itu, untuk sebagian besar aplikasi production, queue lebih aman. Queue memberi isolasi antara transaksi database dan sinkronisasi index, serta memudahkan retry, monitoring, dan pengaturan concurrency.
Konfigurasi Dasar Scout dan Queue
Model Product yang Searchable
Contoh model Product berikut menampilkan praktik yang umum dipakai: hanya produk aktif yang diindex, field index dibuat eksplisit, dan relasi kategori ikut dimasukkan ke payload index.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Laravel\Scout\Searchable;
class Product extends Model
{
use Searchable;
protected $fillable = [
'name',
'sku',
'description',
'price',
'stock',
'status',
'category_id',
'brand_id',
];
public function category()
{
return $this->belongsTo(Category::class);
}
public function brand()
{
return $this->belongsTo(Brand::class);
}
public function shouldBeSearchable(): bool
{
return $this->status === 'published' && $this->stock > 0;
}
public function toSearchableArray(): array
{
$this->loadMissing(['category', 'brand']);
return [
'id' => (int) $this->id,
'name' => $this->name,
'sku' => $this->sku,
'description' => $this->description,
'price' => (float) $this->price,
'stock' => (int) $this->stock,
'status' => $this->status,
'category_id' => $this->category_id,
'category_name' => optional($this->category)->name,
'brand_id' => $this->brand_id,
'brand_name' => optional($this->brand)->name,
'updated_at' => optional($this->updated_at)->toAtomString(),
];
}
}
Mengapa ini penting? Karena payload index sebaiknya stabil, eksplisit, dan tidak tergantung lazy loading yang tidak terkontrol. Jika relasi dibutuhkan di index, masukkan langsung di toSearchableArray().
Konfigurasi Queue
Pastikan aplikasi menggunakan queue backend yang layak untuk production, misalnya Redis. Contoh konfigurasi koneksi queue:
// config/queue.php
return [
'default' => env('QUEUE_CONNECTION', 'redis'),
'connections' => [
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90,
'block_for' => null,
],
],
];
Contoh environment:
QUEUE_CONNECTION=redis
SCOUT_QUEUE=true
REDIS_QUEUE=default
Pada banyak setup Scout, mengaktifkan queue akan membuat sinkronisasi index dikirim sebagai job ke queue. Detail implementasi bisa sedikit berbeda tergantung driver dan versi, tetapi prinsip arsitekturnya sama: request aplikasi tidak mengerjakan indexing langsung.
Menjalankan Worker
Di production, queue worker harus dijalankan sebagai proses terkelola, misalnya dengan Supervisor atau systemd. Contoh perintah worker:
php artisan queue:work redis --queue=default --sleep=1 --tries=3 --timeout=90Parameter penting:
--tries=3untuk retry otomatis jika indexing gagal sementara.--timeout=90agar job tidak menggantung terlalu lama.--sleep=1menekan latency pemrosesan job.
Jangan mengandalkan
queue:listenuntuk production. Worker jangka panjang dengan restart terkontrol jauh lebih efisien.
Menggunakan Observer untuk Kontrol Sinkronisasi yang Lebih Aman
Mengandalkan event default model kadang cukup, tetapi di production sering dibutuhkan kontrol lebih spesifik: hanya field tertentu yang memicu reindex, atau update relasi tertentu harus menyebabkan produk ikut diindex ulang. Observer memberi titik kontrol yang lebih eksplisit.
ProductObserver
<?php
namespace App\Observers;
use App\Jobs\ReindexProductJob;
use App\Models\Product;
use Illuminate\Support\Facades\Log;
class ProductObserver
{
public function created(Product $product): void
{
ReindexProductJob::dispatch($product->id);
}
public function updated(Product $product): void
{
$watched = [
'name',
'sku',
'description',
'price',
'stock',
'status',
'category_id',
'brand_id',
];
if ($product->wasChanged($watched)) {
ReindexProductJob::dispatch($product->id);
}
}
public function deleted(Product $product): void
{
$product->unsearchable();
}
public function restored(Product $product): void
{
ReindexProductJob::dispatch($product->id);
}
}
Daftarkan observer di provider aplikasi:
<?php
namespace App\Providers;
use App\Models\Product;
use App\Observers\ProductObserver;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
Product::observe(ProductObserver::class);
}
}
Mengapa observer berguna?
- Memusatkan logika kapan reindex terjadi.
- Mencegah update field yang tidak relevan memicu indexing.
- Memudahkan audit dan debugging saat index tidak sesuai.
Job Reindex yang Idempotent dan Mudah Dipantau
Alih-alih memanggil searchable() langsung di banyak tempat, buat job khusus agar alur reindex lebih terkontrol.
<?php
namespace App\Jobs;
use App\Models\Product;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ReindexProductJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 90;
public function __construct(public int $productId)
{
}
public function handle(): void
{
$product = Product::with(['category', 'brand'])->find($this->productId);
if (! $product) {
Log::warning('Reindex skipped: product not found', [
'product_id' => $this->productId,
]);
return;
}
if ($product->shouldBeSearchable()) {
$product->searchable();
Log::info('Product indexed', [
'product_id' => $product->id,
'updated_at' => optional($product->updated_at)->toAtomString(),
]);
} else {
$product->unsearchable();
Log::info('Product removed from index', [
'product_id' => $product->id,
'status' => $product->status,
'stock' => $product->stock,
]);
}
}
}
Job di atas bersifat idempotent: jika dipanggil berkali-kali untuk produk yang sama, hasil akhirnya tetap benar berdasarkan kondisi terbaru di database. Ini penting untuk menghadapi retry, duplicate event, atau update beruntun.
Contoh Update Stok dan Status Produk
Misalnya ada service yang mengubah stok setelah checkout:
<?php
namespace App\Services;
use App\Models\Product;
use Illuminate\Support\Facades\DB;
class InventoryService
{
public function decreaseStock(int $productId, int $qty): Product
{
return DB::transaction(function () use ($productId, $qty) {
$product = Product::lockForUpdate()->findOrFail($productId);
$product->stock = max(0, $product->stock - $qty);
$product->save();
return $product;
});
}
public function publish(int $productId): Product
{
$product = Product::findOrFail($productId);
$product->status = 'published';
$product->save();
return $product;
}
}
Karena observer memantau perubahan stock dan status, update tersebut otomatis memicu reindex. Saat stok menjadi 0, produk dapat dikeluarkan dari index jika shouldBeSearchable() mengembalikan false.
Menangani Perubahan Relasi: Kategori, Brand, dan Data Turunan
Kasus yang sering terlupakan adalah perubahan pada relasi. Misalnya nama kategori berubah dari “Elektronik” menjadi “Gadget”. Jika category_name disimpan di index produk, maka semua produk dalam kategori itu harus diindex ulang.
Contoh observer kategori:
<?php
namespace App\Observers;
use App\Jobs\ReindexProductJob;
use App\Models\Category;
class CategoryObserver
{
public function updated(Category $category): void
{
if ($category->wasChanged(['name'])) {
$category->products()
->select('id')
->chunkById(500, function ($products) {
foreach ($products as $product) {
ReindexProductJob::dispatch($product->id);
}
});
}
}
}
Jika relasi sangat besar, strategi ini lebih aman dibanding memuat semua produk sekaligus ke memory. Gunakan chunkById() agar reindex tetap hemat resource.
Trade-off-nya adalah jumlah job bisa banyak. Untuk volume besar, Anda bisa mempertimbangkan satu job batch per kategori yang kemudian memproses produk secara bertahap.
Reimport Massal dan Pemulihan Konsistensi Index
Pada situasi tertentu, reindex incremental tidak cukup. Misalnya setelah perubahan struktur index, bug pada toSearchableArray(), migrasi data besar, atau outage mesin pencarian. Dalam kondisi ini, lakukan reimport massal.
php artisan scout:import "App\Models\Product"Perintah ini akan membaca ulang data dari database dan mengirimkannya ke search index. Sebelum menjalankan di production, perhatikan beberapa hal:
- Pastikan worker queue aktif jika import dijalankan secara asynchronous.
- Gunakan maintenance window atau rate limit bila mesin pencarian sensitif terhadap lonjakan write.
- Pastikan eager loading relasi yang dibutuhkan tersedia agar payload lengkap dan efisien.
Jika perlu, Anda juga bisa membuat command atau job khusus:
<?php
namespace App\Jobs;
use App\Models\Product;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class BulkReindexProductsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle(): void
{
Product::with(['category', 'brand'])
->chunkById(500, function ($products) {
$products->searchable();
});
}
}
Pendekatan ini berguna jika Anda ingin mengontrol batch size, logging, atau antrean khusus untuk indexing.
Masalah Umum di Production dan Cara Mengatasinya
1. Index Tertinggal
Gejalanya: data database sudah berubah, tetapi hasil pencarian masih lama. Penyebab umum:
- worker queue mati,
- job gagal dan masuk failed jobs,
- queue backlog terlalu panjang,
- event/observer tidak terpanggil.
Langkah cek cepat:
php artisan queue:failed
php artisan queue:retry all
Periksa juga dashboard worker atau metrik Redis untuk melihat apakah antrean menumpuk.
2. Race Condition
Race condition sering terjadi saat produk diupdate berkali-kali dalam waktu sangat singkat. Misalnya stok berubah dari 10 ke 8 lalu ke 0, tetapi job lama diproses belakangan. Jika job membawa snapshot data lama, index bisa salah.
Solusi pentingnya adalah job harus membaca state terbaru dari database saat dieksekusi, bukan menyimpan seluruh payload model saat dispatch. Itulah mengapa contoh ReindexProductJob hanya menyimpan productId.
3. Duplicate Update
Satu perubahan kadang memicu beberapa event, terutama jika ada observer tambahan, event domain, atau proses sinkronisasi lain. Jika job indexing duplikat masuk ke queue, hasil akhir biasanya tidak masalah selama job idempotent. Namun duplicate update tetap menambah beban.
Mitigasi yang bisa digunakan:
- batasi field yang diawasi di observer,
- hindari memanggil
searchable()manual di banyak layer sekaligus, - gunakan queue terpisah untuk indexing agar lebih mudah dipantau,
- pertimbangkan deduplication di level job bila traffic sangat tinggi.
4. Data Relasi Tidak Ikut Terbarui
Ini terjadi saat index produk menyimpan nama kategori atau brand, tetapi observer hanya ada di model Product. Solusinya adalah mendaftarkan observer pada model relasi yang relevan dan menjadwalkan reindex untuk semua entitas turunannya.
Monitoring dan Logging agar Search Tetap Konsisten
Index search adalah derived data, sehingga harus diperlakukan sebagai komponen yang bisa diverifikasi dan dipulihkan. Beberapa praktik yang disarankan:
- Logging terstruktur untuk job reindex: simpan product_id, status job, dan timestamp.
- Monitoring queue backlog: alert jika antrean indexing menumpuk atau worker berhenti.
- Monitoring failed jobs: jangan biarkan job gagal menumpuk tanpa retry.
- Health check berkala: sampling beberapa produk, bandingkan data DB dengan dokumen di index.
- Audit reindex massal: catat kapan
scout:importdijalankan dan berapa batch yang selesai.
Jika Anda menggunakan sistem observability seperti Sentry, Elasticsearch, Grafana, atau layanan log terpusat lainnya, kirim metadata job indexing agar anomali mudah dilacak. Minimal, pastikan ada jawaban cepat untuk pertanyaan berikut:
- Apakah job reindex sedang diproses?
- Apakah ada failed jobs?
- Produk mana yang terakhir diindex?
- Apakah payload index sesuai state database terbaru?
Rekomendasi Arsitektur Praktis
Untuk mayoritas aplikasi production, pendekatan yang aman adalah:
- Gunakan queue untuk semua operasi indexing.
- Buat observer agar trigger reindex eksplisit dan terkontrol.
- Buat job reindex idempotent yang hanya menyimpan ID lalu membaca state terbaru dari database.
- Tangani perubahan relasi dengan observer tambahan pada model terkait.
- Sediakan reimport massal untuk recovery dan perubahan struktur index.
- Pasang monitoring pada queue, failed jobs, dan konsistensi data.
Dengan pola ini, search akan lebih tahan terhadap lonjakan traffic, retry, perubahan data yang cepat, dan kegagalan sementara pada mesin pencarian. Laravel Scout memang menyederhanakan integrasi search, tetapi konsistensi index di production tetap membutuhkan desain event, queue, dan observability yang disiplin.
Jika diterapkan dengan benar, hasilnya bukan hanya fitur pencarian yang cepat, tetapi juga pencarian yang dapat dipercaya: data yang tampil di search selaras dengan kondisi nyata di database aplikasi Anda.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!