Quan es construeixen plataformes d'e-commerce, un dels problemes més desafiants és gestionar atributs de producte dinàmics. A diferència de camps estàtics com preu o stock, les característiques dels productes poden variar àmpliament entre categories i canviar freqüentment a mesura que el teu catàleg evoluciona.
En aquest article, explorarem com implementar filtrat dinàmic d'atributs usant Laravel Scout i Meilisearch sense hardcodejar atributs específics a la teva aplicació.
El problema
Considera una plataforma B2B d'e-commerce que ven milers de productes en diferents categories:
Electrònica pot tenir característiques com "Voltatge", "Consum d'energia", "Garantia". Roba pot tenir "Talla", "Material", "Color". Eines industrials poden tenir "Pes", "Diàmetre", "Parell d'apriete".
Hardcodejar aquests atributs com columnes de base de dades o camps filtrables de Meilisearch crea diversos problemes: rigidesa de l'esquema (afegir nous atributs requereix migracions i redespliegue), dades disperses (la majoria de productes tindrien valors nuls per atributs irrellevants), i càrrega de manteniment (gestionar centenars d'atributs potencials es torna inmanejable).
La solució: indexació dinàmica de característiques
El nostre enfocament involucra tres components clau: indexació flexible de característiques de producte a Meilisearch, registre dinàmic d'atributs filtrables des de fonts externes, i sincronització segura de configuració de Meilisearch sense pèrdua de dades.
Indexant característiques dinàmicament
El desafiament principal és transformar característiques arbitràries de producte en un format cercable. Ho aconseguim al mètode toSearchableArray():
public function toSearchableArray(): array
{
$product = $this->product;
$features = $product->features()->where('lang', 'es')->get();
$featuresFlat = [];
$numericFields = [];
foreach ($features as $feature) {
$featureId = (string) $feature->feature_id;
// Característiques categòriques: emmagatzemades com strings "featureId|value"
if ($normalizedValue = $this->normalizeFacetKey($feature->value)) {
$featuresFlat[] = "{$featureId}|{$normalizedValue}";
}
// Característiques numèriques: emmagatzemades com camps de nivell superior
if ($feature->value_min !== null) {
$numericFields["fn_{$featureId}_min"] = $feature->value_min;
}
if ($feature->value_max !== null) {
$numericFields["fn_{$featureId}_max"] = $feature->value_max;
}
// Valor numèric únic
if ($numericValue = $this->normalizeNumber($feature->value)) {
$numericFields["fn_{$featureId}"] = $numericValue;
}
}
return array_merge([
'product_id' => $this->product_id,
'price' => $this->price,
'features_flat' => $featuresFlat,
], $numericFields);
}Decisions de disseny clau: Les característiques categòriques van a un únic array features_flat com strings tipus "42|Vermell" o "18|Cotó". Les característiques numèriques es converteixen en camps de nivell superior com fn_42, fn_42_min, fn_42_max per filtrat per rangs. Aquest enfocament funciona amb qualsevol nombre de característiques sense canvis d'esquema.
Gestionant filterables dinàmics
Meilisearch requereix declaració explícita d'atributs filtrables. Com les nostres característiques venen d'una API externa, necessitem un sistema de registre dinàmic:
class MeilisearchManagementService
{
protected const DYNAMIC_FILTERABLES_CACHE_KEY = 'meilisearch:dynamic_filterables';
/**
* Registrar filterables dinàmics des de sincronització de característiques externes
*/
public function registerDynamicFilterables(array $featureIds): void
{
$filterables = array_map(fn($id) => "fn_{$id}", $featureIds);
$normalized = $this->normalizeArray($filterables);
Cache::forever(self::DYNAMIC_FILTERABLES_CACHE_KEY, $normalized);
}
/**
* Combinar configuració base amb característiques dinàmiques
*/
public function getExpectedSettings(): array
{
$baseConfig = config('scout.meilisearch.index-settings')[ProductShopSearch::class] ?? [];
$dynamicFilterables = $this->getDynamicFilterables();
if (!empty($dynamicFilterables)) {
$baseFilterables = $baseConfig['filterableAttributes'] ?? [];
$merged = array_merge($baseFilterables, $dynamicFilterables);
$baseConfig['filterableAttributes'] = $this->normalizeArray($merged);
}
return $baseConfig;
}
}Aquest servei emmagatzema filterables dinàmics a la memòria cau (persisteix entre desplegaments), els combina amb la configuració base quan cal, i proporciona una única font de veritat per la configuració esperada de Meilisearch.
Sincronització segura sense pèrdua de dades
La intuïció crítica: canviar atributs filtrables a Meilisearch no requereix reindexació. Tanmateix, cal tenir cura de no esborrar accidentalment el teu índex.
public function detectDrift(): array
{
$expected = $this->getExpectedSettings();
$current = $this->getCurrentSettings();
$drift = [];
foreach ($expected as $key => $expectedValue) {
if ($this->hasChanged($expectedValue, $current[$key] ?? null)) {
$drift[$key] = [
'expected' => $expectedValue,
'current' => $current[$key] ?? null,
];
}
}
return $drift;
}
public function needsReindex(): bool
{
$drift = $this->detectDrift();
// Només aquests canvis requereixen reindexació completa
$reindexKeys = ['searchableAttributes', 'sortableAttributes'];
return !empty(array_intersect_key($drift, array_flip($reindexKeys)));
}El sistema de detecció de desviació compara la configuració esperada vs. actual de Meilisearch, determina si els canvis requereixen una reindexació completa. Afegir atributs filtrables NO requereix reindexar documents existents. Només canvis en atributs cercables o ordenables necessiten reindexació.
El flux de treball complet
Així és com tot encaixa:
# 1. Sincronitzar definicions de característiques des d'API externa
php artisan product-features:names-sync
# → Obté metadades de característiques
# → Emmagatzema a base de dades
# → Registra filterables dinàmics a memòria cau
# 2. Aplicar configuració a Meilisearch
php artisan meili:sync
# → Detecta desviació de configuració
# → Actualitza configuració de Meilisearch
# → Avisa si es necessita reindexació
# 3. Reindexar productes (només si cal)
php artisan scout:import "App\Models\ProductShopSearch"La comanda que sincronitza característiques gestiona el registre automàticament:
private function updateMeiliFilterables(array $featureIds): void
{
$uniqueFeatureIds = array_values(array_unique($featureIds));
$this->meiliService->registerDynamicFilterables($uniqueFeatureIds);
$this->info('Registered ' . count($uniqueFeatureIds) . ' dynamic filterables in cache.');
$this->line('💡 Changes will be applied by running: php artisan meili:sync');
}
Beneficis d'aquest enfocament
Aquest patró ofereix avantatges significatius: Zero downtime (afegir noves característiques no requereix canvis de codi ni desplegaments), flexibilitat (suporta qualsevol nombre d'atributs de producte sense migracions d'esquema), seguretat (la detecció de desviació prevé pèrdua accidental de dades), rendiment (només reindexar quan és realment necessari), i escalabilitat (funciona amb milers de característiques en diversos catàlegs de productes).
Filtrat a la pràctica
Un cop indexat, el filtrat es torna senzill:
// Filtrar per característica categòrica
ProductShopSearch::search($query)
->where('features_flat', '42|Vermell')
->get();
// Filtrar per rang numèric
ProductShopSearch::search($query)
->where('fn_18', '>=', 100)
->where('fn_18', '<=', 500)
->get();L'array features_flat permet filtrat multi-valor, mentre que els camps numèrics fn_* suporten consultes de rang.
Conclusió
El filtrat dinàmic d'atributs resol un desafiament comú de l'e-commerce sense sacrificar rendiment ni flexibilitat. En tractar les característiques com dades en lloc d'esquema, pots construir sistemes de catàleg robustos que s'adapten a requisits de negoci canviants.
Les intuïcions clau: indexa característiques en un format normalitzat i consultable; gestiona atributs filtrables dinàmicament via memòria cau; detecta desviació de configuració abans d'aplicar canvis; i entén quan la reindexació és realment necessària.
Aquest patró funciona igual de bé per qualsevol domini amb atributs variables: llistats immobiliaris, ofertes de feina, gestió d'inventari, o qualsevol sistema on el model de dades evoluciona freqüentment.