Skip to content

Powered by Grav

Coming soon — KahunaCart is in final testing. Join the list and be first to know. Join the list

Products and catalog

This page covers how the KahunaCart catalog is built and how to manage it. It is for the store administrator who creates products, files them into categories, and keeps stock and images up to date.

Products are database rows in kahunacart_products, not Grav pages. You edit them in the admin or over the API, and the storefront renders them through Twig templates you can override.

Before you begin

  • Products, variants, options, images and downloadable files require the kahunacart.products.manage permission.
  • Categories and tags require the kahunacart.settings permission.
  • The storefront lives under the route config key, /shop by default. Product URLs are {route}/product/{slug}, category URLs are {route}/category/{slug}.

Product fields

A product carries the descriptive and classification data. The price lives on the variant, not here.

Field What it does
title Required.
slug The public URL segment, /shop/product/{slug}. Derived from the title when empty, and slugified either way. Must be unique; a clash is refused with a 409.
type physical or digital. Digital lines skip shipping and are eligible for download grants.
status draft, published, or archived. Only published products appear on the storefront.
published_at Stamped the first time a product goes published. Drives the storefront's "newest first" ordering.
summary Short blurb, shown on listing cards.
description Long copy, rendered through Markdown on the product page. See Markdown, not HTML.
tax_class Defaults to standard. Matched against the rates in the customer's tax zone.
shipping_class Optional. Matched against a shipping method's per-class surcharges.

Markdown, not HTML

Product and category descriptions render as Markdown with raw HTML escaped. Link and image targets that execute rather than navigate (javascript:, vbscript:, data:) are replaced with #. A <script> typed into a description renders as the word <script>.

Everything Markdown itself does still works: headings, lists, tables, emphasis, links and images.

Note

No config key turns HTML back on. For markup Markdown cannot express, override kahunacart-product.html.twig in your theme and render it there.

Create a product

Every product gets one default variant, which holds the price.

  1. In the admin sidebar, click KahunaCart, then Products.
  2. Click New product.
  3. Enter a Title. Leave Slug empty to derive it from the title.
  4. Set Type to physical or digital, and Status to published.
  5. Under Pricing & stock, enter a Price, and a Compare-at price if you want a strike-through "was" price.
  6. Under Classes, set Tax class and Shipping class if the defaults do not apply.
  7. Under Categories and Tags, file the product.
  8. Click Save.

The product is live at /shop/product/{slug}. No cache clear is needed.

Tip

Getting started has a curl example that creates a published product with a priced default variant in one call.

Variants

Every product has at least one variant, created automatically as the default. The price lives on the variant, not the product. A single-price product shows no variant picker; a product with several variants renders a <select> in the buy button.

Field What it does
sku Optional. Copied onto the order line at add-to-cart time.
title Optional. Appended to the product title on the order line as Product — Variant.
price_minor The price, in minor units. Over the API, send price as a decimal string.
compare_price_minor A "was" price for strike-through display. Over the API, send compare_price as a decimal string, or empty to clear it.
is_default Exactly one variant carries this. It prices the listing and preselects in the buy button.
position Sort order within the product. Defaults to the order the variants were sent in.
stock_managed 0 (default) means infinite stock. 1 turns stock tracking on.
stock_qty The count. Only read when stock_managed is 1.
backorders deny (default) or allow.
weight_grams Read by weight-based shipping. Order weight is the sum of qty × variant weight, matched against a method's weight bands. Zero contributes nothing.
option_value_ids Read-only. The values this variant combines, in axis order, or []. See Options.
option_label Read-only and derived: the combination spelled out, "M / Navy", or "".

Note

weight_grams has no field in the admin product editor. Set it over the API or through a catalog import.

A product must always keep at least one variant. Deleting the variant that holds is_default promotes a sibling first.

Options

A product may define up to three option axes, such as Size, Colour or Material. A variant becomes the combination of one value from each axis, and keeps its own price, SKU, stock and weight. Stock, holds, tax, shipping and checkout treat a combination as a plain variant.

Options are optional. A product with no axes keeps one variant, or several with free-form titles, and a plain <select> on the storefront.

Axis and value fields

