Al construir plataformas de e-commerce, uno de los problemas más desafiantes es gestionar atributos de producto dinámicos. A diferencia de campos estáticos como precio o stock, las características de los productos pueden variar ampliamente entre categorías y cambiar frecuentemente a medida que tu catálogo evoluciona.
En este artículo, exploraremos cómo implementar filtrado dinámico de atributos usando Laravel Scout y Meilisearch sin hardcodear atributos específicos en tu aplicación.
El problema
Considera una plataforma B2B de e-commerce que vende miles de productos en diferentes categorías:
Electrónica puede tener características como "Voltaje", "Consumo de energía", "Garantía". Ropa puede tener "Talla", "Material", "Color". Herramientas industriales pueden tener "Peso", "Diámetro", "Par de apriete".
Hardcodear estos atributos como columnas de base de datos o campos filtrables de Meilisearch crea varios problemas: rigidez del esquema (añadir nuevos atributos requiere migraciones y redespliegue), datos dispersos (la mayoría de productos tendrían valores nulos para atributos irrelevantes), y carga de mantenimiento (gestionar cientos de atributos potenciales se vuelve inmanejable).
La solución: indexación dinámica de características
Nuestro enfoque involucra tres componentes clave: indexación flexible de características de producto en Meilisearch, registro dinámico de atributos filtrables desde fuentes externas, y sincronización segura de configuración de Meilisearch sin pérdida de datos.
Indexando características dinámicamente
El desafío principal es transformar características arbitrarias de producto en un formato buscable. Lo logramos en el método 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ísticas categóricas: almacenadas como strings "featureId|value"
if ($normalizedValue = $this->normalizeFacetKey($feature->value)) {
$featuresFlat[] = "{$featureId}|{$normalizedValue}";
}
// Características numéricas: almacenadas como campos de nivel 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érico único
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);
}Decisiones de diseño clave: Las características categóricas van a un único array features_flat como strings tipo "42|Rojo" o "18|Algodón". Las características numéricas se convierten en campos de nivel superior como fn_42, fn_42_min, fn_42_max para filtrado por rangos. Este enfoque funciona con cualquier número de características sin cambios de esquema.
Gestionando filterables dinámicos
Meilisearch requiere declaración explícita de atributos filtrables. Como nuestras características vienen de una API externa, necesitamos un sistema de registro dinámico:
class MeilisearchManagementService
{
protected const DYNAMIC_FILTERABLES_CACHE_KEY = 'meilisearch:dynamic_filterables';
/**
* Registrar filterables dinámicos desde sincronización de características externas
*/
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ón base con características dinámicas
*/
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;
}
}Este servicio almacena filterables dinámicos en caché (persiste entre despliegues), los combina con la configuración base cuando es necesario, y proporciona una única fuente de verdad para la configuración esperada de Meilisearch.
Sincronización segura sin pérdida de datos
La intuición crítica: cambiar atributos filtrables en Meilisearch no requiere reindexación. Sin embargo, debes tener cuidado de no borrar accidentalmente tu índice.
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();
// Solo estos cambios requieren reindexación completa
$reindexKeys = ['searchableAttributes', 'sortableAttributes'];
return !empty(array_intersect_key($drift, array_flip($reindexKeys)));
}El sistema de detección de desviación compara la configuración esperada vs. actual de Meilisearch, determina si los cambios requieren una reindexación completa. Añadir atributos filtrables NO requiere reindexar documentos existentes. Solo cambios en atributos buscables u ordenables necesitan reindexación.
El flujo de trabajo completo
Así es como todo encaja:
# 1. Sincronizar definiciones de características desde API externa
php artisan product-features:names-sync
# → Obtiene metadatos de características
# → Almacena en base de datos
# → Registra filterables dinámicos en caché
# 2. Aplicar configuración a Meilisearch
php artisan meili:sync
# → Detecta desviación de configuración
# → Actualiza configuración de Meilisearch
# → Avisa si se necesita reindexación
# 3. Reindexar productos (solo si es necesario)
php artisan scout:import "App\Models\ProductShopSearch"El comando que sincroniza características maneja el registro automáticamente:
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');
}
Beneficios de este enfoque
Este patrón ofrece ventajas significativas: Cero downtime (añadir nuevas características no requiere cambios de código ni despliegues), flexibilidad (soporta cualquier número de atributos de producto sin migraciones de esquema), seguridad (la detección de desviación previene pérdida accidental de datos), rendimiento (solo reindexar cuando es realmente necesario), y escalabilidad (funciona con miles de características en diversos catálogos de productos).
Filtrado en la práctica
Una vez indexado, el filtrado se vuelve sencillo:
// Filtrar por característica categórica
ProductShopSearch::search($query)
->where('features_flat', '42|Rojo')
->get();
// Filtrar por rango numérico
ProductShopSearch::search($query)
->where('fn_18', '>=', 100)
->where('fn_18', '<=', 500)
->get();El array features_flat permite filtrado multi-valor, mientras que los campos numéricos fn_* soportan consultas de rango.
Conclusión
El filtrado dinámico de atributos resuelve un desafío común del e-commerce sin sacrificar rendimiento ni flexibilidad. Al tratar las características como datos en lugar de esquema, puedes construir sistemas de catálogo robustos que se adaptan a requisitos de negocio cambiantes.
Las intuiciones clave: indexa características en un formato normalizado y consultable; gestiona atributos filtrables dinámicamente vía caché; detecta desviación de configuración antes de aplicar cambios; y entiende cuándo la reindexación es realmente necesaria.
Este patrón funciona igual de bien para cualquier dominio con atributos variables: listados inmobiliarios, ofertas de empleo, gestión de inventario, o cualquier sistema donde el modelo de datos evoluciona frecuentemente.