Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Ai Neo4J Store Laravel Package

symfony/ai-neo4j-store

Neo4j vector store integration for Symfony AI Store. Persist embeddings in Neo4j and run vector similarity searches through its native vector indexes, giving Symfony AI applications a graph-database-backed store.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install the bridge and the driver

composer require symfony/ai-neo4j-store:^0.14 symfony/ai-store:^0.14 neo4j/neo4j-php-client

There is no Laravel service provider in this package. Nothing is auto-discovered: no config file, no facade, no publishable assets. Plan to wire it yourself.

Pin both ai-* packages to the same minor. They ship in lockstep out of the Symfony monorepo and the contracts move between them — a ^0.13 store against a ^0.14 core is a class-not-found waiting to happen.

2. Provision Neo4j 5.12+ and create the vector index first

The store does not create its index. Do it once, out of band, in Cypher:

CREATE VECTOR INDEX document_embeddings IF NOT EXISTS
FOR (n:Document) ON (n.embedding)
OPTIONS { indexConfig: {
  `vector.dimensions`: 1536,
  `vector.similarity_function`: 'cosine'
}};

Then wait for it to come online (CALL db.awaitIndexes()), because vector indexes are populated asynchronously.

3. Configure and bind

// config/services.php
'neo4j' => [
    'uri'      => env('NEO4J_URI', 'bolt://localhost:7687'),
    'username' => env('NEO4J_USERNAME', 'neo4j'),
    'password' => env('NEO4J_PASSWORD'),
    'index'    => env('NEO4J_VECTOR_INDEX', 'document_embeddings'),
],
// App\Providers\AiServiceProvider::register()
$this->app->singleton(StoreInterface::class, function ($app) {
    $client = ClientBuilder::create()
        ->withDriver('default', config('services.neo4j.uri'),
            AuthTokens::basic(config('services.neo4j.username'), config('services.neo4j.password')))
        ->build();

    return new Neo4jStore($client, config('services.neo4j.index'));
});

Check the constructor signature in vendor/symfony/ai-neo4j-store/ before assuming the arguments — it changed shape between 0.3.x and 0.4.x, and it is the kind of thing that changes again without a major bump.

Do not look for a StoreFactory for this one. 0.14 introduced StoreFactory for the remaining HTTP-based bridges; Neo4j talks bolt through neo4j/neo4j-php-client, so the factory does not cover it. Keep the manual ClientBuilder wiring above.

4. Where to look first

Read the installed source, not the README (the README is two paragraphs). In order:

  • vendor/symfony/ai-store/ — StoreInterface, VectorDocumentInterface, VectorDocument, Query.
  • vendor/symfony/ai-neo4j-store/ — the translation it performs into Cypher, i.e. which label and properties it writes.
  • vendor/symfony/ai-neo4j-store/CHANGELOG.md — 0.4.0 is where filter support and remove() landed; 0.14.0 is where the document interface and store count landed.

5. First use case

Semantic search over nodes you already have relationships for. Index a Document node, store its embedding, then retrieve by similarity and traverse its edges afterwards:

use Symfony\AI\Store\Document\VectorDocumentInterface;

$store->add(new VectorDocument($doc->uuid, $embedding, ['type' => 'contract']));

$results = $store->query(new Query($questionEmbedding, maxItems: 10));

$ids = array_map(fn (VectorDocumentInterface $d) => $d->id, $results->getDocuments());

// Graph context is your job, not the store's:
$rows = $client->run(
    'MATCH (d:Document)-[:CITES]->(s:Source)
     WHERE d.id IN $ids RETURN d.id AS id, collect(s.title) AS sources',
    ['ids' => $ids]
);

Type-hint VectorDocumentInterface, not VectorDocument. 0.14 split them, and anything you write that touches documents (mappers, result formatters, test fakes) should accept the interface so a future concrete change doesn't ripple.

That last block is the whole point of choosing Neo4j. If you never write it, you should have used symfony/ai-postgresql-store or the memory store.

Implementation Patterns

Wrap the Symfony interface in your own contract. Don't let Symfony\AI\Store\StoreInterface leak into controllers. One small app-level interface plus an adapter gives you a swap path to pgvector later and lets you fake it in tests.