Field What it does
name Free-form: Size, Colour, Length. Two axes on one product cannot share a name.
widget dropdown (default), pills, or swatches. Anything unrecognized falls back to dropdown.
position Axis order. Decides how a combination reads: M / Navy rather than Navy / M.

A value carries a label and an optional color as #rrggbb. Shorthand is expanded, and anything that is not a hex colour is stored as nothing. A value with no colour renders as a pill even on a swatch axis.

Values have stable ids, so reordering S, M, L into L, M, S touches no variant, and renaming Navy to Midnight renames every variant wearing it.

Note

Per-value images are not implemented. A value cannot carry its own picture yet.

Add an option axis

  1. Open the product and scroll to Options & variants.
  2. In New option, enter the axis name, for example Size.
  3. Choose a Storefront widget.
  4. In Values, type the values comma separated, for example S, M, L, XL.
  5. Click Add option.

The axis appears with its values as chips. Repeat for up to three axes.

Generate the variant matrix

POST /kahunacart/products/{id}/options/generate works out the cartesian product of the axes and reports what it would do. It is a dry run unless the body says {"apply": true}, and even then it merges rather than syncs.

Case What happens
Existing combination Left alone. Its price, SKU, stock and weight are preserved.
Missing combination Created, inheriting price, stock policy and weight from the default variant.
Orphan A variant whose combination the axes no longer describe. Listed, never deleted.
Unassigned variant A variant from before the product had axes. Listed separately, not deleted.

In the admin:

  1. Open the product and scroll to Options & variants.
  2. Click Generate variants… to preview the plan.
  3. Review the totals and the combinations listed under To create.
  4. Click Apply to create the missing variants.

Orphans and unassigned variants stay put. Remove one with Delete on its row, or give it a combination with Assign.

Two variants of one product cannot hold the same combination. Each variant stores it as a sorted key in option_key, unique per product. A variant with no combination stores NULL, which the database treats as distinct, so an axis-less product can keep as many variants as it likes.

Manage stock

Stock is only tracked when stock_managed is 1. This section applies to those variants only.

  1. Open the product and scroll to Pricing & stock, or open a variant row and click Edit.
  2. Tick Track stock.
  3. Enter a Stock quantity.
  4. Set Backorders to Not allowed or Allowed.
  5. Click Save, or Save variant on a variant row.

Deduction is atomic. Completing an order runs a conditional UPDATE per line, stock_qty >= qty + held, so two customers racing for the last unit cannot both win. The loser gets an insufficient-stock error and their order rolls back to a cart.

With backorders set to deny, the decrement refuses when there is not enough, and isPurchasable() blocks add-to-cart before that. With allow, stock_qty goes negative, so the admin sees the deficit as a number.

Important

Only an explicit allow permits overselling. An unrecognized value in the backorders column reads as deny.

Stock holds

A hold reserves stock while a customer is away at a hosted payment provider. Without it, their order is still a cart and its stock still looks available to every other shopper.

When payment is initiated with a redirect provider, the order's stock-managed lines get hold rows valid for stock.hold_minutes, default 15. Other carts see the held quantity subtracted from what they can buy, and the order's own hold never counts against itself. Holds are advisory: the decrement at completion is still the check that can fail.

Behaviour Detail
Re-initiating payment Replaces the hold rather than stacking a second one.
Order completes Released inside the same transaction, so a rollback puts them back.
Payment fails or is cancelled Released.
Expiry Swept by the worker on every tick and by bin/plugin kahunacart work.

Set stock.hold_minutes to 0 to disable holds entirely.

Categories and tags

A category is where a product lives. One place in a tree, with a landing page of its own, a picture, and a position among its siblings. "Clothing → Mens → Tops" is three categories.

A tag is what a product is. "Organic", "sale", "last few": flat, unordered, many per product, and with no page of its own. See Tags.

Migration 0013_taxonomy finishes the tree that 0005 started and moves tagging to a table built for it. A store that files everything into three flat categories keeps working as it did.

The category tree

Nesting is parent_id on the category row, NULL for a root, with no depth limit. Siblings are ordered by position, and a new category lands at the end of its row of siblings.

