When building e-commerce platforms, one of the most challenging problems is handling dynamic product attributes. Unlike static fields like price or stock, product features can vary widely between categories and change frequently as your catalog evolves.
In this article, we'll explore how to implement dynamic attribute filtering using Laravel Scout and Meilisearch without hardcoding specific attributes into your application.
The problem
Consider a B2B e-commerce platform selling thousands of products across different categories:
Electronics might have features like "Voltage", "Power Consumption", "Warranty". Clothing might have "Size", "Material", "Color". Industrial Tools might have "Weight", "Diameter", "Torque".
Hardcoding these attributes as database columns or Meilisearch filterable fields creates several problems: Schema rigidity (adding new attributes requires migrations and redeployment), sparse data (most products would have null values for irrelevant attributes), and maintenance burden (managing hundreds of potential attributes becomes unwieldy).
The solution: dynamic feature indexing
Our approach involves three key components: flexible indexing of product features in Meilisearch, dynamic registration of filterable attributes from external sources, and safe synchronization of Meilisearch settings without data loss.
Indexing features dynamically
The core challenge is transforming arbitrary product features into a searchable format. We accomplish this in the toSearchableArray() method:
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;
// Categorical features: stored as "featureId|value" strings
if ($normalizedValue = $this->normalizeFacetKey($feature->value)) {
$featuresFlat[] = "{$featureId}|{$normalizedValue}";
}
// Numeric features: stored as separate top-level fields
if ($feature->value_min !== null) {
$numericFields["fn_{$featureId}_min"] = $feature->value_min;
}
if ($feature->value_max !== null) {
$numericFields["fn_{$featureId}_max"] = $feature->value_max;
}
// Single numeric value
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);
}Key design decisions: Categorical features go into a single features_flat array as strings like "42|Red" or "18|Cotton". Numeric features become top-level fields like fn_42, fn_42_min, fn_42_max for range filtering. This approach works with any number of features without schema changes.
Managing dynamic filterables
Meilisearch requires explicit declaration of filterable attributes. Since our features come from an external API, we need a dynamic registration system:
class MeilisearchManagementService
{
protected const DYNAMIC_FILTERABLES_CACHE_KEY = 'meilisearch:dynamic_filterables';
/**
* Register dynamic filterables from external feature sync
*/
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);
}
/**
* Merge base config with dynamic features
*/
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;
}
}This service stores dynamic filterables in cache (persists across deploys), merges them with base configuration when needed, and provides a single source of truth for expected Meilisearch settings.
Safe synchronization without data loss
The critical insight: changing filterable attributes in Meilisearch doesn't require reindexing. However, you need to be careful not to accidentally flush your index.
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();
// Only these changes require full reindex
$reindexKeys = ['searchableAttributes', 'sortableAttributes'];
return !empty(array_intersect_key($drift, array_flip($reindexKeys)));
}The drift detection system compares expected vs. current Meilisearch configuration, determines if changes require a full reindex. Adding filterable attributes does NOT require reindexing existing documents. Only changes to searchable or sortable attributes need reindexing.
The complete workflow
Here's how everything fits together:
# 1. Sync feature definitions from external API
php artisan product-features:names-sync
# → Fetches feature metadata
# → Stores in database
# → Registers dynamic filterables in cache
# 2. Apply configuration to Meilisearch
php artisan meili:sync
# → Detects configuration drift
# → Updates Meilisearch settings
# → Warns if reindex is needed
# 3. Reindex products (only if needed)
php artisan scout:import "App\Models\ProductShopSearch"The command that syncs features handles registration automatically:
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');
}
Benefits of this approach
This pattern offers significant advantages: Zero downtime (adding new features doesn't require code changes or deployments), flexibility (support any number of product attributes without schema migrations), safety (drift detection prevents accidental data loss), performance (only reindex when truly necessary), and scalability (works with thousands of features across diverse product catalogs).
Filtering in practice
Once indexed, filtering becomes straightforward:
// Filter by categorical feature
ProductShopSearch::search($query)
->where('features_flat', '42|Red')
->get();
// Filter by numeric range
ProductShopSearch::search($query)
->where('fn_18', '>=', 100)
->where('fn_18', '<=', 500)
->get();The features_flat array enables multi-value filtering, while numeric fn_* fields support range queries.
Conclusion
Dynamic attribute filtering solves a common e-commerce challenge without sacrificing performance or flexibility. By treating features as data rather than schema, you can build robust catalog systems that adapt to changing business requirements.
The key insights: index features in a normalized, queryable format; manage filterable attributes dynamically via cache; detect configuration drift before applying changes; and understand when reindexing is truly necessary.
This pattern works equally well for any domain with variable attributes: real estate listings, job postings, inventory management, or any system where the data model evolves frequently.