Skip to content

Memory

A memory driver stores two kinds of data: conversations, so a chat can continue across requests, and any key-value data you choose to keep.

Memory is optional. send() and stream() work without it.


SituationDriver
unit testsArrayMemory
a script or a single serverFileMemory
more than one serverRedisMemory
an app on an adapterthe adapter’s database driver, see Drivers in each adapter

namespace PhpClaw\Memory\Contracts;
interface MemoryInterface
{
public function get(string $key, string $namespace = 'default'): mixed;
public function set(string $key, mixed $value, string $namespace = 'default', ?int $ttl = null): void;
public function forget(string $key, string $namespace = 'default'): void;
public function flush(string $namespace = 'default'): void;
public function all(string $namespace = 'default'): array;
public function has(string $key, string $namespace = 'default'): bool;
}

Attach a driver to the agent:

use PhpClaw\Claw;
use PhpClaw\Memory\FileMemory;
$agent = Claw::builder()->memory(new FileMemory)->build();

Attaching a driver does more than store conversations. On every send() and stream(), before the message reaches the model, the engine:

  1. reads every entry in the default namespace
  2. scores each entry by how many words of three or more letters its stored value shares with the message. The key is printed but never matched, so a value of gold does not match the question “what is my customer tier?”
  3. prepends the top three scoring entries, each as - key: value
[Context from memory]
Relevant memory context:
- customer_tier: gold tier customer
- preferred_language: French language preferred
[User message]
What discount do I get for my customer tier?

So write the words a user would type into the value, not only into the key.

Only the default namespace is scanned. Entries in any other namespace, including stored conversations, are never injected. To keep a driver as a plain store with no injection, write outside default, or use the driver directly without passing it to memory().

Injected text is also scanned by the guards, so a stored entry containing a blocked phrase can block an unrelated message. See what text each guard sees.


DriverClassStores inLifetime
arrayArrayMemoryprocess memorythe current process
fileFileMemoryone JSON file per namespaceuntil deleted
redisRedisMemoryRedis keys phpclaw:{namespace}:{key}until expiry; see TTL below

MemoryRegistry knows file and array out of the box. redis becomes available by name once the memory catalogue is activated, which every adapter except Magento does. Otherwise construct RedisMemory directly.

In-process only, lost when the process ends. Use it in tests.

$agent = Claw::builder()->memory(new ArrayMemory)->build();
$agent = Claw::builder()->memory(new FileMemory(storageDir: '/var/www/app/storage/phpclaw'))->build();

The default directory is storage/phpclaw/memory under the current working directory, so it moves if your process starts elsewhere. Set storageDir explicitly in anything but a script.

Reads take a shared lock and writes an exclusive lock, and each write goes to a temp file that is then renamed into place. Expired entries are pruned on every set(). Each server keeps its own files, so it does not suit more than one server.

Requires ext-redis. Pass a connected \Redis instance, or a URL:

$agent = Claw::builder()->memory(new RedisMemory(url: 'redis://:secret@10.0.0.4:6379/2'))->build();

With neither, it reads REDIS_URL, then falls back to redis://127.0.0.1:6379. The URL may carry a password and a database number.

OptionDefault
defaultTtl86400
prefixphpclaw:

all() and flush() use the Redis KEYS command, which scans the whole keyspace. On a large shared Redis, give phpClaw its own database number.

set() takes an optional $ttl in seconds. Passing a number behaves the same everywhere. Passing nothing does not.

Driverset($key, $value) with no TTLNever expire
ArrayMemorynever expiresomit $ttl
FileMemorynever expiresomit $ttl
RedisMemoryexpires after defaultTtl, 86400 secondspass ttl: 0, or construct with defaultTtl: 0

This includes conversations. The engine stores them without a TTL, so on Redis a conversation left untouched for 24 hours is gone.


Every core driver validates the namespace before any read or write, and throws PhpClaw\Exceptions\MemoryException if it is:

  • empty
  • longer than 100 characters
  • carrying a null byte
  • carrying ..
  • carrying / or \
  • carrying any character outside a-z A-Z 0-9 _ - . :

$agent = Claw::builder()->memory(new FileMemory)->build();
$conv = $agent->conversation();
echo $conv->id; // a ULID; keep it to resume later
$turn1 = $agent->sendInConversation($conv, 'My name is Alice.');
$turn2 = $agent->sendInConversation($turn1->conversation, 'What is my name?');
echo $turn2->response->text;