Operation Behaviour
Slugs Slugified and de-duplicated on write; a second "Shirts" becomes shirts-2. Unique tree-wide, not per parent.
Reparenting Refused with a 422 when it would close a cycle. A category cannot be its own parent or move under its own descendant.
Reordering Swaps two positions. Answers moved: false at the end of a row.
Orphans A row whose parent vanished is shown at the root rather than dropped.
Deleting Reparents the children into the deleted category's own parent. Its assignments, translations and picture go with it.

Category assignments are diff-based: a save that changes nothing writes nothing. Over the API, category_ids replaces the whole set when present and is left alone when absent.

The whole tree is read in one unfiltered SELECT and nested in PHP. Catalog\CategoryTree answers questions about children, ancestors, paths, subtrees and depth, with no database and no Grav in it.

Category product counts

A category carries two counts.

Count What it counts
product_count Products filed directly on that category.
subtree_count Distinct products filed anywhere in that category's subtree.

subtree_count is a distinct count of product ids, not a sum, so a product filed in both Clothing and Clothing → Mens counts once. The storefront sidebar shows subtree_count and counts published products only. The admin list shows both counts and includes drafts.

Create a category

  1. In the admin sidebar, click KahunaCart, then Categories.
  2. Enter a Title, and a Slug if you do not want it derived from the title.
  3. Choose a Parent, or leave it empty for a root category.
  4. Set Page layout. See What a category's page shows.
  5. Click Create category.

To add a child directly under an existing category, click New child on its row. To move a category within its row of siblings, use the up and down buttons on the row.

What a category's page shows

A category page is either a department that shows subcategory cards, or a shelf that shows a product grid. The display column on the category decides which.

Value Admin label The page shows
auto Automatic Cards when it has children, a product grid when it does not. The default.
products Products only Always the product grid, children or no children.
subcategories Subcategories only Cards only; the product grid is suppressed.
both Subcategories, then products Cards first, then the product grid underneath.

Two guards override the column:

  • subcategories on a category with no children still shows the product grid, so the page is never empty.
  • A filtered listing always shows products, whatever the column says.

Anything unrecognized in the column, including a hand-edited row, reads as auto.

Upload a category image

A category carries one optional picture, used as the face on its card. Uploading replaces the existing picture, and the previous file is removed from disk once the new row is written.

  1. In the admin sidebar, click KahunaCart, then Categories.
  2. Expand the category and find the Image panel.
  3. Choose a file under Image file, or Replace image if one is already set.
  4. Enter Alt text describing what the picture shows.
  5. Click Save.

The bytes live in user/images/kahunacart/categories/{categoryId}/, and image_path is stored relative to that root so a store can move between servers without rewriting rows. image_alt is NULL when nobody fills it in, which renders as alt="".

Accepted formats, format sniffing and images.max_upload_mb match product images. A file over the ceiling is refused with a 413.

Why category URLs stay flat

A category's URL is {base}/category/{slug} however deep it sits: /shop/category/mens, never /shop/category/clothing/mens. Nesting the path is not supported.

What this costs: two categories under different parents cannot both be slugged tops. The second becomes tops-2. Where that reads badly, name them mens-tops and womens-tops.

The hierarchy is carried in the visible breadcrumb and in the JSON-LD BreadcrumbList, both built from the real ancestor chain.

Why nesting the path was rejected

Frontend\StorefrontRoutes decides whether a URL is one of its routes without touching a database or a session. A nested category path is a variable number of segments, so matching one means either accepting any depth, which makes /shop/category/a/b/c/d answer 200 with duplicate content, or reading the database inside route matching.

Slugs are globally unique, so a lookup is one indexed hit. A path URL would cost a walk down the tree instead.

Reparenting would break every URL beneath the moved category: move "Tops" from Mens to Womens and every link, bookmark and shared URL below it stops resolving.

One canonical slug per category in every language is the established decision, so /en/shop/category/mens and /de/shop/category/mens are the same category. A path URL turns that one decision into one per level of the tree. See Multilanguage.

Filters ride in the query string, so /shop?tag[]=organic and /shop/category/mens?min=10 match the same two routes. See Storefront.

