Storefront
This page covers the pages KahunaCart renders for customers: routes, templates, Twig functions, styling and cache headers. It is for store administrators and Twig themers.
KahunaCart creates pages automatically under a configurable base route (route, default /shop). You do not need to create corresponding pages in Grav.
Routes
| Method | Route | What it does |
|---|---|---|
GET |
{base} |
Product listing |
GET |
{base}/search?q= |
Product search |
GET |
{base}/category/{slug} |
Products in one category |
GET |
{base}/product/{slug} |
Product detail |
GET |
{base}/cart |
Cart view |
POST |
{base}/cart |
Cart actions — add, update, remove, coupon |
POST |
{base}/cart/currency |
The currency switcher's button: moves the open cart to another currency and sends the shopper back where they were. Only on a store that sells in more than one currency; on any other it is a 404 |
GET |
{base}/cart/currency |
The question a shopper is asked before a switch empties their cart. Writes nothing |
GET |
{base}/checkout |
Checkout form |
POST |
{base}/checkout |
Checkout submit |
GET |
{base}/order/{hash} |
Confirmation and receipt, reached by a signed link |
POST |
{base}/order/{hash} |
The guest's account offer, and the "email me a new link" request |
GET |
{base}/order/{hash}/invoice |
Printable invoice, same rule. See Customer accounts |
GET |
{base}/order/{hash}/invoice.pdf |
The same invoice as a downloadable PDF |
GET |
{base}/order/{hash}/pay |
Pay an order the store built. See Orders and the admin |
GET |
{base}/account |
Account overview — login required |
GET |
{base}/account/orders |
Order history — login required |
GET |
{base}/account/downloads |
Every live download grant — login required |
GET |
{base}/account/addresses |
The address book — login required |
POST |
{base}/account/addresses |
Address actions — save, delete, set default |
GET |
{base}/account/wishlist |
The customer's saved lists — login required |
POST |
{base}/account/wishlist |
List actions — create, rename, share, delete, change a quantity, move to the cart, estimate delivery |
GET |
{base}/wishlist/{token} |
A list its owner shared. Outside the account area, no login, Cache-Control: no-store |
GET POST |
{base}/account/profile |
The Login plugin's password and two-factor form, in KahunaCart's account chrome — login required |
GET POST |
{base}/signin |
The sign-in link request form; the POST asks for the link. See Customer accounts |
GET POST |
{base}/signin/{token} |
The confirmation page a sign-in email opens. Its one button posts to sign the customer in |
GET POST |
{base}/password/{token} |
"Choose your password", where the invitation email lands. The POST sets the password and signs the customer in |
GET |
{base}/download/{token} |
A purchased file, token-protected and counted |
GET |
{base}/payment/return/{transactionHash} |
Where a redirect provider sends the customer back |
POST |
{base}/webhook/{slug} |
Provider webhooks — public, signature-verified by the provider |
GET |
{base}/feeds/{file} |
The Google and OpenAI product feeds, while feeds.enabled is on. See Product feeds |
POST |
{base}/mcp |
The public storefront MCP endpoint for AI agents, while storefront_mcp.enabled is on. See Storefront MCP |
GET POST PUT |
{base}/ucp/... |
UCP checkout and cart sessions, the catalog, orders and the buyer's continue_url handoff, while ucp.enabled is on. See UCP checkout |
Route matching is exact. A URL with extra segments, such as /shop/product/my-widget/anything/here, falls through to normal Grav routing, so a real page at /shop/about still works and anything else 404s. Filters live in the query string and do not affect matching.
The profile route's slug is account.profile_slug, default profile. See Customer accounts.
Category URLs stay flat however deep the tree gets: /shop/category/mens, never /shop/category/clothing/mens. See Products and catalog.
Unpublished products and unknown category slugs return a 404. So does an order still in cart status, for the receipt and the invoice but not for the pay page. Webhooks and downloads render no page: a webhook answers JSON and exits, and a download streams bytes or renders the denial page.
The three agent routes, and /.well-known/ucp at the site root, answer 404 until agentic.enabled and their own switch are on. They never start a Grav session and set no cookie. See Agentic commerce.
On a multilanguage site {base} carries the language prefix, so /de/shop/product/reef-runner-tee and /en/shop/product/reef-runner-tee are the same product. Every link, form action and redirect keeps the visitor in their language, and kahunacart_base_route() returns the prefixed route. Webhook URLs stay unprefixed. See Multilanguage.
Important
Changing route changes your webhook URLs. Update them at every payment provider when you do.
Mounting the shop at the site root
Set route: '/' and the shop's front page becomes the site's home page. / is the product listing, and the shop answers /cart, /product/{slug}, /category/{slug}, /checkout, /account and every other route in the table above with {base} left off.
- The home page is the shop's.
/shows the product listing, not Grav's own home page. That page is still in your page tree and still answers at its own route,/homeby default. - A page with the same name as a shop address is hidden. A top-level page at
/cart,/searchor/accountcan't be opened any more, because the shop answers there first. It's the same thing that happens to a page at/shop/cartwhen the shop is at/shop, andbin/plugin kahunacart checkwarns about any it finds. At the root the shop owns/,search,checkout,cart,category,product,download,wishlist,webhook,order,account,payment,api,signin,password,stock-alert,feeds,mcpanducp. - Everything else stays Grav's.
/about,/blog/..., the admin, the login pages and any address the shop doesn't recognise go to Grav as they always did, and an unknown address gets Grav's 404. The shop's stylesheet still loads only on the shop's own pages. - The storefront JSON API can't be reached at
/api. The API plugin, which Admin Next needs, answers everything under/apibefore the shop is asked. A store that uses the Storefront API keeps the shop under its own route. - Links have one slash in them.
kahunacart_url()gives/for the front door (/dewith a language prefix,/storeon a Grav installed in a folder) and/cartfor the cart, andkahunacart_base_route()gives/. Thekahunacart_basevariable is empty at the root of a domain, so a template that writes{{ kahunacart_base }}/cartkeeps working. Link to the front door itself withkahunacart_url(). - Webhook addresses lose
/shop. They become/webhook/{slug}, so update them at every payment provider, as with any change toroute. - Add-ons need a version that knows about the root. An older add-on builds its links for a shop under its own route, so at the root its links come out as
//…or its own pages answer 404. These are the first versions that work at the root: Subscriptions 1.1.8, Licenses 1.0.17, Bookings 1.1.8, Rentals 1.0.7, Newsletters 1.1.11 and Polar 1.1.11.bin/plugin kahunacart check, the Health screen and a dashboard alert name any installed add-on that's older. A shop under/shopworks with any version.
Signed order links
The four order routes are reached by a signed link, not by the hash alone. ?t= carries a token minted for one order and one page, and it expires after that page's configured lifetime.
| Link | Config key | Default |
|---|---|---|
| Receipt | security.order_link_days |
30 days |
| Invoice and invoice PDF | security.invoice_link_days |
30 days |
| Pay page | orders.pay_link_days |
7 days |
Three things open one of these pages: a valid unexpired link, a logged-in customer whose id is on the order, or an admin holding kahunacart.orders.view. Everything else gets the same expired-link page, with a button that emails a fresh link to the address on the order: a missing, tampered or lapsed token, and a hash that is not an order at all.
Each link on the receipt is signed for its own page. A token minted for the invoice does not open the pay page.
See Hardening.
Rate limits
Every storefront route is rate limited per client address, in a named bucket with its own budget: 60 cart writes a minute, 10 checkout submits, 30 receipt views, 3 link requests an hour. The routes addressed by an order hash are counted per order as well. A limited request answers 429 with a Retry-After header.
Important
Behind a CDN or a load balancer, fill in security.trusted_proxies or every visitor shares one bucket. See Hardening.
Templates and theme overrides
The plugin appends its own templates/ directory to the Twig search path, so your theme wins: a file at the same relative path in user/themes/<your-theme>/templates/ is drawn instead of the plugin's. The same directory is also registered as @kahunacart, so that file can extend the plugin's page and override only the regions it changes. That is how a theme keeps receiving the plugin's later changes. See Theme blocks.
| Template | Rendered at |
|---|---|
kahunacart-products.html.twig |
{base} |
kahunacart-search.html.twig |
{base}/search |
kahunacart-category.html.twig |
{base}/category/{slug} |
kahunacart-product.html.twig |
{base}/product/{slug} |
kahunacart-cart.html.twig |
{base}/cart |
kahunacart-currency.html.twig |
{base}/cart/currency — the question a shopper is asked when switching currency would empty their cart. See The currency switcher |
kahunacart-checkout.html.twig |
{base}/checkout |
kahunacart-order.html.twig |
{base}/order/{hash} |
kahunacart-invoice.html.twig |
{base}/order/{hash}/invoice |
kahunacart-account.html.twig |
{base}/account |
kahunacart-account-orders.html.twig |
{base}/account/orders |
kahunacart-account-downloads.html.twig |
{base}/account/downloads |
kahunacart-account-addresses.html.twig |
{base}/account/addresses |
kahunacart-processing.html.twig |
{base}/payment/return/{hash} while a payment settles |
kahunacart-pay.html.twig |
{base}/order/{hash}/pay |
kahunacart-denied.html.twig |
A download that was refused |
kahunacart-expired.html.twig |
An order link that has run out, with the form that emails a fresh one |
kahunacart-closed.html.twig |
{base}/cart and {base}/checkout while the store is coming soon or paused, with the store's message and a 503. See Configuration → Store status |
kahunacart-paywall.html.twig |
A paywalled article for a visitor who can't read it: the title, the teaser and the subscribe panel. KahunaCart switches the page to it for that visitor. See Customer accounts → Articles with a teaser |
partials/kahunacart-buy-button.html.twig |
Add-to-cart form, including the option picker |
partials/kahunacart-store-status.html.twig |
The banner on every page while the store is coming soon or paused. See Theming → The store status |
partials/kahunacart-product-grid.html.twig |
The card grid, shared by the listing, categories and search |
partials/kahunacart-gallery.html.twig |
Product images on the detail page |
partials/kahunacart-kit.html.twig |
What a grouped product or a kit contains, and its buy button. A part the shopper picks gets a dropdown of its variants. See Which variant a part sells |
partials/kahunacart-set-price.html.twig |
The price line of a grouped product or a kit: the bundle price with the parts struck through, or what the parts add up to. See The set price of a grouped product |
partials/kahunacart-currency-switcher.html.twig |
The currency menu, for a theme's header. It draws nothing on a store that sells in one currency. See The currency switcher |
partials/kahunacart-search.html.twig |
Search form |
partials/kahunacart-seo.html.twig |
JSON-LD block, included from the page body |
partials/kahunacart-seo-head.html.twig |
Canonical link, included from a head block |
partials/kahunacart-categories.html.twig |
Category navigation as a flat row of chips |
partials/kahunacart-catalog-nav.html.twig |
The sidebar: the category tree and the filter panel |
partials/kahunacart-subcategories.html.twig |
Subcategory cards on a category landing page |
partials/kahunacart-tags.html.twig |
A product's tags, on its own page |
partials/kahunacart-breadcrumbs.html.twig |
The visible ancestor trail, on category and product pages |
partials/kahunacart-coupon.html.twig |
Discount-code form |
partials/kahunacart-totals.html.twig |
Subtotal, adjustments, total |
partials/kahunacart-messages.html.twig |
Flash messages, printed raw because every message in the queue is already escaped. See Messages with a link in them |
partials/kahunacart-paywall-panel.html.twig |
The subscribe panel under a paywalled article's teaser |
partials/kahunacart-paywall-meter.html.twig |
The bar above an article opened on a free read |
partials/kahunacart-continue-reading.html.twig |
The receipt's Continue reading line, included from kahunacart-order.html.twig |
partials/kahunacart-bundle-suggestions.html.twig |
The "cheaper as a bundle" notice on the cart and checkout. See Bundle suggestions |
partials/kahunacart-cart-cross-sells.html.twig |
Goes well with your order under the cart: the cross-sells of what is in the basket. See Theming → Cross-sells under the cart |
partials/kahunacart-account-nav.html.twig |
Account section navigation |
partials/kahunacart-address.html.twig |
One address as a postal block |
partials/kahunacart-address-form.html.twig |
Add/edit address form |
partials/kahunacart-address-suggestion.html.twig |
Address-validation panel at the top of checkout |
partials/kahunacart-country-select.html.twig |
The country field, as a closed list of ISO codes |
partials/kahunacart-order-status.html.twig |
The three status axes of one order |
emails/order-confirmation.html.twig / .txt.twig |
Confirmation email |
emails/payment-received.html.twig / .txt.twig |
Payment-received email |
emails/shipped.html.twig / .txt.twig |
Shipped email |
emails/refund.html.twig / .txt.twig |
Refund email |
emails/set-password.html.twig / .txt.twig |
Set-password invitation |
emails/abandoned-cart.html.twig / .txt.twig |
Reminder email |
Every page template extends partials/base.html.twig and fills a content block, which your theme must provide. The processing, product and category templates also override a head block: a meta-refresh on the first, the canonical link on the other two. Without a head block in your base template, the payment-return page does not auto-refresh and no canonical link is emitted. Meta tags work without it; see Search engines.
Important
The messages partial drains the flash queue. Include it exactly once per page.
Override a storefront template
-
Copy the template from
user/plugins/kahunacart/templates/intouser/themes/<your-theme>/templates/, keeping the same relative path. -
Edit your copy.
-
If you copied
kahunacart-product.html.twigorkahunacart-category.html.twig, carry the two SEO includes across:TWIG{% block head %}{{ parent() }}{% include 'partials/kahunacart-seo-head.html.twig' %}{% endblock %} {# ...and inside your content block: #} {% include 'partials/kahunacart-seo.html.twig' %} -
If you copied
kahunacart-checkout.html.twig, includepartials/kahunacart-address-suggestion.html.twigat the top of the form.
Your copy now renders instead of the plugin's. Skipping step 3 costs those pages their structured data and canonical link; skipping step 4 means the address-validation panel never renders.
Prices in the visitor's currency
On a store that sells in more than one currency, every price a storefront template is handed is already in the visitor's currency, and a template that prints price_minor raw prints the right number. The visitor's currency is the open cart's, else the one they picked, else the one their saved address or a country header gives, else the store's base currency. See How the currency is chosen.
| Where core hands a template a price | What is in the visitor's currency |
|---|---|
kahunacart_products(), kahunacart_sections(), and the listing, category, search and shop-section pages |
default_variant.price_minor, default_variant.compare_price_minor, and every amount of sale and default_variant.sale |
kahunacart_related(product, kind) and the related, cross-sell and up-sell blocks of the product page |
The same: they are drawn as cards |
kahunacart_product(slug) and the product page |
variants[].price_minor, variants[].compare_price_minor, variants[].sale, default_variant, sale, options_payload (the picker's prices as text, price_html, and the price number) |
kahunacart_sale(variant, product) |
The offer is worked out on the variant row's own currency |
kahunacart_pay_what_you_want(product, variants) |
The minimum is the variant's price, the suggested and maximum amounts are the product's own in that currency, and there is none where nobody set one |
kahunacart_addons(product) |
price.amount_minor of a fixed add-on price |
| The wishlist page | variant_price_minor on each item |
| The wishlist's shipping and tax estimate | Priced in the visitor's currency, with an item that has no price there left out |
A price list shown in the catalogue is read in the visitor's currency too, and a list is in one currency, so a trade price in euro is a list in euro. The cart page, the checkout and the receipt are the order's own, and always were.
The keys keep their names: price_minor, compare_price_minor, sale_minor and regular_minor. Amounts are whole minor units of the currency, so 4200 is 4,200 yen and 42.00 euro, and a theme formats them with kahunacart_price(). A price in euro is a number the merchant typed or, on a currency set to convert, one core worked out from the base price before the template saw it, so a template never converts anything itself.
A variant with no price in the visitor's currency says so. Its price_minor and compare_price_minor are null and it carries unavailable_in_currency: true. It also carries currency, the visitor's. A product carries unavailable_in_currency: true when none of its variants has a price there. The key is absent everywhere else, and on a store with one currency, and for a visitor in the base currency, no row carries it or currency: the rows are what they were before the store sold in a second currency.
A kit or a grouped product is never marked, because the cart prices what is inside it and never the parent's own variant.
| Page | What an unavailable product looks like |
|---|---|
| Product page | Where the price and the add-to-cart form would be, "Not available in EUR." and a link to see the product in the store's base currency (?currency=USD). A shopper whose cart has lines in it gets a "Switch my cart to USD" button instead of the link, because the link would do nothing for them |
| Product page, some variants priced | The priced ones as usual. The page opens on a priced variant, the unpriced ones are disabled in the picker and in the plain select with "Not available in EUR" beside them, and ?variant= naming one is ignored |
| Card, hero or compact row | No price, no sale badge and no tax note, and a small muted "Not available in EUR" |
| Wishlist | The same muted line in place of the price, no Move to cart button, and the item stays on the list |
| Add-on options | A fixed add-on price with no amount in the currency is disabled with "Not available in EUR" beside it, and never shows the base amount |
A card of a product whose default variant has no price there but another variant does is priced from the first variant that has, because a card prices what can be bought. The structured data (JSON-LD) on a product page offers only the variants that have a price in the visitor's currency, and has no offer at all for a product that has none.
All of this is partials/kahunacart-unavailable-in-currency.html.twig, which a theme includes to show the same state. See Theming → Prices on a store with more than one currency.
The currency switcher
A store that sells in more than one currency lets a shopper pick the one they browse and pay in. The pick is the page's ?currency= address, remembered in the kc_currency cookie when the visitor allows it. A shopper with a cart open is in the cart's currency, so picking another with a cart open moves the cart: POST {base}/cart/currency with currency, back and the page's security token. It prices every line again, takes off what does not apply in the new currency, leaves one notice, and sends the shopper back to back, kept to a page of this store and with the currency on its address. A cart that would lose every line is not emptied on that click: the shopper is taken to GET {base}/cart/currency (kahunacart-currency.html.twig), which lists what would go and has the button that posts it again with confirm=1. The post is counted with every other cart write. How it works, and what is taken off, is in Currencies, and the partial that draws it is in Theming.
With currencies.lock_to_country on, the checkout form and the storefront API's checkout move the cart to the currency the shipping country sells in, and stop with a notice before anything is charged. See Letting the shipping country decide.
The set price of a grouped product
A grouped product or a kit never shows its own price, because the cart never charges it. The product page, the listing card and the hero and compact sections show what the cart will charge for the whole set instead. When the product is sold as a set that is the bundle price, with what the parts add up to struck through. When it isn't, it is what the parts add up to. A product that isn't grouped or a kit prints nothing from this, and neither does a grouped product with a part that has no price in the visitor's currency, so a template can fall back to its own price line.
Two things draw it. kahunacart_set_price(product) answers parts_minor (one set of the parts at their own prices, or null when a part has no price in the visitor's currency), price_minor (the bundle price when it is sold as a set, else null), saving_minor and sells_as_set. partials/kahunacart-set-price.html.twig prints the line from it:
{% include 'partials/kahunacart-set-price.html.twig' with {product: product} only %}
The shipped kit partial includes it above the list of parts, and the grid, hero and compact partials include it in place of the variant price. A theme that prints its own lead price passes show_set_price: false to the buy button so the page doesn't say it twice. See Theming for the classes and Twig functions.
Product images
A product's pictures come from the plugin's own table, not from a page media folder. They live in user/images/kahunacart/products/{productId}/, which the web server delivers directly. Upload and reorder them in the admin; the endpoints are in Products and catalog.
- Product cards show the first image, cropped to 600×450. The variable is
product.image, and it isnullfor a product with no picture, in which case the card renders without a figure. - The product page shows a gallery: the images scaled to fit 900 pixels wide in a scroll-snapping row, with 120×120 thumbnails beneath when there is more than one. The thumbnails are in-page links, so the gallery works with a click, a swipe, a keyboard and with scripting off.
altis always rendered. An undescribed image getsalt="", marking it decorative.- Every
<img>carrieswidthandheight. Everything but the first gallery image isloading="lazy".
Resized copies are generated by Grav's media machinery and cached in images/. Set images.derivatives to false to serve the uploaded files at their original size instead.
Note
Animated GIFs are always served as uploaded, and so are AVIF files on hosts whose image library was built without AVIF support.
Product safety
The product page's Product safety section is partials/kahunacart-product-safety.html.twig, a closed <details> under the specification table. It holds the manufacturer, the EU responsible person when there is one, and the safety information as Markdown: the product's own, or the default text of its manufacturer when the product has none. The product route hands it product_safety, which is {manufacturer, responsible_person, safety_info} or null when there is nothing to show. manufacturer and responsible_person are each {name, trade_name, address_lines, email, url} or null. It renders nothing when the variable is null, so a store that hasn't set any of it up sees no change. A theme that overrides the product page includes it with ignore missing, so the theme still runs on a plugin older than the partial:
{% include 'partials/kahunacart-product-safety.html.twig' ignore missing %}
Its classes are kahunacart-product-safety, kahunacart-safety-party (with --manufacturer and --responsible-person), kahunacart-safety-address and kahunacart-safety-prose. What each field means and where a merchant sets it is in Products and catalog → Product safety (GPSR).
Search engines
seo.enabled (default true) adds structured data and social metadata to product and category pages, and to nothing else.
Structured data. A Product block on product pages carries the name, description, image URLs, the default variant's SKU, and an offer with the price, currency and availability. Availability follows the same stock rules the buy button obeys.
In more than one currency. On a store that sells in more than one currency the structured data is in the currency the page is shown in, as its prices are: priceCurrency, price, lowPrice and highPrice are that currency's, a sale price is worked out on the price in it, and the amounts in shippingDetails and the return fee are the ones set for it in policies (a plain amount is the store's own currency's). A variant with no price in the currency is not in the offer or the range, and a product with none has no Offer at all, rather than one at a wrong price. A shipping line with no amount in the currency is left out of that page's markup.
A BreadcrumbList goes on both page types and names the ancestor chain: shop, then every category from the root down. A product filed in more than one category walks the trail of the first by position. Both blocks come from partials/kahunacart-seo.html.twig.
What an offer states. While agentic.enabled is on, each Offer says more than the price, once you have given KahunaCart the facts. Each one is left out until you have, so a store that sets none of them emits the markup it always did. With agentic.enabled off, none of the additions below is emitted, byte for byte.
itemCondition, from the product's condition, new when unset.seller, anOrganizationnamed byseller.name.priceValidUntil, the last day of a running sale that has an end date.shippingDetails, oneOfferShippingDetailsper country inpolicies.shipping_summary, with its rate and handling and transit days, plus a free rate from the country'sfree_overthreshold up. Nothing for a product that does not ship.seo.shipping_detailsswitches it off.hasMerchantReturnPolicy, frompolicies.returns, oncepolicies.refund_urlis set: the window, the method, who pays and the countries. A final-sale product or a store that takes no returns getsMerchantReturnNotPermitted.seo.return_policyswitches it off.
The Product also carries its brand (its own, else seller.default_brand) and, on a product with one variant, that variant's gtin and mpn. See Product data for search engines and agents.
One product per variant. seo.variant_markup: group emits a product with two or more variants as a ProductGroup instead of one Product with one offer. The group carries what the variants share and a productGroupID of kc-p{id}, the same group id the product feeds publish. Each variant is a Product of its own, with its name, its ?variant= URL, its SKU, GTIN and MPN, its picture and its offer. variesBy names the colour, size, material and pattern axes. The default, aggregate, is unchanged.
The store itself. seo.organization puts an OnlineStore block on the shop's front page (the first page, with no filter and no search). It names the seller with its URL, logo, support email and phone, and the store-wide return policy. It needs a name, from seller.name, invoice.store_name or the site title. A theme override of kahunacart-products.html.twig older than 1.3.0 needs the JSON-LD include added; see Going live → Template drift.
A link to one variant. While agentic.enabled is on, {base}/product/{slug}?variant={id} opens the product page on that variant: the option picker, the gallery, the price box and the structured data offer all start on it. The canonical link stays the bare product URL. The id must be a purchasable variant of this product that the picker can reach; anything else is ignored and the page opens on the default variant. This is the link the product feeds publish for each row of a product with several variants.
Social and description metadata. description, og:title, og:description, og:image, og:url, og:type, og:site_name, og:locale and the Twitter card tags. These ride on Grav's page metadata, not a template block, so they work in any theme. The description is the product's summary, or the opening of its description with the Markdown stripped and cut at a word boundary near 160 characters. The card type is summary_large_image with a picture and summary without.
Canonical links. seo.canonical (default false) emits <link rel="canonical"> from partials/kahunacart-seo-head.html.twig. Set it to true only if your theme emits no canonical link of its own. The Kahuna and Quark2 themes both emit one, and a search engine that finds two discards both.
Sitemap. seo.sitemap (default true). With grav-plugin-sitemap installed, every published product and category is added to sitemap.xml with the date it was last edited. The plugin's own page stubs carry sitemap: ignore.
A store that is coming soon stays out of search. While the store status is coming soon, every storefront page carries noindex, as a robots meta tag and an X-Robots-Tag header, and its products and categories drop out of the sitemap. A paused store changes nothing here, so a short pause doesn't cost it its rankings.
Turn off SEO output
- Set
seo.enabledtofalse.
Product and category pages now emit no JSON-LD, no meta description and no Open Graph tags. To drop only one part, leave seo.enabled alone and set seo.canonical, seo.sitemap, seo.organization, seo.shipping_details or seo.return_policy to false instead.
Search
{base}/search?q= searches published products by title, summary, description and variant SKU. On the ranked index it also matches each product's categories, tags, brand and attribute values, so tops finds a tee filed under Tops. The form is a GET, so a result page has a URL you can link, share and bookmark; kahunacart_search_url() builds it. Results paginate at catalog.per_page, so ?page=2&q=cotton is the second page of that search. See Pagination. Search result pages are noindex, follow.
Three engines, and which one you have
search.products.driver selects the search engine. It accepts four values and defaults to auto, which selects the most capable engine available on this installation.
| Value | What answers the box | What it needs |
|---|---|---|
auto |
The most capable engine available on this installation. The default. | Nothing |
yetisearch |
The ranked index: a relevance-scored file beside the store | PHP with pdo_sqlite and FTS5 compiled in |
sql |
The catalog index's own full-text search, in your database engine | The catalog index built and enabled |
like |
The portable substring scan this plugin shipped with | Nothing |
A selected engine can fall back to a less capable one if its requirements are not met. yetisearch on a PHP build that cannot open an FTS5 table falls to sql, and sql on a store that has never built the catalog index falls to like.
like is the exception: it is honoured exactly, so a store set to it searches the plain way whatever else is available. See Without an index.
bin/plugin kahunacart status prints which search engine the store is using.
The search engines work together. The ranked index answers and ranks a plain search over the whole catalog. Narrowing it by a category, a tag, a price band or an attribute value moves the page to the sql engine, the only one that can express the term as a SQL predicate.
A refined search loses its ranking only where the catalog index has never been built. With the index built, a refined search is ranked by the sql engine; without it, it drops to like and comes back in catalog.sort order. See The catalog index.
The ranked index (yetisearch). A relevance-scored full-text index in a SQLite file of its own at user/data/kahunacart/search/products.db, built by the YetiSearch library. It holds one document per published, listed product: the title, the summary, the description, every variant SKU, the path of each category it's in, its tags, its brand, its text and select attribute values and its option values, with every translation. A renamed category or tag reaches a product the next time it's saved, or straight away with bin/plugin kahunacart search:reindex. The file is independent of your store's database, so SQLite, MySQL and PostgreSQL stores get the same results in the same order.
On auto, the index file is written on the first search that needs it. A fresh deploy without user/data pays for one build on the first customer search, around a second on a catalog of a few thousand. Put bin/plugin kahunacart search:reindex in your deploy step to build it first.
Measured on a benchmark catalog of 4,598 published products:
like |
yetisearch |
|
|---|---|---|
| Average search | 6.2 ms | 0.47 ms |
| 95th percentile | ~7 ms | 0.62 ms |
| Result order | newest first | best match first |
| A misspelled term | nothing | the product |
| Cost per search | reads every published row | one index probe |
The catalog index's search (sql). The catalog index carries a search text column with whatever full-text index your engine offers over it: SQLite's FTS5, MySQL's FULLTEXT, PostgreSQL's tsvector with a GIN index. An engine that offers none falls back to portable prefix matching. It ranks, applies the merchant's synonym table as the search runs, and survives being refined. The catalog index covers it in full.
The portable scan (like). A case-insensitive substring match, with % and _ escaped so a search for 100% finds the product with a percent sign in its name. It behaves identically on SQLite, MySQL and PostgreSQL. It reads every published product row, and it is what answers whenever neither index can.
What ranks first
On the sql engine, results are ordered by:
- How many of the term's words are in the product's name, meaning its title and its SKUs. A product called Linen Shirt leads a search for
linen shirt; a jacket whose description mentions linen follows it. - The engine's own relevance score, where it has one. The haystack is weighted by repetition: title 3, SKU 3, summary 2, attributes 1, description 1.
catalog.sort, as a tie-break, so two equally relevant products come back in a stable order.
Set catalog.search.rank to false to order search results by catalog.sort alone, with no relevance.
A second word narrows: linen shirt finds products matching both, not either. The last word is matched as a prefix, so the box is usable while it is still being typed.
On the yetisearch engine the order is the library's own relevance score, and catalog.sort is not consulted.
Search synonyms
A shopper who types jumper finds no sweater, because the word is not in the catalog. kahunacart_search_synonyms holds a merchant-editable list of a word and the words it should also find.
| Term | Also finds |
|---|---|
jumper |
sweater, pullover |
trainers |
sneakers |
fizzy |
soda, pop |
Expansion happens as the search runs, so a new row takes effect on the next search with no rebuild. It runs in one direction: jumper finds sweaters, and sweater does not find jumpers unless you add that row too. A word and its synonyms are ORed together while the words are ANDed, so jumper blue still narrows.
Terms and synonyms are folded the same way product text is, so case, accents and punctuation do not matter. A term may carry up to twelve synonyms. Edit the table in the admin under KahunaCart → Search. Set catalog.search.synonyms to false to ignore the table without deleting it.
Synonyms belong to the sql engine. A store on yetisearch gets them once a search is refined, and on like they do nothing.
What changes for a shopper
Results are ordered by how well they match, not by publication date. catalog.sort still orders every other listing, including a search that has been refined.
Terms match words, not substrings. like finds otto inside cotton; neither index does. Going the other way, the ranked index finds BENCH-100821 from 100821 and matches a multi-word query against any of its words, where like looks for the whole phrase.
A typo still finds the product. With search.products.fuzzy on (the default), a yetisearch search that comes back empty is tried again against the words the catalog contains, so rucksak finds the Rucksack. It runs only on a search that already found nothing, and the other two engines ignore it.
A search that matches more than search.products.max_matches shows the best ones and stops. 500 by default, on the yetisearch engine.
The order changes the first time a filter is ticked. Applying a filter switches the search engine from yetisearch to sql. A search on a category page is refined by the same rule.
The ranked index keeps itself up to date
Nothing has to be run after a product changes. Every write of a product or a SKU announces itself and the index writes that one document, whether it came from the admin, the API, bin/plugin kahunacart import, a catalog sync or an add-on. Publishing adds a product, unpublishing or unlisting removes it, deleting removes it, editing a title replaces it, and adding a variant makes its SKU findable.
The writes are collected and applied once at the end of the request, so a bulk edit or a CSV import costs one write of the index. Anything that searches inside the same request sees the pending writes first.
The catalog index is kept current by a different mechanism, a queue drained by the background worker. See The catalog index.
It rebuilds itself when it has to. Any of these and the next search rebuilds the ranked index in place, then answers the query:
- the index file is missing: a fresh deploy, a
user/datathat was not copied across, somebody deleted it; - the index file is unreadable, or was written by a version of the library that has moved on;
- the store's highest product id has run past what the index was told about, which is how a bulk SQL load, a restored dump or the benchmark seeder look from the index's side.
Each rebuild writes one line to Grav's log saying which of those it was. A healthy index says nothing.
What it cannot notice. The high-water mark is a cheap check, not a checksum: a bulk UPDATE that rewrote every title in place, or a bulk delete, leaves the ids where they were. Run bin/plugin kahunacart search:reindex to rebuild the ranked index from the catalog, bin/plugin kahunacart index --rebuild to rebuild the catalog index, and bin/plugin kahunacart status to see what both hold.
Without an index
A store with search.products.driver set to like, or one where neither index can be built, gets the portable substring match. It has no ranking, so results come back in catalog.sort order, newest first until you change it. LIKE '%term%' cannot use an index, so every search reads every published product row.
like is honoured on the search page even when the catalog index exists for other reasons. The store joins the index on every other listing and still answers its search pages from the live queries.
Note
On SQLite, LOWER() folds ASCII only, so a search on the portable scan for an accented capital matches only what a plain lowercase comparison would. The ranked index folds Unicode properly and does not have this edge.
When search cannot answer
If a disk write, index rebuild or library call fails, the request falls back to the next available engine. The shopper still gets results, and one line goes to the log per request.
Multilanguage search
Every engine matches translated text. The portable scan matches the active language's translated title, summary and description as well as the base columns. The ranked index holds every language's text in one document, so it matches all of them whichever box the term was typed into. A product nobody has translated yet is still findable from the translated search box. See Multilanguage.
Semantic search
Keyword search finds products that contain the words a shopper typed. Semantic search also finds products that mean the same thing. A search for hat finds the cap and the beanie, automobile finds a product called a car, and something to keep my feet dry finds the waterproof boots.
The ranked index runs both searches and merges the two rankings. A product that matches on keywords and on meaning ranks highest, and one found by meaning alone still makes the list. So a product somebody names in the search box still comes first, and meaning fills in the rest.
It's off by default. Off is exactly the keyword search described above: the same results, and nothing sent anywhere.
Switch it on
Semantic search needs the yetisearch engine (search.products.driver on auto or yetisearch) and an embedding model, which turns text into numbers that can be compared for meaning. On ordinary PHP hosting that means a hosted API, since running a model inside PHP isn't practical.
Turn it on under Settings → General → Semantic Search in the store admin, or in kahunacart.yaml:
search:
products:
semantic:
enabled: true
api_key: 'env:OPENAI_API_KEY' # or the key itself, which is stored encrypted
That's all most stores need. With nothing else set, it uses OpenAI's text-embedding-3-small model at 512 dimensions. Any service that speaks the OpenAI embeddings API works if you set base_url and model:
| Service | base_url |
model |
Also set |
|---|---|---|---|
| OpenAI | https://api.openai.com/v1 |
text-embedding-3-small |
api_key, dimensions: 512 |
| OpenRouter | https://openrouter.ai/api/v1 |
openai/text-embedding-3-small |
api_key, dimensions: 512 |
| Ollama, on your own server | http://localhost:11434/v1 |
nomic-embed-text |
dimensions: 0, query_prefix: 'search_query: ', document_prefix: 'search_document: ', min_similarity around 0.5 |
The job worker embeds the catalog in the background, so give it a few minutes after you switch it on. Or run bin/plugin kahunacart search:embed and watch it happen. Until the products are embedded, search works by keywords as before.
If you already run YetiSearch Pro with semantic search on, leave the connection settings empty and KahunaCart uses the same provider, model and key. Set any one of them (even just model) and KahunaCart uses its own connection instead, and never borrows YetiSearch Pro's key. On the settings screen, an empty field shows the value it will use in grey.
What it costs and what gets sent
Every product's text, in every language, goes to the provider to be embedded, and so does every search term a shopper types. A hosted API charges for both, but not much: at text-embedding-3-small prices, a catalog of five thousand products costs a few cents to embed. A product is only sent again when its text changes, and a popular search term is cached, so it isn't sent every time.
Customer data never goes. Nothing from an order, a cart or an account is embedded.
If product text or search terms must stay on your own server, run Ollama beside the store. The embedding happens locally and nothing leaves. The API key is stored encrypted, like a payment provider's secret, and env:NAME reads it from an environment variable so it never sits in a config file. See Hardening.
What gets embedded
A product's title, the path of each category it's in (Clothing > Mens > Tops), its tags, its brand, its text and select attribute values (Material: Organic Cotton), its option values (Size: 28, 30, 32), its summary and its description.
The categories, tags and attributes matter more than you'd think. Lots of products are short: a title, one line of summary and no description. That gives a model very little to go on, and the catalog's own structure is often what says what a product actually is.
Numbers, yes/no values, dates, colors and links are left out. "File Size: 8.4 MB" describes every download the same way and would make short products look alike. SKUs are left out too.
Each product also gets a short second entry, its card: Product: Shorebreak Cap (Accessories), which is just the title and its categories. A short search compared with a whole product scores low and flat, because the title is one line among everything else. Compared with the card, it scores what it means. Cards only exist while semantic search is on, which is why switching it on or off rebuilds the index once.
When a search goes to the provider
The shopper's search term is embedded while the page is built. If the provider errors, or doesn't answer within five seconds (query_timeout), that search simply loses its meaning part and answers from keywords, as if semantic search were off. Terms shorter than three letters and digits (min_query_chars) are keyword-only and never sent.
Embedding the catalog never happens while a shopper waits. After a reindex, or when product saves change the index, a search.embed job goes into the queue. The worker sends the changed products in batches, stops at the first error the provider returns, and records the error. After a failed run it waits fifteen minutes before trying the provider again.
Typos still work. When a search finds nothing at all, the typo pass has a go, and its guess has to agree with the meaning. So hoodei still finds the hoodie, while a keyboard mash like asdf finds nothing, where it would otherwise have landed on some product that happens to share a few letters with it.
Changing the model (or dimensions, or a prefix) marks every product as waiting. Until they've all been embedded again, the store searches by keywords alone rather than compare numbers from two different models. The worker re-embeds them on its next ticks.
To see how it's doing, run bin/plugin kahunacart status. It prints how many products are embedded and waiting, the model, the noise gate below, and the last run's error if there was one. The Semantic search check on the Health screen says the same, and warns when the last run failed.
Tuning semantic search
You shouldn't need to touch any of this. The defaults were measured on real product catalogs, not on long articles, and the noise gate measures itself on your catalog. But here's what the settings do. The numbers come from the demo catalog (seventeen products, many of them a title and one line) with text-embedding-3-small.
query_frame(a {query}) is the phrase the search term is embedded in. On its own,hatis read as a word, not as a thing to buy: it scored 0.20 against the Shorebreak Cap and ranked it fifth, behind a longboard wall rack. Asa hatit scored 0.39 and ranked the cap first.{query}on its own turns this off, and a store in another language can write the phrase in its own.min_similarity(0.25) is the floor. A product less similar than this is never found on meaning alone. It depends mostly on the model: OpenAI's text-embedding-3 puts related text around 0.3 to 0.6, and nomic-embed-text needs around0.5.min_margin(0.10, measured per catalog) is the noise gate. A search that means nothing in particular is about as similar to every product card, while a real search makes a few stand out. So meaning only adds results when the best card stands at least this far above the median card. On the demo catalog,asdf,qwertyandlorem ipsumstood 0.04 to 0.06 above the median, and real searches fromshoestoworkshopstood 0.14 to 0.41.relative_similarity(0.85) trims the list. A product has to be at least this share as similar as the best match. That's what keepshatto the cap and the beanie rather than the whole accessories department.weight(0.5) sets the balance, from0(keywords only) to1(meaning only).
The noise gate calibrates itself. No single min_margin works for every store, because nonsense scores higher against a big catalog than a small one: with more products, one of them is more likely to land near any string at all. On a 4,415-product catalog, the same nonsense searches stood 0.12 to 0.20 above the median, so 0.10 let all of them in.
So with calibration: auto (the default), the worker measures it for you at the end of an embedding run. It searches for 88 nonsense strings (made-up words, keyboard mashes, placeholder Latin and everyday phrases nobody shops for), sees how far each one stands above the median, and sets the gate two standard deviations above their average. That came out at 0.119 on the demo catalog and 0.200 on the big one. On both, every nonsense search we tried found nothing, and real searches kept their meaning.
It's measured again when the model or query_frame changes, or when the catalog grows or shrinks by more than a quarter. The first measurement costs a fraction of a cent, and after that it's cached. calibration_strictness (2.0) moves the cutoff: lower lets more borderline searches through, and more nonsense with them. calibration: off uses min_margin exactly as you set it. bin/plugin kahunacart search:calibrate prints the numbers if you want to see them.
Every key is in Configuration → Semantic search.
Not the same as the admin's order search
The Orders screen has its own search and it is a different engine: a per-engine text index inside the store, phrase-selective across seven fields, described in Orders and the admin. bin/plugin kahunacart status names both.
Add the search form to a page
-
Add the include to any template:
TWIG{% include 'partials/kahunacart-search.html.twig' %}
The form posts to {base}/search. The product listing ships with it already.
Catalog navigation and filters
Presence of data is the switch for everything here except whether the filter panel is drawn, which catalog.filters decides. A store with no categories renders one flat listing: no sidebar, no tree, no breadcrumb, no landing pages. Per block: the tree needs categories, the subcategory facet needs the category being viewed to have children, and the tag facet needs tags. When no block has anything to offer, catalog_nav is null and the template emits no <aside>. See Turn the filter panel off.
Filtering is not tied to categories. A category-free store still gets its filter panel of tags, price and in stock, but no tree above it and no subcategory facet inside it.
Category landing pages
A category page is either a department or a shelf, decided by that category's display column and described in Products and catalog. A department renders subcategory cards (picture, name and product count) above the product grid or instead of it.
Card images are cropped to 640×360, and a category with no picture renders the same card without the figure. The count is descendant-inclusive. The category's own picture renders above the description at up to 900 pixels wide.
Opening a parent category lists the products in it and in every category under it. A product filed in two categories inside the same subtree comes back once.
The category tree sidebar
The tree is server-rendered and contains no JavaScript. Branches expand with <details>, and the branch down to the category being viewed starts open. The link sits inside the <summary>: clicking the text navigates, clicking the marker toggles the branch.
The current category is marked with aria-current="page", and its ancestors carry a class of their own so a theme can draw the trail.
Each node's count is the number of products in that category and everything under it, under whatever else the shopper has ticked. A branch that would come back empty reads 0, carries is-unavailable and aria-disabled, and stays a link. catalog.facets.empty: hide takes dead branches out instead, except the category being viewed and the trail down to it. See Counts follow the filters.
Filter parameters
The filter panel is a <form method="get">, so a filtered listing is a real URL you can link, share, bookmark and crawl. It needs no nonce and no JavaScript. With scripting on it applies as it is ticked; with scripting off the Apply button navigates the form. See Filters apply as they are ticked.
| Parameter | Means |
|---|---|
sub=<slug> |
Refine to one subcategory of the category being viewed. Single value. |
tag[]=<slug> |
A tag facet, repeatable. tag=a,b is accepted too and means the same thing. |
attr[<slug>]=<value> |
An attribute facet, one parameter per attribute. attr[<slug>][]= and the comma form are accepted, and a number attribute also takes attr[<slug>]=100-200 or attr[<slug>][min]= and attr[<slug>][max]=. |
min=, max= |
A price range, typed the way a price is written in the visitor's currency, which is the store's own unless the store sells in more than one. |
stock=1 |
In stock only. |
q= |
The search term, shared with the search route. |
Within the tag facet the values are ORed; across facets it is AND. tag[]=organic&tag[]=sale means organic or on sale, and adding min=10 narrows that set. The repeated tag[] form is what an HTML checkbox group submits; the comma form suits a hand-written link.
Attribute facets follow the same rule: values inside one attr[...] are ORed and separate attributes are ANDed. Only attributes a merchant has marked filterable are read, and an attribute named in a URL that is not filterable is ignored. See Products and catalog for the whole grammar and which types get a control in the panel.
Filters compose with search rather than replacing it. The search term rides through the panel as a hidden field, and Clear filters drops the refinements and keeps the term. {base}/search with no q lists nothing even when facets are ticked.
Prices are typed in the visitor's currency (the store's own on a store with one currency, and see Sorting and filtering by price for a store with several) and converted through Money, so 10 is 1000 minor units in USD and 10 in JPY. A comma is accepted and normalized; anything else clears the field. Price and stock are two independent questions of the variants table.
Values that name nothing are dropped rather than emptying the page: a tag[] slug from a renamed tag contributes nothing, and a sub= slug not below the category being viewed drops the refinement. One request may name at most 40 tags.
Counts follow the filters
Every count in the panel is a drill-down count, taken over the listing being looked at with every active constraint applied except the facet's own. It answers "if I ticked this, how many products would I get?".
Inside Material, the other materials are counted without the material ticks applied, because values inside one facet are ORed. Everywhere else the tick is applied, so the certifications, the tags and the category tree all say what is left now that Material is on.
The base set is the listing in front of the shopper.
| Page | What the counts are taken over |
|---|---|
| Shop | The whole catalog |
| Category | That category's subtree |
| Search | The search hits |
Switching category changes every number in the panel. The category tree keeps its full context on a category page: standing in Clothing, the number beside Digital says what going there would give, not what Digital holds inside Clothing.
A value that would give an empty grid is greyed out, not removed. It keeps its place with its 0, disabled on the input, and aria-disabled plus an is-disabled class on the label.
Whatever is ticked is never disabled and never hidden, at any count.
catalog.facets.empty |
Admin label | What the panel does with an unreachable value |
|---|---|---|
grey |
Grey out | The default. Leave it in place, disabled, with its 0. An empty facet keeps its heading and the "None match the current filters" line. |
hide |
Hide | Take it out. An empty facet disappears entirely. For a catalog whose facets are long and sparse. |
catalog.filters: auto counts what the catalog offers rather than what is reachable right now, so drilling down never deletes the panel. See Turn the filter panel off.
There is one aggregate query per facet group and never one per value, each built from the same WHERE body the listing runs, so a count and the grid beside it cannot disagree. A listing with nothing ticked is three queries however many facets it draws.
Long facets
Past catalog.facets.max_values (twelve by default) a facet draws the values most of the catalog answers and puts the rest behind a Show all N toggle, where N is how many values the facet has in total.
The list is never reordered: the merchant's order is kept, and the count only decides which values survive the cap. Whatever a shopper has ticked is always in the visible part, even when there are more ticks than the cap allows.
The toggle is a plain <details> holding real checkboxes in the same form. Opening it costs no request and needs no script. Because the script re-renders the panel after every change, a value ticked inside the toggle is in the visible part next time round.
Set catalog.facets.max_values to 0 to draw every value however long the list runs. For an attribute with more values than a panel can hold, untick Show in the filter panel instead, which drops the facet and keeps the links. See Products and catalog.
Filters apply as they are ticked
Tick a box and the grid changes. There is no page load and no Apply. Every apply pushes the exact URL the form would have navigated to, so Back, Forward, reload, bookmark and copy-paste all work.
The form still works with JavaScript off: assets/kahunacart-filters.js hides the Apply button only once it has booted.
| Control | When it applies |
|---|---|
| Tick boxes, radios and selects | 250 ms after the last change, so running down a column of checkboxes is one request rather than six |
| Price and number-range boxes | On change, which a browser fires on blur and on Enter, never per keystroke |
| A chip, or Clear all | Immediately. They are links, and the script only saves the page load |
| A page link | Immediately, for the same reason |
While a request is in flight the results region is dimmed and its clicks are switched off, with aria-busy="true" and an is-loading class on it. A stale response is dropped rather than pasted over a newer one, and a request that fails falls back to navigating to the same URL.
The script is enqueued on the listing, category and search routes, and only on a page that actually drew a panel. A store with catalog.filters set to off loads nothing.
The fragment contract
For developers wiring their own script or reading the response by hand.
A filter change is a GET of the same listing URL carrying the header X-Requested-With: kahunacart-filters, or ?fragment=filters in the query string. The exact value matters: X-Requested-With: XMLHttpRequest is not this. Anything else gets the whole page.
What comes back is the regions a filter change touches and nothing else, in this order:
<div id="kahunacart-catalog-results" class="kahunacart-catalog-results" data-kc-catalog-results> … </div>
<form class="kahunacart-filters" … data-kc-catalog-facets> … </form>
<nav class="kahunacart-cat-tree" … data-kc-catalog-tree> … </nav>
No <html>, no theme head, header or footer, no heading, no <aside>. The facet form is absent when the store draws no panel, and the tree is absent on a store with no categories. A script that finds one missing leaves the one on the page alone.
The pager rides inside the results region, so a fragment answering page 3 carries the pager for page 3.
The tree is in the response because its counts move too: every number beside a category is taken over the listing being drawn. See Counts follow the filters.
Response headers are Content-Type: text/html; charset=utf-8, Cache-Control: no-store and Vary: X-Requested-With. The full page carries the Vary too.
The attributes are the contract, not the ids. data-kc-catalog-results, data-kc-catalog-facets and data-kc-catalog-tree are what the script swaps against, because a theme is free to rename any of the ids.
Every region comes from a file the full page also includes, so first paint and every later swap are the same markup.
| File | What it is |
|---|---|
partials/kahunacart-catalog-results.html.twig |
The results region on the listing and category pages: subcategory cards, chips, sections or grid, pager |
partials/kahunacart-search-results.html.twig |
The results region on the search page: chips, summary line, grid, pager. Carries both data-kc-catalog-results and its own id="kahunacart-search-results" |
partials/kahunacart-pagination.html.twig |
The pager, included by both results regions and therefore returned inside the fragment |
partials/kahunacart-catalog-filters.html.twig |
The facet form, included by partials/kahunacart-catalog-nav.html.twig |
partials/kahunacart-catalog-tree.html.twig |
The category tree, included by the same sidebar |
partials/kahunacart-catalog-chips.html.twig |
The chip row, included by both results regions |
Override any of them in user/themes/<your-theme>/templates/partials/ and the override takes over the page and the fragment.
Warning
A theme that writes its own grid into its listing template, instead of including the results partial, will have the plugin's markup pasted in over its own the first time somebody ticks a box. Put the theme's markup in an override of kahunacart-catalog-results.html.twig and include that. The shipped Kahuna and Kahuna Site themes both do this.
Two fragment contracts share the search route, named by different headers and different flag values. A filter change returns the results, the facet form and the tree. Live search returns the results region alone.
Active filters above the results
Above the grid, one chip per active filter value with a Clear all beside them:
Material: Cotton ✕ Tags: Organic ✕ $10 to $50 ✕ In stock ✕ Clear all
Every chip is a link, rendered server side in the same fragment as the grid, so the row works with scripting off. Its href is this same listing with that one value taken off. The script intercepts the click and swaps in place.
- A price range and a number range are one chip each.
- Clear all drops the refinements and keeps the search term, the same rule the panel's own Clear filters link follows.
- Labels come from the facet blocks the panel was built from, not from the URL. A chip reads
Material: Cottonrather thanmaterial=cotton, and a stale token disappears rather than becoming a chip that removes nothing. - The accessible name of a chip is "Remove filter: Material: Cotton".
The row renders nothing on an unfiltered listing, or when every token in the URL names something that no longer exists.
A chip is drawn for an attribute the panel never showed. A merchant can mark an attribute filterable and keep it out of the panel, and a shopper arriving from a product-page link on ?attr[author]=le-guin still sees a chip reading Author: Le Guin with the cross that takes it off. See Products and catalog.
Turn the filter panel off
catalog.filters is the one setting the catalog sidebar has. Everything else about it is presence-driven.
| Value | Admin label | Means |
|---|---|---|
auto |
Automatic | The default. Draw the panel when at least one facet has a real choice in it: two or more subcategories, two or more tags, two or more values on an attribute drawn in the panel, or a filterable number attribute the catalog answers. One value is not a choice. Counted over the catalog rather than over what is reachable right now, so drilling down never deletes the panel. |
on |
Always | Always draw it. |
off |
Never | Never draw it, and ignore the filter parameters in the URL, so a stale link cannot narrow a listing by a control nobody was offered and nobody can undo. The search term survives, because search is a different question with its own box. |
Anything unrecognized reads as auto.
A refinement already in the URL overrides auto's counting, so somebody arriving on a filtered listing can see what is narrowing it. off is the only value that means never.
When the panel is not drawn and there is no category tree, there is no <aside> and the listing is a flat single-column page. The search box lives in the page template, above the layout wrapper, and is unaffected.
- In the admin sidebar, click KahunaCart, then Settings.
- Open the Storefront tab and find Catalog Order.
- Set Filter Panel to Never.
- Click Save.
Listings now render with no facet panel, and a filtered URL shows the unfiltered listing.
Pagination
Listings are paginated. catalog.per_page (default 24) is the page size, and it applies to the shop page, category pages, tag and attribute filtered results, ?sale=1 and search.
| Rule | Behaviour |
|---|---|
?page=N |
1-based. ?page=2 is the second page. |
| Page 1 | Carries no parameter. /shop, never /shop?page=1. |
| A page past the end | Clamps to the last page, so a stale bookmark shows the end of the shop rather than an error. |
An unreadable ?page |
Reads as page 1. |
| Changing a filter | Resets to page 1. |
Every pager link carries the whole filter, so page 2 of "in stock, under $20, tagged organic" is page 2 of that listing. Ordering is stable across pages under every catalog.sort, manual included, because every sort clause ends in the product id. See Products and catalog.
On a store that sells in more than one currency every pager link, chip and tree link carries the shopper's currency (?currency=EUR) when they are not in the base currency, and the filter panel carries it as a hidden field of its GET form. rel=prev and rel=next don't: they are canonical-family links. See Currencies → Caching and CDNs.
Set catalog.per_page to 0 to turn paging off. The listing then stops at search.limit (default 50) and says "There are more products than fit on one page. Narrow it down with the filters."
Note
catalog.sections blocks are never paginated. A filter collapses the page to the flat paginated grid anyway. See Sectioned listings.
The pager renders from partials/kahunacart-pagination.html.twig, inside the results region, so paging goes through the same fragment contract a filter change does. Every control in it is an anchor to a real URL, so it works with JavaScript off.
The pagination template variable is null when the store does not paginate or everything fits on one page, and otherwise:
| Key | Holds |
|---|---|
page, pages, per_page, total |
Where you are, how many pages, the page size, and the whole result count |
prev_url, next_url |
The neighbours, or null at each end |
first_url, last_url |
The ends |
urls |
One entry per page, as {page, url, current}. An entry whose page is null is an elision in a long pager |
A paginated listing emits <link rel="prev"> and <link rel="next"> from partials/kahunacart-seo-head.html.twig, and a paginated category page's canonical points at the page being read rather than at page 1.
An empty result says which kind it is. A category with nothing in it says so; a filter combination that matches nothing says "Nothing matches those filters. Try removing one." and offers the clear-filters link.
Partials a theme can override
| Partial | Variable it reads |
|---|---|
partials/kahunacart-catalog-nav.html.twig |
catalog_nav — the sidebar wrapper, or null. Includes the two below |
partials/kahunacart-catalog-tree.html.twig |
catalog_nav — the category tree, or nothing. Included by the sidebar and returned in a fragment, because its counts follow the filters |
partials/kahunacart-catalog-filters.html.twig |
filters — the facet form, or nothing. Included by the sidebar and returned in a fragment |
partials/kahunacart-catalog-results.html.twig |
products, catalog_sections, filter_chips, show_subcategories, show_products, pagination, result_truncated, empty_message — the region the listing and category pages include and a fragment returns |
partials/kahunacart-pagination.html.twig |
pagination, or nothing. Included by both results regions |
partials/kahunacart-catalog-chips.html.twig |
filter_chips and filter_clear_url — one chip per active value, and Clear all |
partials/kahunacart-subcategories.html.twig |
subcategories — the children of the category being viewed, each with image and product_count |
partials/kahunacart-tags.html.twig |
tags — {slug, label, url}, already translated |
partials/kahunacart-breadcrumbs.html.twig |
ancestors — {title, url}, root first, never including the current page |
Each renders nothing when it has nothing to draw, and the shipped stylesheet styles all of them through the same --kc-* token chain as everything else.
The classes are kahunacart-catalog-layout and kahunacart-catalog-main around the two columns, plus kahunacart-has-nav on the page wrapper when there is a sidebar. Then kahunacart-cat-* for the tree, kahunacart-filter* for the panel and the chip row, kahunacart-catalog-results for the region a filter change replaces (with is-loading on it while a request is in flight), kahunacart-chip* for the chips, kahunacart-subcategory-* for the cards, kahunacart-breadcrumb*, kahunacart-tags / kahunacart-tag-list / kahunacart-tag, kahunacart-category-media, kahunacart-listing-more, and the kahunacart-pagination* family for the pager. The state classes are is-current and is-ancestor on the tree, is-unavailable on a branch holding nothing under the filters currently on, and is-disabled on a facet value nothing reachable answers. Theming lists them all.
The two-column layout exists only when there is a sidebar, and below 40rem it collapses to one column with the sidebar above the listing.
Every string in these partials goes through |t against a PLUGIN_KAHUNACART.* key, such as REFINE, CATEGORIES, FILTER_TAGS, FILTER_IN_STOCK and PRODUCT_COUNT. Translate the panel by adding a languages/<code>.yaml.
Sectioned listings
catalog.sections turns the shop page into a sequence of blocks instead of one grid: a featured hero, a row per tag, then everything else. Each block has a title, a layout and one selector, and they render in the order they are written.
The default is empty. A store that has never opened the setting renders the flat listing and runs no extra query.
# user/config/plugins/kahunacart.yaml
catalog:
sections:
- layout: hero
featured: true
limit: 2
- title: 'Extras'
layout: grid
tag: add-on
sort: manual
- title: 'Payment providers'
layout: compact
tag: payment
sort: title
columns: 2
- title: 'Everything else'
layout: grid
rest: true
At most 20 blocks are drawn.
What a section takes
| Key | Takes | Effect |
|---|---|---|
title |
Text | The h2 above the block, with an anchor of its own so the block can be linked to. Leave it out for a block with no heading, which is what a hero at the top of the page usually wants. Run through \|t, so a translation key works as well as a plain sentence. |
layout |
hero, grid or compact |
How the block is drawn. Defaults to grid, and anything unrecognized reads as grid. |
featured |
true |
Selector: the products marked featured. |
tag |
A slug, a list, or slugs separated by commas | Selector: products carrying any of them. |
category |
A slug, a list, or slugs separated by commas | Selector: products in any of them, subtrees included, exactly as clicking the category on the shop does. |
products |
Slugs, in the order you want them | Selector: exactly these, in exactly that order. Wins over every other selector on the same block, and sort does not apply to it. At most 200 slugs. |
rest |
true |
Selector: everything no earlier block took. Put it last. |
sort |
One of the six sort words | This block's order. Defaults to the store's catalog.sort, so a block that says nothing follows the shop. |
limit |
A number, 1 to 500 | Most products this block shows. Unset is the store's usual listing length. |
columns |
A number | Columns at full width. Three in a grid, two in a hero or compact block, by default. A hero is capped at 2, a grid at 6 and a compact block at 4. Narrow screens use fewer whatever this says. |
tag, category and featured on one block are ANDed together, the same rule the storefront's own facets follow, so tag: add-on with featured: true is the featured add-ons. A block with no selector at all behaves as rest: true.
featured is read as "only when it is true", so a toggle left untouched (which stores a 0) applies no selector.
A product appears once
Blocks are evaluated top to bottom against a running set of what has already been placed, and every query excludes it. A product tagged both add-on and bundle lands in whichever block is declared first.
That makes rest: true the remainder, and it is why declaration order matters: a rest block written first eats the whole shop.
An empty block is dropped, not drawn as a heading over nothing. If every block resolves to nothing, the page falls back to the flat grid.
Unlisted products stay out of every block, products: included. Naming one does not put it back into the shop.
Sections and filters
Ticking a filter collapses the page to one grid. It happens in place, through the same fragment a filter change already swaps, so it costs no page load. A search term typed on the shop page counts as a filter here, because it narrows the listing.
The blocks are resolved from config on every request, so taking the last tick off brings them back.
Category pages and search results are never sectioned. They are filtered views already.
Configure sections
- In the admin sidebar, click KahunaCart, then Settings.
- Open the Storefront tab and find Listing Sections.
- Click to add a block, and fill in Heading, Layout and one selector: Featured Products, Tag, Category, Named Products or Everything Else.
- Set Order, Most Products and Columns if the defaults do not suit. Leave Order on The store's order to follow Listing Order.
- Add the remaining blocks, in the order they should read down the page.
- Click Save.
The shop page now renders those blocks. Leave the list empty to go back to the flat grid.
Tip
Tag, Category and Named Products are text boxes. Type several slugs separated by commas: add-on, bundle.
A worked example
This is the configuration kahunacart.com runs. It sells one product and a short shelf of add-ons, so the filter panel is turned off.
catalog:
# Quoted: an unquoted `off` is a boolean to a YAML 1.1 reader.
filters: 'off'
sections:
# No heading: the block at the top of a shop page does not need a label
# saying that it is the top of the shop page.
- layout: hero
tag: core
- title: 'Extras'
layout: grid
tag: extras
sort: title
- title: 'Payment providers'
layout: compact
tag: payment
sort: title
columns: 2
- title: 'Renewals and plans'
layout: compact
rest: true
sort: title
columns: 2
The last block picks up everything the three above it did not claim. Because a product only ever appears in the first block that claims it, the tags do not have to be mutually exclusive. The hero selects on a tag because nothing in that catalog is marked featured.
What a theme sees
The blocks arrive in the results region as catalog_sections, and each is rendered by the partial its layout names.
| Partial | Draws |
|---|---|
partials/kahunacart-section-hero.html.twig |
One large card per product: picture beside the title, summary, price and a link through to the product. Two side by side at most |
partials/kahunacart-section-grid.html.twig |
The listing's own product grid, under a heading. It includes partials/kahunacart-product-grid.html.twig, so a theme that has overridden the grid has already overridden this |
partials/kahunacart-section-compact.html.twig |
Dense rows: a thumbnail, a title and a price, the whole row a link. For the long shelf people scan rather than browse |
Each is handed one variable, section, which is {title, layout, columns, anchor, products}. Every row in products carries default_variant, image and sale, like every other listing row.
The hero's action is a link to the product page rather than an add-to-cart form, because a listing row carries one variant and no option axes.
Anchors are kahunacart-section-{slug} derived from the title, or from the block's position when it has no title, and are de-duplicated.
The classes are kahunacart-section and kahunacart-section--{layout} on each block, kahunacart-section-title on the heading, then kahunacart-hero-* and kahunacart-compact-* inside the two layouts that have markup of their own. The configured column count is written onto the block as --kahunacart-section-columns. Theming lists them all.
Styling
KahunaCart ships assets/kahunacart.css, enqueued automatically on its own routes and nowhere else. It styles the kahunacart-* classes the shipped templates carry: .kahunacart-product-grid, .kahunacart-product-card, .kahunacart-cart-table, .kahunacart-totals, .kahunacart-adjustment--{type}, .kahunacart-denied--{reason} and the rest. You need write no CSS to have a usable shop.
Customizing with custom properties
The stylesheet is built on CSS custom properties, so a theme integrates by defining a handful of variables rather than writing rules. Internal --kc-* tokens resolve through a chain: your --kahunacart-* overrides first, then the theme's own --grav-* tokens, then --theme-accent, then neutral fallbacks. Set the --kahunacart-* properties in your theme's CSS.
:root {
--kahunacart-accent: #0f766e;
--kahunacart-radius: 4px;
}
The full variable reference lives in Theming.
Turn off the shipped stylesheet
- Set
builtin_csstofalse.
Nothing is enqueued, and the kahunacart-* classes are yours to style from scratch. Use this setting when you have replaced every template with your own markup.
Note
The email templates carry no English of their own. Every string goes through |t against a PLUGIN_KAHUNACART.EMAIL_* key, and subjects are translated from PHP with the order number as a positional %s. The emails.abandoned_subject config key still wins outright when it is set. See Emails.
Twig functions
These are available on every page, not just plugin-injected ones.
| Function | Returns |
|---|---|
kahunacart_products(limit = 50, offset = 0) |
Published products in the store's listing order, each with default_variant, image, sale, position and featured. |
kahunacart_products({...}) |
The same rows, narrowed by an options map. See The products options map. |
kahunacart_sections() |
The store's sectioned listing, resolved. See kahunacart_sections(). |
kahunacart_product(slug) |
One published product or null, with variants (each with option_value_ids and option_label), default_variant, options, options_payload, images, image. |
kahunacart_categories() |
The nested category tree. Each node has children, product_count, subtree_count. Empty when there are none. |
kahunacart_category_image(category, width = 160, height = null) |
A category's picture at the size you ask for, as {url, alt, width, height}, or null when it has none. See kahunacart_category_image(). |
kahunacart_tags() |
Every tag, with translated label, slug, product count and filtered listing URL. Empty when there are none. |
kahunacart_attributes(product) |
The product's answered attributes as {slug, label, type, value, display, unit, group, options, filter_url, values} in definition order, or []. See Theming. |
kahunacart_attribute(product, slug) |
One attribute's display string, or null when the product does not answer it. |
kahunacart_attribute_url(product, slug) |
The listing narrowed to that attribute's value, or null when the attribute is not filterable, the product does not answer it, or the value cannot be named in a URL. |
kahunacart_product_images(slugOrId) |
A product's pictures in gallery order, each {id, path, url, alt, position}. |
kahunacart_image(image, width = 600, height = null) |
One image as {url, alt, width, height}, or null for null input. |
kahunacart_search_url(query = null) |
{base}/search, or that result page when you pass a term. |
kahunacart_price(minor, currency = null, with = null) |
Minor units as money in the store currency, localized. with names the prices shown beside this one, so a pair is printed alike when Whole Amounts is on. Also a filter. See Whole amounts. |
kahunacart_price_set(vars, units = null) |
Every amount a cart or order summary prints, as one list for the with of kahunacart_price(). See Whole amounts. |
kahunacart_whole_amounts() |
Whether the store prints whole amounts without their cents (storefront.whole_amounts). |
kahunacart_cart_word() |
The storefront's word for the cart: cart, bag or basket (storefront.cart_word). |
kahunacart_cart_count() |
Total units in the current session's cart, or 0. |
kahunacart_store_status() |
Whether the store is open and whether this visitor may buy: {status, open, buying, preview, noindex, message, custom}. See Theming → The store status. |
kahunacart_quantity_limits(product, variant = null) |
How many of one thing this shopper may buy, ready for a number input: {min, max, step, sold_individually, hint}. See The quantity input. |
kahunacart_stock(product, variant = null) |
A product's stock as a card or a buy button draws it: {tracked, qty, sold_out, backorder, low, preorder, in_stock, digital, reason, label}. See Stock on a card or a buy button. |
kahunacart_set_price(product) |
What a grouped product or a kit costs in the visitor's currency: {parts_minor, price_minor, saving_minor, sells_as_set}, or null for any other product. See The set price of a grouped product. |
kahunacart_quick_add(product) |
The variant a product card's Add button can post, or null when the card should link to the product. See kahunacart_quick_add(). |
kahunacart_preorder(product) |
Whether a product is a pre-order, and when it comes out: {active, release_at, date_label, short_label, date, short_date}, or an empty array. See kahunacart_preorder(). |
kahunacart_date(at, form) |
A date the way the store's language writes one, in the store's timezone. See kahunacart_date(). |
kahunacart_releases(product, limit) |
A product's published releases newest first, or the latest across the store. See kahunacart_releases(). |
kahunacart_tax_note(product, variant = null) |
The small "incl. VAT" or "excl. VAT" line for under a price, or '' when tax.price_note is off or checkout would charge no tax. See Saying whether a price includes tax. |
kahunacart_paywall(page) |
What this visitor gets of a page with a paywall: header: locked, reader or metered, the teaser and the subscribe offers. Never the body. See kahunacart_paywall(page). |
kahunacart_base_route() |
The configured base route, leading slash, no trailing one. |
kahunacart_account_url() |
The customer account route, {base}/account. |
kahunacart_account_profile_url() |
The password and two-factor page, {base}/account/{account.profile_slug}, or '' when there is no page to link. Never build this route by hand: the slug is configurable. |
kahunacart_customer() |
The visitor's customer record as an array, or null. |
kahunacart_avatar(size = 64) |
The signed-in customer's picture at that pixel size. Always a usable URL: their own picture, otherwise a generated initials circle. account.gravatar (off by default) decides whether the fallback may come from gravatar.com. |
kahunacart_buy_button_fields(product, variants) |
The form fields other plugins want inside this product's add-to-cart form, already rendered. Emit with \|raw. Empty on a store where nothing listens. |
kahunacart_cart_line_notes(order, items) |
What other plugins need the customer to read beside a cart or receipt line, keyed by line item id. A note may carry a link, so print it with \|kahunacart_message, which does the escaping. Empty on a store where nothing listens. |
kahunacart_login_url() |
The Login plugin's sign-in route, or '' when Login is off. |
kahunacart_sign_in_url(return = null) |
A sign-in link that brings the shopper back to a storefront page afterwards: this page, or the one you name. '' when Login is off. See Messages with a link in them. |
kahunacart_logout_url() |
The Login plugin's logout task on this page with its nonce, or ''. |
kahunacart_countries() |
Every ISO 3166-1 alpha-2 code with its English name, ordered by name. |
kahunacart_default_country() |
The code checkout.default_country names, or ''. |
\|kahunacart_markdown |
Merchant Markdown as HTML, raw markup escaped and link targets defused. |
\|kahunacart_message |
A storefront sentence as HTML: everything escaped, then its links and bold drawn. For cart line notes, refusals and offers. |
\|kahunacart_message_text |
The same sentence with the formatting taken out, for an attribute or anywhere a link cannot go. |
Hand a product list to the shipped grid and it draws the pictures, prices and empty states for you:
{% include 'partials/kahunacart-product-grid.html.twig' with {products: kahunacart_products(8)} %}
Use subtree_count rather than product_count in navigation: on a catalog where every product sits on a leaf, a department's direct count is 0. For ready-made markup, partials/kahunacart-categories.html.twig is the flat row of chips. partials/kahunacart-catalog-nav.html.twig is the full sidebar, but it reads the catalog_nav variable the plugin's own pages build, so include it from a storefront template rather than an arbitrary page.
Given both dimensions, kahunacart_image() crops the picture to fill that box. Given a width alone it scales to fit, and an image narrower than that width passes through untouched. Null in, null out:
{% set hero = kahunacart_image(product.image, 1200, 500) %}
{% if hero %}
<img src="{{ hero.url }}" alt="{{ hero.alt }}" width="{{ hero.width }}" height="{{ hero.height }}">
{% endif %}
{{ kahunacart_price(1999) }} {# $19.99 #}
{{ item.total_minor|kahunacart_price }}
{{ kahunacart_price(order.total_minor, order.currency) }}
Important
Always pass the order's own currency when you have one. An order stores its currency at creation and does not follow later config changes.
kahunacart_customer() returns null for both guests and logged-in accounts that have never bought anything. It requires the Login plugin, which also owns the sign-in and logout routes:
{% set customer = kahunacart_customer() %}
{% if customer %}
<a href="{{ kahunacart_account_url() }}">Your account</a>
<a href="{{ kahunacart_logout_url() }}">Sign out</a>
{% elseif kahunacart_login_url() %}
<a href="{{ kahunacart_login_url() }}">Sign in</a>
{% endif %}
Use |kahunacart_markdown rather than Grav's markdown filter for anything from the commerce database: a product description, a category description, the offline payment instructions, the invoice footer. Grav's own filter passes raw HTML straight through. See Products and catalog.
<div class="kahunacart-description">{{ product.description|kahunacart_markdown }}</div>
Every function here reads the commerce database. If one throws, it logs why and returns the emptiest value it can: no products, a count of zero, an empty string. With Grav's debugger enabled the exception is rethrown instead.
Note
There is also a kahunacart_base Twig variable, but it exists only on plugin-injected pages. Use kahunacart_base_route() anywhere else.
kahunacart_paywall(page)
What this visitor gets of a page, for a theme or a listing template that wants to decide for itself. It never returns the body, and it never counts a free read.
{% set pw = kahunacart_paywall(child) %}
{% if pw.locked %}
{{ pw.teaser|raw }}
{% else %}
{{ child.content|raw }}
{% endif %}
| Key | What it holds |
|---|---|
paywalled |
Whether the page has a paywall: header at all. |
locked, reader, metered |
Which of the three this visitor is: shown the teaser, allowed by a group they hold (or by being an editor), or reading on a free read. All false on a page that isn't paywalled. |
reason |
Why a locked visitor is locked, when the meter decided it: used_up, accounts_only, crawler, or empty. |
groups |
The group codes the page names that the store has. |
teaser |
The free part, as HTML. |
offers |
Rows of label, price_html (already escaped), url (with ?from= added) and group, from the add-ons. Empty unless the visitor is locked. |
subscribe_url |
The page's own link or the store's fallback. |
sign_in_url, register_url |
The sign-in link, and the create-account link when the meter is for accounts only. |
signed_in |
Whether the visitor is signed in. |
meter |
free, left and unit when the meter applies, else null. |
title, url |
The page's. |
If the call fails, it answers as a locked page with no teaser, because the one wrong answer is the body. See Customer accounts → Articles with a teaser.
Stock on a card or a buy button
kahunacart_stock(product, variant = null) answers {tracked, qty, sold_out, backorder, low, preorder, in_stock, digital, reason, label}, so a theme never works out the sold-out rule for itself:
{% set stock = kahunacart_stock(product) %}
{% if stock.sold_out %}
<span class="badge badge--{{ stock.reason }}">{{ stock.label }}</span>
{% elseif stock.backorder %}
<span class="badge badge--backorder">{{ stock.label }}</span>
{% elseif stock.low %}
<span class="badge">Only {{ stock.qty }} left</span>
{% elseif stock.in_stock %}
<span class="badge">In stock</span>
{% endif %}
sold_outis tracked stock at zero with backorders off.labelis the words for it, already translated and already picked for the product: "Out of stock" for one that ships, and for one that does not, whatever the store set understock.digital_sold_out("Not available" by default, "Coming soon", or the merchant's own words).reasonis the same thing as a code for a class name:out_of_stock,not_available,coming_soonorcustom. Bothlabelandreasonare empty while the product is in stock.backorderis tracked stock at zero or below with backorders allowed. The product can still be bought, sosold_outis false.reasonisbackorder, andlabelis the variant's own Backorder note, else the store'sstock.backorder_text, else "On backorder". Check it before printing "In stock", which a tracked variant at zero is not.qtyisnullfor a variant nobody counts.lowuses the store's low-stock threshold,notifications.low_stock_threshold, the same number the dashboard alert counts with.in_stockanswers "should a stock line say In stock": the variant is tracked, has some left, is not low, is not on backorder and its product is not a pre-order.preorderis true while the product's release date is ahead.kahunacart_preorder()has the date. The count itself (tracked,qty) does not change for a pre-order.
A pre-order is not in stock, it is not out yet. A record on pre-order with 112 copies on the shelf has no In stock line, while "Only 9 left" and sold out print as they do for anything else. A theme that printed In stock for every tracked variant (stock.tracked) should test stock.in_stock instead.
The variant defaults to the product's default_variant. Pass one to ask about another. Print label rather than a string of your own, so a download never says "Out of stock". See When a download runs out and Telling the shopper it is on backorder.
Note
A theme written before 1.3.3 gets no backorder key, and stock.backorder|default(false) reads as not on backorder. Since 1.3.3, label and reason are filled in for a backorder too, so a theme that took a non-empty label to mean sold out should check sold_out instead.
A line on backorder
The items handed to the cart, checkout, receipt, pay page and confirmation email templates each carry a backorder key. It is null when the shelf covers the line, and otherwise {qty, of, label, text}. text is the sentence to print, such as "2 of 3 on backorder · Expected to ship late October", or just the label when the whole line is past the shelf.
A cart's lines are worked out from the stock as it is now. A placed order's lines come from what was frozen onto the line when it was placed, so they do not change when the stock arrives. A placed line whose quantity was changed afterwards gets the label as its text, since the count no longer fits it.
{% if item.backorder|default(null) %}<small class="kahunacart-line-backorder">{{ item.backorder.text }}</small>{% endif %}
Saying whether a price includes tax
kahunacart_tax_note(product, variant = null) answers the small line under a price: "incl. VAT", "excl. VAT", or "incl. VAT, plus shipping". It is empty unless Say whether prices include tax (tax.price_note) is on, and empty on a store that charges no tax, so it is safe beside any price.
{{ kahunacart_price(variant.price_minor) }}
{% set note = kahunacart_tax_note(product) %}
{% if note %}<small class="kahunacart-tax-note">{{ note }}</small>{% endif %}
It gives the answer checkout would give, for the place the shopper is known to be: the cart's address when checkout has one, the country in the cart's estimate box when it does not, and otherwise the store's default country (checkout.default_country). The name of the tax is the store's own: the matched zone's rate for the product's tax class ("VAT", "MwSt.", "GST"), or the flat tax.label, or the word "tax" when the store has not named it. The note belongs to the product, not the variant, because whether a price includes tax is set per product. variant is there so a bare variant can find its product. See Tax → Saying so under the price.
The shipped buy button, product grid and hero section cards print it through partials/kahunacart-tax-note.html.twig, which is also the easiest way for a theme to print it. A theme that also has to run on a plugin older than 1.3.4 includes the partial with ignore missing, and gets nothing instead of an unknown-function error:
{% include 'partials/kahunacart-tax-note.html.twig' ignore missing with {product: product, class: 'my-price-note'} only %}
The products options map
Pass a map instead of a limit to ask for a particular row of products rather than the top of the catalog:
{% set related = kahunacart_products({
tag: 'add-on',
category: 'apparel',
sort: 'manual',
featured: true,
featured_first: true,
limit: 8,
offset: 0,
exclude: [product.id]
}) %}
| Key | Takes | Effect |
|---|---|---|
tag |
A slug or a list of them | Products carrying any of those tags. |
category |
A slug or a list of them | Products in any of those categories, subtrees included: category: 'clothing' gets what is filed on its leaves, exactly as clicking it on the shop does. |
sort |
One of the six sort words | Defaults to the store's catalog.sort, so a theme that says nothing follows the shop. |
featured |
true, false or nothing |
true narrows to the featured products alone, false to everything else. Leaving it out asks for the whole shop. |
featured_first |
true or false |
Whether featured products lead. Defaults to the store's catalog.featured_first. |
limit |
A number | How many, capped at 500. Defaults to 50. |
offset |
A number | How many to skip. Defaults to 0. |
exclude |
A list of ids, slugs, or a mix | Products to leave out, which is how a related-products grid avoids repeating the product it sits beside. Excluded inside the query, so a grid asking for four still gets four. |
Values inside one key are ORed and the keys are ANDed, the same rule the storefront's own facets follow: the example above means "in apparel, tagged add-on". A key left out applies no facet.
A slug naming a tag or category that no longer exists contributes nothing rather than emptying the grid, the same way a stale tag= in a URL does.
kahunacart_sections()
The store's sectioned listing, resolved: the blocks catalog.sections declares, each already holding its products, for a theme that wants to draw them its own way rather than through the shipped partials.
{% for section in kahunacart_sections() %}
{% if section.title %}<h2 id="{{ section.anchor }}">{{ section.title|t }}</h2>{% endif %}
{% include 'partials/kahunacart-product-grid.html.twig' with {products: section.products} %}
{% endfor %}
Each block is {title, layout, columns, anchor, products}, and every row in products carries what kahunacart_products() returns. It is empty on a store that has declared no sections, and blocks that resolved to nothing are dropped.
This is the config resolved, not the page. The shop page collapses its blocks to one grid while a filter is on; a theme calling this from a home page gets the blocks either way.
kahunacart_category_image()
kahunacart_category_image(category, width = 160, height = null) draws a category's picture at the size you ask for, as {url, alt, width, height}, or null when the category has none. With a height it is cropped to that box. Without one it is fitted to the width.
{% for category in kahunacart_categories() %}
{% set picture = kahunacart_category_image(category, 120, 120) %}
{% if picture %}<img src="{{ picture.url }}" alt="{{ picture.alt }}" width="60" height="60">{% endif %}
{% endfor %}
It takes a row from kahunacart_categories() or a node of the listing's own tree (catalog_nav.tree), which carries the id but not the picture. A row of category tiles over the shop can be drawn from the tree with its counts and its current category.
Whole amounts
On a store with storefront.whole_amounts on, kahunacart_price() prints a whole amount without its cents ($55). with names the prices shown beside this one, as one amount or a list, so a pair is printed alike: if any of them has cents, all keep them.
<s>{{ kahunacart_price(offer.regular_minor, with: offer.sale_minor) }}</s>
<span>{{ kahunacart_price(offer.sale_minor, with: offer.regular_minor) }}</span>
The amounts of one cart or order summary are one set. kahunacart_price_set(vars, units = null) builds the list for the with of kahunacart_price(), so the summary is printed alike: if any amount in it has cents all of them keep them, and if every one is whole none show cents. The cart, checkout, pay and receipt pages already carry the list as price_set, and the plugin's partials pass it. Call the function in a theme that draws a summary of its own where the page has none, a mini cart in the header for one.
{% set mini = kahunacart_price_set({order: order, items: items, adjustments: adjustments}) %}
{{ kahunacart_price(item.total_minor, order.currency, mini) }}
vars may hold order, items, cart_rows, adjustments or summary_adjustments, provider_charge, tax_at_payment and deposit. Whatever is missing is skipped. units says whether each line's own price is printed beside its total, which is true by default when cart_rows is given.
kahunacart_whole_amounts() answers the setting, for a theme that formats a number some other way. kahunacart_price() already follows it. An invoice, credit note or report always prints full amounts, whatever the setting. See Theming → Whole amounts.
kahunacart_cart_word()
Answers the storefront's word for the cart, cart, bag or basket (storefront.cart_word), for a theme that picks an icon or a class by it. KahunaCart's own strings already follow it. See Theming → The word for the cart.
kahunacart_quick_add()
kahunacart_quick_add(product) answers the variant a product card's Add button can post, or null when the card should link to the product instead.
{% set add = kahunacart_quick_add(product) %}
{% if add %}
<form method="post" action="{{ kahunacart_url('/cart') }}">
{{ nonce_field('kahunacart', 'nonce')|raw }}
<input type="hidden" name="action" value="add">
<input type="hidden" name="variant_id" value="{{ add }}">
<input type="hidden" name="qty" value="1">
<button type="submit">Add</button>
</form>
{% else %}
<a href="{{ kahunacart_url('/product/' ~ product.slug) }}">Choose</a>
{% endif %}
Only a product whose buy button would post nothing but the variant and a quantity of one gets an id. That means:
- one variant, in stock
- no add-on options
- not pay what you want, not a set, not an external link
- nothing an add-on draws into the buy form (a booking's dates, a subscription's plan)
- a price in the shopper's currency, open to this shopper
- no minimum above one
Everything else has a choice to make on its own page. It is the same answer the cart's cross-sells carry as quick_add, and a row that already carries one is answered from it. It is answered once per product per page.
kahunacart_preorder()
kahunacart_preorder(product) says whether a product is a pre-order, and how to say when it comes out.
{% set pre = kahunacart_preorder(product) %}
{% if pre.active %}<span class="badge">{{ 'PLUGIN_KAHUNACART.PREORDER'|t }}</span> <span>{{ pre.date_label }}</span>{% endif %}
While the product's release_at is ahead, it answers {active: true, release_at, date_label, short_label, date, short_date}:
release_atis epoch seconds.date_labelis "Out Friday, November 13", what the shipped buy button prints beside itself.short_labelis "Pre-order · out Nov 13", what a cart line says.dateandshort_dateare the two dates on their own.
Dates are written in the store's timezone and in the language being browsed ("Freitag, 13. November" in German), with the year when the release is not this year's. The answer is an empty array for an ordinary product, one already out, or anything that is not a product row, and never a throw. A row that does not carry the column is read from the table by its id. The words are PREORDER, PREORDER_OUT, PREORDER_SHORT and PREORDER_SOON in the language file.
A placed order's items (the receipt, the pay page, the confirmation email, the admin) each carry a preorder key. It is null for a line that was not a pre-order, otherwise {release_at, label, text, current_release_at, released}. release_at, label and text ("Pre-order · out Nov 13") were frozen when the order was placed. current_release_at is the product's date now, and released says whether it is out. A cart's lines carry null and say it through kahunacart_cart_line_notes() instead.
{% if item.preorder|default(null) %}<small class="kahunacart-line-preorder">{{ item.preorder.text }}</small>{% endif %}
The downloads a page or an email lists carry soon: "Soon · Nov 13" for a pre-order's file that is not out yet, empty otherwise. Print it in place of the link, which is refused until then.
kahunacart_date()
kahunacart_date(at, form) writes a date the way the store's language does, in the store's timezone: "November 19, 2026" on a US English store, "19 November 2026" on a British one, "19. November 2026" on a German one.
{{ kahunacart_date(kc_rr.return_closes_at) }} {# November 19, 2026 #}
{{ kahunacart_date(kc_rr.request.created_at, 'day') }} {# November 19 #}
at is epoch seconds, a date string or a DateTime. form is long (the default: day, month and year) or day (day and month). The order comes from ICU for the store's locale, the same way pre-order dates are written. Without the intl extension it falls back to the US English order. It answers an empty string for nothing at all, and never throws.
Everything a shopper reads a date in goes through it: the return and refund request panels, review dates, the account's order dates, a shipment's "Sent on", a download's expiry, a deposit's balance due date, the pay page and the quote, pay-link, balance-request, payment-failed, gift-card and set-password emails. Invoices, credit notes, packing slips, the till receipt and reports keep the year-month-day form, since they are records rather than sentences. An email template that a theme overrides needs the function too. It is registered with the rest.
kahunacart_releases()
kahunacart_releases(product, limit) lists a product's releases, newest first, or the latest across the store: a changelog, a "what's new" strip or a ticker of what shipped lately.
{% for release in kahunacart_releases('lathe', 5) %}
<article>
<h3>{{ release.product.title }} {{ release.version }}{% if release.latest %} <small>Latest</small>{% endif %}</h3>
<time datetime="{{ release.date_raw|date('Y-m-d') }}">{{ release.date }}</time>
{{ release.notes_html|raw }}
<ul>
{% for file in release.files %}<li>{{ file.label }} {% if file.size_label %}({{ file.size_label }}){% endif %}</li>{% endfor %}
</ul>
</article>
{% endfor %}
With a product slug it lists that product's published releases. With null it lists the newest releases across every product a shopper can see, which is the listing's own rule: published, listed and open to the shopper's customer group. A named product is looked up by its address, so an unlisted product answers when its slug is given. A draft release is never listed, whatever the product. limit is 6 when left out and at most 100.
Each release is {product: {slug, title, url}, version, date, date_raw, notes_html, latest, files}:
| Key | Holds |
|---|---|
date |
The day written in the store's language (Grav's month names, in the order the DATE_LONG string gives) |
date_raw |
The Unix timestamp |
notes_html |
The release notes through the same hardened Markdown a product description gets, or an empty string. Safe to print |
latest |
True on each product's newest published release, even when the limit cut it off from the list |
files |
The release's own builds as {label, name, size, size_label}: the title or else the file name, the file name, bytes, and a readable size that is empty when none was recorded |
Files are facts, never links: a file belongs to its buyer, and the downloads page is where it is handed over. Anything it cannot answer is an empty list: a slug that names nothing the shopper can see, an empty or non-text slug, a limit of nothing, a store with no releases. See Product releases.
Caching
Storefront responses declare what a cache may do with them. Without that, Grav's default system.pages.expires: 604800 would put seven days of Cache-Control: max-age=604800 on every page, session pages included.
| Page | Header |
|---|---|
| Cart, checkout, payment return, receipts, invoices, the pay page, the download denial page, the expired-link page, the whole account area | private, no-store, max-age=0 |
| Catalog pages (listing, category, product, search) for a visitor with a cart or a login | private, no-cache, max-age=0 |
| Catalog pages for an anonymous visitor with no cart | Whatever system.pages.expires says |
Downloaded files carry the same private headers, because a purchased file's URL carries the token that proves it is that customer's. The session pages also ship noindex, nofollow.
A catalog page becomes personal as soon as the visitor has a cart, because a theme's header draws a basket count. It then stays cacheable by that visitor's browser and never by a shared cache, so a store behind a CDN keeps its catalog cache for anonymous visitors.
That middle row is also what keeps member-only products out of a shared cache. A member is signed in, so their catalog pages are already private, no-cache and never reach a CDN, and the only pages a shared cache may keep are the ones every anonymous visitor sees alike. The caches the plugin keeps itself — related-products lists, the sitemap, facet counts — are keyed on the viewer's group as well. A store that restricts no product contributes a constant to every one of those keys and keeps the hit rate it has today.
While the store is coming soon or paused, every page on the site that would otherwise be cacheable goes out private, no-cache, max-age=0, so no shared cache keeps a page with the banner on it; the session pages keep their no-store. An open store goes back to the table. Purge your CDN after you change the status. See Configuration → Store status.
On a store that sells in more than one currency the last row changes: a catalogue page is public only when its address alone decides its currency, and private otherwise. See Caching and CDNs.
Add a buy button to your own page
-
Drop the partial into any template, blog post layout or landing page:
TWIG{% include 'partials/kahunacart-buy-button.html.twig' with {product: kahunacart_product('my-widget')} %}
The page now has a working add-to-cart form. kahunacart_product() attaches everything the partial reads: a <select> when there is more than one variant, a hidden field when there is one, a price, a quantity input and a submit.
A product with option axes also gets the picker: dropdowns, button pills or colour swatches, one per axis. It ships hidden and is revealed by assets/kahunacart-options.js, which writes the resolved variant into the same <select> the form posts. With scripting off the page keeps the plain select.
Important
Outside KahunaCart's own routes, nothing enqueues kahunacart-options.js for you. See Theming.
The form posts to {base}/cart:
| Field | Value |
|---|---|
nonce |
From nonce_field('kahunacart', 'nonce') — required on every storefront POST |
action |
add |
variant_id |
The chosen variant |
qty |
Within the product's quantity rules |
line_data[pay_amount] |
Only on a pay-what-you-want product: the amount the shopper named, as a decimal. See The price box |
For your own markup, copy those four fields. The quantity is judged server-side against the rules that apply to the product whatever the form sends, and a quantity outside them is refused by name rather than silently clamped. Adding the same variant twice bumps the existing line's quantity rather than creating a second line.
The quantity input
The quantity input on the product page, in the cart and on a wishlist carries min, max and step from the limits that actually apply to it: the variant's own where it sets them, the product's where it does not, the product's categories under that, and cart.quantity_min_default and cart.quantity_max_default under all three. The box starts at the smallest allowed quantity rather than at 1, so a product sold in sixes opens on six.
Beside the box is a sentence saying the rule in words: "Sold in packs of 6", "Limit 2 per order", "Between 6 and 12 per order". It is empty on the overwhelming majority of products, which set no limits at all.
The maximum is also what is on the shelf. A variant that tracks stock and refuses backorders cannot sell more than it has, so the box's maximum comes down to the stock on hand, rounded to the step like every other number. A variant that allows backorders is one the merchant has said may be sold below zero, and the storefront does not overrule that.
Switching variant re-resolves all of it. The buy button writes every variant's limits onto the input as a JSON map, and assets/kahunacart-options.js moves min, max, step, the hint and the current value onto the resolved variant's answer when a shopper picks a size. Nothing is recomputed in the browser. A browser with no JavaScript keeps the default variant's attributes, which are never wrong, only sometimes wider than the size now selected.
A quantity outside the rules is refused rather than clamped, and the message names the product and the limit: "You may buy at most 2 of Sandbar Boardshort per order." A quantity off the step says so by name too: "Session IPA is sold in multiples of 6."
The same limits are re-checked at checkout, over the whole basket, summed per product and with the category rules read the same way. A product with combine_variants off is checked per line instead, against each line's own variant. A basket already breaking a product's rule carries the sentence above the cart table, computed exactly as checkout computes it.
A theme building its own buy form asks the same question the shipped templates do:
{% set qty = kahunacart_quantity_limits(product, variant) %}
<input type="number" name="qty" value="{{ qty.min }}"
min="{{ qty.min }}" max="{{ qty.max }}" step="{{ qty.step }}">
{% if qty.hint %}<small>{{ qty.hint }}</small>{% endif %}
kahunacart_quantity_limits(product, variant = null) answers {min, max, step, sold_individually, hint}. The variant is optional and is what makes the box change when the shopper switches size. Where you have ids rather than rows — a cart line, a wishlist row — pass those instead: kahunacart_quantity_limits(item.product_id, item.variant_id) looks both up, which is two indexed reads a cart page can afford and a listing grid should not.
See Quantity rules for the columns behind it.
The price box
A pay-what-you-want product gets a Name your price box on the buy button, with the minimum under it. The box posts line_data[pay_amount], a decimal in the store's currency. Currency symbols, spaces and thousands separators are ignored, and 12,50 reads as 12.50. The cart checks the amount against the minimum and maximum whatever the box says, so the box is a convenience, not the rule.
The buy button writes every variant's terms onto the box as data-kc-pwyw-terms, and assets/kahunacart-options.js moves the hint to the resolved variant's minimum when the shopper picks a size. The same amount again bumps the line's quantity; a different amount replaces the line.
A theme drawing its own buy box asks the same question the shipped partial does:
{% set pwyw = kahunacart_pay_what_you_want(product, variants) %}
{% if pwyw %}
<input type="text" inputmode="decimal" name="line_data[{{ pwyw.field }}]" value="{{ pwyw.suggested }}">
<small>{{ pwyw.min_minor > 0 ? 'PLUGIN_KAHUNACART.PWYW_MINIMUM'|t(pwyw.min_html) : 'PLUGIN_KAHUNACART.PWYW_FREE'|t }}</small>
{% endif %}
kahunacart_pay_what_you_want(product, variants = null) answers null for a product sold at its price. Otherwise it answers the default variant's min_minor, suggested_minor and max_minor (null for no maximum), the same three as decimals (min, suggested, max), min_html and max_html formatted in the shopper's locale, field (pay_amount), and variants, the same terms for every variant by id. The words are translated strings: PLUGIN_KAHUNACART.PWYW_LABEL, PWYW_MINIMUM, PWYW_FREE, PWYW_INVALID, PWYW_TOO_LOW and PWYW_TOO_HIGH.
The cart
{base}/cart renders line items with quantity inputs, a coupon form and the totals partial. Under them it can offer the cross-sells of what is in the basket as Goes well with your order. See Products and catalog → Cross-sells in the cart.
A cart line's identity is the variant it names plus a canonical hash of its line_data, stored on the line as kahunacart_order_items.line_key. Two adds carrying different line_data are two distinct lines; two adds carrying the same line_data merge and bump the quantity. For the events and the buy-form fields behind this, see the Provider developer guide.
The update form posts action=update with qty[{item_id}] per row; a quantity of 0 removes the line. Removal is a separate one-field form per item, posting action=remove with item_id.
Set rows in the cart
When a grouped product's buy button adds its parts, the cart page draws them as a set: one row for the product with its parts under it. See Kits and grouped products → In the cart for what a shopper sees. A theme that overrides kahunacart-cart.html.twig loops over cart_rows instead of items. These are the cart's lines in the order to draw them. Each has a type of set or line.
A set row is the product's own row, and everything it needs is in its set object:
| Key | What it holds |
|---|---|
product_id |
The grouped product |
key |
The set's identity. It's the product id for a set with no picked parts (54), and the product id followed by the picked variant ids for one that has them (54-311), so a Walnut easel set and a Birch easel set are two rows |
title, url |
The product's name and address |
picked |
The names of the variants the shopper picked, for a line under the title. Empty for a set with no picks |
image |
The product's picture as {url, width, height}, or null |
sets |
How many whole sets the cart holds. 0 means a part has been turned below one set, and the row should say "Not a full set" and leave out the quantity box |
unit_price_minor |
What one set costs at the parts' own prices |
total_minor |
What all its lines add up to, before any bundle price |
expansion |
children for a grouped product, kit for a kit |
locked |
True when the parts can only change through the set |
item_ids |
The ids of the cart lines in the set |
A line row holds a cart line in item. When it's a part of a set, set names that set, and when it's something bought on its own set is null. Draw a part's quantity as plain text and leave out its remove button when its set.locked is true.
The set's controls go inside the cart's update form, with the key in the field name:
| Field | What it posts |
|---|---|
set_qty[<key>] |
How many sets the shopper wants. 0 removes the set |
set_was[<key>] |
What the box said when the page was drawn, as a hidden field, so only a box the shopper changed is acted on |
The × on the set's row is a form of its own that posts action=remove_set and set=<key>. A bare product id, which is what a theme written before sets could be picked posted, still finds the set of that product when the cart holds only one. Each cart line also has an image, the picture its variant shows on the product page cropped to a 120-pixel square, or null when the product has no pictures. The shipped template is the example to copy.
The coupon form posts action=coupon. A code field adds a code, a remove field naming a code takes that one off and leaves the rest standing, and an empty code with no remove clears every code on the cart.
The coupon partial is handed coupon_codes (every code on the cart, in the order they were added), coupon_code (the first of them) and coupon_max_codes (the config key coupons.max_codes), so it can list the applied codes with a Remove beside each and hide the input once the cart is full. See Coupons for how several codes interact.
Important
Every POST under {base} requires a valid nonce. A stale one flashes "Session expired — please try again." and redirects to the cart without doing anything.
Order rules on the cart
A store can also say what the whole order has to be: cart.order_min_qty, cart.order_max_qty, cart.order_min_total and cart.order_max_total. All four are off as the plugin ships. See Configuration.
The two value rules measure the merchandise subtotal after discounts, before shipping and tax. A £50 minimum reached by paying for postage is not a £50 minimum, and a coupon that takes a £55 basket to £45 puts it back under one.
A basket outside the rules prints the sentence at the top of the cart, and the checkout button stays where it was: a disabled button with no explanation is the thing shoppers write in about. A shopper who walks past it is refused at checkout with the same words, and POST {base}/api/checkout refuses with that sentence under code: "validation".
A product marked Exclude from order rules counts for nothing in any of the four, and a basket of nothing but excluded lines is refused nothing at all.
Bundle suggestions
A shopper who puts "Photos" and "Video only" in the basket separately pays $298 for what "Photos + Video" sells for $249, notices afterwards, and asks for a refund. Turn on Settings → Cart → Bundle Suggestions (cart.bundle_suggestions) and the cart says so first:
Photos and Video only come to $298.00 here. Photos + Video is $249.00 and includes both.
Swap to the bundle and Keep them separate sit under it. The same notice appears at the top of checkout for a shopper who went straight past the cart. It is off as the plugin ships.
The notice names things the way a shopper would. A product that sells one variant is named by its title ("Photos"), and a variant of a product that sells several is named by its own variant name ("Video only", "Photos + Video"). Cart lines keep their full titles.
A bundle is a variant with a Bundle of list. The notice appears when all of these hold:
- Every part is in the basket, at least one of each.
- The bundle can be bought: its product is published, this shopper may buy it, and there is stock for it. A bundle that is sold out or not on sale yet is never offered.
- It is cheaper for this shopper. Both sides are priced by the cart's own arithmetic, with price lists, sales and coupons, over the basket as it is and as it would be after the swap. The bundle has to cost less than the lines it replaces, and the basket as a whole has to come out lower too, so a swap that would lose a discount somewhere else is never suggested. Shipping and tax are left out.
What a line was bought with goes with it, when it is the same on every part. Lines carrying the same buy-form data make a set, and the bundle line takes that data over, so two add-ons both aimed at the same license become one bundle aimed at it. Lines aimed at different things are not one set and no offer is made for them. A line with priced add-on options, a price an add-on set, or a free gift is a particular purchase of that one variant and is never swapped away. With higher quantities the swap takes as many whole sets as the basket holds and leaves the rest, so three Photos and two Video only become two bundles and one Photos. Two bundles that share a part are never offered at once; the one that saves more is.
Swap to the bundle checks the whole thing again rather than trusting the page, adds the bundle through the ordinary add (stock, quantity rules and every plugin's veto included), lowers the parts it replaces, and says "Swapped. Your basket now has Photos + Video instead." If anything refuses, the basket is left exactly as it was and the refusal is shown.
Keep them separate is remembered on the basket, so the notice does not come back on the next page or the next visit. It comes back only when the basket forms a new match: another whole set, or a part removed and added again. Changing a quantity that does not make another set leaves it hidden.
It works alongside a rule that lets a part be bought only with another, such as an add-on's Requires list in Licenses: that rule decides whether the basket may check out, and this notice says it would be cheaper as one.
Bundle suggestions in a theme
A theme includes one partial where it wants the notice. The shipped cart and checkout templates already do:
{% include 'partials/kahunacart-bundle-suggestions.html.twig' %}
{# at checkout, so both buttons come back there #}
{% include 'partials/kahunacart-bundle-suggestions.html.twig' with {bundle_return: 'checkout'} %}
It reads bundle_suggestions, which the cart and checkout pages are both handed. Each offer has message (the whole sentence, translated, with the store's money formatting), bundle_variant_id, bundle_product_id, bundle_title (the full line title), bundle_name (the short name the sentence uses), sets, parts (each {item_id, variant_id, title, name, qty}), separate_minor, bundle_minor, saving_minor, currency and signature. It is an empty list with the setting off.
The two buttons are forms posting to {base}/cart: action=bundle_swap or action=bundle_keep, each with bundle_variant_id and the nonce. Add return=checkout to come back to checkout rather than the cart. A request carrying HX-Request: true gets the notice's region (#kahunacart-bundle-suggestions) back on its own, so a theme can swap it in place; everything else is redirected to the page it came from.
The JSON API carries the same list on the cart and takes both answers. See Storefront JSON API → Bundle suggestions.
Messages with a link in them
The sentences the storefront says to a shopper may carry a very small piece of Markdown, and nothing else. That covers the notice after Add to cart, a refusal at the cart or the checkout, and a cart line note. It is how an add-on says "Sign in to the account that holds your license" and the shopper gets a link rather than brackets.
| Write | Get |
|---|---|
[Sign in](/shop/account?return=%2Fcart) |
a link |
**Video only** |
bold |
*Photos* |
emphasis |
\*, \[, \], \(, \), \\ |
the character itself |
Everything is escaped first, so a product called <script> prints as the word and a message can carry no HTML at all. A link is drawn only for a path on this site or an http/https URL. javascript:, data:, mailto: and //elsewhere print the link text without the link, and so does a link with nothing between the parentheses: [Sign in]() is just Sign in, which is what a sentence reads when kahunacart_sign_in_url() answers '' on a store without the Login plugin. A link to another site opens in a new tab with rel="noopener noreferrer nofollow".
Where each place gets it:
- Flash notices. KahunaCart's notices go into Grav's message queue as HTML that is already escaped, because that is what the queue holds. Grav's
partials/messages.html.twigprints it raw, and so do KahunaCart'spartials/kahunacart-messages.html.twigand the Kahuna themes. A theme that printedmessage.messageescaped should print it with|raw, as Grav does. - Templates. A sentence handed to a template, such as a cart line note, goes through
|kahunacart_message. Use|kahunacart_message_textfor atitleattribute or anywhere a link cannot go. - The JSON API.
detailandfieldsstay plain text, with the formatting taken out, so a client printing them as text never shows brackets. When there is a link or bold to draw, the same sentence also comes asdetail_html(andfields_html), escaped and safe to put in a page. See the error format. - The admin and emails. Plain text.
<small class="kahunacart-line-note">{{ note|kahunacart_message }}</small>
<button title="{{ note|kahunacart_message_text }}">…</button>
A sign-in link that comes back
kahunacart_sign_in_url(return = null) gives a sign-in link that brings the shopper back to a storefront page afterwards: this page when no page is named, or the one you name as a path under the shop ('/product/' ~ product.slug, '/cart') or as a whole kahunacart_url(). It is '' when the Login plugin is not enabled.
<a href="{{ kahunacart_sign_in_url('/product/' ~ product.slug) }}">Sign in</a>
The Login plugin takes no return address on its own URL, so the link goes through {base}/account?return=…. A guest is sent on to the Login plugin with the page remembered, and somebody already signed in goes straight to the page. Only a storefront page can be a return; anything else is dropped. In PHP it is KahunaCartFrontend::signInUrl($returnTo).
Writing a refusal in an add-on
An add-on that refuses a cart line through onKahunaCartCartLineValidate builds its sentence with Frontend\StorefrontMessage. Wrap every name you drop in with literal(), so a product title with a * or a [ in it reads as typed, and make links with link(). link() with an empty URL gives back the text alone, so the sentence still reads on a store without the Login plugin:
use Grav\Common\Grav;
use Grav\Plugin\KahunaCart\Frontend\KahunaCartFrontend;
use Grav\Plugin\KahunaCart\Frontend\StorefrontMessage;
use Grav\Plugin\KahunaCart\KahunaCart;
$frontend = new KahunaCartFrontend(Grav::instance(), KahunaCart::instance());
$signIn = StorefrontMessage::link('Sign in', $frontend->signInUrl('/product/' . $event['product']['slug']));
$event['errors'][] = sprintf(
'%s needs a license. %s to pick one of yours.',
StorefrontMessage::literal((string)$event['product']['title']),
$signIn
);
The JSON API answers that sentence as plain text in detail, with the HTML beside it in detail_html, and the admin reads it as plain text. See Events for the event itself.
Member-only products
A product can name one or more customer groups, and then only a shopper resolved into one of them may see or buy it. The concert case is the one it was built for: a Member ticket and a General admission ticket, two products at two prices, and the member one absent from the shop for everybody else.
Absent means absent. The listing predicate is applied everywhere visibility is — the shop page, category and tag pages, the counts beside them, the facet counts, search, related-products blocks, the catalog index and the sitemap — so there is no listing a restricted product turns up in by accident.
The product page still renders, because the person following that link is usually a member who has not signed in yet. Instead of the buy form the page shows:
<p class="kahunacart-buy-unavailable kahunacart-members-only">
This is for members only. <a href="/login">Sign in</a>
</p>
The two strings are PLUGIN_KAHUNACART.MEMBERS_ONLY and PLUGIN_KAHUNACART.MEMBERS_ONLY_SIGN_IN. The link is kahunacart_login_url() and is left out entirely when the Login plugin is not installed.
A theme that draws its own buy button reads product.members_only, a boolean set on the product row both by the plugin's own product route and by kahunacart_product(). See Theming.
If a shopper reaches the cart anyway — a hand-typed POST, a link somebody sent — add-to-cart refuses with PLUGIN_KAHUNACART.MEMBERS_ONLY_REFUSED, which names the product. Checkout asks again over every line before it starts a payment, because a group can change between the two.
Checkout
Checkout collects contact details, a billing address, an optional shipping method, a payment method and notes. From the customer's side:
- Click Checkout from the cart.
- Enter an email address and a name.
- Enter the billing address, choosing a country from the list.
- Leave Ship to the billing address ticked, or untick it and continue to enter a delivery address.
- Choose a shipping method, if the store offers any. See The two-step shipping flow.
- Choose a payment method and click Place order.
- Pay on the provider's hosted page, if the provider is a redirect one.
The customer lands on their receipt at {base}/order/{hash}.
Checkout field names
| Field | Notes |
|---|---|
nonce |
Required |
email |
Required, validated |
name |
The contact name, also written into the billing address |
billing_address1, billing_address2, billing_city, billing_region, billing_postcode, billing_country |
Country is an ISO 3166-1 alpha-2 code, submitted from a <select> and checked against the list server-side |
shipping_same |
Checkbox, checked by default. When set, the shipping address is a copy of billing. |
shipping_address1 … shipping_country |
Same set with a shipping_ prefix, used when shipping_same is unchecked |
shipping_method_id |
Radio, one per available method |
provider |
Radio, one per registered payment provider |
notes |
Free text, truncated to 2000 characters |
terms |
Only when checkout.require_terms is on |
save_address |
Checkbox, logged-in customers only, checked by default. See Customer accounts |
Both address prefixes also accept _name, _company and _phone. The stock template does not render company or the billing phone; the server handles them, so add them to your override if you need them. Every address field is truncated to 190 characters.
Checkout asks for a name once, at the top, and writes it into the billing address, and into the shipping address when that carries no name of its own. A logged-in customer's form arrives prefilled from their customer record and address book; see Customer accounts. The delivery-address fields appear once the customer unticks Ship to the billing address and continues, so the form works the same with scripting off.
The country field
Tax and shipping zones are matched on the ISO 3166-1 alpha-2 code, and a free-text country field fails that match silently. The field is a <select> of all 249 codes with their English names, and a code that is not on the list is refused with "Please choose a country from the list." The same applies to addresses saved in the account address book.
Set checkout.default_country to the code the select starts on, for example 'US'. Leave it empty and the customer chooses. Override partials/kahunacart-country-select.html.twig to restyle the field, translate the names, or ship the short list of countries you sell to. The Twig functions behind it are kahunacart_countries() and kahunacart_default_country().
What a refused submit keeps
Checkout bounces for ordinary reasons: a lapsed discount code, a shipping method not yet chosen, a mistyped email. Every field the customer filled in comes back on the form: contact details, both addresses, order notes, the payment method, the shipping method and the checkboxes. The values ride the redirect in the session, are read once, and are escaped on output. A customer who comes back to checkout later starts fresh.
Address validation
Address validation is off by default. With a provider and an API key of your own configured, checkout checks the delivery address against a third-party geocoder and offers a correction when one comes back, once per address. A service that is down, slow or out of quota is logged and waved through, so validation never blocks an order.
checkout:
address_validation:
provider: mapbox # none | mapbox | google
api_key: 'pk.…'
mode: suggest # suggest | off
The panel is partials/kahunacart-address-suggestion.html.twig, included at the top of the checkout form. See Address validation for the drivers, the outcome table, and what the order records.
The two-step shipping flow
If your store has shipping zones and the cart contains something physical, checkout becomes two steps, because shipping methods cannot be priced until an address is known.
- The customer fills in their address and submits. No method has been chosen, so the order is refused and they are redirected back with a message.
- Checkout re-renders. The zone now resolves and the real methods appear with their real prices. The submit button says Continue to shipping on the first pass and Place order once a method is selected.
- They pick a method and submit again.
The address is remembered between the two passes. A store with no shipping zones, or a digital-only cart, never sees this: the shipping fieldset is not rendered at all.
Blocking messages the customer may see:
- "Please enter your delivery address, including the country, so we can work out shipping." — no country on the address.
- "We are unable to ship to that address. Please check it, or contact us for help." — the address matches no shipping zone.
- "Please choose a shipping method." — methods exist, none selected.
What happens on submit
The order gets the customer's details, then shipping and tax are recalculated against the new addresses before anything is charged. If a coupon has stopped applying, the customer goes back to checkout with the reason. Then the chosen provider starts the payment, and one of three things happens:
- complete — the order is placed on the spot and the customer lands on their receipt. This is the offline method, and any provider that captures synchronously.
- redirect — the customer goes to the provider's hosted page. Their stock is held for the trip.
- failed — an error is flashed and they stay on checkout.
The confirmation page
{base}/order/{hash} is the receipt. The hash is 32 hex characters of randomness and is the customer's only handle on the order, so the page is linked from nowhere and needs no login.
It shows the order number, payment status, line items, adjustments and the total. Offline orders also get the payment instructions from offline.instructions, rendered as Markdown.
Digital orders also get a Your downloads section listing each live grant with its filename, remaining downloads (or "Unlimited downloads") and expiry date. Revoked grants are filtered out. See Digital products.
Tell your analytics about a sale
The receipt describes the sale that just happened, so a GA4, Google Tag Manager, Plausible or Meta snippet can report it without anyone writing Twig against the order row. There are two halves to it: a block of JSON in the page, and an event your snippet listens for.
The block is on the confirmation page, once, and looks like this:
<script type="application/json" data-kahunacart-purchase>
{
"order": {
"number": "1042",
"currency": "USD",
"total": "249.00",
"subtotal": "250.00",
"tax": "19.00",
"shipping": "0.00",
"discount": "20.00",
"coupons": ["EARLYBIRD"],
"provider": "stripe",
"status": "completed",
"completed_at": "2026-09-12T14:08:31Z"
},
"items": [
{
"product_id": 17,
"variant_id": 44,
"sku": "KC-BOARD-L",
"title": "Longboard — Large",
"variant_title": "Large",
"qty": 1,
"unit_price": "250.00",
"line_total": "250.00"
}
]
}
</script>
Every amount is a decimal string in the currency's major units — "249.00", never 24900 — because that is what every analytics tool takes, and a store that hands GA4 an integer in cents reports a hundred times its own revenue. A currency with no decimal part comes out as "2500" and a three-decimal one as "24.900"; the conversion goes through the same money rules everything else in the plugin uses, so you do not have to know which is which. See Money.
On a store that sells in more than one currency the event is in the currency the order was placed in: order.currency is that order's own code, and every amount in it is in that currency's major units, so a euro order reads "26.00" and a yen order "3900". A report that groups by currency is therefore right across the lot, and one that adds the totals up without looking at it is adding euro to yen.
tax is the tax on the sale, and on a tax-inclusive store that is the tax already sitting inside the prices rather than zero. discount is what came off, as a positive number. coupons lists each code once however many lines it was spread across. status is the status code, not the word a customer reads, so renaming a status on the Statuses screen does not change what your reports group by.
total is what the customer was charged, and the other figures describe that charge rather than add up to it — subtotal is the line totals before any adjustment, a payment-method fee is in the total without a field of its own, and on a tax-inclusive store the tax is inside subtotal as well as reported in tax. Report total as the revenue and treat the rest as the breakdown.
{base}/order/{hash} is a page somebody can bookmark and come back to, so the script stamps the order number in localStorage and adds one more field to what your snippet is handed:
repeat: false— the first time this browser has seen this receipt.repeat: true— a reload, a back button, a bookmark opened next week. Count the sale onfalseand you count it once.
A browser that cannot store anything — a private window, site data blocked — reports repeat: false every time. Counting one refresh twice is a smaller problem than never counting a sale at all.
The event fires on document once the page is ready, and the payload is also left on window.kahunacartPurchase for a tag manager that finishes loading later:
<script>
document.addEventListener('kahunacart:purchase', function (event) {
var purchase = event.detail;
if (purchase.repeat) return;
// …your analytics call goes here
});
</script>
What is never in it
No email, no name, no address, no phone, no customer id, no payment reference. Nothing that identifies a person is in the block at all.
That is deliberate and it is not a setting. The block is in the page's HTML and gets read by third-party scripts, and Google's own terms forbid sending personally identifiable information to Google Analytics — accounts do get terminated for it. So the payload has no channel for a person: it carries an order number, some money, and a list of what was bought. If you override kahunacart-order.html.twig, keep it that way.
The plugin also sends nothing anywhere on its own. It has no idea whose consent banner it is standing behind, so which tool hears about the sale — if any — is the one snippet below that you choose to add.
Google Analytics 4
<script>
document.addEventListener('kahunacart:purchase', function (event) {
var p = event.detail;
if (p.repeat) return;
gtag('event', 'purchase', {
transaction_id: p.order.number,
value: Number(p.order.total),
currency: p.order.currency,
tax: Number(p.order.tax),
shipping: Number(p.order.shipping),
coupon: p.order.coupons.join(',') || undefined,
items: p.items.map(function (item) {
return {
item_id: item.sku || String(item.product_id),
item_name: item.title,
item_variant: item.variant_title,
price: Number(item.unit_price),
quantity: item.qty
};
})
});
});
</script>
Google Tag Manager
<script>
window.dataLayer = window.dataLayer || [];
document.addEventListener('kahunacart:purchase', function (event) {
var p = event.detail;
if (p.repeat) return;
window.dataLayer.push({ecommerce: null});
window.dataLayer.push({
event: 'purchase',
ecommerce: {
transaction_id: p.order.number,
value: Number(p.order.total),
currency: p.order.currency,
tax: Number(p.order.tax),
shipping: Number(p.order.shipping),
coupon: p.order.coupons.join(','),
items: p.items.map(function (item, index) {
return {
item_id: item.sku || String(item.product_id),
item_name: item.title,
item_variant: item.variant_title,
price: Number(item.unit_price),
quantity: item.qty,
index: index
};
})
}
});
});
</script>
The ecommerce: null push first is GTM's own advice: it clears the last purchase out of the data layer so a second one cannot inherit its items.
Plausible
<script>
document.addEventListener('kahunacart:purchase', function (event) {
var p = event.detail;
if (p.repeat) return;
plausible('Purchase', {
revenue: {amount: p.order.total, currency: p.order.currency},
props: {provider: p.order.provider}
});
});
</script>
Plausible takes the amount as the string it already is, so there is no conversion to get wrong. Revenue goals have to be turned on for the Purchase goal in your Plausible settings before the money shows up.
Meta (Facebook) pixel
<script>
document.addEventListener('kahunacart:purchase', function (event) {
var p = event.detail;
if (p.repeat) return;
fbq('track', 'Purchase', {
value: Number(p.order.total),
currency: p.order.currency,
contents: p.items.map(function (item) {
return {
id: item.sku || String(item.product_id),
quantity: item.qty,
item_price: Number(item.unit_price)
};
}),
content_type: 'product'
});
});
</script>
When it fires, and turning it off
The block is drawn on an order the store counts as a sale — a completed order, whatever its payment state. An offline order waiting on a bank transfer is a sale the merchant is expecting to report, so it fires; a cart, a draft, an unanswered quote and a cancelled order are not sales, so nothing is drawn on any of them. The test is the status's flag rather than the word completed, so a store that added Picking beside it gets the event on the orders it counts as sales. See Order statuses.
storefront.purchase_event: false in user/config/plugins/kahunacart.yaml — or the Purchase Event switch on the plugin's General tab — draws no block and loads no script.
The payment-return page
A customer coming back from a hosted provider lands on {base}/payment/return/{transactionHash}. If the webhook has already completed the order, they are redirected straight to their receipt. If it has not landed yet, they get a holding page that meta-refreshes every three seconds until it has. That is common, because a browser redirect often beats a server-to-server callback.
The webhook is the authoritative confirmation. The customer coming back is advisory.
The storefront JSON API
Beside the HTML pages above, KahunaCart answers a JSON API over the cart, shipping quotes, payment methods, checkout and the country list, for a JavaScript or headless front end. It never replaces the pages and forms.
It answers under {base}/api, so /shop/api by default and /de/shop/api on a multilanguage site. It is on by default. Set storefront_api.enabled to false to answer every /api URL with a 404. Every status code, the one error format and worked curl examples are in Storefront JSON API.
| Method | Path | Answers |
|---|---|---|
GET |
{base}/api/cart |
The session's cart, empty or not |
PATCH |
{base}/api/cart |
The cart after setting the estimate address or the shipping method |
DELETE |
{base}/api/cart |
204, the cart emptied |
POST |
{base}/api/cart/items |
201 and the cart, a line added |
PATCH |
{base}/api/cart/items/{id} |
The cart, a quantity changed. 0 removes the line |
DELETE |
{base}/api/cart/items/{id} |
The cart, a line removed |
POST |
{base}/api/cart/coupons |
The cart, a code applied |
DELETE |
{base}/api/cart/coupons/{code} |
The cart, a code taken off |
POST |
{base}/api/cart/bundles |
The cart, a bundle's parts swapped for the bundle |
DELETE |
{base}/api/cart/bundles/{variant_id} |
The cart, that bundle no longer offered |
GET |
{base}/api/shipping |
The zone and the methods on offer, or 422 |
GET |
{base}/api/payment-methods |
The gateways and merchandised methods this cart may pay with |
POST |
{base}/api/checkout |
The order placed, as complete, redirect, or 422 |
GET |
{base}/api/countries |
Every country, and which have regions |
GET |
{base}/api/countries/{code}/regions |
The states or provinces of one |
Every endpoint is public: a shopper is not an authenticated user. The cart is the session's. A write must arrive as Content-Type: application/json or it is a 415. This provides CSRF protection: a cross-site form cannot send that content type without a preflight, so there is no nonce to fetch and no token to pass. A shipping quote with no address is a 422, not an empty list.
The extensions object
An add-on can add keys of its own to any of these responses. Everything it returns lands inside one reserved top-level extensions key, namespaced to the add-on.
{
"totals": { "total": "48.00" },
"items": [],
"extensions": {
"kahunacart-licenses": { "seats": 5, "expires_at": 1780000000 },
"kahunacart-subscriptions": { "renews_at": 1780000000 }
}
}
Two add-ons can both call a key status and neither can read the other's. No documented key moves. extensions is appended beside them, and it is absent on a store where no add-on registered one.
| Rule | What happens |
|---|---|
| A namespace two plugins claim | Refused with a message naming both plugins. The second registration never takes effect |
| A key the callback returns but did not declare | Dropped, with a log line naming the namespace, the key and the point |
| A value whose type does not match the declared one | Dropped, with a log line saying what was returned and what was declared |
| A callback that fails | That namespace is missing from the response. Every other namespace still goes out |
The storefront points are storefront.cart, storefront.line_item, storefront.shipping, storefront.payment_methods, storefront.checkout and storefront.countries. See Provider developer guide → Schema extensions for how to register one, and GET /kahunacart/schema-extensions for what an installed store has registered.
Currency helpers
kahunacart_current_currency()
Returns code (the visitor’s currency), base (the store’s), is_base and pinned. pinned means a cart with lines decides the currency. A link to another currency then needs a cart switch. On a single-currency store both codes are the base, is_base is true and pinned is false.
kahunacart_currency_notice()
What to tell a shopper whose cart is in one currency and who followed a link to another. Null almost always: it answers only on a store that sells in more than one currency, for a visitor whose open cart has lines in it and who asked for a different currency with ?currency=, or after a refused hosted-cart switch or country lock.
{% set notice = kahunacart_currency_notice() %}
{% if notice %}
<p>{{ notice.text }}</p>
{% if notice.can_switch or notice.can_restart %}
<form method="post" action="{{ notice.action }}">
{{ nonce_field('kahunacart', 'nonce')|raw }}
<input type="hidden" name="currency" value="{{ notice.asked }}">
{% if notice.can_restart %}
<input type="hidden" name="restart" value="1">
<input type="hidden" name="source" value="{{ notice.source }}">
{% endif %}
<input type="hidden" name="back" value="{{ notice.back }}">
<button type="submit">{{ notice.label }}</button>
</form>
{% endif %}
{% endif %}
The array has cart and asked (currency codes), text (the sentence, in the shopper's language), can_switch (false after any hosted payment), can_restart (offers a fresh cart instead), source (the old cart id), label (the button's words), action and back. The button posts to the same address as the currency switcher. partials/kahunacart-currency-notice.html.twig is this, and the shop, category, search and listing pages include it under their messages. See Currencies.
kahunacart_currency_link(url)
Use {{ kahunacart_currency_link(item.url) }} after building a complete header, footer or account link. It carries an explicit URL currency through site navigation and keeps existing query parameters and fragments. Cookie-only choices keep plain links. External hosts, email links, fragment-only links and links on a single-currency store stay as supplied. kahunacart_url() builds storefront links.