phpClaw creates three tables and writes only to those: {prefix}phpclaw_conversations, {prefix}phpclaw_messages, {prefix}phpclaw_memory. Same three on all 8 adapters. Message text lands in a plaintext content column — there is no encryption of message content anywhere in the PHP tree. There is also no cron job, scheduled task, sweeper or pruning job in any PHP package, so nothing expires on a schedule: key-value rows are dropped only when something reads them again, and conversation and message rows carry no expiry at all. On the other side of the ledger, at most 3 stored memory entries ever reach the model on a given turn.
That combination — everything is kept, very little is used — is the thing to understand before you put this in front of a client’s data.
The problem this section is really about
Every agent framework has a memory chapter, and most of them describe an interface. The interface is the easy part. The questions that decide whether you can ship are different:
- If a customer asks what you hold about them, which table do you query?
- If you turn message storage off today, what happens to the rows written yesterday?
- When a conversation runs long, who pays for keeping it coherent — and in what currency?
- On a WordPress multisite, does site 4’s agent see site 2’s memory?
phpClaw answers all four, and two of the answers are ones you have to plan around rather than rely on.
The contract: MemoryInterface, six methods
Memory in phpClaw is a key/value store, not a queryable history engine. The contract is six methods on MemoryInterface:
| Method | What it does |
|---|---|
get() | Read one key |
set() | Write one key |
has() | Existence check |
all() | Return everything in the namespace |
forget() | Delete one key |
flush() | Delete everything in the namespace |
That is the whole surface. There is no search(), no since(), no whereRole(). Conversation history is stored in its own tables and handed to the agent as an ordered array; the key/value store is a separate thing that holds facts you want carried between runs.
This matters more than it sounds, because all() is the method the per-turn injector calls. Relevance is computed in PHP over the full namespace, not pushed down into an index. Section five covers the cost of that.
The five implementations
Core ships three drivers and two wrappers.
ArrayMemory — in-process only. Nothing survives the request. Expired entries are evicted lazily when touched, not on a timer. Useful for tests and for a single CLI invocation; it is not persistence.
FileMemory — one JSON file per namespace. Writes go through a temp file and a rename with an exclusive lock, so a partially written file is never visible to a reader. It is the only place in the entire PHP tree that prunes anything: expired keys are dropped inline during its own operations. That inline prune is what the “no sweeper anywhere” finding excepts, and it is file-local — it does nothing for your database tables.
RedisMemory — persists to Redis, with TTL handled by Redis itself. One caveat that the docs do not lead with: MemoryRegistry pre-registers only file and array. Asking for redis by driver name throws until you register the class yourself. Redis is a supported driver, not an out-of-the-box one.
Router memory — routes a key or namespace to one of several underlying drivers, holding no storage of its own. Every adapter ships its own router class, and those are the ones worth naming because they are what actually runs: wpdb_router is WordPress’s default driver, oc_router OpenCart’s, ps_router PrestaShop’s, router Magento’s. On WordPress and Joomla the default driver is hardcoded at the factory — you cannot swap it from settings the way you can on Laravel or Symfony. (The generic core RouterMemory class exists; its internal routing rules were not read directly for this article, so treat the adapter classes above as the confirmed description.)
PrivacyAwareMemory — a decorator, and the one whose semantics you must get exactly right. When store_messages is false, it gates set() only. Reads, forget() and flush() all pass straight through to the underlying driver.
Read that again in operational terms: turning off message storage stops new writes. It does not hide, delete, or make unreadable anything already stored. A site that ran for three months with storage on and then switched it off still has three months of conversation content in its phpclaw_messages table, still readable, still plaintext. If you need it gone, you have to remove it — see the retention section.
Where the state physically lives
The 3-table schema is locked and identical in shape across all 8 adapters, adapted to each platform’s native database layer rather than reimplemented per platform.
{prefix}phpclaw_conversations— one row per conversation{prefix}phpclaw_messages— one row per turn, message text in a plaintextcontentcolumn{prefix}phpclaw_memory— the key/value store, withexpires_atfor TTL
Primary keys are ULIDs stored as CHAR(26) throughout — sortable by creation time, no auto-increment coordination, no UUID index fragmentation. The key/value table’s key column is named lookup_key, not key, because key is a MySQL reserved word. That is a small decision, but it is the kind of thing you only get right by having hit it.
There is no tenant, site or blog column in any of the three tables.
Here is the shape as confirmed by source. It is illustrative, not a verbatim dump of any adapter’s DDL — the columns named are the ones confirmed here; each adapter’s migration adds platform-idiomatic details around them.
-- Illustrative shape. Do not run this to create the tables —
-- each adapter's own installer/migration owns the real DDL.
CREATE TABLE wp_phpclaw_conversations (
id CHAR(26) NOT NULL PRIMARY KEY -- ULID
-- no expires_at: conversations have no TTL
);
CREATE TABLE wp_phpclaw_messages (
id CHAR(26) NOT NULL PRIMARY KEY,
conversation_id CHAR(26) NOT NULL,
content LONGTEXT -- plaintext, not encrypted
-- no tool_result column; no expires_at
);
CREATE TABLE wp_phpclaw_memory (
id CHAR(26) NOT NULL PRIMARY KEY,
lookup_key VARCHAR(191) NOT NULL, -- named to dodge reserved `key`
metadata TEXT,
expires_at INT UNSIGNED -- enforced lazily, on read
);
To see what your own install actually created, this runs as written against MySQL or MariaDB (swap your_db for your schema name):
SELECT table_name,
table_rows,
ROUND((data_length + index_length) / 1024 / 1024, 2) AS mb
FROM information_schema.tables
WHERE table_schema = 'your_db'
AND table_name LIKE '%phpclaw%';
Three rows come back. If a fourth appears, it is not phpClaw’s — the runtime writes to its own three tables and its own settings row, and nowhere else. It does not write to your posts, products, orders or users.
Per-turn assembly, and what gets silently dropped
This is where cost lives, and it has two independent mechanisms that are easy to conflate. They are not the same thing and they operate on different data.
Mechanism one: MessageAugmenter — the memory injector
Before each provider call, MessageAugmenter builds the message that will actually be sent. For memory it:
- Calls
all()on the memory driver — the entire namespace, loaded into PHP. - Scores each entry by keyword overlap against the current user message.
- Injects at most 3 entries. The cap is a constant,
MAX_MEMORY_HITS = 3. - Drops everything scoring zero overlap.
Step 4 is the honest part. This is a relevance filter, not truncation. An entry that never shares a word with a future message is never injected — not “injected later”, not “injected in summary form”. Never. You can store two hundred facts about a client’s brand voice and, if the user asks about “shipping delays”, none of them qualify. Stored context can go permanently unused, and nothing in the product tells you that happened.
There is no embedding, no vector, no similarity search anywhere in core. Every relevance decision in phpClaw — memory and skills alike — is word-overlap scoring.
Roughly, in pseudocode:
PSEUDOCODE — the scoring shape, not the source
entries = memory.all() # whole namespace into PHP
words = tokenise(userMessage)
scored = []
for each (key, value) in entries:
overlap = count(words ∩ tokenise(key + " " + value))
if overlap > 0:
scored.push({key, value, overlap})
sort scored by overlap descending
inject top 3 into the outgoing message # MAX_MEMORY_HITS = 3
# everything with overlap == 0 is dropped, silently
Two consequences worth planning around:
Cost is bounded, and that is deliberate. Three entries is three entries whether you stored ten or ten thousand. Your per-turn prompt does not grow with the size of your memory store. That is a real design win and the reason the cap exists.
Read cost is not bounded. all() loads the namespace. There is no index behind the relevance scan. A namespace that grows without limit costs more to scan on every single turn, even though only three entries ever ship. Namespace hygiene is your job.
The same class then wraps skill context around the memory context — memory is scored and injected first, skills second, both capped at three, both by word overlap. If you are sizing prompts, you need both halves of that picture; the Skills article covers the other half.
Mechanism two: HistoryCompactor — conversation history overflow
Different data, different trigger, different cost. HistoryCompactor operates on the conversation history array, not on the key/value memory store.
It fires when either a message count or an estimated token count exceeds a configured limit. The token estimate is a character heuristic — strlen / 4 — not a real tokeniser. phpClaw does not ship a tokeniser; the only accurate token numbers it ever reports come from the provider’s own usage block.
When it fires, it summarises the oldest half of the history and prepends the result to the kept half as [Conversation summary]\n{summary}.
That summarisation is a second provider call, carrying the older history in its body. Stated plainly: compaction is not free and it is not local. A conversation that compacts pays one extra request each time it does, and the content being condensed leaves the process to be condensed. If the summary call fails, the run survives — it degrades to (summary unavailable) and continues.
Now the correction that most descriptions of this feature miss. Compaction is off in practice on every shipped adapter. Both thresholds default to 0, and no adapter surfaces them in settings, config or environment variables. It is reachable through the library’s fluent builder and nowhere else. So if you install the WordPress plugin or the Laravel package and hold a very long conversation, history is not being automatically condensed for you — it is being sent in full, and it will grow until the provider rejects it. Compaction is an opt-in library feature, not automatic context management.
Per adapter: storage, cleanup, multi-tenancy
Every adapter is fully shipped here — none of the eight is a stub or a reference implementation. What differs is the database layer underneath and a few migration-level details.
| Adapter | Storage backend | Default driver | Notable detail |
|---|---|---|---|
| WordPress | 3 custom tables via $wpdb | wpdb_router (hardcoded) | Multisite not supported — see below |
| Joomla (5/6, one registration) | 3 tables via DatabaseInterface | joomladb (hardcoded) | Single-site, #__ prefix; no per-language partitioning |
| Laravel | Eloquent, one migration, 3 tables | eloquent | Also ships eloquent_kv, eloquent_conversation and cache drivers |
| Symfony (6.4/7.x) | Doctrine, one migration, 3 tables | doctrine | Falls back to FileMemory if Doctrine is absent |
| Drupal (10/11) | Drupal DB API (Connection::merge/delete/query), no PDO | database | Dates migrated VARCHAR → INT UNSIGNED in phpclaw_update_8003() |
| Magento (2.4+) | ResourceConnection, declarative db_schema.xml | router | TTL via expires_at; no cron sweep found |
| OpenCart (3/4) | Native OC DB class, auto-detecting OC3 DB vs OC4 \Opencart\System\Library\DB | oc_router | metadata is TEXT not JSON; no FOREIGN KEY declared — cascade is manual PHP |
| PrestaShop (8.2/9) | PsDbAdapter wrapping Db::getInstance() | ps_router | No PS8/PS9 divergence in the memory layer |
Cleanup, stated once for all eight: there is no scheduled cleanup. No cron event, no scheduled task, no sweeper, no pruning job exists in any PHP package. TTL on the key/value table is enforced lazily — an expired row is removed the next time that specific key is read. A key written with a one-hour TTL and never read again sits in your table indefinitely. It is expired in the sense that a read will not return it; it is not gone. The conversation and message tables have no TTL at all; where a driver accepts a $ttl argument on the conversation path, it is a documented no-op.
Two adapter-level exceptions to the “nothing cleans up” rule, both narrow: FileMemory prunes inline during its own operations (file-backed namespaces only), and WordPress’s wp_transient driver — not the default — inherits WordPress’s native transient expiry. Neither touches the three database tables.
Multi-tenancy. There is no multi-site or multi-tenant capability in the runtime at all: no site, tenant or blog identifier anywhere in the packages, and no tenant column in any of the three tables. Two specifics are worth stating precisely because operators assume otherwise:
- WordPress multisite is not supported. There is no
switch_to_blog()oris_multisite()call anywhere in the memory drivers. Network-activating the plugin creates the tables for the primary site only. If you run a network and expect per-site isolation of agent memory, you do not have it, and the failure mode is silent. - Joomla is single-site with no per-language partitioning. Memory is not partitioned by language or by site context; if you need that separation, the caller has to namespace keys manually.
On Laravel, standard application-layer multi-tenancy patterns still apply — but they apply because you implemented them, not because the adapter is tenant-aware. The Laravel queue path is the one adapter-specific TTL in the set: RunAgentJob writes to the phpclaw_jobs namespace with a 3600-second default.
Privacy: what is retained, and for how long
Agencies ask this first, so here it is without hedging.
Is customer data retained? It can be, and by default the switch is on. store_messages defaults to TRUE — confirmed by direct citation for WordPress, Laravel, Joomla, Magento, OpenCart and PrestaShop, and at the core config layer. For Symfony and Drupal specifically, the literal default is not confirmed; both are confirmed to wire the PrivacyAwareMemory wrapper and drive it from config, but neither one’s default value is confirmed. Check your own config on those two rather than assuming it matches the other six.
What lands in the tables? User and assistant message text, in plaintext, in the content column. There is no tool_result column in the schema. On WordPress, though, tool output is persisted anyway: the adapter splices tool results into the history through a beforePersist mutator and writes them as a role=tool row into content. That applies to WordPress’s chat surfaces. Core on its own persists only user and assistant turns, so the WP-CLI path and the public REST route store neither. If a tool read an order or a customer record and returned it, that data is what gets written on those chat surfaces.
Is it encrypted? No. There is no encryption of message content anywhere in the PHP tree. The single encryption call in the packages encrypts Magento settings values.
For how long? Until you delete it. The message and conversation tables have no expiry mechanism, and no scheduled job exists to age them out.
How do you delete it? Deletion is CLI-only — forget and flush commands per adapter. No adapter registers a DELETE REST route, and there is no delete button in any admin UI. Uninstalling drops all three tables on five of the CMS adapters; Drupal’s phpclaw_uninstall() removes only its queue and file storage, and Laravel and Symfony require a migration rollback.
Does stored content leave the host? Yes, on the normal path. The message sent to the provider is the augmented one — retrieved memory is part of the prompt body. Tool results are replayed verbatim on the following turn. If the optional cloud layer is enabled, its scan guard posts the full augmented message text to the scan endpoint.
One second-order effect worth knowing. Content guards inspect the augmented message, which includes retrieved memory. The default PII guard blocks on match — it throws, it does not redact. So a customer address stored in memory months ago can be pulled in by keyword overlap and halt an unrelated later run, before any provider request is made. That is not a bug, it is the guard doing its job on a prompt you did not realise contained PII — but it will look like a mystery outage if you have not read the Guard article.
The bottom line for anyone handling customer data under a retention obligation: phpClaw does not manage local retention for you. It is your job, and it needs to be a real scheduled job that you write. Something like this, run from your own scheduler:
-- Runs as written on MySQL/MariaDB. Adjust prefix and window.
-- Delete messages first: OpenCart declares no FOREIGN KEY, so there is
-- no database-level cascade you can rely on across adapters.
DELETE m FROM wp_phpclaw_messages m
JOIN wp_phpclaw_conversations c ON c.id = m.conversation_id
WHERE c.created_at < DATE_SUB(NOW(), INTERVAL 90 DAY);
DELETE FROM wp_phpclaw_conversations
WHERE created_at < DATE_SUB(NOW(), INTERVAL 90 DAY);
Test it on a copy first, and confirm your adapter’s actual timestamp column name against the inspection query above before you schedule it.
Limitations
Stated plainly, because you will meet all of these in production.
-
No automatic retention or cleanup for the conversation and message tables. No cron, scheduled task, sweeper or pruning job exists in any PHP package. Locally stored conversation data is retained until an operator removes it. If you have a retention obligation, you own the job that satisfies it.
-
TTL on the key/value table is lazy. An expired row is removed only when that key is read again. Expired-but-unread rows persist indefinitely. Table size is not a reliable indicator of live data.
-
Memory injection drops silently. At most 3 entries per turn, chosen by keyword overlap; anything with zero overlap is dropped with no signal. Stored context can go permanently unused, and there is no diagnostic that tells you which entries never matched.
-
The relevance scan has no index.
MessageAugmenterloads the whole namespace viaall()and scores it in PHP on every turn. Injection cost is capped at three entries; read cost grows with the namespace. -
Turning off
store_messagesis not retroactive and does not restrict reads.PrivacyAwareMemorygatesset()only. Existing rows stay readable throughget()andall(), and continue to be eligible for injection into prompts. -
WordPress multisite is not supported. No
switch_to_blog()oris_multisite()anywhere in the memory drivers; network activation creates tables for the primary site only. There is no tenant column in any of the three tables on any adapter. -
The
store_messagesdefault on Symfony and Drupal is not confirmed. Both wire the config-driven privacy wrapper; the literal default value isn’t confirmed for either. Do not assume it matches the other six — verify against your own config. -
History compaction is off by default and not exposed. Both thresholds default to
0and no adapter surfaces them. Compaction is reachable only through the library’s fluent builder. Nothing condenses your context automatically. -
When compaction is enabled, it costs a provider request and it is not local. Each compaction is a second call carrying the older history in its body. The trigger is a
strlen / 4character estimate, not a real token count. -
Stored content is plaintext. No encryption of message content exists anywhere in the PHP tree.
-
Deletion is CLI-only. No DELETE REST route on any adapter, no admin delete button. Uninstall behaviour differs: five CMS adapters drop all three tables, Drupal’s uninstall removes only queue and file storage, Laravel and Symfony need a migration rollback.
-
Memory lifecycle events fire without a run id. The three memory events in the lifecycle enum are not correlated to the run that caused them, so memory writes do not appear inside a run’s event tree.
What to read next
- phpClaw Skills — the other half of what
MessageAugmenterputs in your prompt. Same class, same word-overlap scoring, same cap of three. Read it before you estimate prompt size. - phpClaw Guard — why content guards see retrieved memory, and why that means stored data can block a future run.
- phpClaw Providers — where the augmented message actually goes, and which endpoint receives it.
- phpClaw Hooks — the lifecycle events, including the three memory events, and what you can observe from outside.
- phpClaw Tools — what produces the tool output that ends up in
contenton WordPress’s chat surfaces.
Before production: install on staging, run the inspection query, decide your retention window, and write the job that enforces it. The product will not do that part for you, and it does not pretend to.