Tags

A tag narrows the catalog down. Tags are flat: no parent, no position, no landing page, no place in the navigation. On a product page they render as links into the filtered listing, {base}?tag[]=organic, with rel="nofollow".

Two tables, added by 0013_taxonomy:

TXT
kahunacart_tags(id, slug UNIQUE, label, created_at, updated_at)
kahunacart_product_tags(id, product_id, tag_id, UNIQUE (product_id, tag_id))

slug is the identity the filter URL, the exporter and an import use; label is what a merchant types. The slug is derived from the label on write, so "Organic", "organic" and " ORGANIC " all derive organic and land on one tag. There is no UNIQUE constraint on label.

Create and assign tags

Tags are created on first use, so typing "Organic" on the first product creates the tag and typing it on the fiftieth lands on the existing one.

  1. Open the product and scroll to Tags.
  2. Type a label and press Enter.
  3. Click Save.

To manage tags on their own, click KahunaCart, then Tags, and use Create tag.

Over the API, tags is a plain list of labels. Ids naming no real tag are ignored rather than failing the product save.

Renaming a tag leaves its slug alone, because the slug is in circulation in bookmarked filter URLs and exported catalog files. Ask for a new slug and it is slugified and de-duplicated, sale then sale-2.

Labels are translatable the same way category titles are: a de override on the tag, resolved per field, with a missing row meaning fallback. See Multilanguage.

Deleting a tag removes the assignments, the label's translations and the row. Products that wore it keep every other tag they had.

Product images

A product image is a picture attached to a product, ordered by position. The lowest one is the primary image: the card thumbnail, the gallery's large image, and the one social previews and structured data lead with.

Field What it does
path Relative to the store's images root. Derived server-side on upload.
alt Alternative text. An empty one declares the image decorative.
position Gallery order. A new upload lands at the end.

Upload a product image

  1. Open the product and scroll to Images.
  2. Choose a file under Image file.
  3. Enter Alt text for anything that carries information about the product.
  4. Click Upload.

The image joins the gallery at the end. Use Move earlier and Move later to reorder, and Remove image to delete.

Images live in user/images/kahunacart/products/{productId}/, not in the deny-all downloads root, because a browser fetches them directly. user/images is the one directory under user/ that Grav's shipped web-server rules leave readable.

Format is decided by reading the file, never by its extension. JPEG, PNG, WebP, AVIF and GIF are accepted, matched on their magic numbers, and payload.php renamed to payload.png is refused with a 415. images.max_upload_mb (default 8) caps the size, and PHP's upload_max_filesize and post_max_size apply on top.

The stored filename is derived from the product directory, a sanitized basename, a random suffix and the sniffed extension, so a crafted filename cannot escape the images root and two uploads of hero.jpg do not collide.

Note

The storefront always renders the alt attribute, empty when nobody has filled it in, which declares the image decorative. A missing alt is different: a screen reader falls back to reading the filename.

Deleting an image deletes the bytes. Deleting a product deletes all of its images, rows and bytes both, and the product's directory with them.

See Storefront for how images are rendered and how resized copies are cached.

Product files (digital goods)

A product file is a downloadable attached to a product. Leave variant_id null to give it to every buyer of any variant, or pin it to one variant.

Field What it does
name The display name, and the filename the browser saves. Independent of the name on disk.
path Relative to the store's files root. Derived server-side on upload.
size_bytes Measured at upload.
download_limit Downloads allowed per grant. Null falls back to downloads.default_limit.
expiry_days Grant lifetime. Null falls back to downloads.default_expiry_days.
position Admin display order.

Attach a downloadable file

  1. Open the product and scroll to Downloadable files.
  2. Choose a file under File to upload.
  3. Click Upload.
  4. Set Download limit and Lifetime on the new row if you want to override the store defaults.
  5. Click Save file.

downloads.max_upload_mb (default 512) caps a single upload.

The bytes are immutable, because grants already issued promised a specific file to a specific buyer. Editing an attachment changes its name, limit, expiry and position, never its content. Replacing a file means uploading a new one.

Deleting a file removes the bytes from disk but leaves existing grants in place, and they deny with "not found". See Digital products for the full delivery story.

