From 1c7efc6e4d3adaaf2584f22083c53922a5047c30 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Tue, 22 Sep 2026 21:17:01 +0300 Subject: [PATCH] Feature: Adding Custom Fields to Products --- ...01_add_custom_fields_to_products_table.php | 40 ++++++++++ src/Catalog/Models/Product.php | 52 +++++++++++++ src/Catalog/Services/ProductIndexer.php | 6 ++ src/Providers/CatalogServiceProvider.php | 59 ++++++++++++++- src/Providers/CustomerServiceProvider.php | 15 +++- .../Extensions/ProductResourceExtension.php | 74 +++++++++++++++++++ 6 files changed, 241 insertions(+), 5 deletions(-) create mode 100644 database/migrations/2026_09_22_000001_add_custom_fields_to_products_table.php create mode 100644 src/Catalog/Models/Product.php diff --git a/database/migrations/2026_09_22_000001_add_custom_fields_to_products_table.php b/database/migrations/2026_09_22_000001_add_custom_fields_to_products_table.php new file mode 100644 index 0000000..b1a6862 --- /dev/null +++ b/database/migrations/2026_09_22_000001_add_custom_fields_to_products_table.php @@ -0,0 +1,40 @@ +json('custom_fields')->nullable()->after('attribute_data'); + }); + } + + public function down(): void + { + Schema::table(config('lunar.database.table_prefix').'products', function (Blueprint $table) { + $table->dropColumn('custom_fields'); + }); + } +}; diff --git a/src/Catalog/Models/Product.php b/src/Catalog/Models/Product.php new file mode 100644 index 0000000..76f84b8 --- /dev/null +++ b/src/Catalog/Models/Product.php @@ -0,0 +1,52 @@ +update($data)) — no error, no exception, the admin form shows + * the repeater's rows as saved right up until the next page load, when + * they're simply gone. Caught in practice. + * + * ModelManifest::replace() only changes what code resolving Product + * through the CONTRACT (app(Contracts\Product::class), Filament's own + * ProductResource — its $model is ProductContract::class, not the + * concrete class) or the morph map receives — it does NOT retroactively + * change what a hardcoded `Lunar\Models\Product::query()`/`::find()` + * elsewhere in this codebase (or Lunar's own internals, e.g. the + * scheduled Meilisearch reindex command — see CatalogServiceProvider, + * which references this subclass by name specifically so that path picks + * it up too) resolves to. Most of this codebase's existing Product + * references are plain type-hints (they accept whichever instance is + * handed to them, subclass included) or don't touch `custom_fields` at + * all, so they're unaffected either way. + */ +class Product extends \Lunar\Models\Product +{ + // NOT `protected $casts = [...]` — that property assignment REPLACES + // the parent's own $casts array wholesale rather than merging with + // it (PHP class property redeclaration has no merge semantics), which + // would silently drop every cast Lunar\Models\Product already + // defines (attribute_data, status, etc.). mergeCasts() is Eloquent's + // own documented mechanism for a subclass adding to, not replacing, + // its parent's casts. + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + + $this->mergeCasts([ + 'custom_fields' => 'array', + ]); + + $this->mergeFillable([ + 'custom_fields', + ]); + } +} diff --git a/src/Catalog/Services/ProductIndexer.php b/src/Catalog/Services/ProductIndexer.php index 0ed4516..7597442 100644 --- a/src/Catalog/Services/ProductIndexer.php +++ b/src/Catalog/Services/ProductIndexer.php @@ -139,6 +139,12 @@ class ProductIndexer extends BaseProductIndexer ->all(); $data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all(); $data['skus'] = $model->variants->pluck('sku')->filter()->unique()->values()->all(); + // Only decoded correctly when $model is an instance of + // Modules\Core\Catalog\Models\Product (the custom_fields cast + // lives there, not on the base Lunar\Models\Product) — see + // CatalogServiceProvider's own comment on why the scheduled + // reindex command references that subclass by name specifically. + $data['custom_fields'] = $model->custom_fields ?? []; $data['tags'] = $model->tags->pluck('value')->all(); $data['media'] = $model->media->map(fn (Media $media) => $this->mapMedia($media))->all(); $data['variants'] = $model->variants->map(fn (ProductVariant $variant) => $this->mapVariant($variant, $currency))->all(); diff --git a/src/Providers/CatalogServiceProvider.php b/src/Providers/CatalogServiceProvider.php index 428dffc..770c753 100644 --- a/src/Providers/CatalogServiceProvider.php +++ b/src/Providers/CatalogServiceProvider.php @@ -5,12 +5,15 @@ namespace Modules\Core\Providers; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; -use Lunar\Models\Product; +use Lunar\Facades\ModelManifest; +use Lunar\Models\Contracts\Product as ProductContract; +use Lunar\Models\Product as LunarProduct; use Lunar\Models\ProductOption; use Lunar\Models\ProductOptionValue; use Modules\Core\Catalog\Events\ProductDeleted; use Modules\Core\Catalog\Events\ProductSaved; use Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct; +use Modules\Core\Catalog\Models\Product; use Modules\Core\Catalog\Observers\ProductOptionReindexObserver; use Modules\Core\Catalog\OptionTypes\ColorOptionType; use Modules\Core\Catalog\Services\ProductOptionTypeManager; @@ -40,13 +43,55 @@ class CatalogServiceProvider extends ServiceProvider ProductOptionValue::saved(fn (ProductOptionValue $value) => $observer->valueSaved($value)); ProductOptionValue::deleted(fn (ProductOptionValue $value) => $observer->valueDeleted($value)); - Product::saved(fn (Product $product) => Event::dispatch(new ProductSaved($product))); - Product::deleted(fn (Product $product) => Event::dispatch(new ProductDeleted($product->id))); + // 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 @@ -58,8 +103,14 @@ class CatalogServiceProvider extends ServiceProvider // 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', ['Lunar\\Models\\Product', '--refresh']) + ->command('lunar:search:index', [Product::class, '--refresh']) ->dailyAt('03:00'); }); } diff --git a/src/Providers/CustomerServiceProvider.php b/src/Providers/CustomerServiceProvider.php index 4b5f995..9d0cde3 100644 --- a/src/Providers/CustomerServiceProvider.php +++ b/src/Providers/CustomerServiceProvider.php @@ -19,7 +19,20 @@ class CustomerServiceProvider extends ServiceProvider { public function boot(): void { - ModelManifest::replace(LunarCustomer::class, Customer::class); + // Deferred to booted() — LunarServiceProvider (lunarphp/core) + // calls Facades\ModelManifest::register() from its OWN boot(), + // which re-discovers every Lunar\Models\* class and repopulates + // the whole manifest from scratch, silently undoing a replace() + // call made from a boot() that ran earlier in the provider list. + // booted() fires only once every provider's boot() has completed, + // guaranteeing this is the one that actually sticks — same fix, + // same reasoning, as Providers\CatalogServiceProvider's own + // Product replace() call. This one likely only "worked" before + // because 3dealer's own AppServiceProvider independently + // re-registers Customer correctly in its own booted() — a + // consuming app without that redundant registration would have + // silently gotten the base Lunar\Models\Customer back. + $this->app->booted(fn () => ModelManifest::replace(LunarCustomer::class, Customer::class)); Event::listen(UserCreated::class, CreateCustomerForUser::class); diff --git a/src/Review/Filament/Extensions/ProductResourceExtension.php b/src/Review/Filament/Extensions/ProductResourceExtension.php index 9d36031..59baa9b 100644 --- a/src/Review/Filament/Extensions/ProductResourceExtension.php +++ b/src/Review/Filament/Extensions/ProductResourceExtension.php @@ -2,9 +2,31 @@ namespace Modules\Core\Review\Filament\Extensions; +use Filament\Forms\Components\Repeater; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; +use Filament\Schemas\Components\Section; +use Filament\Schemas\Schema; use Lunar\Admin\Support\Extending\ResourceExtension; use Modules\Core\Review\Filament\Pages\ManageProductReviews; +/** + * CorePlugin allows exactly one extension class per Lunar resource — this + * one already owned ProductResource (adding the Reviews sub-page) before + * Catalog needed its own product-form addition, so extendForm() lives + * here too rather than competing for the same resource slot. + * + * extendForm() adds a "Custom Fields" repeater authoring Product:: + * $custom_fields (see the migration adding that column, and Modules\Core\ + * Catalog\Models\Product's own docblock on why this needed a first-party + * Product subclass rather than being addable to the base Lunar model) — + * per-product, customer-authored input (a reference photo upload, an + * optional engraving textarea) rendered on the storefront product page, + * NOT a Lunar ProductOption: an option's values are a fixed, admin- + * authored list that define variants, which doesn't fit "the customer + * uploads their own unique photo." + */ class ProductResourceExtension extends ResourceExtension { public function extendPages(array $pages): array @@ -18,4 +40,56 @@ class ProductResourceExtension extends ResourceExtension { return [...$pages, ManageProductReviews::class]; } + + public function extendForm(Schema $schema): Schema + { + return $schema->components([ + ...$schema->getComponents(), + $this->customFieldsSection(), + ]); + } + + private function customFieldsSection(): Section + { + return Section::make('Custom Fields') + ->description('Extra input the shopper fills in on this product\'s page before adding it to their cart — a reference photo, personalization text, etc.') + ->collapsible() + ->collapsed(fn ($record) => blank($record?->custom_fields)) + ->schema([ + Repeater::make('custom_fields') + ->hiddenLabel() + ->schema([ + TextInput::make('label') + ->label('Label') + ->helperText('Shown to the shopper above the field.') + ->required(), + Select::make('type') + ->label('Field type') + ->options([ + 'text' => 'Short text', + 'textarea' => 'Long text', + 'file' => 'File upload', + ]) + ->default('text') + ->native(false) + ->live() + ->required(), + TextInput::make('key') + ->label('Key') + ->helperText('Machine-facing identifier — stored on the order/cart line, used to look up this answer elsewhere. Cannot be changed once orders reference it.') + ->required() + ->alphaDash() + ->maxLength(64), + Toggle::make('required') + ->label('Required') + ->helperText('Shopper cannot add this product to their cart without answering.') + ->default(false), + ]) + ->columns(2) + ->addActionLabel('Add a custom field') + ->reorderable() + ->collapsible() + ->itemLabel(fn (array $state): ?string => $state['label'] ?? null), + ]); + } }