plumtreesystems/neo4j-bolt
Connect your Laravel or PHP application to Neo4j graph databases over the Bolt protocol. This package offers a lightweight client for running Cypher queries and working with graph data directly from your PHP code.
This is a protocol-level driver, not an ORM or a query builder. There is no Neo4j::match() fluent API, no Eloquent analogue, no migrations, and no Laravel service provider shipped. You get PackStream packing/unpacking plus the Bolt message classes (HELLO, RUN, PULL, BEGIN, COMMIT) and you drive Cypher yourself. Plan for ~150 lines of glue code before it's pleasant to use.
1. Install
composer require plumtreesystems/neo4j-bolt
2. Where to look first. The README is thin. Go straight to src/ and read, in this order: Connection.php (public surface you'll actually call: connect, run, pull/discard, credentials), Protocol.php (version handshake), Message/* (what actually goes over the wire), and PackStream/Unpacker.php (this determines the shape of every value you get back — nodes, relationships, temporals, byte arrays).
3. Wire it into Laravel as a singleton with a config file, so the rest of the app never touches Bolt\* directly:
// config/neo4j.php
return [
'host' => env('NEO4J_HOST', '127.0.0.1'),
'port' => (int) env('NEO4J_PORT', 7687),
'username' => env('NEO4J_USERNAME', 'neo4j'),
'password' => env('NEO4J_PASSWORD'),
'timeout' => (float) env('NEO4J_TIMEOUT', 5),
];
// app/Services/Neo4j.php
namespace App\Services;
use Bolt\Connection;
class Neo4j
{
private ?Connection $conn = null;
public function __construct(private array $config) {}
public function run(string $cypher, array $params = []): array
{
$conn = $this->connection();
// Method names below match the 2020 release; confirm against
// src/Connection.php if you pin a different tag.
$conn->run($cypher, $params);
$rows = [];
foreach ($conn->getResponse()->getContent() as $record) {
$rows[] = $record;
}
return $rows; // caller maps Bolt\Structures\Node etc. to arrays
}
private function connection(): Connection
{
if ($this->conn === null) {
$this->conn = new Connection($this->config['host'], $this->config['port']);
$this->conn->setTimeout($this->config['timeout']);
$this->conn->connect();
$this->conn->setCredentials($this->config['username'], $this->config['password']);
}
return $this->conn;
}
}
Bind it in AppServiceProvider::register() via $this->app->singleton(Neo4j::class, fn () => new Neo4j(config('neo4j')));
4. First use case — pick a read-only, non-critical one. Recommendations, "people you may know", permission/ACL traversal, or graph-backed facets. Do not make your first integration a write path that must be transactionally consistent with MySQL; there is no two-phase commit here and you will be reconciling by hand. A good starter query:
MATCH (u:User {id: $id})-[:FOLLOWS]->(o:User)
WHERE NOT (u)-[:FOLLOWS]->(o)
RETURN o.id AS id, o.name AS name
LIMIT 10
$suggestions = Neo4j::run($cypher, ['id' => $user->id]);
Wrap, then forget. Never let controllers, jobs, or models touch Bolt\* classes. One Neo4j service with run(), runInTransaction(), and mapValue() is the whole abstraction. Everything below assumes that boundary.
Parameter binding is not optional. Bolt's RUN takes a parameters map, and the driver serialises it through PackStream — there is no string interpolation happening and no SQL-injection analogue if you use it. Building Cypher with sprintf() and $user->id is the single most common way this goes wrong in PHP graph code. Always run($cypher, $params).
Translate structures at the edge. PackStream returns typed objects, not arrays. Node gives you getProperties(), getLabels(), and an element id that is only meaningful for the duration of the transaction. Map to plain arrays or DTOs inside Neo4j, before the result escapes:
private function mapNode(\Bolt\Structures\Node $node): array
{
return $node->getProperties() + ['_labels' => $node->getLabels()];
}
Transactions. Use the explicit messages (BEGIN, RUN, PULL, COMMIT / ROLLBACK) rather than relying on a single auto-commit RUN, and always ROLLBACK in a finally. If you don't run the closing message, the connection is left mid-transaction and the next unrelated query fails with a protocol error you'll misdiagnose as a network problem.
Connection strategy: one per request, disposed. Bolt is a stateful, ordered, single-stream protocol. A shared connection is not safe across concurrent requests and does not survive long idle periods reliably. Register a terminate()/__destruct on the service and, in jobs, close in finally. If you run Octane/Swoole, this is non-negotiable — reset the connection per request, the way DB::purge() works for PDO:
// AppServiceProvider::boot()
Event::listen(Terminating::class, fn () => app(Neo4j::class)->disconnect());
Repository pattern for Cypher. Keep .cypher files in resources/cypher/ (or as const strings on a repository class) and load them with file_get_contents(). Cypher gets long, and inlining multi-line MATCH/MERGE blocks in PHP is where maintainability dies. This also lets you dd() a query verbatim when debugging.
Async/queued work. For graph writes triggered by Eloquent events, use a queued listener — graph round-trips are 1–10ms locally but can spike on large traversals, and you don't want them inside a web request's write path.
Testing. Don't fake the driver; fake your Neo4j service ($this->mock(Neo4j::class)) in unit tests, and run one or two integration tests against a real Neo4j in CI via testcontainers/Docker Compose. Bolt is a binary protocol with a version handshake — an in-memory stub tells you nothing about whether your query is valid Cypher or whether your result mapping works.
The maintenance risk is the headline. Last release 2020, 11 stars, 0 dependents. Before committing, verify which Bolt protocol versions it negotiates. Neo4j 4.4 speaks Bolt 3/4; Neo4j 5 dropped Bolt 3 in most configurations, so a driver frozen at a 3.x handshake will fail at connect with an unhelpful error, or hang. If you're on Neo4j 5, test against a real server on day one — not after you've built the repository layer. If it doesn't connect, laudis/neo4j-php-client is the maintained alternative with an HTTP transport fallback and a much friendlier API; the honest recommendation for most Laravel teams is to treat this package as a learning/reference implementation and use that instead.
You must drain the result stream. Bolt is a pipelined protocol: after RUN, the server pushes records until SUCCESS. If you read three rows of a thousand-row result and return, the remaining records stay queued on the socket and every subsequent call on that connection reads someone else's leftovers. Either consume the full response, or issue DISCARD. Symptom: "my query returns the previous query's data" — it's not caching, it's an undrained stream. Same class of bug applies to PULL with a batch size: loop until the summary message.
No TLS. Plain bolt:// only; bolt+s:// / neo4j+s:// requires an encrypted socket the driver doesn't set up. Use stunnel, a sidecar proxy, or keep Neo4j on a private network. Do not assume this is handled because your PDO connection is fine.
No pooling, no reconnect, no retry. Wrap calls with a single retry on connection-level exceptions (stale socket, server restart), and only when the operation is idempotent. Keep a isConnected()-style check or just recreate on exception.
Temporal and numeric edge cases. Neo4j Date, DateTime, Duration, Point, and LocalDateTime arrive as PackStream structures, not PHP objects — you get raw class instances you must convert. Large integers can exceed PHP int on 32-bit, and byte arrays (bytea properties) come back as strings. Normalise all of this in your mapper or you will ship Duration objects into json_encode() and get {}.
Node IDs are not stable. Node::getId() is an internal element id, reused after deletion and only valid within a transaction. Always carry your own id property from the application and key all lookups on it.
Timeouts are per-socket, not per-query. A slow Cypher statement hangs the PHP worker until the socket read times out (or forever if it's 0). Set a finite timeout in config, and put an outer guard on expensive traversals — variable-length [:KNOWS*1..6] patterns are how you accidentally run a graph-wide scan.
PHP 8.1+ deprecations. A 2020-era codebase will likely emit ReturnTypeWillChange/dynamic-property notices. Run the test suite with error_reporting(E_ALL) once; if it's noisy, pin phpunit.xml to suppress deprecations rather than patching vendor code — and if you do patch, use a patches block (cweagans/composer-patches) so it survives composer install.
Extension points are thin, by design. There's no plugin system. The intended extension point is PackStream types: subclass or post-process Unpacker output for custom structures, and do all mapping in your own service. Treat the library as an immutable transport layer and keep 100% of your logic outside it — if you do end up needing to swap drivers, only one class changes.
How can I help you explore Laravel packages today?