Skip to content

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.

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.

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-locked products.variant_sequence, so a deleted variant’s number is never reused. ProductVariant throws if a SKU is updated.
  • Variants hold no prices. A variant has an optional price_adjustment (an amount or a percentage) that ProductPricer adds after the engine prices. See pricing and decoration.

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.

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.

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.

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() turns f[<field_key>][]=<slug> into a FacetSelection. 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.
  • PriceRange filters on products.mrp, the stored From price. A full-span range is inactive and emits no query parameters.
  • FacetUrls separates 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 ProductList sets noindex,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.

Conditions are the cheapest kind of facet, as they need no new table.

  1. Add its configuration under catalog.filters in app-modules/catalog/config/catalog.php.
  2. Build it in ProductFacetFields with FacetField::fromConditions(), each option an SQL condition on products.
  3. Add a test beside tests/Feature/LayeredNav/PrintFacetsTest.php.

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.