wizcodepl / lunar-product-schemas
Migration-style schema builder for Lunar product types and attributes (toggle filterable/searchable/required, attach/detach attrs, rename, drop with data cleanup).
Package info
github.com/wizcodepl/lunar-product-schemas
pkg:composer/wizcodepl/lunar-product-schemas
Requires
- php: ^8.2
- lunarphp/core: ^1.3
Requires (Dev)
- filament/filament: ^3.2
- laravel/pint: ^1.18
- orchestra/testbench: ^9.0|^10.0|^11.0
- phpunit/phpunit: ^11.0
Suggests
- filament/filament: Required for the bundled Schema Health admin page.
README
Migration-style schema builder for Lunar product types and attributes. Manage searchable / filterable / required flags, attach or detach attributes per product type (both product-level and variant-level), rename or drop attributes (with cleanup of values stored in attribute_data JSON on either layer) — all from versioned definition files that ship with your code.
Inspired by Laravel's Schema::table() builder, but for the catalog layer Lunar exposes through Attribute, AttributeGroup, and ProductType.
Why
Lunar lets you toggle Attribute::filterable / searchable / required from the Filament admin panel. That works, but on a real shop you typically want:
- Attribute structure tracked in code, not panel clicks.
- A clear history of changes (who, when, why).
- Repeatable, environment-agnostic deploys.
Doing this with raw Attribute::where(...)->update(...) calls in Laravel migrations works, but quickly turns into copy-pasted boilerplate. This package is the thin wrapper that makes those operations readable, plus a dedicated product-schema:* command set so catalog changes don't fight Laravel's own migrations table for history.
Requirements
- PHP 8.2+
- Lunar core ^1.3 (which itself pulls in Laravel 11 or 12)
Install
composer require wizcodepl/lunar-product-schemas
The service provider auto-registers via Laravel package discovery.
Run migrate once to create the tracking table the package ships with:
php artisan migrate
This creates product_schema_migrations (separate from Laravel's own migrations table) so DB schema changes and product-catalog changes don't share batch numbers.
(Optional) publish the config to override the path where definitions live:
php artisan vendor:publish --tag=lunar-product-schemas-config
// config/lunar-product-schemas.php return [ 'path' => database_path('product-schemas'), ];
Concepts: product-level vs variant-level attributes
Lunar stores attribute values on two layers, and this package manages both:
| Where the value lives | Use it for | Lunar admin tab | This package |
|---|---|---|---|
Product.attribute_data JSON |
Same value for all variants of one product (e.g. material, season, gender) | "Product Attributes" | attribute() |
ProductVariant.attribute_data JSON |
Per-SKU descriptive data the customer doesn't pick (e.g. lead time, pantone code, batch number) | "Variant Attributes" | variantAttribute() |
What this package does not manage: customer-pickable variant axes like Size and Color — those are Lunar's separate ProductOption / ProductOptionValue mechanism. See Out of scope below.
Quick start
Create a definition file:
php artisan product-schema:make add_t_shirt_attributes
# → database/product-schemas/2026_05_01_120000_add_t_shirt_attributes.php
Fill it in:
<?php use Illuminate\Database\Migrations\Migration; use WizcodePl\LunarProductSchemas\ProductSchema; return new class extends Migration { public function up(): void { ProductSchema::productType('t-shirts', 'T-shirts') // Product-level: same value across all variants of a given t-shirt. ->attribute('material', name: ['en' => 'Material'], filterable: true, required: true) ->attribute('season', name: ['en' => 'Season'], filterable: true) ->attribute('gender', name: ['en' => 'Gender'], filterable: true) // Variant-level: per-SKU data the customer doesn't pick. ->variantAttribute('lead_time_days', name: ['en' => 'Lead time (days)']) ->variantAttribute('batch_number', name: ['en' => 'Batch number']); } public function down(): void { ProductSchema::productType('t-shirts') ->dropVariantAttribute('batch_number') ->dropVariantAttribute('lead_time_days') ->dropAttribute('gender') ->dropAttribute('season') ->dropAttribute('material'); } };
Apply it:
php artisan product-schema:apply
Commands
| Command | What it does |
|---|---|
product-schema:make {name} |
Generate a new timestamped definition file from the stub. |
product-schema:apply |
Run all pending definition files, recording each in product_schema_migrations. |
product-schema:rollback |
Roll back the most recent batch (or --step=N to roll back N batches). |
product-schema:status |
List every definition file with Pending or Ran [batch N]. |
apply and rollback accept --pretend to print SQL without executing.
Usage
Single product type
use WizcodePl\LunarProductSchemas\ProductSchema; ProductSchema::productType('t-shirts', 'T-shirts') ->attribute('material', name: ['en' => 'Material'], filterable: true, required: true) ->attribute('season', name: ['en' => 'Season'], filterable: true) ->variantAttribute('lead_time_days', name: ['en' => 'Lead time (days)']);
Many product types in one chain
When several product types share an attribute set, fan out with productTypes():
ProductSchema::productTypes([ 't-shirts' => 'T-shirts', 'shoes' => 'Shoes', 'bags' => 'Bags', ]) ->attribute('material', filterable: true, required: true) ->attribute('season', filterable: true) ->variantAttribute('lead_time_days') ->variantAttribute('batch_number');
Pass a flat list when names match the handles:
ProductSchema::productTypes(['t-shirts', 'shoes', 'bags']) ->attribute('material', filterable: true);
Restrict subsequent calls to a subset:
ProductSchema::productTypes(['t-shirts', 'shoes', 'bags']) ->attribute('material', filterable: true) // applied to all three ->only('t-shirts', 'bags') ->attribute('pattern'); // only t-shirts and bags
Variant-level attributes
Per-SKU data the customer doesn't pick — lead time, batch number, pantone code, supplier ID, manufacturer SKU. Values land in ProductVariant.attribute_data JSON.
ProductSchema::productType('t-shirts') ->variantAttribute('lead_time_days', name: ['en' => 'Lead time (days)']) ->variantAttribute('batch_number', name: ['en' => 'Batch number']) ->variantAttribute('pantone_code', name: ['en' => 'Pantone code']);
variantAttribute() takes the same flags as attribute() — filterable, searchable, required — wired through to the underlying Attribute row:
ProductSchema::productType('t-shirts') ->variantAttribute( handle: 'manufacturer_sku', name: ['en' => 'Manufacturer SKU'], searchable: true, required: true, // every variant must carry it ) ->variantAttribute( handle: 'lead_time_days', name: ['en' => 'Lead time (days)'], filterable: true, // facet on the storefront ) ->variantAttribute( handle: 'pantone_code', name: ['en' => 'Pantone code'], searchable: false, // internal, hide from search index );
Same tristate semantics: pass null (default) to leave an existing flag untouched, true/false to force.
These show up under the "Variant Attributes" tab in Lunar admin (Product Types → [t-shirts]).
Note: if you want customers to pick a value (Size: S/M/L, Color: Red/Blue), that's a
ProductOption— a different Lunar mechanism not handled here. See Out of scope below.
Localized names
Pass a string for the current app()->getLocale(), or an array keyed by locale for multilingual setups:
ProductSchema::productType('t-shirts') ->attribute( handle: 'material', name: ['en' => 'Material', 'pl' => 'Materiał'], groupName: ['en' => 'Specifications', 'pl' => 'Specyfikacja'], group: 'specifications', filterable: true, required: true, );
Renaming and toggling flags globally
ProductSchema::attribute(...) operates on a product-level attribute regardless of which product types use it. ProductSchema::variantAttribute(...) is the equivalent for variant-level attributes.
// flip flags on a product-level attribute ProductSchema::attribute('material')->filterable(true)->required(false); // rename handle (chunked migration of attribute_data JSON keys across every product) ProductSchema::attribute('material')->rename('fabric'); // rename a variant-level attribute (chunked migration across every ProductVariant) ProductSchema::variantAttribute('lead_time_days')->rename('processing_days'); // localized label ProductSchema::attribute('material')->name('Materiał', locale: 'pl');
Dropping attributes
Per product type — keeps the attribute alive for other types still using it, but strips the value from this type's products (or variants):
// product-level ProductSchema::productType('shoes') ->dropAttribute('lining'); // variant-level ProductSchema::productType('t-shirts') ->dropVariantAttribute('batch_number');
Globally — detaches from every product type, strips values from every product or variant (auto-detected from the attribute's type), then deletes the attribute row:
ProductSchema::dropAttribute('legacy_color_code');
Lunar's polymorphic pivot (lunar_attributables) lacks cascade; the package wipes those pivot rows for you.
Authoritative attribute set per type
Detach every attribute whose handle is not in the list. The product-level and variant-level lists are independent — syncAttributes() doesn't touch variant attrs, and syncVariantAttributes() doesn't touch product attrs.
ProductSchema::productType('t-shirts') ->syncAttributes(['material', 'season', 'gender']) // product-level ->syncVariantAttributes(['lead_time_days', 'batch_number']); // variant-level
Dropping a product type
ProductSchema::dropProductType('legacy-products');
Lunar cascades the ProductType ↔ Attribute pivot. Products of this type are not deleted — orphaning them is rarely what you want, so migrate the data explicitly first.
API reference
ProductSchema::productType(string $handle, ?string $name = null): ProductTypeBuilder
Creates the product type if missing. If $name is supplied and differs, updates it.
ProductSchema::productTypes(array $types): ProductTypesBuilder
Either a flat list of handles or a [handle => name] map. Every method on the returned builder fans out to each underlying ProductTypeBuilder.
ProductSchema::attribute(string $handle): AttributeBuilder
Cross-type operations on a product-level attribute. Throws if it doesn't exist.
ProductSchema::variantAttribute(string $handle): AttributeBuilder
Cross-type operations on a variant-level attribute. Throws if it doesn't exist.
ProductSchema::dropAttribute(string $handle): void
Global drop with full cleanup (pivot rows + correct attribute_data JSON layer based on the attribute's type).
ProductSchema::dropProductType(string $handle): void
Deletes the product type row only.
ProductTypeBuilder::attribute(...) / ProductTypeBuilder::variantAttribute(...)
attribute( string $handle, string|array|null $name = null, ?string $type = null, string $group = 'spec', // 'variant_spec' for variantAttribute() string|array|null $groupName = null, ?bool $searchable = null, ?bool $filterable = null, ?bool $required = null, )
Both are idempotent. Defaults (type=Text, searchable=true, filterable=false, required=false) are applied only on first create. Tristate flags (null = leave existing value alone) make it safe for multiple migrations to touch the same attribute without unintentionally resetting flags.
ProductTypeBuilder::dropAttribute(string $handle) / ProductTypeBuilder::dropVariantAttribute(string $handle)
Detach + strip JSON values from this type only. Other product types are untouched. Each method targets its own layer.
ProductTypeBuilder::syncAttributes(array $keep) / ProductTypeBuilder::syncVariantAttributes(array $keep)
Detach every attribute on the matching layer whose handle is not in $keep. Cross-layer attrs are untouched.
ProductTypeBuilder::rename(string $newHandle, ?string $newName = null)
Rename the product type itself.
AttributeBuilder methods
filterable(bool $value = true)searchable(bool $value = true)required(bool $value = true)name(string $name, string $locale = 'en')rename(string $newHandle)— also migratesattribute_dataJSON keys (chunked) on the correct layer (Product or ProductVariant) based on how the builder was constructed.
Schema Health (Filament)
A bundled Filament admin page surfaces how complete your catalog actually is against the required attributes you've declared. Lives under Catalog → Products → Schema Health (sibling of Product Types), so it's right where someone looking at the catalog model would expect it.
Opt in by registering the plugin in your PanelProvider:
use WizcodePl\LunarProductSchemas\Filament\LunarProductSchemasPlugin; public function panel(Panel $panel): Panel { return $panel->plugin(LunarProductSchemasPlugin::make()); }
What you get:
- Header stats widget — three native Filament
StatsOverviewWidgetcards aggregating the whole catalog: Complete / Partial / Missing. - Filament Table of every ProductType with: name, total products, completeness %, complete / partial / missing counts. Searchable, sortable, paginated.
- Click a row → slide-over with the per-type breakdown:
- Three stat boxes for that type
- Progress bar with exact %
- Required-fields list
- Per-attribute gap breakdown ("23 products missing
material, 8 missinggtin") - Each gap is collapsible — expand to see the actual list of products that lack the field
It uses only what Lunar already exposes — the required flag on Attribute and attribute_data on Product. No new tables, no new concepts, no extra configuration. The moment you mark an attribute required: true (via this package or otherwise), it lights up in the dashboard.
If you don't install Filament or don't register the plugin, the rest of the package works as before — the report data is also available programmatically:
use WizcodePl\LunarProductSchemas\Reports\SchemaHealthReport; $rows = app(SchemaHealthReport::class)->compute(); foreach ($rows as $row) { $row->productType; // Lunar ProductType $row->totalProducts; // int $row->complete; // int $row->partial; // int $row->missing; // int $row->completePercentage(); // float $row->requiredAttributeHandles; // ['material', 'gtin'] $row->missingByAttribute; // ['material' => 23, 'gtin' => 8] } // Health for a single ProductType by handle: $health = app(SchemaHealthReport::class)->forType('t-shirts'); // Drill-down for a specific (type, attribute) pair: $incomplete = app(SchemaHealthReport::class) ->productsMissing('t-shirts', 'material');
Translations
The Filament page ships with English (default) and Polish out of the box. The widget, table columns, slide-over content and action labels all go through Laravel's translation system.
For other locales, or to customise the wording, publish the translation files into your app:
php artisan vendor:publish --tag=lunar-product-schemas-translations
This copies the bundled translations to lang/vendor/lunar-product-schemas/{en,pl}/filament.php where you can edit them directly. Adding a new locale (say German):
cp lang/vendor/lunar-product-schemas/en/filament.php \
lang/vendor/lunar-product-schemas/de/filament.php
# translate the values, set app.locale = 'de'
Laravel's standard fallback chain applies — if a key is missing in the active locale, it falls back to config('app.fallback_locale') (English by default), so partial translations are safe.
Out of scope
This package covers Attribute schema on both product and variant layers. It does not wrap:
ProductOption/ProductOptionValue— the customer-pickable variant axes (Size, Color). Those are typically generated at sync time from external systems (vendor APIs, ERP exports, marketplace feeds) with vendor-specific identifiers, not declared statically in code.- Generating
ProductVariantrows per option combination — also sync-time / vendor-specific.
If your shop has a curated catalog where variant axes are stable design decisions and you'd like tooling for them, open a GitHub issue — happy to discuss.
Notes
- Operations are idempotent where possible: re-running a definition that creates an attribute already in the DB is a no-op-with-update.
- Flag parameters are tristate —
null(default) means "leave the existing value alone". - The package uses
saveQuietly()when modifying products and variants in bulk so observers (e.g. Scout) don't fire one-by-one. Re-index in bulk after applying definitions if needed. - The
requiredflag lives on theAttributeitself in Lunar 1.3, so it's effectively global — flipping it for one product type flips it everywhere.
Testing
composer install vendor/bin/phpunit
Tests run via Orchestra Testbench, with Lunar core's migrations and this package's handle-column migration applied automatically. The default driver is in-memory SQLite (zero setup), and CI also runs the suite against MySQL 8 to catch JSON-column behavior that SQLite glosses over.
Switch the local run to MySQL by exporting DB_CONNECTION=mysql and the standard DB_HOST / DB_PORT / DB_DATABASE / DB_USERNAME / DB_PASSWORD variables before invoking phpunit.
Code style is enforced with Laravel Pint:
vendor/bin/pint # auto-fix vendor/bin/pint --test # check only (CI uses this)
License
MIT — see LICENSE.