pandawan-technology/neo4j-bundle
Pandawan Neo4j Bundle integrates the Neo4j graph database into Symfony/PHP applications, providing configuration and services to connect, query, and manage graph data. Useful for projects needing graph relationships, traversal, and Cypher-backed persistence.
First, do not composer require this. The README opens with "NOT MAINTAINED ANYMORE" and redirects to neo4j-contrib/neo4j-symfony, and the package is a Symfony bundle — pulling it into a Laravel app drags in symfony/http-kernel, symfony/dependency-injection, and a bootstrapping model that does not exist in Laravel. Its 1 star and 0 dependents mean you are the only person who will ever file a bug against it. Treat the repo as reference source, not a dependency.
Where to look first (30 minutes, timeboxed):
DependencyInjection/Neo4jExtension.php — the config tree: connection names, driver URI, auth, and the clients map. This is the entire contract of the bundle.Resources/config/services.yml — how the client is constructed and aliased. This is the only design decision worth copying: a named client service per configured connection, built once and injected.That is the whole package. Everything else is Symfony plumbing you will not reuse.
The Laravel-shaped first use case — a graph of User nodes with FOLLOWS edges, backed by the maintained driver directly:
composer require graphaware/neo4j-php-client
// config/neo4j.php
return [
'default' => env('NEO4J_CONNECTION', 'default'),
'connections' => [
'default' => [
'uri' => env('NEO4J_URI', 'bolt://neo4j:[email protected]:7687'),
'database' => env('NEO4J_DATABASE', 'neo4j'),
],
],
];
// app/Providers/Neo4jServiceProvider.php
class Neo4jServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(config_path('neo4j.php'), 'neo4j');
// scoped(), not singleton() — see Gotchas (Octane).
$this->app->scoped(Client::class, function ($app) {
$cfg = $app['config']->get('neo4j.connections.'.config('neo4j.default'));
return ClientBuilder::create()
->addConnection('default', $cfg['uri'])
->setDefaultTimeout(30)
->build();
});
}
public function boot(): void
{
$this->publishes([config_path('neo4j.php') => config_path('neo4j.php')], 'neo4j-config');
}
}
// first query
app(Client::class)->run(
'MERGE (u:User {id: $id}) RETURN u',
['id' => $userId]
);
If the target stack is genuinely Symfony, stop reading this repo and evaluate neo4j-contrib/neo4j-symfony against your Symfony version matrix instead. For Laravel, the driver + a thin provider is the package.
1. Map the bundle's multi-connection YAML to Laravel config. The bundle's one real feature is named clients. Preserve that shape so multiple graph connections (e.g. analytics, identity) stay addressable:
public function register(): void
{
$this->app->scoped('neo4j.connection.'.$name, fn () => ...);
// or a small ConnectionManager that lazily builds by name
}
Keep a Neo4jManager with a connection(string $name = null): Client method if you run more than one graph. Otherwise a single scoped Client is enough and the manager is over-engineering.
2. Repository-per-aggregate, thin. Do not leak the client into controllers or models. One class per graph aggregate, constructor-injected, Cypher inline, parameters always bound:
final class FollowGraph
{
public function __construct(private Client $client) {}
public function follow(int $from, int $to): void
{
$this->client->run(
'MATCH (a:User {id: $from}), (b:User {id: $to})
MERGE (a)-[:FOLLOWS]->(b)',
['from' => $from, 'to' => $to]
);
}
public function followers(int $id, int $limit = 50): array
{
return $this->client->run(
'MATCH (:User {id: $id})<-[:FOLLOWS]-(f:User)
RETURN f.id AS id LIMIT $limit',
['id' => $id, 'limit' => $limit]
)->toArray();
}
}
This is exactly what the bundle's service-injection pattern gives you in Symfony — you are just re-implementing it in four lines of provider code.
3. Reimplement the profiling hook as a decorator. The bundle's logging support is a wrapper around the client. Do the same, bind the decorator in the container, and gate it on app()->isLocal():
final class LoggingClient implements ClientInterface
{
public function __construct(private ClientInterface $inner) {}
public function run(string $statement, array $params = [])
{
$t = hrtime(true);
try {
return $this->inner->run($statement, $params);
} finally {
Log::channel('neo4j')->debug('cypher', [
'ms' => (hrtime(true) - $t) / 1e6,
'cypher' => $statement,
'params' => $params, // redact PII here
]);
}
}
}
Bind ClientInterface to the decorator when config('app.debug') is true, otherwise bind directly.
4. Health check as an Artisan command. Every integration needs one and the bundle does not ship it:
Artisan::command('neo4j:ping', function () {
$this->app->make(Client::class)->run('RETURN 1 AS ok');
$this->info('neo4j ok');
})->purpose('Verify Neo4j connectivity');
Wire it into your deploy pre-flight and your /health endpoint via Queue::fake-style checks so container health reflects graph health.
5. Testing: swap the binding, don't hit a server. Because everything resolves through the container, tests are clean:
$this->app->instance(Client::class, $fake);
For integration tests, spin up Neo4j with Testcontainers (or a docker-compose service) and point the connection URI at it via env — the config layer already supports that. Do not attempt to unit-test Cypher; test the repository against a real instance and assert on results.
6. Queue workers and Octane: reset per unit of work. If you use singleton(), the client holds a stale Bolt connection across jobs. Use scoped() (Laravel resets it between Octane requests) and, for queue workers, close the driver in a JobProcessed listener so long-running workers do not leak sockets during a Neo4j restart.
7. Graceful degradation. Graph features are usually non-critical relative to your relational core. Wrap repository calls in a circuit breaker (resilience helpers or a small Retry/fallback) so a Neo4j outage degrades recommendations/recommendation panels rather than 500-ing the app.
app->scoped() already give you. If you find yourself writing a Symfony\Component\DependencyInjection\ContainerInterface shim, stop — you have gone off the rails.config/ conventions. Laravel's container does not read the bundle's services.yml, so out of the box this package does literally nothing in a Laravel app.composer.json constraints will pin ancient Symfony versions and, transitively, an old graphaware/neo4j-php-client whose Bolt protocol version may not talk to Neo4j 5.x. Symptom: composer require resolves a strange set, or the server rejects the handshake. Check the target Neo4j server version before anything else.graphaware/neo4j-php-client for neo4j/neo4j-php-client, you want one line changed in your provider, not a grep across the codebase. Type-hint your own Neo4jClient interface, implement it around the driver.app.debug. Neo4j queries are chatty and per-query Log::debug in production will flood the log and dominate wall time.php artisan serve all keep processes alive. A cached Client will hold a dead socket after a database restart and every call will fail until the worker recycles. scoped() + a health check in the supervisor loop (or a JobProcessed teardown) is the fix.bolt://user:pass@host:port or bolt+routing://. If you copy a URI into .env and quote it wrong, auth failures surface as opaque connection errors. The ping command above is how you tell the difference between "wrong password" and "server down".services.yml is editable in the vendor directory but any such edit is lost on composer update. Forking an abandoned repo to add a feature means you have adopted a maintenance burden nobody signed off on. Do not.pg_edge table solves the same queries with one fewer runtime, one fewer driver, and one fewer thing abandoned on you. The bundle being abandoned is a data point about the PHP graph ecosystem's thinness — weight it in the architecture decision, not just the dependency decision.How can I help you explore Laravel packages today?