vinelab/neoeloquent
NeoEloquent brings Eloquent-style models to Neo4j in Laravel. Define graph nodes and relationships as expressive PHP classes, use familiar query building and eager loading, and work with Cypher-backed graph data without leaving Laravel's ORM conventions.
NeoEloquent is an Eloquent driver for Neo4j — you get Model::find(), where(), save() and relationships, but they compile to Cypher over Neo4j's HTTP endpoint instead of SQL.
1. Install and wire up
composer require vinelab/neoeloquent
php artisan vendor:publish --provider="Vinelab\NeoEloquent\NeoEloquentServiceProvider"
Register the provider (and optionally the NeoEloquent facade) in config/app.php if you're on a Laravel version without package auto-discovery, and confirm the package's Laravel constraint against your composer.json before you commit to it.
2. Configure the connection
config/neoeloquent.php holds named connections. Point them at env vars so staging/production differ:
'default' => env('NEO4J_CONNECTION', 'neo4j'),
'connections' => [
'neo4j' => [
'driver' => 'neo4j',
'host' => env('NEO4J_HOST', 'localhost'),
'port' => env('NEO4J_PORT', 7474),
'username' => env('NEO4J_USERNAME', 'neo4j'),
'password' => env('NEO4J_PASSWORD'),
'database' => env('NEO4J_DATABASE', 'neo4j'), // added in 1.8.2
],
],
3. First model
use Vinelab\NeoEloquent\Eloquent\Model;
class User extends Model
{
protected $label = 'User';
protected $fillable = ['name', 'email'];
protected $dates = ['created_at', 'updated_at'];
public function follows()
{
return $this->hasMany('App\User', 'FOLLOWS');
}
}
$user = User::create(['name' => 'Ada', 'email' => '[email protected]']);
$user->follows()->attach($other->id, ['since' => now()->toDateString()]);
Where to look first: config/neoeloquent.php (connection shape), then the package's Model class — it's the contract you're extending, and it documents the label/edge/timestamp conventions you'll otherwise discover by hitting errors.
First use case: anything where the interesting question is "how is X connected to Y" — social graphs, recommendations, permissions/inheritance trees, tagging. If your queries are all single-table lookups, Neo4j is the wrong store and this package will just add latency.
Labels, not tables. $label for a single label, $labels = ['User', 'Admin'] for multiple. Treat labels as your "tables + indexes" and keep them few and stable — every distinct label is a query-planning hint in Neo4j. If you're tempted to make a label per tenant, use the database selector instead.
Selecting a database (1.8.2+). On Neo4j 4.x the connection targets the database key from config. Two patterns worth knowing:
NEO4J_DATABASE for staging/prod so a misconfigured deploy can't touch live data.config()->set("neoeloquent.connections.tenant_{$id}", [...]) and call Model::on('tenant_'.$id), which keeps tenant isolation at the server rather than in where clauses. Cache the resolved connection per request; don't rebuild config in a loop.Relationship verbs carry the edge type. The second argument is the Cypher relationship type, not an attribute. Name it loudly (FOLLOWS, WORKS_AT, PURCHASED) and give edges their own properties — attach($id, ['role' => 'owner']) — rather than stuffing metadata onto nodes. Edge properties are what makes graph queries cheap later.
Eager loading works, but it's still N queries. ->with('follows.follows') is fine for bounded depth; unbounded expansion is a foot-gun. For anything more than two hops, drop to raw Cypher and hydrate what you need:
$rows = NeoEloquent::connection('neo4j')->statement(
'MATCH (u:User {uuid: $uuid})-[:FOLLOWS*1..3]->(f:User) RETURN DISTINCT f',
['uuid' => $user->uuid]
);
Put traversal logic behind a repository. Graph access patterns change faster than schema does; a Graph/ namespace with one class per question ("who should we recommend", "what can this user see") keeps Cypher out of controllers and makes it testable against a fixture graph.
Migrations are not schema. Don't look for create_users_table. Create indexes and constraints with Cypher in a deploy step or a seeding command:
CREATE CONSTRAINT ON (u:User) ASSERT u.uuid IS UNIQUE
CREATE INDEX ON :User(email)
Seeding and tests. Build a small fixture graph in a seeder and point tests at a dedicated database (or a throwaway Neo4j in CI). Between tests you need a wipe — MATCH (n) DETACH DELETE n — because there's no RefreshDatabase equivalent here.
The 1.8.2 mass-assignment fix changes what you catch. Previously, filling a non-fillable attribute blew up with a "class does not exist" error for Vinelab\NeoEloquent\Eloquent\MassAssignmentException; it now behaves correctly. If you had a workaround — a try/catch on that phantom class, or defensive array_intersect_key before fill() — remove it, and update any catch blocks to Illuminate\Database\Eloquent\MassAssignmentException so your handling still fires.
delete() and edges. Neo4j refuses to delete a node that still has relationships. Prefer a soft-delete flag (deleted_at property) on business nodes, and reserve hard deletes for DETACH DELETE run explicitly. A delete() that works in isolation and fails in production is usually a node that acquired an edge since you last tested.
Reserved property names. id, label, type, created_at, updated_at are handled specially — an id you define yourself will collide with the internal node id, and setting type on a model that also uses edge typing produces confusing results. Namespace anything ambiguous (external_id, user_id).
Timestamps are strings. Neo4j has no date type here, so created_at/updated_at are written as strings. Always list them in $dates so they come back as Carbon; otherwise comparisons and ->diffForHumans() silently misbehave.
It's the HTTP REST API, not Bolt. Port 7474, JSON round-trips, per-query overhead. Wrap multi-step writes in a transaction where the connection supports it, and never put a graph query inside a loop over a few hundred records — batch it. Latency, not correctness, is the usual production complaint.
Debug with the Cypher, not the model. The query builder can emit the statement it would run — log or dd() that instead of guessing why a where matched nothing. Property filters are case- and type-sensitive: '20' will not match 20, and a missing property is not the same as an empty string.
No joins means whereHas-style filtering is expensive. Subqueries that would be a single EXISTS in SQL become MATCH patterns. For list screens with counts, run a separate aggregate query and merge in PHP rather than loading every node.
Package age is a real risk. Pin your Laravel version deliberately, read the changelog before minor bumps, and keep a thin escape hatch (raw Cypher through the connection) so a future migration to laudis/neo4j-php-client is a swap of the repository layer, not the whole domain model.
How can I help you explore Laravel packages today?