Most applications add caching after something gets slow, which means it arrives as a retrofit:
scattered cache_get() calls, ad-hoc keys, a bug where stale data appears on one page
and not another, and an invalidation strategy that is really a TTL and a hope.
Tiknix caches at the layers where invalidation can be reasoned about generically — the database adapter and the permission lookup — so that application code does not participate at all.
Layer 1: transparent query cache
lib/CachedDatabaseAdapter.php is a drop-in replacement for RedBeanPHP's database
adapter. It caches SELECT results and invalidates them on writes. Your controllers do not know it
is there:
[cache]
enabled = true
query_cache = true
query_cache_ttl = 60
Two implementation choices carry the design:
Table-version invalidation
Rather than tracking which cached rows a write affects — which is intractable in the general case — the adapter tracks a version number per table. An INSERT, UPDATE, or DELETE bumps the version of the tables involved, and every cached query that referenced those tables is invalidated in one move. JOIN queries are tracked across all their tables, so a write to any participating table correctly invalidates the joined result.
It is coarse — a write to one row invalidates cached queries over that whole table — and that coarseness is exactly why it is correct. Precise invalidation is where cache bugs live. Bulk invalidation trades some hit rate for the guarantee that you never serve data a write should have removed.
Namespaced by site
$siteId = md5(__DIR__ . '_' . ($_SERVER['HTTP_HOST'] ?? 'cli'));
The cache prefix is derived from the installation directory and hostname, so two Tiknix instances sharing an APCu segment cannot see each other's cached rows. That matters directly here: the AI Builder provisions many instances on one host, and cross-tenant cache bleed would be a serious data leak rather than a performance bug.
Layer 2: permission cache
Every request performs at least one permission check, so lib/PermissionCache.php
layers three tiers:
- Process memory — a static array; after the first check in a request, the rest are free.
- APCu — shared across requests on the host; the common path avoids the database entirely.
- Database — cold-start only, then promoted upward.
The cache is versioned, which is the part that makes it operationally pleasant: changing
permissions bumps a version, and running processes pick up the change on their next check. No
PHP-FPM restart, no deploy, no stale-authorization window. If you edit
authcontrol rows directly:
php scripts/resetcache.php
Layer 3: OPcache preloading
PHP's OPcache stores compiled bytecode so each request skips parsing. Preloading goes further, loading framework files into memory at server start so they are resident before the first request arrives. The preload list is configurable — worth pointing at your hot controllers and libraries, not just the framework's.
The numbers, and how to read them
The project reports roughly 9.4× faster queries with a ~99.9% hit rate for the
query cache, and about 99.7% faster permission checks at ~175,000 checks/second.
There is an admin panel at /admin/cache showing live hit rates, memory usage per
tier, cached query counts, APCu and OPcache status, plus one-click clear and warm.
Read those numbers as what they are: measurements from a specific workload on specific hardware. A 99.9% hit rate is characteristic of a read-heavy application with a warm cache — which is what an admin-panel-driven app looks like. A write-heavy workload will invalidate constantly and see much less benefit. The right move is to look at the dashboard on your traffic rather than to inherit anyone's benchmark.
Having the dashboard is the more durable point. Caching you cannot observe is caching you cannot debug, and "is the cache actually helping?" should be a question with an answer on a screen.
Why cache at these layers specifically
Application-level caching requires the developer to reason about invalidation at every call site, and that reasoning is where it goes wrong — someone caches a user's permissions in a controller, someone else updates permissions in an admin screen, and nothing connects the two.
The database adapter and the permission lookup are chokepoints. Every query goes through one; every authorization check goes through the other. Caching at a chokepoint means the invalidation logic exists once, can be reviewed once, and applies to code written later by people who never think about it — including agents.
This is the same principle as enforcing permissions in the dispatcher rather than in each controller. Put the guarantee where the traffic funnels, and correctness stops depending on discipline.
Getting it running
sudo apt-get install php8.5-apcu
sudo systemctl restart php8.5-fpm
# optional: enable for CLI so you can test cache behavior from scripts
echo "apc.enable_cli=1" | sudo tee -a /etc/php/8.5/cli/conf.d/20-apcu.ini
Without APCu, the system degrades to process-memory caching and the database. It works; it is just slower.
- APCu is per-server. It is shared memory on one machine, not a distributed cache. Across multiple app servers each has its own, which is fine for permissions (versioned, self-correcting) and worth thinking about for query results. Multi-server deployments want Redis or Memcached at this layer.
- Table-version invalidation is coarse. On a write-heavy table, cached queries are invalidated constantly and the hit rate collapses. That is the cache doing its job correctly, not failing — but do not expect the headline numbers on a write-heavy workload.
- A TTL is still a staleness window. With
query_cache_ttl = 60, data changed by something outside the application — a direct SQL edit, another service writing the same database — can be up to a minute stale, because no invalidation event fired. - Caching hides N+1 queries. A loop issuing hundreds of cached queries looks fast in development and falls over the moment the cache is cold or the data changes. Profile with the cache off occasionally.
- Preloading pins code at startup. Preloaded files are the ones loaded at server start, so deploys need a reload for those files to change. Expected behavior, surprising the first time it bites you.
- Cache before correctness is a mistake. These layers are transparent and safe by construction. Any caching you add on top is yours to invalidate — hold it to the same standard.