symfony/ai-cloudflare-message-store
Cloudflare KV message store integration for Symfony AI Chat, backed by Cloudflare's KV API with bulk read and write support.
Read the Symfony AI Chat / message-store documentation before this package's README β the README here is essentially a pointer to the main symfony/ai monorepo, so the real integration contract (the message store interface, conversation/session semantics, how the store is injected into the agent) lives there. Treat this repo as an adapter, not a framework.
Minimal path:
symfony/ai-chat's message store abstraction. Laravel does not ship that abstraction, so without symfony/ai-chat there is nothing to bind it to. If your AI layer is openai-php/laravel, prism, or a custom client, you are not adopting this package β you are copying its idea (KV as message backend) and reimplementing it against your own contract. Be honest about that fork upfront.composer require symfony/ai-chat symfony/ai-cloudflare-message-store
Pin exactly β this package is 0.x (v0.14.0 at time of writing) with several releases marked "no significant changes", so minor bumps carry API churn risk with no semver guarantee.wrangler kv namespace create CHAT_MESSAGES or the dashboard), then an API token scoped to KV Storage: Edit for that account. Put CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_KV_NAMESPACE_ID, and CLOUDFLARE_API_TOKEN in .env, and mirror them into config/services.php so config:cache works.Where to look first in the source: the store class itself (how it keys messages, whether it uses bulk get/update, what it serializes) and the Symfony AI factory registration. Those two files tell you everything the README doesn't.
Bind the store through the container, not by instantiating it. Even in Laravel, resolve the store as a singleton from a service provider so tests can swap it. If you're bridging into Laravel's own abstractions, define a narrow contract of your own:
// app/Contracts/ChatMessageStore.php
interface ChatMessageStore
{
public function append(string $conversationId, array $messages): void;
public function load(string $conversationId): array;
public function forget(string $conversationId): void;
}
Then have one adapter wrap the Symfony store. Everything else in the app depends on your interface, which means the Cloudflare dependency is a single swappable class rather than a leak across the codebase.
Key design matters more than the store. Cloudflare KV is a flat keyspace with no query language, so decide the key scheme before you write a line. A workable scheme:
conv:{conversationId}:meta -> JSON: user_id, agent, created_at, last_message_at
conv:{conversationId}:msgs:{seq} -> JSON: single message or small batch
user:{userId}:conversations -> JSON: index of conversation IDs
The index key is what saves you β without it, "list this user's conversations" is impossible in KV. Keep the index append-only and rewrite it on new conversations; it is small and read-hot, which suits KV.
Use bulk operations deliberately. The package exposes Cloudflare's bulk endpoints specifically to avoid N round trips when loading a long transcript. Load a conversation by reading the index/metadata key, then bulk-getting the message keys in one call. Batch size is capped by Cloudflare (roughly 100 keys per bulk get); chunk your reads above that. For writes, append the new turn(s) via bulk update rather than one call per message.
Hybrid storage is the pragmatic default. Store the searchable, joinable stuff β conversation metadata, user mapping, token counts, cost attribution β in MySQL/Postgres, and keep only message payloads in KV. You get SQL for dashboards and analytics, KV for the hot read path, and you sidestep KV's lack of secondary indexes entirely. This is the pattern most Laravel teams will actually end up at.
Queue the writes when latency matters. The agent response shouldn't wait on a KV round trip. Dispatch a job after the response is streamed:
dispatch(new PersistChatTurn($conversationId, $assistantMessage))->afterResponse();
Reads stay synchronous (that's where KV's edge latency pays off); writes go async. Add a retry/backoff policy on the job, since transient Cloudflare 5xx are a real category.
Set TTLs, don't rely on cleanup jobs. Cloudflare KV supports per-key expiration. Give message keys and conversation metadata a TTL aligned with your retention policy (e.g. 90 days) β it costs nothing and removes an entire class of "why is this namespace 40GB" problems. Just make sure the index and the message keys agree on expiry, or you get dangling IDs.
Testing: never hit Cloudflare from the suite. Implement your contract with an in-memory or database-backed fake and bind it in TestCase::setUp():
$this->app->instance(ChatMessageStore::class, new InMemoryChatMessageStore());
Reserve one integration test, tagged and excluded from CI by default, that exercises the real adapter against a scratch namespace.
Eventual consistency will bite you in tests and demos. A write to KV is not immediately visible in all locations. If your flow is "save assistant reply β reload page β render history", a fast reload from a different edge can miss the message. Mitigate by keeping the last N turns in the session or a short-lived Redis cache, and by not asserting read-after-write in acceptance tests.
No queries, no transactions, no ordering guarantees. Anything that smells like "find messages whereβ¦" needs a secondary store. Plan the metadata table early; retrofitting an index over an existing namespace means a full key scan, which is painful and slow.
The 64KB per-value limit is a hard edge. Long tool outputs, base64 images, and multi-megabyte RAG contexts will blow through it. Truncate tool results before persisting, store attachments in R2/S3 and keep only the reference in the message, and consider one-message-per-key rather than per-conversation blobs so a single giant turn doesn't poison the whole history.
Credential handling. CLOUDFLARE_API_TOKEN in .env is fine for dev but is a real secret in production β pull it from your platform's secret manager (Forge/Vapor/ECS/SSM) and make sure it never lands in config:cache-ed files or logs. Scope the token to KV on one account, and use a separate namespace per environment so a staging bug can't touch production history.
Config caching and env access. If the Symfony store reads $_ENV/getenv() directly, php artisan config:cache environments can surprise you. Resolve credentials in a Laravel config file and pass them explicitly into the store's constructor when you register it in the provider.
Serialization is your problem. Chat messages are objects, not scalars; whatever the store's default serializer does may not round-trip your message types (tool calls, function results, custom metadata). Lock this down with a test that asserts a full message β role, content, tool call payload β survives store/load intact. This is the single most likely place for silent data loss.
Cost shape favors reads but bulk calls are not free. KV pricing is per-operation, and a naive load of a 200-message conversation as 200 individual gets is 200 operations each time the agent runs. Bulk get and sensible conversation-windowing (last 10β20 turns in context, older turns fetched only on demand) keep this linear-but-small. Log operation counts per request in staging for a week and look at the distribution.
Watch the version pin and the release notes literally. Multiple 0.x releases describe themselves as "no significant changes", which tells you the project is still stabilizing under the Symfony monorepo's release train. Pin ~0.14.0, read the changelog before bumping, and keep the adapter thin so a breaking rename costs you one file.
Debugging visibility is thin. There's no Laravel package here β no facade, no config file, no log channel. Wrap the store in a decorator that logs key, operation type, byte size, latency, and outcome; that decorator is also where you put circuit-breaking so a Cloudflare outage degrades to "chat has no memory" instead of "chat 500s". Cloudflare's own dashboard metrics are the other half of the picture β check them against your logged operation counts monthly.
Escape hatches to plan for. Because your app depends on your ChatMessageStore contract rather than this package, migrating to Redis, DynamoDB, or a plain messages table is a one-class change. Keep the serialization format documented (JSON, versioned by a schema field on each value) so a bulk export/import is a weekend job instead of a rewrite.
How can I help you explore Laravel packages today?