Performance
What to set in KahunaCart, in Grav and on the server to keep a storefront fast, and what the plugin already handles without being asked.
Before you begin
- Shell access to the server, or a control panel with the PHP-FPM and opcache settings, for the server sections below.
- Benchmarking if you want numbers of your own.
What KahunaCart handles on its own
None of these need a setting.
| What | How it works |
|---|---|
| Product listings | A page of product cards is read in bulk: one query fetches every card's default variant, which holds the price, and one fetches the images. The shop's front door dropped from 225 queries to 41. |
| Repeated questions | The category tree, the attribute definitions, whether a sale is running and the store currency are each read once per request, however many parts of the page ask. |
| The schema check | "Is the database schema current?" is answered from Grav's cache instead of rescanning every migration file on every request. bin/grav clear, or any change to a migration file, asks afresh. |
| The customer list | The admin reads one page of customers and then the order counts and lifetime totals for exactly those customers, so page 1 costs the same on a store with 25 customers as on one with 250,000. |
| The catalog index | One row per purchasable unit carries the status, price, sale price, stock state, sort keys and search text a listing needs, so a filtered listing reads one table instead of joining several. It rebuilds itself as products change, and it is joined only when it helps. See The catalog index. |
Tune the catalog
| Config key | Default | What it does |
|---|---|---|
catalog.facet_cache |
60 |
How many seconds the filter-panel counts are kept between requests. 0 asks the database on every listing page. |
catalog.per_page |
24 |
How many products one listing page holds. 0 turns paging off and truncates the listing at search.limit. |
Cache the filter-panel counts
The counts beside the tick boxes in the filter panel are the heaviest thing a listing page does, and every visitor to the same listing gets the same numbers, so KahunaCart keeps them for catalog.facet_cache seconds.
Exactly four things can lag behind an edit, and every one of them is a number:
| What | Where a visitor sees it |
|---|---|
| A tag's count | The number beside a tag tick box in the filter panel |
| An attribute value's count | The number beside a value tick box, and whether it is greyed out |
| Whether an attribute facet has anything reachable in it | Whether that block appears in the panel at all |
| Category membership counts | The numbers in the category tree and on the subcategory cards |
Everything else is read fresh on every request. A product unpublished a moment ago leaves the listing immediately; the number beside its tag catches up within the lifetime.
Set catalog.facet_cache between 300 and 3600 on a catalog that changes rarely, or to 0 where a count must never lag an edit. bin/grav clear empties the cache.
The catalog index
The catalog index is one denormalised row per purchasable unit: status, visibility, type, price, sale price, stock state, the sort keys and the text a search matches on. A filtered listing, a facet count, a sort and a search all read it.
In the default catalog.index.mode: auto the index is joined only when a sale, a price list or a search term is in play. Otherwise the store runs the live queries it always ran. An index that has never been built is never joined either, so a store that updates and never rebuilds loses the speed and nothing else.
| What to run | When |
|---|---|
bin/plugin kahunacart index |
Report what is there without changing it: enabled, mode, built, whether any listing is reading it, rows, products, queue depth and the text index this engine gave the store. |
bin/plugin kahunacart index --rebuild |
After a big import, a bulk edit that went around the plugin, or a database restore. |
bin/plugin kahunacart index --dirty |
After saving a sale, when you do not want to wait for the next worker tick. |
Two settings decide how much the index costs to keep current. catalog.index.chunk is how many products one rebuild transaction writes, and catalog.index.per_tick is how many the once-a-minute worker drains. A large catalog with no promotions that still wants cheap deep pages sets catalog.index.mode: always; catalog.index.enabled: false puts everything back on the live-query path. See The catalog index and Configuration.
Speed up the search box
search.products.driver is a ladder of four values. auto, the default, takes the highest rung this installation can run.
| Value | Answers the search box | Needs |
|---|---|---|
auto |
The highest rung available. The default. | Nothing. |
yetisearch |
The BM25 file index, with typo tolerance. | pdo_sqlite plus FTS5. |
sql |
The catalog index's per-engine full-text search, with no extra file to keep. | The catalog index built and enabled. |
like |
The portable substring scan. | Nothing. |
auto falls back to the next available driver, so a PHP build with no FTS5 tries sql and then like. The like substring scan reads every published product row for every search and cannot use an index, so move off it first on a large catalog. See Storefront for how each driver affects storefront search.
bin/plugin kahunacart search:reindex rebuilds the yetisearch file index from the catalog. The index follows product writes on its own and a missing one rebuilds on the next search, so run it only when a bulk UPDATE bypassed the plugin, after restoring a database, or in a deploy step that does not carry user/data across.
Tip
When a listing or a search page is slow and the data has been changed underneath the plugin, run bin/plugin kahunacart index --rebuild and bin/plugin kahunacart search:reindex. See CLI.
Keep Grav's caches on
system.cache.enabledmust staytrue. KahunaCart keeps the schema check's answer there; with it off, every request rescans every migration file.system.twig.cachemust staytrue, so compiled templates persist between requests.
Both are on by default.
Configure PHP-FPM and opcache
Set these on the pool that serves the storefront.
| Setting | Production value | Why |
|---|---|---|
opcache.enable |
1 |
Without it every request recompiles every PHP file it touches, about 750 of them on a catalog page. |
opcache.validate_timestamps |
0 |
Stops PHP stat-ing every file on every request. |
opcache.memory_consumption |
192 or more |
A KahunaCart store caches around 1,000 scripts. |
opcache.max_accelerated_files |
20000 |
The default of 10,000 is close to the ceiling for a site with several plugins and a large theme. |
realpath_cache_size |
4096K |
Grav resolves stream paths constantly. |
Important
With opcache.validate_timestamps set to 0, PHP does not notice changed files. Reload PHP-FPM after every deployment and after every plugin update.
Size pm.max_children from memory: a KahunaCart catalog page peaks around 8 MB of PHP memory, and a PDF invoice or a report is several times that. Divide the memory you are willing to give PHP by that peak, leaving headroom for the database if it shares the box.
Warning
A slow admin request starves the storefront when the two share one pool. An order search or a sales report can hold a worker for a second or more, and a handful at once on a pool of ten leaves the shop with nothing to answer on. Give the admin its own pool, or leave headroom in the shared one.
Configure the database
SQLite has no server to tune. KahunaCart already opens it in WAL mode with the right synchronous setting for a store. Give the machine enough free memory for the OS page cache to hold the database file.
MariaDB and MySQL performance depends heavily on innodb_buffer_pool_size. Set it to 60–70% of the memory the database may use, and set max_connections above the PHP pool size, because each worker holds one connection. A buffer pool smaller than the working set is the most common cause of a slow KahunaCart store: the package default of 128 MB against a 4.5 million row store on a machine that could not cache the rest came out two orders of magnitude slower than SQLite, while the same default with the whole store in memory beats SQLite on five of the eighteen benchmark queries. See Benchmarking.
For PostgreSQL, set shared_buffers at 25% of the memory the database may use, effective_cache_size at 50–75% of total system memory, and work_mem around 16MB for the admin reports.
Sort the customer list on a large store
GET /kahunacart/customers lists customers newest first. Four orderings (recent, oldest, name, email) sort on an indexed column. Two (value and orders) sort on an aggregate of the whole orders table, which no database can page: on the benchmark store ?sort=recent answers page 1 in 0.1 ms and ?sort=value takes 270 ms. The response says "sort_scans_everything": true when a scanning sort answered, so treat those two as a report, not a screen somebody leaves open.
Note
per_page on this endpoint is capped at 100, below the API plugin's own pagination.max_per_page ceiling of 1000. A thousand customers with their order aggregates is an export, and the reports have one.
What the migrations added
Run bin/plugin kahunacart migrate after updating. Each of these applies once and costs nothing after that.
| Migration | What it adds | Where it shows |
|---|---|---|
| 0031 | A search_text haystack column and the best text index each engine offers |
The admin order search: from a full table scan per keystroke to an indexed read. See Orders and the admin. |
| 0032 | Daily report rollup tables | An all-time sales report reads a few thousand small rows instead of scanning every order. See Reports. |
| 0034 | The catalog index tables and their rebuild queue | Filtered listings, facet counts and sorts read one denormalised table. Joined only when a sale, a price list or a search term needs it. See The catalog index. |
| 0035 | A search_text column on the index and the best text index each engine offers, plus the synonym table |
The sql rung of the search ladder: ranked search inside the store's own database, with the merchant's synonyms applied at query time. |
| 0063 | A (customer_id, status) index on the orders table |
The customer list's order totals become a set of range scans instead of a full scan: page 1 went from 273.5 ms to 0.1 ms on the benchmark store. The storefront's own account pages narrow by the same columns. |
Measure before changing anything
bin/plugin kahunacart bench:queries times the store's own queries in the CLI, with no HTTP, Twig or PHP-FPM in the way. Run it before and after a change to see whether the database was the problem at all. See Benchmarking.
The route latencies and capacity figures we publish are on the benchmarks page. Capacity comes from a stepped k6 probe, one route at a time, quoted at the highest concurrency where the 95th percentile stayed under 500 ms with no errors.
Related
- Benchmarking: seed a store of benchmark size and take numbers of your own.
- The catalog index: what the index holds, when it is joined, and how to rebuild it.
- Configuration: every config key, including the three database engines.
- Hardening: trusted proxies, and the headers a CDN in front of the catalog needs.
- Troubleshooting: what to check when a page is slow on a real store.
- Published benchmarks: the numbers these settings move, rerun after every development wave.