mergeConfigFrom(__DIR__ . '/../../config/catalog.php', 'catalog'); } public function boot(): void { $this->publishes([ __DIR__ . '/../../config/catalog.php' => config_path('catalog.php'), ], 'core-config'); ProductOptionTypeManager::get()->register([ ColorOptionType::class, ]); $observer = new ProductOptionReindexObserver; ProductOption::saved(fn (ProductOption $option) => $observer->optionSaved($option)); ProductOption::deleted(fn (ProductOption $option) => $observer->optionDeleted($option)); ProductOptionValue::saved(fn (ProductOptionValue $value) => $observer->valueSaved($value)); ProductOptionValue::deleted(fn (ProductOptionValue $value) => $observer->valueDeleted($value)); // Registered on BOTH classes — Eloquent model events are keyed by // the literal class ::saved()/::deleted() was called on // (registerModelEvent() uses static::class at registration time), // not by inheritance, so a listener registered only on one class // never fires for an instance of the other. Code resolving Product // through the contract (Filament's own ProductResource, anything // using app(Contracts\Product::class)) gets the subclass once // ModelManifest::replace() below takes effect; code that still // hardcodes `Lunar\Models\Product` directly (e.g. MigrateImport\ // Shopify\ShopifyExportImporter — importing has no reason to need // the subclass's own custom_fields cast) keeps creating base-class // instances. Both must dispatch ProductSaved/ProductDeleted, since // ReindexProductsRecommendingProduct listens to those regardless // of which path created/updated the product. $dispatchSaved = fn (LunarProduct $product) => Event::dispatch(new ProductSaved($product)); $dispatchDeleted = fn (LunarProduct $product) => Event::dispatch(new ProductDeleted($product->id)); LunarProduct::saved($dispatchSaved); LunarProduct::deleted($dispatchDeleted); Product::saved($dispatchSaved); Product::deleted($dispatchDeleted); Event::listen(ProductSaved::class, [ReindexProductsRecommendingProduct::class, 'handleSaved']); Event::listen(ProductDeleted::class, [ReindexProductsRecommendingProduct::class, 'handleDeleted']); $this->app->booted(function () { // Deferred to booted() to run after every provider (Lunar's // own included) has finished its own boot() — matches // 3dealer's own AppServiceProvider, which registers Customer // the same way for the same reason. // // The first argument MUST be the CONTRACT // (Lunar\Models\Contracts\Product), not the concrete // Lunar\Models\Product — HasModelExtending::modelClass() // (which every Lunar model's __callStatic()/newModelQuery() // consults to decide "is there a registered replacement for // me") looks itself up by // ModelManifest::guessContractClass(static::class), which // resolves to the CONTRACT interface, then does // ModelManifest::get($thatContract) — so the manifest must be // keyed by the contract, or the lookup simply misses and // silently falls back to the base class. Caught in practice — // passing the concrete LunarProduct::class here (mirroring // Modules\Core\Customer\Providers\CustomerServiceProvider's // own replace() call, which has this exact same bug) left // Product::modelClass() resolving to Lunar\Models\Product no // matter what, until this was corrected. ModelManifest::replace(ProductContract::class, Product::class); // A full nightly reindex, on top of the per-event reindexing // above — catches everything event-driven reindexing // deliberately doesn't cover: a newly-created product not yet // appearing as a recommendation elsewhere, in_stock/price // drifting from an order decrementing stock outside a product // save, and any other staleness ProductIndexer's own docblock // already documents as accepted between reindexes. --refresh // re-syncs filterable/sortable field settings too, not just // documents, so a deploy that changed ProductIndexer's field // list self-heals here even if `lunar:meilisearch:setup` // wasn't run manually after that deploy. // References the subclass, not 'Lunar\Models\Product' — Scout's // own reindex loop (Searchable::makeAllSearchable()) queries // via whatever class name is passed here, so this determines // which class's casts (custom_fields included) are actually // applied to $model in ProductIndexer::toSearchableArray() // during this nightly full reindex. $this->app->make(Schedule::class) ->command('lunar:search:index', [Product::class, '--refresh']) ->dailyAt('03:00'); }); } }