interface EmbeddingIndex
{
    public function put(string $id, array $vector, array $meta = []): void;
    public function search(array $vector, int $topK = 10, array $filter = []): array;
    public function forget(string ...$ids): void;
    public function count(): int;
}

count() is the 0.14 addition; mirror it on your adapter rather than letting callers reach for the Symfony store. Check the vendor source for whether it is count() on the store or a separate countable interface before you implement it — the release note says "count capabilities", so confirm the shape and don't guess in your adapter's signature.

Ingestion belongs in queued jobs, batched. Never call add() inside a request cycle or a loop over Model::all(). One job per chunk, and dispatch from an observer:

class IndexDocument implements ShouldQueue
{
    public function __construct(private string $documentId) {}

    public function handle(EmbeddingIndex $index, Embedder $embedder): void
    {
        $doc = Document::find($this->documentId);
        if (!$doc) { return; }

        $index->put($doc->uuid, $embedder->embed($doc->body), [
            'tenant_id' => $doc->tenant_id,
            'type'      => $doc->type,
            'year'      => $doc->year,
        ]);
    }
}

Write a vector:init Artisan command. Index creation, existence check, dimension validation, and backfill all live here so a fresh environment is one command:

$client->run(<<<'CYPHER'
CREATE VECTOR INDEX document_embeddings IF NOT EXISTS
FOR (n:Document) ON (n.embedding)
OPTIONS { indexConfig: {`vector.dimensions`: $dims, `vector.similarity_function`: 'cosine'}}
CYPHER, ['dims' => config('ai.dimensions')]);
$client->run('CALL db.awaitIndexes()');

Read the dimension from the same config value your embedder uses. This is the single most common outage source.

Two-stage retrieval: vector then graph. This is the pattern that justifies the dependency. Stage 1 gets candidates by cosine similarity; stage 2 expands, filters, or reranks using relationships the vector store cannot see:

$candidates = $index->search($queryVector, topK: 50, filter: ['tenant_id' => $tenantId]);

$rows = $client->run(
    'MATCH (d:Document) WHERE d.id IN $ids
     MATCH (d)-[:AUTHORED_BY]->(a:Author)
     WHERE a.verified = true
     OPTIONAL MATCH (d)-[:CITES]->(c:Document)
     RETURN d.id AS id, a.name AS author, count(c) AS citations',
    ['ids' => array_column($candidates, 'id')]
);

Over-fetch in stage 1 (50–100) then slice to 10 after graph filtering. If you ask the store for exactly 10 and then filter, you will frequently return fewer than 10 and look broken.

Filters are equality on metadata, not Cypher. Since 0.4.0 the Query accepts a filter array, which the store maps onto node property predicates. That means: no range queries beyond what the package supports, no relationship conditions, no boolean composition you didn't verify in the source. Anything richer goes in the second Cypher stage above. Keep the vector-side filter to a single high-cardinality scoping key (tenant_id, workspace_id) so the index actually prunes.

Use the new count for dashboards, never in a request hot path. The 0.14 counter is the natural source for "N documents indexed" in an admin panel or a Prometheus gauge. On Neo4j it is still a graph traversal underneath; cache it (Cache::remember('vec:count:'.$tenantId, 60, ...)) and refresh on the reindex command, not per page view.

Cache hot queries. Vector search on Neo4j is not free, and identical embeddings appear constantly in chat UIs:

$key = 'vec:'.hash('xxh128', $tenantId.json_encode($vector).$topK);
return Cache::remember($key, now()->addMinutes(10), fn () => $index->search(...));

Invalidate by tenant tag on reindex.

Test against a fake, smoke-test against a container. Unit tests get an in-memory EmbeddingIndex implementation using naive cosine similarity over an array. Reserve a real Neo4j (Testcontainers or a CI service) for one integration test that asserts the index exists, dimensions match, and a round-trip put/search/forget/count returns the inserted id.

Keep the driver as a separate singleton. Bind Neo4j\Client itself, not just the store. You will need it for index management, backfills, health checks, delete-by-filter, and every graph query the store can't express.

Gotchas and Tips

The store writes to a fixed label and property — your index must match exactly. The package chooses a node label and an embedding property name internally. If you create the index on (:Document {embedding}) but the store writes (:Vector {vector}), every add() succeeds silently and every query() returns zero rows. Dump the actual Cypher once (grep for MERGE or UNWIND in the vendor source, or enable Neo4j query logging) and align the index to its labels, not the other way round.