Order-line snapshots

When a variant is added to a cart, the line item stores a JSON snapshot of the product and variant rows, alongside the copied title, sku, unit_price_minor and is_digital flag.

  • The tax class and shipping class applied to a completed order come from the snapshot, never from the live product row.
  • Renaming or deleting a product does not rewrite history. Reports show the name a product sold under.
  • Deleting a product deletes its variants. Orders keep their snapshots and their line items.
  • The line title is snapshotted in the language the customer was shopping in, and frozen there. Nothing translates an order after the fact. See Multilanguage.

Catalog export and import

bin/plugin kahunacart export and bin/plugin kahunacart import carry the whole taxonomy by slug rather than by id, so a file written on staging applies to production.

YAML
categories:
  - slug: mens
    title: Mens
    parent: clothing
    position: 0
    display: both
    image:
      path: 4/mens-8c21f0a91b3e.jpg
      alt: Two men on a jetty
products:
  - slug: reef-runner-tee
    tags: [organic, sale]
Key Behaviour
display Written only when it is not auto.
parent Names a slug. Categories import in two passes, rows first and parents second, so a child may be listed before its parent.
image Records the path, not the bytes. Import copies no files, so a category whose picture is missing renders a card without one.
tags Plain labels. An unknown label becomes a new tag on the receiving store.

A product declares its axes under options:, and each variant names its combination as a list of value labels in the same order the axes are declared.

Important

Import is a merge and never deletes. A file's tags are added to whatever a product already carries. To remove a tag, remove it in the admin, where you can see how many products it is on.

Seed demo data

bin/plugin kahunacart seed fills the catalog from seed/sample-products.yaml: a nine-category tree and eleven products, physical and digital, single- and multi-variant, with one item down to its last few units and one (the Sandbar Boardshort) built on two option axes.

TXT
Clothing
  Mens
    Tops
    Bottoms
    Accessories
  Womens
    Tops
    Accessories
Digital Goods

Products are filed on the leaves, so "Clothing" has nothing assigned directly to it and still shows the whole wardrobe. The two categories titled "Tops" are slugged mens-tops and womens-tops. See Why category URLs stay flat.

The seeder is idempotent. A slug that already exists is skipped and never updated, parent included, so re-seeding cannot undo a reparent somebody did in the admin.

bin/plugin kahunacart seed --purge removes exactly the slugs the file names, after listing them and asking. It deletes seeded categories deepest first and removes their pictures. Seeded tags go too, but only the ones nothing else is wearing.

Demo products carry the tags organic, new and sale, created on first use.

No photographs ship with the seed. Drop a picture named after a product's slug into seed/images/ (reef-runner-tee.jpg), and one named after a category's slug into seed/images/categories/ (mens.jpg), and the seeded rows pick them up. See seed/README.md for the naming rule.

API reference

Product, variant, option, image and file endpoints require kahunacart.products.manage. Category and tag endpoints require kahunacart.settings.

TXT
GET    /kahunacart/products
POST   /kahunacart/products
GET    /kahunacart/products/{id}
PATCH  /kahunacart/products/{id}
DELETE /kahunacart/products/{id}

GET    /kahunacart/products/{id}/files
POST   /kahunacart/products/{id}/files          # multipart, bytes arrive as `file`
PATCH  /kahunacart/products/{id}/files/{fileId}
DELETE /kahunacart/products/{id}/files/{fileId}

GET    /kahunacart/products/{id}/options
POST   /kahunacart/products/{id}/options                             # {name, widget, values: [...]}
POST   /kahunacart/products/{id}/options/generate                    # {apply: false} by default
POST   /kahunacart/products/{id}/options/assign/{variantId}          # {option_value_ids: [...]}
PATCH  /kahunacart/products/{id}/options/{optionId}                  # name, widget, position
DELETE /kahunacart/products/{id}/options/{optionId}
POST   /kahunacart/products/{id}/options/{optionId}/values           # {label, color}
PATCH  /kahunacart/products/{id}/options/{optionId}/values/{valueId} # label, color, position
DELETE /kahunacart/products/{id}/options/{optionId}/values/{valueId}

