symfony/ai-cache-message-store
PSR-6 cache message store for Symfony AI Chat: persist and retrieve conversation messages through any PSR-6 cache implementation, keeping chat history fast and simple to manage.
Install and pin.
composer require symfony/ai-chat symfony/ai-cache-message-store symfony/cache
Then edit composer.json and replace the caret constraints with exact versions ("symfony/ai-cache-message-store": "0.14.0", not "^0.14"). This package rides a 0.x line where any minor may break; you want upgrades to be a deliberate commit, not a side effect of composer update.
Look at the source first, not the README. The README is a stub — a description and links to the symfony/ai monorepo. The authoritative docs are:
vendor/symfony/ai-cache-message-store/src/ — this is a one-class bridge. Read it end to end; it will take ten minutes.vendor/symfony/ai-chat/ — specifically the MessageStore interface. That contract, not this package, determines whether adoption is cheap or painful.vendor/symfony/cache/Adapter/RedisAdapter.php — the PSR-6 pool you'll actually instantiate.Do not copy constructor signatures or method names from blog posts or AI-generated snippets. The published examples circulating for this package are reconstructions and have been wrong before (three-arg constructors, findAll('chat_id'), new Message('user', ...)). Confirm against the file on disk.
First use case: a session-scoped conversation cache. The job-to-be-done is "the user's last N turns survive an HTTP round-trip without hitting the database." Not audit logging, not compliance retention, not cross-device sync. If your requirement is any of those, this is the wrong tool — you want a durable store and this package as an optimization layer at most.
Minimal wiring. Sketch — verify the constructor arity against source before trusting it:
// app/Providers/AiServiceProvider.php
public function register(): void
{
$this->app->singleton(CacheItemPoolInterface::class, function () {
return new RedisAdapter(
RedisAdapter::createConnection(config('ai.chat.dsn')),
cacheNamespace: config('ai.chat.prefix'), // 'ai_chat_'
defaultLifetime: config('ai.chat.ttl'), // seconds
);
});
$this->app->singleton(MessageStore::class, fn ($app) => new CacheMessageStore(
$app->make(CacheItemPoolInterface::class)
));
}
// config/ai.php
'chat' => [
'dsn' => env('AI_CHAT_CACHE_DSN', 'redis://127.0.0.1:6379/2'),
'prefix' => 'ai_chat_',
'ttl' => 60 * 60 * 24 * 7,
],
Then prove the round-trip in php artisan tinker — store a message, re-resolve the store in a fresh call, read it back — before wiring it into a controller. Serialization failures surface at persistence time, mid-conversation, which is the worst place to discover them.
Bind behind your own interface. Do not let Symfony\AI\...\MessageStore leak into controllers or jobs.
interface ChatHistory
{
public function load(string $sessionId): array; // Message[]
public function append(string $sessionId, Message $m): void;
public function forget(string $sessionId): void;
}
final class CachedChatHistory implements ChatHistory
{
public function __construct(private MessageStore $store) {}
// ...
}
This costs an hour and buys you the option to swap in a native Laravel implementation later without touching feature code — worth it given the build-vs-buy gap is roughly one engineering day.
Namespace keys by tenant and session. chat:{tenant_id}:{session_id} as a convention encoded in your ChatHistory implementation, not left to the pool. There are no cache tags here; a naming convention is your only invalidation mechanism. If you need bulk invalidation (e.g. "drop everything for tenant X after a data purge"), add an epoch key — chat:epoch:{tenant} — that you bump, and include its value in the session key.
Trim before you persist. The store will faithfully keep every message forever within the TTL window, and every turn re-reads the full history. Memory per session grows linearly, and the prompt sent to the model grows with it. Slice to the last N turns (plus any pinned system messages) in your ChatHistory::append() path, or you will discover the problem as a Redis memory alert.
Per-environment pools. ArrayAdapter for tests and local dev, RedisAdapter for production, ChainAdapter if you want an APCu hot layer in front of Redis. Inject the pool, don't new it up inside the store consumer.
Test against real message shapes, not arrays. The integration risk is not the cache, it's the round-trip of your actual domain objects — tool calls, multimodal parts, enums, anything holding a reference. Write a contract test that serializes representative conversations through an ArrayAdapter and asserts equality. Run it in CI on every dependency bump.
Keep persistence off the request path when you can. dispatchAfterResponse() or a terminable middleware for the write; synchronous read only when the conversation is actually needed.
Read-through fallback. If the cache misses, hydrate from your durable store, repopulate, and serve. This is what makes a cache-only design survivable and is a small amount of code if your ChatHistory interface is already the single entry point.
PSR-6 vs PSR-16 — the most likely first failure. Laravel's Cache facade and Illuminate\Contracts\Cache\Repository are PSR-16 (simple key/value). This package needs Psr\Cache\CacheItemPoolInterface, which is PSR-6 (item objects with isHit(), expiresAfter()). You cannot pass Cache::store('redis') to it. That's why symfony/cache is a required install, and why you end up with a second Redis configuration alongside config/cache.php. Keep the DSN in one env var and reference it from both places, or accept two configs drifting.
Double prefixing. RedisAdapter has its own cacheNamespace; Laravel's Redis connection options may add a prefix too. redis-cli --scan --pattern 'ai_chat_*' and check what the keys actually look like before assuming the store isn't writing.
Serialization is the sharp edge. Symfony's default marshaller uses PHP serialize(). Enums are fine on 8.1+. Closures, resources, generators, and any object graph with unserializable nodes will throw — usually at write time, sometimes at read time with a confusing "you must call the unserialize method" style error. If your message types wrap SDK objects, flatten them to plain data before storing. Reach for SodiumMarshaller/DeflateMarshaller only after you've confirmed the store's expectations; adding a marshaller to the pool is not invisible to the store.
TTL ownership is ambiguous. Both the pool (defaultLifetime) and possibly the store itself may set expiry. Pick one place — pool-level — and leave the other alone, or you'll get conversations expiring at a time neither config predicts. Watch for 0 meaning "never" in Symfony cache semantics versus "immediate" elsewhere.
Eviction is data loss, not a performance event. Set maxmemory-policy noeviction, or volatile-lru with TTLs set on every key. allkeys-lru on a chat session cache silently discards live conversations and your AI answers start referencing context that no longer exists. Alert on eviction count and on the message-age distribution.
No stampede protection. Nothing in the bridge coalesces concurrent misses on a hot session. If that matters, wrap hydration in a symfony/lock lock keyed on the session id — a few lines, but write it deliberately rather than discovering the problem under load.
Extension points are thin by design. You get: swap the pool, decorate the store, wrap it in your own interface. There is no plugin seam, no event dispatcher, no serializer hook exposed. If you need behavior the class doesn't have, you're writing a decorator or forking — and given the class is ~one file, forking is genuinely reasonable.
Debugging checklist. Value present but unreadable → marshaller/serialization. Value absent → key convention mismatch (check prefix and epoch). Value stale → TTL not applied where you thought. Read path slow → full-history hydration, not the cache.
Treat every minor bump as a change to review. Releases 0.5 through 0.14 were "no significant changes," which is reassuring about churn but tells you nothing about the next one. Bump in a branch, run the contract test, ship. Do not let this float.
How can I help you explore Laravel packages today?