Skip to content

Powered by Grav CMS + Helios Theme

Demo store

A public demo lets visitors explore the store while preventing admin changes. This page covers the switch that locks the admin, the second switch that protects a shared customer login, and how to reset the whole thing on a timer.

Before you begin

  • A store you are happy to publish, ideally on SQLite, seeded with bin/plugin kahunacart seed.
  • Command-line access, for the reset.

Lock the admin with one switch

YAML
# user/config/plugins/kahunacart.yaml
demo:
  readonly: true

KahunaCart → Settings → Database → Demo Store is the same setting.

With it on, every request to the KahunaCart admin API that is not a GET, HEAD or OPTIONS is refused with 403 and the message "This is a demo store — changes are switched off." Admin Next shows that as an error toast. It applies to everyone signed in, including a super admin.

What it locks

Everything reached through the KahunaCart admin API:

  • products, variants, option axes, product files and images
  • categories and tags
  • orders: refund, mark paid, record a shipment, cancel, revoke a download grant
  • customers: invite, clear two-factor, change state
  • tax zones, shipping zones, coupons
  • webhook log maintenance
  • add-on admin screens whose controllers extend AdminController
  • the Health screen's network probes. A demo answers as if Skip network checks were on, so opening Health never pings a webhook target or a provider. See Going live.

What it leaves open on purpose

The customer-facing features keep working:

  • The storefront. Browsing, search, filters, the cart, checkout with whatever provider is configured (the dummy provider is the obvious choice), the receipt page and the account area.
  • Payment webhooks, which arrive on a storefront route, not the admin API.
  • The job worker (bin/plugin kahunacart work): confirmation email, download grants, abandoned-cart mail.
  • The CLI, including migrate and seed. The reset below is a CLI job.
  • Migrations, so a plugin update still lands its schema.

Important

It does not lock the rest of Grav's admin. Pages, accounts, plugin settings and the file manager belong to the Admin and API plugins, and something else has to guard them.

Two ways to cover the rest:

  • The API plugin has its own per-account demo mode. Give the demo account access.api.demo and its writes are refused across the whole API, with a configurable writable allowlist and a content baseline it resets to. See the API plugin's demo configuration.
  • Or write a small site plugin that refuses writes for a flagged account. The KahunaCart site skeleton ships one, plugins/demo-guard, together with a read-only demo account. That is how demo.kahunacart.com is built.

Where it is enforced

One place: the constructor of Grav\Plugin\KahunaCart\Api\AdminController, which every KahunaCart admin API controller extends. The API plugin's router builds a controller with new $class($grav, $config) before it calls the matched route method, so no route can run without passing the gate. The rule is about the HTTP method, not the route: GET, HEAD and OPTIONS are reads, and an unfamiliar verb fails closed.

An add-on with admin screens of its own gets the same treatment by extending AdminController instead of the API plugin's AbstractApiController. Its public, unauthenticated endpoints, such as a license validation call, must not extend it. See Admin sections.

Protect a shared storefront login

A demo that shows the account area has to publish a customer sign-in. Without protection, the first visitor to open the credentials page can change the password, the email address or two-factor and lock everyone else out until the next reset. "Forgot password" is the same hole from outside. demo.readonly guards the admin API only, so the second switch is:

YAML
# user/config/plugins/kahunacart.yaml
demo:
  readonly: true
  protected_customers:
    - d[email protected]

KahunaCart → Settings → Database → Protected Customers is the same setting. Addresses are matched case-insensitively against the customer's username, their account email and their customer record's email, so one entry per shared login is enough.

What a protected customer cannot do

  • change their password, email address or name. {base}/account/profile stays in the account navigation and a theme's account menu, and draws the form read-only (disabled, with no buttons) under a notice saying the shared demo sign-in's details are fixed, so a visitor can still see what a customer's account holds
  • enrol or cancel two-factor authentication
  • ask for a password-recovery email, or use a reset link. login.forgot and login.reset are refused at request time, before any mail is sent
  • POST to any other page in the account area, including one an add-on registers later. Everything under {base}/account is refused unless it is on the allow-list

What still works, in full

  • signing in and out
  • the address book at {base}/account/addresses
  • orders, receipts, invoices and downloads
  • account tabs other plugins registered, such as deactivating a licence seat
  • the cart, the checkout and everything else on the storefront

The refusal is a friendly message and a bounce back to the page, not a 403.

It is enforced in three places, all server-side. The storefront's account POST dispatch refuses on an allow-list, so a handler added later is covered. onTask.login.forgot, onTask.login.reset and onTask.login.regenerate2FASecret are refused before the Login plugin's controller runs. And the Form plugin's update_user action is refused wherever a site renders the profile form. The read-only form is a courtesy, never the guard. See Customer accounts.

Reset the demo

The storefront is live, so the database fills with carts, test orders, customers and minted license keys. For a SQLite store, a reset is four commands: throw the database away, migrate a fresh one, seed the sample catalog, clear Grav's cache.

BASH
cd /path/to/site
rm -f user/data/kahunacart/db/kahunacart.sqlite
bin/plugin kahunacart migrate
bin/plugin kahunacart seed --account --password 'DemoCustomer2026!'
bin/grav clear

seed is idempotent, and --account adds a demo customer with order history. The catalog seed also writes seventeen reviews across the products, two of them waiting in the moderation queue, and the demo customer adds two verified ones, so product pages show stars and KahunaCart → Reviews shows a queue. See CLI → seed.