VectorDocumentInterface is new in 0.14 — re-check your type hints. If you wrote __construct(private VectorDocument $doc) anywhere, or a mapper that returns the concrete class, an upgrade will either break it or quietly narrow what you can accept. Code against the interface in your adapter, your result formatters, and your test fakes; keep new VectorDocument(...) only where you are actually constructing.

Vector indexes are eventually consistent. CREATE VECTOR INDEX returns immediately; nodes are only queryable once the index is ONLINE. A test that creates the index, inserts, and queries in the same process will flake. Always CALL db.awaitIndexes() after creation and before the first query.

Dimension mismatch is a silent failure. Embed 768-dim vectors from a local model against a 1536-dim index and you get empty result sets rather than an exception in some driver/Neo4j combinations. Assert the dimension at boot:

abort_unless(count($embedder->embed('probe')) === config('ai.dimensions'), 500, 'Embedding dimension mismatch');

Do it in a health check, not per request.

Similarity function is baked into the index, not the query. If the index is cosine and you normalize for euclidean, rankings will be plausible-but-wrong — the worst kind of bug. Pick one, record it in config, and don't let two services disagree.

Neo4j property types are narrow. Store metadata as strings, numbers, booleans, or arrays of those. Nested arrays (['a' => ['b']]), objects, Carbon instances, and enums will either throw or be coerced in a way you won't like. Flatten timestamps to ISO strings and cast on the way out; JSON-encode anything structural and accept that it's no longer filterable.

remove() takes ids, not predicates. There is no "delete everything for tenant X" in the store's API, and 0.14 did not add one. Deleting by filter is a Cypher job:

$client->run('MATCH (n) WHERE n.tenant_id = $t DETACH DELETE n', ['t' => $tenantId]);

Also: nothing cascades. Delete a Document in MySQL and its Neo4j node survives as an orphan that will happily surface in search results. Hook deleted model events — and remember to bust your cached count when you do.

No service provider means no config('ai.stores'). symfony/ai-bundle provides the Symfony wiring; you are not using it. StoreFactory (0.14) is the Symfony-side equivalent for HTTP-based bridges and does not apply to a bolt-connected Neo4j store. Don't copy Symfony bundle docs into a Laravel app and wonder why nothing resolves. Your AppServiceProvider (or a dedicated AiServiceProvider) is the whole integration surface.

Watch the API rename between minors. Pre-0.4 the contract was VectorStoreInterface with similaritySearch(); 0.4 introduced the query abstraction (Query, filter support) and remove(); 0.14 introduced VectorDocumentInterface and store count. Code found in blog posts and older assessments will not compile. 0.14 is largely additive and should be a cheap upgrade, but the interface split is the one change that can bite a project that type-hinted the concrete document class. Read CHANGELOG.md on every bump.

Memory scales with index size. Neo4j keeps vector indexes in memory: roughly dimensions × 4 bytes × node count, plus overhead. 1M documents at 1536 dims is ~6 GB before indexing overhead. Size the instance against that before you commit, and prefer filtering during retrieval over brute-forcing a large unfiltered scan.

Debugging, in order. SHOW INDEXES YIELD name, type, state, labelsOrTypes, properties (is it online, is it on the right label?); EXPLAIN MATCH (n:Label) WHERE ... (is the planner actually using db.index.vector.queryNodes?); then log the raw parameters the store sends. Most "the store is broken" reports are an index mismatch or an offline index.

Extension point is decoration, not inheritance. Neo4jStore is a thin concrete class with no configuration surface. Don't subclass it — decorate your own EmbeddingIndex adapter to add caching, retries, tenant scoping, metrics, the count passthrough, and the graph-expansion stage. Keep the Symfony class as an implementation detail you can swap for a PgVectorIndex in one binding.

Cost check before the sprint, not after. AuraDB's free tier is fine for a POC at 10K vectors; production-sized vector indexes on managed Neo4j are a line item. If the graph traversal in stage 2 turns out to be optional in your product, the whole package is optional too.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log
hsluv/hsluv
seampact/codegen
seampact/hooks
seampact/ir
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table
siberfx/flysystem-wasabi
hollyit/token-replace
ippanelcom/laravel-sdk