Catalog
The catalog module (app-modules/catalog) is the product layer on top of
content-management. It adds what is specific to selling
promotional products. URLs, the per-site projection, SEO and availability rules are inherited, so
read sites and the projection first.
app-modules/catalog/ARCHITECTURE.md is the exhaustive reference. This page is the map.
The shape
Section titled “The shape”| Model | Kind | What it is |
|---|---|---|
Product |
content type, handle product |
The thing being sold. Gets a page per site. |
ProductCategory |
taxonomy, handle product_category |
Hierarchical, classifies products. Gets a page per site. |
ProductVariant |
child of Product |
One purchasable combination of option values: house SKU and stock. |
VariantSource |
child of ProductVariant |
The supplier’s SKU, cost breaks and expiry for a variant. |
ProductOptionType / ProductOptionValue |
plain models | An axis (Colour, Size) and its vocabulary. |
ProductImage |
child of Product |
A photo, tagged with the option values it shows. |
Brand, Certification |
plain models | Linked to products. No pages of their own. |
Supplier |
plain model | The supplier company, for admin and purchase orders. No URL. |
SupplierProduct |
plain model | The staging copy of a feed. See supplier feeds. |
CatalogServiceProvider registers Product with ContentTypeRegistry and ProductCategory with
TaxonomyRegistry. That registration is what gives them site documents, URL generation and SEO
templates. ProductCategory only declares what it classifies (contentTypeHandle() returns
product), and content-management wires $product->productCategories and $category->products
at boot. There are no hand-written relations for them on the models.
Every product has a source: manual (Product::SOURCE_MANUAL, typed here or imported from the
legacy data) or a feed’s handle.
Options and variants
Section titled “Options and variants”An option is never also a custom field. Colour, size and ink colour are option types and only option types, and layered navigation filters on them through the variants. Custom fields are for attributes that don’t make variants, such as material (see custom fields).
An option type’s kind says what its values can do. It is registered in the
Options\OptionKinds singleton (Contracts\OptionKind), chosen when the type is created, and can
never change.
| Kind | Class | Colour chip | Changes photos | Groups | Values per source |
|---|---|---|---|---|---|
| Colour swatch | Options\Kinds\ColourSwatch |
yes | yes | yes | yes |
| Text swatch | Options\Kinds\TextSwatch |
no | yes | no | no |
| Option | Options\Kinds\PlainOption |
no | no | no | no |
“Values per source” means each supplier keeps its own colour shades, while every supplier’s “M” is the same value. Groups bring per-source shades together for shoppers (a “Blue” group). The storefront filter then merges values with matching names into one option, so a listing mixing suppliers shows “Black” once.
Services\ProductVariants is the only way to create variants:
ensureDefault()runs when a product is created, so every product has a variant from the start.- A product with no options has one default variant with an empty
combination_key. create()needs exactly one value per option type of the product. The first combination takes over the default variant, keeping its SKU.generate()sets up a product’s first options and refuses a product that already has combinations.
The invariants:
- Combinations are unique per product, enforced by a unique index on
(product_id, combination_key). - House SKUs never change. They look like
GP6235-01:CATALOG_SKU_PREFIX, the product id, and a number from the row-lockedproducts.variant_sequence, so a deleted variant’s number is never reused.ProductVariantthrows if a SKU is updated. - Variants hold no prices. A variant has an optional
price_adjustment(an amount or a percentage) thatProductPriceradds after the engine prices. See pricing and decoration.
Stock and what counts as sellable
Section titled “Stock and what counts as sellable”Stock lives on the variant: is_available, tracks_quantity with available_quantity, incoming
and stock_checked_at.
| Term | Means | Scope |
|---|---|---|
| Offered | Not is_hidden (staff) and not is_discontinued (the sync) |
ProductVariant::offered() |
| Sellable | Offered and is_available |
ProductVariant::sellable() |
products.in_stock is derived: true when any variant is sellable.
Product::syncStockFromVariants() runs on every variant save and delete, and writes the product
only when the value flips. Anything that lists products reads the column.
An out-of-stock product still has a routable page, it is just never listed. What the page does is a
per-site setting (SiteCatalogSettings::$oos_product_page). redirect is the default and sends a
302 to the product’s primary category through Listeners\RedirectOutOfStockProductPages. show
renders the page with a notice.
Categories
Section titled “Categories”ProductCategory is canonical-eligible, so which category shapes a product’s URL is decided per
site and per type, not on the model (see routing). Pivot columns
carry sort_order and a featured flag, so each category arranges its own products.
Services\CategoryTree loads the category tree in one slim query per request or job, and
Product::minimumOrder(), categoryIdsWithAncestors() and BrandingOptions walk it. A product can
sit in over a hundred categories, so don’t load parents as models in a loop. ProductList eager
loads a card’s categories as ids only: read any other category field from your own query.
Product::minimumOrder() says what a product’s minimum is and where it comes from: its own, else the
highest set by its categories or their ancestors, else (for a feed product) its feed’s minimum order
or minimum order value, else 1.
Per-site availability
Section titled “Per-site availability”Availability is not a pivot. A product appears on a site when a SiteContentRule covers it, either
the whole product type or a product_category term with its classifiables, and the projection
writes the answer to site_documents.is_available. The staff view of this is
content rules.
Product::scopeListableOn(?Site) is the storefront listing rule, and you should always go through
it:
Product::query()->listableOn($site);It means available on the site and in_stock. The stock half is
Product::applyListingConstraints(), an override of a content-management hook, with
listingConstraintColumns() returning ['in_stock']. So a category’s cached item count follows the
same rule and refreshes when stock or classification changes.
Prices, by contrast, are not projected. A price change does not reindex.
Layered navigation
Section titled “Layered navigation”src/LayeredNav/ is the category-page filter sidebar. Staff see it as
filters.
ProductFacetFields discovers facets from four sources (FacetSource):
| Source | Reads | Examples |
|---|---|---|
CustomField |
Custom fields whose type implements Contracts\ProductFacetFieldType |
Legacy-imported filters |
ProductOption |
Option types, through sellable variants | Colour, Size |
Link |
A link table | Imprint Method, Brand (config/catalog.php, catalog.filters) |
Condition |
SQL conditions on products written in code |
Imprint Colours, Full Colour Print, Production Time |
ProductFacets is the service:
parseSelection()turnsf[<field_key>][]=<slug>into aFacetSelection. Unknown keys and slugs are dropped, never an error.apply()ORs within a field and ANDs across fields, on a builder or a relation.facets()returns groups with counts. Relevance comes from the unfiltered listing, and counts are disjunctive: a field’s counts apply every other field’s selection but not its own.
Things worth knowing before you change it:
- The caller owns the base listing. Facets only add constraints.
- Slugs are derived from labels (
FacetOption::$slug), not stored. Renaming a label changes its URL slug, which is harmless as filtered pages are noindex. PriceRangefilters onproducts.mrp, the stored From price. A full-span range is inactive and emits no query parameters.FacetUrlsseparates filter state (f,price_min,price_max) from listing options (sort, per page). Every URL it builds is page 1.- While a filter is active
ProductListsetsnoindex,follow. Sort and per page alone stay indexable.
The markup emits .layered-nav__* classes and data-facet-option attributes. The look and the
option icons are theme CSS, not PHP.
Adding a condition filter
Section titled “Adding a condition filter”Conditions are the cheapest kind of facet, as they need no new table.
- Add its configuration under
catalog.filtersinapp-modules/catalog/config/catalog.php. - Build it in
ProductFacetFieldswithFacetField::fromConditions(), each option an SQL condition onproducts. - Add a test beside
tests/Feature/LayeredNav/PrintFacetsTest.php.
How the storefront reads it
Section titled “How the storefront reads it”The active theme renders the page by the source type’s handle, so product.blade.php and
product_category.blade.php (in themes/default/views/). See
themes.
A category page is <x-catalog::product-list :category="$productCategory" :filters="true">
(View\Components\ProductList), with slots for the header, intro, sidebar and footer content.
<x-catalog::product-card> draws each product. View\ProductCardFacts::for($products) works out
every card’s badges and colours in a few queries, so pass each card its facts rather than letting it
work out its own. <x-catalog::category-nav> and <x-catalog::subcategory-grid> read the shared
per-site tree (SiteTaxonomyTrees).
A product page builds View\ProductConfiguratorData::for($product): plain data for the page’s
script (options from offered variants only, photos with their tags, the engine’s choices, the
quantities to show). Prices are deliberately not in it. The page posts to the quote-cart.price
route, which asks ProductPricer, so there is one place a price comes from.
Cards and sorting read stored columns rather than pricing each product: products.mrp (the From
price) and products.mrp_with_charges (the same with one-off charges spread over the quantity).
Product::fromPriceFor($site) picks between them by SiteCatalogSettings::pricesIncludeCharges().
SiteCatalogSettings holds the per-site settings (display ladder, price display, out-of-stock
behaviour), edited on the site’s Catalog tab (Filament\SitePages\ManageSiteCatalogSettings).
A site with no row gets an unsaved instance carrying the defaults, through forSite(). Staff details
are in catalog settings.