It also writes five custom fields across products, orders and customers, three customer groups, four coupons including an expired one and a campaign, and a Trade price list scoped to the trade group. With --account the demo customer joins that group, so signing in as them shows trade prices on four products. Nothing needs a rebuild afterwards; a price list marks the catalog index dirty when saved.

Two things to know:

  • Deleting the file is the reset. seed --purge removes the seeded products, not the orders and customers a visitor created, so it is the wrong tool here.
  • Nothing else needs clearing. Product and category images live outside the database, and with demo.readonly on nobody can upload one, so the images on disk stay the seeded set.

Caution

The reset destroys the database on purpose. Never point it at a store that takes real orders.

What the catalog seeder does not put back

seed owns the catalog and the merchandising around it: sales, reviews, custom fields, customer groups, coupons and a price list. The reset wipes everything else too, so something has to write it again. The site skeleton does it with commands in its own demo-guard plugin, each idempotent and each a no-op when the add-on it is about is not installed.

Command Puts back
bin/plugin demo-guard seed-licenses The licensing rules, the release history and the six demo keys
bin/plugin demo-guard seed-zones Tax jurisdictions and delivery zones, so the cart's estimate box quotes real numbers
bin/plugin demo-guard seed-plans The one subscription plan, so the Subscriptions screens are not an empty table
bin/plugin demo-guard seed-webhooks Webhook subscriptions and a delivery history against inert example.com targets, so the log has rows and nothing leaves the box
bin/plugin demo-guard seed-payments Three payment methods, two saved cards on the demo customer through the dummy provider, and one order left authorised under manual capture
bin/plugin demo-guard seed-jobs One job in each terminal state, so the queue screen shows what a failure looks like as well as a success

Copy the split, not the commands: a demo's own arrangement belongs in the demo's own plugin.

Settings the demo turns on

The skeleton's config/plugins/kahunacart.yaml turns on the three settings that change what the shop page looks like: catalog.sort: manual, catalog.filters: auto and a four-block catalog.sections. See Storefront → Sectioned listings. The catalog seed suits them: every product carries a position, five carry the new tag the second block selects on, and three are featured for the hero block.

The site skeleton ships the whole reset as bin-demo-reset.sh with the paths already filled in. Run it from cron as often as suits the demo:

TXT
0 * * * * /path/to/site/bin-demo-reset.sh >/dev/null 2>&1

KahunaCart registers no scheduler job for this; how often a reset runs is your call.

Keep the pictures across a reset

A reset rebuilds the catalog from seed/sample-products.yaml. Any picture attached by hand in the admin is a database row and goes with the database, so the seeder has to own the pictures. Put them where it looks:

TXT
user/data/kahunacart/seed-images/
    reef-runner-tee.jpg
    dawn-patrol-hoodie.jpg
    sandbar-boardshort.jpg
    sandbar-boardshort-kelp.jpg
    sandbar-boardshort-slate.jpg
    …
    categories/
        clothing.jpg

One file per slug, named after it, in any of the five accepted formats: jpg, png, webp, avif, gif, tried in that order. The two sandbar-boardshort-* files are option values: a colourway's photograph is named the same way a product's is. See seed/README.md.

Each product gets the picture named after its slug, imported through the same service an admin upload goes through, so derivatives and thumbnails behave as they would for an upload. A slug with no file gets no picture, which is not an error.

Nothing under user/data is served. These are the source files; the copy under user/images/kahunacart/products/{id}/ is what a browser fetches. seed --images <dir> overrides the directory for a one-off run, and the plugin's own seed/images/ is the last place searched.

Note

Samples need none of this. The audio and the PDFs under a product's Preview block ship inside the plugin, in seed/media/, and are attached again on every reset.

Running the reset twice does not duplicate anything. The name a seeded picture is stored under carries a digest of its bytes, so after a reset the seeder attaches the file it wrote last hour instead of writing a second copy. A store where the rows survived is left alone.

Check it once after wiring a demo up:

BASH
sqlite3 user/data/kahunacart/db/kahunacart.sqlite \
  'select product_id, kind, role, count(*) from kahunacart_product_media group by product_id, kind, role;'
ls user/images/kahunacart/products/1/

One row per product, one file per directory, after any number of resets.

Showing more than one currency

A demo is the easiest place to see a store that sells in more than one currency. Seed it with the other currencies and list them in the config:

BASH
bin/plugin kahunacart seed --account --zones --currencies=EUR,GBP --password 'DemoCustomer2026!'
YAML
# user/config/plugins/kahunacart.yaml
currencies:
  enabled: [EUR, GBP]

The seed writes the prices and never the configuration, so the currencies: block is yours to add. When it is missing the command prints it. It gives every seeded variant a price in each currency, worked from the base price by a fixed rate and tidied so it looks typed, and gives the free-shipping minimum, the fixed coupons, the shipping methods and one fixed-amount sale an amount in each. Three products, coldwater-cool-box, saltwater-socks and tide-chart-2027, are left without a euro price on purpose, so a shopper in euro meets "Not available in EUR". JPY works too, in whole yen. The rates, the rounding and what is left out are under CLI → seed.

A reset that rebuilds the database passes the same flag, and the demo's configuration keeps currencies.enabled, since the reset throws away the database and not the config. After the seed, run bin/plugin kahunacart index --rebuild so price sorting and filters know the new prices. Your theme needs the switcher in its header for a visitor to change currency. See Currencies.