Shop Module: Developer Reference
Online sales channel for Laraplate. Orchestrates CMS content, ERP items and SAO support into a storefront.
Status
Catalog foundation plus a backoffice write surface. The module ships the product catalog core (product, variant, variant composition), the seed of the product content entity, and the actions and Filament resource that create and edit a whole product (see «Admin write surface»). It has no stock, cart, checkout or payment behaviour, and no read or API surface yet (see «Not in this module yet»).
The module is enabled in the committed modules_statuses.json. Enabling it registers
shop.product as the first production content extender in the CMS
ContentExtenderRegistry (see «Seam registration»).
Boundaries
- Depends on Core, CMS, ERP and SAO (one-way: none of them knows Shop).
- Every table is prefixed
shop_and centralised inShopTables(app/Enums/ShopTables.php). - Content belongs to CMS, items, stock and orders belong to ERP, support belongs to SAO. Shop composes them and must not duplicate their logic.
- CMS never references a Shop class. The only link is the content-extension seam,
where Shop registers the opaque alias
shop.product.
Catalog model
CMS Content <--1:1--> Product --1:n--> ProductVariant --1:n--> VariantItem --> ERP Item
(the body) shop_products shop_product_variants shop_variant_items erp_items
- The body of a product (title, slug, description, validity, translations,
dynamic fields) is a CMS
Content. Shop does not store it. - The
Productis the commercial anchor that extends that content. - A
ProductVariantis the sellable unit. A simple product has one variant. - A
VariantItemcomposes a variant from ERPItems: one row is a simple product, several rows are a virtual bundle, no row is an item-less variant. ProductKind(app/Enums/ProductKind.php) says what the product is:physical(ships, stocked, variants resolve to ERP items),digital(delivered as a download or entitlement, no ERP item) anddisplay_only(shown in the catalog, not sold through the shop). The kind, not the row count, tells an item-less variant apart.
Product content entity
Shop owns the dynamic-content entity its products are created against (decision
E20). It is deliberately not a CMS EntityType case.
app/Casts/EntityType.php: the Shop entity types, today onlyproducts.app/Models/Entity.php,Preset.php,Pivot/Presettable.php: Shop specializations of Core's abstractEntity,PresetandPresettable. Core resolves them by module namespace throughDynamicContentsService(fetchAvailableEntities(EntityType::Products)returns the ShopEntity, and likewise for presets and presettables).Entityis scoped to the Shop entity types andPresetto presets of such entities, so the Shop and CMS entity layers do not see each other's rows.Preset::getRelatedModelClass()is the CMSContent: the dynamic content a Shop preset describes is a CMS content.ShopDatabaseSeeder(database/seeders) creates theproductentity (is_default) and itsstandardpreset, attaches the body field (content, typeEditor, translatable, required on the preset) and freezes the preset snapshot withPresetVersioningService. It is idempotent on the entity type: an operator renaming or deactivating the entity does not get a second entity and preset on the next seed. Thecontentfield is shared with CMS by name: the seeder reuses an existing one (restoring it when soft-deleted) and creates it only when none exists, so the order in which Shop and CMS are seeded does not matter.- Storefront classification will be a CMS
Categoryand per-category presets (attribute sets) come later. Today the product has this single default preset.
To create a product content by hand, create a CMS Content with
presettable_id set to the Shop product presettable (this also syncs
entity_id) and let the product persist it (see «Creating a product»).
Product
app/Models/Product.php, table shop_products. Extends Core's Model and
implements CMS ExtendsContent with ExtendsContentTrait. It is the first
production consumer of the content-extension seam (Modules/CMS/docs/CONTENT_EXTENSION.md).
Columns:
| Column | Notes |
|---|---|
company_id |
Tenant, through ERP BelongsToCompany (E23): company global scope, creating auto-fill from the current company, company_id added to $fillable |
content_id |
NOT NULL and UNIQUE (E16): one extender per content, a product always has a body. FK to CMS contents, cascadeOnDelete |
kind |
ProductKind. physical is the column default only: the model does not apply it in memory ((new Product)->kind is null) and getRules() requires kind on create |
is_published_in_shop |
boolean, default false |
featured |
boolean, default false |
release_date |
nullable date |
metadata |
nullable json, cast to array |
Index shop_products_company_published_IDX on (company_id, is_published_in_shop).
Validation lives in getRules() (kind required on create and bounded to
ProductKind, content_id must exist in contents and not be soft-deleted).
Seam registration
ShopServiceProvider::register() registers Product::CONTENT_ALIAS
(shop.product) in the CMS ContentExtenderRegistry, both when the registry
resolves and, if it already resolved earlier, immediately, so registration does
not depend on provider order. The trait stamps contents.extended_type with that
alias on the first save.
Consequences of the seam to keep in mind:
- An extended content is hidden from a default
Contentquery (HidesExtendedContentscope) and from generic CMS search. Read products throughProduct, or useContent::withExtended()to reach the contents. Product::query()always eager-loadscontent($with). Upcasting a page of extended contents with the CMSContentExtensionResolvercosts a constant number of queries per distinct alias (one for the page, one batched query for the extenders), not one per row. That holds for a lean page: the test strips thepresettableandtranslationeager-loadsContentcarries in$with. The resolver queriesProductthrough its company global scope whileContentis not company-scoped, so once a company context is active, upcasting a product content of another company throws (it finds no extender row). This is a tracked follow-up.$product->contentresolves despite the hide scope:content()removes onlyHidesExtendedContent, not the soft-delete scope. Once the product is soft-deleted its content is trashed with it (E17), socontentis null and the merged reads (E2a) return null.
Creating a product
The pair is created through the extender, in one transaction, via the seam's
save() bridge:
$content = new Content();
$content->presettable_id = $presettable->id; // Shop product presettable
$content->valid_from = now();
$product = new Product(['kind' => ProductKind::Physical]);
$product->setTempContent($content);
$product->save(); // saves the content (stamps extended_type), links it, saves the product
company_id is NOT NULL: it is auto-filled from the current ERP company on
creating, so with no active company it has to be passed explicitly (it is
fillable).
ProductFactory does exactly this against the seeded Shop product entity (call
ShopDatabaseSeeder first) and adds a default-locale translation to the content.
Transparent content merge (E2a)
Content-owned attributes read and write as if they were native on the product,
the way CMS Contributor surfaces its User.
getAttribute()/setAttribute()route any key outside theOWN_COLUMNSwhitelist (the product's own columns, timestamps,deleted_at,is_deleted) to the mergedContent. A key also stays native when it ispivot, is already present in the product's attribute bag, or is an accessor, a mutator, a relation or a method of the product.- The merged content is the loaded
contentrelation, or the content staged bysetTempContent()for a first save. The merge never triggers a lazy load, so attribute access on an unretrieved product stays query-free. - A write through the product root raises the
contentDirtyViaMergeflag. Thesavinghook persists the merged content when it is dirty or the flag is set. The flag is what carries translatable fields (title,slug, translatable dynamic fields): they buffer in the content's pending translations and never make the content dirty. A product save with no content write does not save the content, so it never spawns a spurious content version or approval. - The first save is the seam trait's job (it stages and persists the temp content); the hook only clears the flag in that case.
- The body field
contentis the one key the merge does not cover. The product's single seeded dynamic field is namedcontent(it reuses the CMS Editorcontentfield), but onProductcontentis also the relation method fromExtendsContentTrait, soisNativeKey('content')is true.$product->contenttherefore always returns theContentmodel, never the body value. Read and write the body through the merged content:$product->content->content. The product write actions sidestep the key:contentis not one of their content fields (see «Admin write surface»), and the dynamic fields, the body included, travel as the translatablecomponentsobject.
Lifecycle
Product and content are one unit with a symmetric lifecycle (E17):
- Product soft delete, force delete and restore cascade to the content
(
ExtendsContentTrait; restore goes through the content'sreviveInMemory()andsave()). - Deleting the content cascades to the product. A shared re-entrancy guard
(
ContentExtensionCascade) stops the two directions from looping. - A hard-deleted content cannot leave a bodiless product: the
content_idFK cascades on delete.
The catalog subtree below the product follows the same lifecycle. The rules live in
the booted() hooks of Product, ProductVariant and VariantItem:
- Soft delete cascades down. Soft-deleting a
Productsoft-deletes its live variants and their live items; soft-deleting aProductVariantsoft-deletes its live items. No variant is left live under a trashed product, so$variant->productand the tenant derived from it (E23) resolve for every live variant. The cascade runs on thedeletedevent, after the parent row is written, and stamps every cascaded row with the parent's owndeleted_at. - Restore is timestamp-matched. Restoring a product revives only the variants
and items whose
deleted_atequals the product'sdeleted_at, that is the rows the cascade trashed. A child soft-deleted on its own earlier keeps its own timestamp and stays trashed. Restoring a variant does the same for its items. - Force delete is the foreign key's job.
cascadeOnDeletereaps the whole subtree (product to variants to items); the hooks skip a force delete. - A child is restored through its parent while the parent is trashed.
Restoring a
ProductVariantwhose product is trashed, or aVariantItemwhose variant is trashed, throws aValidationException(keysproduct_idandvariant_id). The parent's restore revives the children in bulk and does not run that guard. Once the parent is live, a child that was removed on its own can be restored by itself. - A restore cannot recreate a duplicate pair. Restoring a
VariantItemthrows aValidationException(keyitem_id) when a different live row already composes the same variant with the same item. Together with the previous rule, a parent's restore cannot revive a duplicate either: a trashed sibling cannot be restored ahead of it.
Developer notes on the cascade:
- Second precision.
deleted_atis stored with second precision. A child soft-deleted on its own in the same wall-clock second as its parent's delete carries the same timestamp, so the parent's restore revives it. The window is narrow (scripts and tests, not a person using the UI) and there is no sub-second column to close it. - Bulk updates, no child events. The cascade stamps and clears
deleted_atwith bulk updates, so model events do not fire on the children: versioning, approvals and search indexing do not see the cascade. It also runs only through modeldelete()andrestore()on the parent. A query-levelProduct::query()->delete()(or any bulk soft delete) bypasses it entirely. - Not transactional. The cascade is not wrapped in a transaction with the
parent write, the same as the cascade to the content in
ExtendsContentTrait. A failure in the cascade leaves a trashed parent with live children. - Re-deleting a trashed parent re-stamps it. Calling
delete()again on a stale instance of an already trashed parent rewrites itsdeleted_at, so the children cascaded earlier no longer match and stay trashed when the parent is restored. Restore them one by one, parent first. - Per-table soft-delete setting. Core can switch soft deletes off per table. The
cascade writes
deleted_atby bulk update and does not consult the child's setting. A row deleted on a table with soft deletes off is removed for good, with its subtree through the foreign keys, and the hooks skip it (no stamp to propagate).
Search
The seam merges Product::searchableExtension() into the content's search
document under the nested extension object (kind, is_published_in_shop,
featured; CMS adds a type key holding the shop.product alias), and searchableExtensionMapping() contributes the typed mapping
(keyword and boolean filters). The trait re-sends the content to the index after
every product save, so the document never lags behind the extension.
ProductVariant
app/Models/ProductVariant.php, table shop_product_variants. The sellable unit
under a product.
| Column | Notes |
|---|---|
product_id |
FK to shop_products, cascadeOnDelete, indexed |
is_default |
boolean, default false |
attributes |
nullable json, cast to array: the purchase-selection axis (size, colour, ...). Label and projection only, never a source of truth and not relational (E22) |
- No
company_id: the tenant is derived from the product (E23). - One default per product is enforced at the model level, because a partial
unique index is not portable across the supported drivers. A
savedhook demotes every other variant of the product (trashed ones included, so a restore cannot bring back a second default) when the saved variant is the default and itsis_defaultorproduct_idis dirty. It runs onsaved, notsaving, because Core validates increating/updating: a rejected write must not wipe the current default. A plain re-save of a stale in-memory default does not demote the real default. The demotion is a bulk update, so it fires no model events. - A product can have zero defaults until one is set. A variant starts as non-default.
- A variant never changes parent:
product_idis prohibited on update when it is dirty. A variant cannot be created under a trashed (or missing) product, nor restored on its own while its product is trashed (see «Lifecycle»). Product::variants()is thehasMany;Product::defaultVariant()is thehasOnefiltered onis_default.
VariantItem
app/Models/VariantItem.php, table shop_variant_items. Composes a variant from
ERP Items (E7b).
| Column | Notes |
|---|---|
variant_id |
FK to shop_product_variants, cascadeOnDelete, indexed |
item_id |
FK to erp_items, restrictOnDelete, indexed |
quantity |
decimal(15,4), cast decimal:4; validated as at most four decimals, minimum 0.0001, maximum 99999999999.9999 |
role |
closed set main / component (E31): VariantItem::ROLE_MAIN, ROLE_COMPONENT. A validated string, not an enum |
mainis the sole sellable item of a simple product (or the anchor of a range),componentis a constituent of a bundle. Storage accepts a bundle ofcomponentrows and a variant with no row at all. That a simple product has exactly onemainrow, and that the item and the product belong to the same company, are checkout concerns and are not enforced here.- No
company_id: the tenant is derived through the variant and its product. - Uniqueness of
(variant_id, item_id)among live rows is enforced by validation (a unique rule scoped to the variant anddeleted_at IS NULL), not by a database index, so a soft-deleted row does not block re-adding the item. The rule runs on create and on an item swap (only when the item changes). Core skips update validation when onlydeleted_atchanges, so a restore is guarded separately: arestoringhook throws aValidationExceptionwhen a different live row already composes the same variant with the same item. A row also cannot be restored on its own while its variant is trashed (see «Lifecycle»). - A row never moves to another variant.
item_idmust exist and be live (not soft-deleted) when a row is created or its item is swapped.variant_idmust exist and not be trashed. restrictOnDeleteonitem_idguards hard deletes only: force-deleting an ERP item that still composes a variant fails with a query exception, so a bundle component cannot silently vanish. A soft-deleted item stays referenced on purpose. A hard-deleted variant removes its composition rows by FK cascade.
Soft-delete contract for the composition
ProductVariant::items()returns the full composition (theVariantItemrows, soft-deleted rows excluded by the model's soft delete).VariantItem::item()useswithTrashed(): a row always resolves its ERP item, even a soft-deleted (discontinued) one, so reading the composition never hits a null. Check$row->item->trashed()for the item lifecycle. Use this relation for writes too.ProductVariant::erpItems()is the live-only, purchasable view: abelongsToManythroughshop_variant_items(pivotquantity,role, timestamps) that leaves out soft-deleted items and soft-deleted composition rows.
Admin write surface
A product is created and edited as one aggregate: the Product, its CMS Content
(through the content-extension seam), its ProductVariants and their VariantItem
composition. Three domain actions write it, and the Filament resource is a thin form
over them. The actions are the single write path: the panel and any future caller
(an API, an importer) go through them, never through the models directly.
Write actions
app/Actions/Products/, all final, injected through the container.
CreateProductAction::handle(array $data): Productbuilds the aggregate in one transaction, derived from the owning model ($product->getConnection()).Product::newBackingContent()returns a new, unsavedContentattached to the seeded Shop product entity and its default preset (it needsShopDatabaseSeederto have run; it throws when the entity or preset is missing). The action fills it with the content fields of the payload, stages it withsetTempContent()and saves the product, so the seam persists and links the content on the first save. It then hands the variants toSyncProductVariantsAction.UpdateProductAction::handle(Product $product, array $data): Productupdates the product columns and merges the content fields onto the product root, so the product'ssavinghook flushes the content, translatable fields included. Only the keys present in the payload are touched. The variants are synced only when the payload carries avariantskey; an absent key leaves them as they are. The tenant (company_id) never changes. It returns the refreshed product.SyncProductVariantsAction::handle(Product $product, array $variants): voidbrings the variants and their composition in line with the payload, which is the desired final state. A variant entry with anidupdates that variant (anidthat is not one of the product's variants raises aValidationExceptiononvariants.{n}.id), one without creates a new variant, and a live variant that is not listed is soft-deleted. Inside a variant the composition rows are matched byitem_id: a listed item is updated or added, an unlisted one is removed, and a variant entry without anitemskey leaves its composition alone.
Payload (create; update takes the same shape without company_id):
[
'company_id' => 1, // create only; defaults to the current company
'kind' => 'physical',
'is_published_in_shop' => true,
'featured' => false,
'release_date' => '2026-11-01',
'metadata' => ['k' => 'v'],
'content' => ['title' => 'T', 'slug' => 't', 'components' => [], 'valid_from' => '...', 'valid_to' => null, 'order_column' => 1],
'variants' => [
['id' => 7, 'is_default' => true, 'attributes' => ['size' => 'M'],
'items' => [['item_id' => 3, 'quantity' => '2', 'role' => 'main']]],
],
]
- Allowlists.
CreateProductAction::COLUMNS(kind,is_published_in_shop,featured,release_date,metadata) bounds the product columns andCreateProductAction::CONTENT_FIELDS(title,slug,components,valid_from,valid_to,order_column) bounds the content fields. Any other key is dropped, so content keys are never mass-assigned onto the product. The translatable fields (title,slug,components) are written in the current locale. - Title. A create needs a non-blank string
content.title; an update refuses a blank one and leaves an absent one alone. Both raise aValidationExceptiononcontent.title. A create derivesslugfrom the title when none is given, and so does an update that gives the first translation in the current locale. - Invariants stay in the models. The actions do not judge the values: the single
default variant, the closed
roleset, uniqueness of an item within a variant and the soft-delete cascade are enforced byProductVariantandVariantItem(see the sections above). A rejected write aborts the action's transaction.
Filament resource
app/Filament/Resources/Products/. ProductResource (slug shop/products, group
shop.navigation.main) is registered with the admin panel by ShopPlugin, whose
ModuleFilamentPlugin trait discovers the module's Filament/Resources. The pages
carry no write logic.
- Create (
CreateProduct) is a 4-step wizard built bySchemas/ProductWizard: Product (columns), Content, Variants, Composition. The Content step reuses the fields of the CMSContentForm, read off a schema of their own, so none of the entity and preset selects CMS adds applies: a product's content is always created on the Shop product entity and its default preset. The submit reshapes the wizard state withProductForm::createPayload()and callsCreateProductAction. - Edit (
EditProduct) is tabbed sections over the same four concerns, laid out bySchemas/ProductForm::configure()from the component lists the wizard also uses. The form is filled from the whole aggregate (ProductForm::stateOf(): the product columns, the merged content fields and the live variants with their composition), not from the bare record, and the save reshapes the state withProductForm::updatePayload()and callsUpdateProductAction.afterSave()fills the form again from the stored product, so a variant added in the form keeps its id and the next save updates it instead of creating it twice. AValidationExceptionfrom the action is shown in a notification and mapped onto the form's field keys. The tenant is not editable. - Variants and composition are one piece of form state (
variants) edited by two repeaters: the variants step (hiddenid, default flag, attributes) and the composition step (the rows of each variant: item, quantity, role). The payload is refused unless exactly one variant is the default, and the item options are the items of the company chosen for the product. The variants can be reordered only while creating, the one time the order is stored. - Table (
Tables/ProductsTable) queriesProduct, notContent, so the rows are the products themselves: the company global scope (the active company) and the soft deletes apply to them. The title is the one of the merged content, with the company and the content translation eager-loaded. Core's view and edit actions are removed from it, because the edit one opens a modal that fills from the bare columns and saves with$record->update(), outside the action and its transaction. Its edit action is a plain URL to the edit page, visible only to whocanEdit, and runs nothing. - Discontinued items. A composition may reference a soft-deleted ERP
Item. The edit form resolves the itemwithTrashed()and labels it «discontinued», so such a product still opens and saves.
Permissions
ProductPolicy (registered in ShopServiceProvider) gates the resource pages by the
CRUD permissions of the products table, {connection}.shop_products.{operation}
(default.shop_products.select, insert, update, delete, restore,
forceDelete). permission:refresh generates them from the models, so the module
declares none. Opening the resource takes select (canAccess() requires
canViewAny()); the create page also takes insert and the edit page update. A
super admin passes; anybody else needs a grant on the request's guard, and a
permission that does not exist is not held.
The policy gates the pages by the products table only. ProductVariant and
VariantItem are parts of the product (IsPartOfParent), and the model layer checks
the permissions of their own tables (shop_product_variants, shop_variant_items)
on every write, once those permissions are registered. A user holding only the
shop_products permissions opens the pages, but a save that touches a variant is
refused there and rolled back. Failing closed is intended: managing a product takes
the part permissions as well.
No Shop role grants are seeded, so the surface is super-admin-only until an operator grants the permissions.
Scope
- Default preset only. The content is created on the Shop product entity and its
default
standardpreset. Storefront categories, per-category presets, facets, the browse snapshot and the/appread endpoints are not built yet (plan #3). - Backoffice only. Filament is the superadmin backoffice. The merchant-facing
catalog management experience is left to
laraplate-ui.
Known limitation: no aggregate optimistic locking
The product edit does not guard the aggregate against a stale write. Product has no
lock_version, the edit page takes no lease on the content and does not use the
content stale-guard, and the variant sync writes the desired final state. A save from
a form opened before another admin added a variant therefore removes that variant
silently. This is a known gap, accepted for the superadmin-only backoffice of this
version; locking the aggregate is a feature of its own (see EditProduct).
Not in this module yet
- Stock and availability. They will consume the ERP stock reservation, not duplicate it.
- Cart, checkout and payments.
- Digital fulfilment (download grants for
digitalproducts). - Reviews, order and delivery read models, customer support through SAO.
- Storefront classification: CMS categories, facets and per-category presets.
- A read or API surface for the catalog: no API resources,
/appor/api/v1endpoints (see «HTTP surface»). The write surface is the one described in «Admin write surface». - Merchant-facing catalog management (left to
laraplate-ui) and optimistic locking of the product aggregate.
Developer caveats
- Content keys are not fillable. A product's content-owned fields route only
through direct property assignment (
$product->title = '...') andforceFill(the one exception is the body fieldcontent, see «Transparent content merge»: write it as$product->content->content). They do not route throughcreate([...])/update([...])arrays, because those keys are not in$fillableandfill()does not know about the merge. Core setsModel::preventSilentlyDiscardingAttributes(! isProduction())(CoreServiceProvider), so passing a content key in such an array throwsMassAssignmentExceptionoutside production and is silently discarded in production. The product write actions do not use mass assignment for content: they filter the payload throughCONTENT_FIELDSand set each key withsetAttribute()(see «Admin write surface»). - Entity and preset are not resolved from the content. For a product
Content,$content->entityand$content->presetreturn null:$content->presettableis a CMSPresettable, and the CMSEntityandPresetscoping hides the Shopproductsrows. A product-specific accessor for the Shop entity and preset comes with the storefront plan. Until then reach them throughShop\Models\Entity/PresetorDynamicContentsService. - Preset-version migrations must see extended contents. The seam's global
scope hides extended contents, so any preset-version migration that walks
product contents has to go through
Content::withExtended(). - Create a product through the product. Saving a bare
Contentdoes not make it a product, andextended_typeis guarded: it is set only by the seam's create path.
HTTP surface
The module has no /app or /api/v1 surface: no controllers, Form Requests or API
resources. routes/web.php and routes/api.php are empty. The only routes are the
three pages of the Filament product resource under the admin panel
(admin/shop/products, /create and /{record}/edit, named
filament.admin.resources.shop.products.*; see «Admin write surface»). The catalog's
read surface comes with a later plan.
Configuration
config/config.php holds only the module name. The module defines no environment
variables and no shop.* configuration keys yet.
Locked decisions
See docs/superpowers/specs/2026-09-17-shop-module-design.md (E-series) for the
module design. The ones this foundation implements: E2a (transparent content
merge), E4 (content-extension seam), E7b (variant to item pivot), E16 and E17
(mandatory content, symmetric lifecycle), E20 (Shop-owned product entity), E22
(attributes are labels), E23 (company_id on the product, derived below it), E31
(role value set). The delivery record is
docs/superpowers/plans/2026-10-06-shop-catalog-foundation.md. The admin write
surface is specified in docs/superpowers/specs/2026-10-20-shop-product-admin-design.md
and delivered by docs/superpowers/plans/2026-10-20-shop-product-admin.md.