symfony/ai-cloudflare-store
Cloudflare Vectorize vector store integration for Symfony AI Store, enabling upsert, query, and delete-by-ID operations through Cloudflare's Vectorize API.
This is a Symfony AI bridge, not a Laravel package — there is no auto-discovery, no publishable config, and no facade. Budget an hour for wiring before you get your first query back.
1. Install and inspect the source
composer require symfony/ai-cloudflare-store
ls vendor/symfony/ai-cloudflare-store/src/
Read the concrete Store class and StoreFactory (added in v0.11) first. Constructor argument names and option-key names are the one thing no README here will tell you, and they shift between 0.x minors. Also read vendor/symfony/ai-store/src/StoreInterface.php to see exactly which methods (upsert, query, remove, clear, count) you are contractually bound to.
2. Prepare Cloudflare
3. Configure in Laravel style
// config/services.php
'cloudflare' => [
'vectorize' => [
'account_id' => env('CLOUDFLARE_ACCOUNT_ID'),
'api_token' => env('CLOUDFLARE_VECTORIZE_TOKEN'),
'index' => env('CLOUDFLARE_VECTORIZE_INDEX'),
],
],
4. Bind it once, in a provider
// app/Providers/VectorStoreServiceProvider.php
public function register(): void
{
$this->app->singleton(StoreInterface::class, function () {
$cfg = config('services.cloudflare.vectorize');
return StoreFactory::create('cloudflare', [
'account_id' => $cfg['account_id'],
'api_token' => $cfg['api_token'],
'index' => $cfg['index'],
]);
});
}
Wrap that binding in your own App\Contracts\VectorStore interface and have application code depend on that, not on StoreInterface. You will thank yourself when you swap stores or need a test double.
First use case: embed ~20 rows from an existing model with your embeddings provider, upsert them with id => "post:{$id}" and minimal metadata, then query with a top-K of 5 in php artisan tinker. If that round-trips, everything else is plumbing.
The store does not embed. It stores vectors and metadata. Embedding generation is a separate Symfony AI platform bridge (OpenAI, Voyage, Workers AI, …) or your own client. Keep an Embedder service and treat the store as a dumb vector sink — this keeps retries and cost accounting clean.
Mapper between Eloquent and documents. Since v0.14 there is a VectorDocumentInterface; before, you passed arrays. Either way, centralise it:
final class PostVectorMapper
{
public function toDocument(Post $post, array $embedding): array
{
return [
'id' => "post:{$post->id}",
'values' => $embedding,
'metadata' => [
'model' => 'post',
'tenant_id' => $post->tenant_id,
'locale' => $post->locale,
'updated_at' => $post->updated_at->timestamp,
],
];
}
}
Keep metadata small and filterable, not descriptive. Store the text in MySQL; the index is for retrieval, not storage.
Write path: queue, chunk, stay idempotent. Upsert in a job, chunk 100–200 vectors per call (Vectorize caps vectors per upsert request — currently 1,000; verify against live docs), and use deterministic IDs so a re-run is a no-op. Backfill with an artisan command that pages the model and dispatches the same job — one code path for backfill and live updates means one thing to debug.
Model observers for deletes. deleted → dispatch a job → $store->remove(["post:{$id}"]). Never do this inline in a request; Vectorize deletes are network calls and failures should be retried, not swallowed.
Retrieval-augmented generation: query → hydrate → prompt. The idiomatic flow is query the store for IDs + scores, then Post::whereIn('id', $ids) and rebuild the context in DB order. Don't put content in metadata and hope it comes back intact.
$hits = $store->query($embedding, ['topK' => 8, 'filter' => ['tenant_id' => $tenantId]]);
$ids = collect($hits)->pluck('id')->map(fn ($id) => (int) str($id)->after('post:'));
$docs = Post::whereIn('id', $ids)->get()->keyBy(fn ($p) => "post:{$p->id}");
Multi-tenancy via metadata filters, not indexes. Creating indexes per tenant is slow and hard to clean up. Filter on tenant_id and accept the shared index.
Cache aggressively in front of the store. Cache query results keyed by hash(embedding + filter + topK) for minutes-to-hours, and cache embeddings keyed by content hash. Embedding spend usually dwarfs store spend.
Testing: fake at your own boundary. Laravel's Http::fake() will not intercept this package — it uses the Symfony HTTP client. Bind a FakeVectorStore implementing your App\Contracts\VectorStore in tests, and if you need to test the bridge itself, inject a Symfony MockHttpClient into the store constructor.
Pin the exact version. This is a 0.x package; minors have added clear() (v0.11), VectorDocumentInterface and count() (v0.14). Use "symfony/ai-cloudflare-store": "0.14.*" and read every changelog. Keep all symfony/ai-* packages on the same minor — mismatched abstraction versions produce confusing interface errors.
Changing embedding models means a new index. Dimensions and metric are effectively immutable. New model → new index → full reindex → flip a config flag. Plan for dual-write during that window.
Eventual consistency bites tests. An upsert followed immediately by a query in the same test will occasionally return nothing. Don't write "upsert then assert hit" tests against a real index; do it against a fake, or poll with a timeout in an integration suite pointed at a dedicated dev index.
Never point automated tests at your production index. clear() is destructive and there is no local emulator. A stray RefreshDatabase-style teardown against prod is unrecoverable.
Symfony HTTP client, not Guzzle. That means no Http::retry(), no Laravel HTTP events. Add resilience by decorating the store: wrap calls in try/catch, retry on 429/5xx with backoff, and put a circuit breaker in front so a Cloudflare outage degrades to keyword search (Scout/SQL fulltext) instead of 500-ing your chat feature.
Cloudflare rate limits and cost. The client API is rate-limited per account; bulk operations without chunking + queue throttling will surface as 429s that look like random failures. count() adds another billed API path — cache it, expose it on a health endpoint or scheduler, and never call it in a user request.
Metadata limits and filter capabilities. Metadata has a per-vector size cap (order of a few KB) and filtering is largely equality/$in-style — don't architect range filters or complex boolean logic that Vectorize won't serve. Verify current operator support before designing around it.
ID constraints. Vector IDs are strings with a length cap; use a type:id prefix rather than raw integers so multiple models can share one index, and keep the raw PK in metadata for hydration fallbacks.
No hybrid search out of the box. If you need vector + keyword, implement reciprocal rank fusion in your own service by combining store hits with Scout results. Treat that as application code, not a bridge responsibility.
Observability is yours. Log every store call with latency and status, alert separately on 401 (token rotation), 429 (throttling), and 400 dimension-mismatch (model drift). Given ~0 dependents, community troubleshooting is thin — write the runbook while wiring it, not after the first incident.
How can I help you explore Laravel packages today?