DELETE /kahunacart/products/{id}/variants/{variantId}

GET    /kahunacart/products/{id}/images
POST   /kahunacart/products/{id}/images         # multipart: `file`, optional `alt`
PATCH  /kahunacart/products/{id}/images/{imageId}   # alt, position
DELETE /kahunacart/products/{id}/images/{imageId}

GET    /kahunacart/categories
POST   /kahunacart/categories
PATCH  /kahunacart/categories/{id}
DELETE /kahunacart/categories/{id}
POST   /kahunacart/categories/{id}/move        # {parent_id, position} — refuses a cycle with a 422
POST   /kahunacart/categories/{id}/reorder     # {direction: "up" | "down"}
POST   /kahunacart/categories/{id}/image       # multipart: `file`, optional `alt`
DELETE /kahunacart/categories/{id}/image

GET    /kahunacart/tags
POST   /kahunacart/tags                        # {label, slug?}
PATCH  /kahunacart/tags/{id}                   # label, slug
DELETE /kahunacart/tags/{id}

Products

On create, the settable fields are slug, type, status, title, summary, description, tax_class, shipping_class, plus the variants. category_ids, tags and translations are accepted on create and update alike: absent leaves them alone, present replaces the set.

A variant object takes sku, title, stock_managed, stock_qty, backorders, weight_grams, position, is_default, and the money fields price and compare_price as decimal strings in the store currency. Anything that is not a decimal amount is refused with a 422 before the write starts.

Create takes either a single variant object or a variants list. Update takes variants as a map keyed by variant id and writes only the keys each entry names, so a PATCH that mentions price will not clear a compare_price.

is_default applies to the whole set. On create the flagged variant becomes the default, and the first one does when nothing is flagged. On update it promotes that variant and demotes its siblings in the same transaction. The flag cannot be cleared; move the default by naming its replacement.

DELETE /kahunacart/products/{id}/variants/{variantId} refuses to take the last variant with a 422, and promotes a sibling before removing one that holds is_default.

Note

Every product write queues a catalog push to any provider configured as local-master. Sync carries the default variant only. See Sync.

Images

The image endpoints all speak one object:

JSON
{"id": 3, "path": "12/hero-5af24baa2cec.png", "url": "/user/images/kahunacart/products/12/hero-5af24baa2cec.png", "alt": "Blue boots", "position": 0}

url is the site-relative URL a browser loads. GET answers {"images": [...]} in position order, POST answers 201 with {"image": {...}}, PATCH answers 200 with the same, and DELETE answers {"deleted": true}. An image id belonging to a different product is a 404.

GET /kahunacart/products/{id} returns the whole gallery. The listing returns only the primary image, as a one-element images array plus an image_count of the real total.

The image bytes are immutable. PATCH changes alt and position.

Options

Options are carried in one form everywhere:

JSON
{"id": 4, "name": "Colour", "widget": "swatches", "values": [{"id": 12, "label": "Navy", "color": "#1f3a5f"}]}

color is always present and is null when the value has none. GET /kahunacart/products/{id} and the listing both carry an options array, and every variant carries option_value_ids and option_label.

generate answers with the axes, the variants, and a plan:

JSON
{"plan": {"total": 8, "existing": 3, "missing": 5, "created": 0,
          "missing_labels": ["30 / Slate", "32 / Kelp"],
          "orphans": [{"id": 91, "sku": "OLD-1", "label": "M", "is_default": false, "stock_qty": 4}],
          "unassigned": []}}

Orphans and unassigned variants are named rather than counted.

Categories and tags

The category list is flat, with a parent_id on every row. Each row carries product_count, subtree_count, image (the same {path, url, alt} object a product image uses, or null) and its unresolved translations. The storefront gets the nested form from kahunacart_categories().

move answers the updated category, or a 422 when the new parent is the category itself or one of its own descendants. reorder answers {"moved": false} at the end of a row. The image routes replace whatever picture the category had and delete the file they replace.

Tags are assigned through the product endpoints, as tags on a product body. Every tag carries product_count, and DELETE answers {"deleted": true, "products_affected": 80}.