Each turn returns the updated conversation, so pass $turn->conversation to the next call.

Conversations are stored in the conversations namespace, keyed by their ULID. Resume one later by id:

$conv = $agent->conversation(id: $savedId);

Claw::builder()->storeMessages(false) sets a flag that you can read back with $agent->storeMessages(). In core, nothing else reads it. Setting it alone does not stop a single write.

To stop message content being stored, wrap the driver in PrivacyAwareMemory:

use PhpClaw\Memory\FileMemory;
use PhpClaw\Memory\PrivacyAwareMemory;
$agent = Claw::builder()
->memory(new PrivacyAwareMemory(new FileMemory, storeMessages: false))
->storeMessages(false)
->build();

With storeMessages: false, every set() is skipped, silently and completely: no conversation row, no content, no metadata. Reads, forget() and flush() still pass through. Each conversation then starts empty.

Every adapter applies PrivacyAwareMemory for you, based on its own message-storage setting. The wrapping is only your job when you use core directly.


Send each namespace to a different driver:

use PhpClaw\Memory\RouterMemory;
$memory = new RouterMemory(
default: new FileMemory,
routes: [
'conversations' => new RedisMemory(redis: $redis),
'scratch' => new ArrayMemory,
],
);

A namespace matches a route only exactly. Anything unmatched goes to default.

driverFor($namespace) returns the driver a namespace goes to, routes() returns the map without the default, and defaultDriver() returns the default.

RouterMemory and PrivacyAwareMemory do no validation of their own; the driver they call does.


Every adapter registers drivers backed by its own database and writes to three tables, phpclaw_conversations, phpclaw_messages and phpclaw_memory, with the platform’s table prefix. PrestaShop adds a fourth, phpclaw_api_token.

AdapterDriver usedChosen byAlso registered
LaraveldatabasePHPCLAW_MEMORY_DRIVERdatabase_kv, database_conversation, eloquent, eloquent_kv, eloquent_conversation, cache
Symfonydoctrinememory_driver in phpclaw.yamldoctrine_kv, doctrine_conversation, cache
WordPresswpdb_routerfixedwpdb, wpdb_conversation, wp_options, wp_transient, file
Drupaldatabasefixedfile, cache
Joomlajoomladbfixedfile
MagentoRouterMemory, injected directlyfixedresource
OpenCartoc_routerfixedoc_db, oc_setting, and opencart when a database connection is available
PrestaShopps_routerfixedps_db, ps_setting

On Laravel the eloquent* names are aliases for the database* drivers.

Every driver in the Driver used column is a namespace router: the conversations namespace goes to the conversation and message tables, and every other namespace to phpclaw_memory. On Magento the agent is given RouterMemory directly; the resource name refers to its key-value half, ResourceMemory, alone.

AdapterRouter classTables created by
LaravelDatabaseRouterMemorypackage migrations, run with php artisan migrate
SymfonyDoctrineRouterMemorythe Doctrine migration Version20240101000001
WordPressWpRouterMemorydbDelta() on activation
DrupalDrupalDbRouterMemoryhook_schema() when the module is enabled
JoomlaJoomlaDbRouterMemorythe package’s install SQL
MagentoRouterMemoryetc/db_schema.xml on setup:upgrade
OpenCartOcRouterMemorysql/install.sql on install
PrestaShopPsRouterMemorysql/install.sql on install

WordPress and Drupal register their own file driver in place of core’s.


Implement the six methods of MemoryInterface and pass the instance to memory(). No registration is needed.

To make it available by name, register it:

use PhpClaw\Memory\MemoryRegistry;
MemoryRegistry::register('mydriver', fn () => new MyMemory);
$memory = MemoryRegistry::build('mydriver');

register() accepts a class name or a factory closure. A class name must exist and implement MemoryInterface, or it throws. Names are case-insensitive.

Call NamespaceValidator::validate($namespace) at the start of each method to apply the same rules as the core drivers, and fire the memory.read, memory.write and memory.forget hooks if you want listeners to see your driver’s activity.


  • Hooks: memory.read, memory.write, memory.forget
  • Guards: why injected memory is scanned