global-innovation/neo4j-laravel-eloquent
Brings native Neo4j graph database support to Laravel's Eloquent ORM. Define nodes and relationships as Eloquent models, run Cypher queries through a fluent query builder, traverse graphs via relationships, and manage schemas with Laravel-native syntax, migrations, and conventions.
neo4j connection block with host, port, username, password, and database name — keep it out of source control and mirror it in config/neo4j.php (publish the package config if the service provider exposes one).composer require global-innovation/neo4j-laravel-eloquent
NEO4J_HOST=127.0.0.1
NEO4J_PORT=7687
NEO4J_USERNAME=neo4j
NEO4J_PASSWORD=secret
NEO4J_DATABASE=neo4j
Look first at the package's base model and its relationship methods. The whole day-to-day surface is: a Neo4jModel base class, a label property, $fillable, CRUD statics (create, find, all), and relationship helpers (hasOne, hasMany, belongsTo, belongsToMany). Read those signatures before defining domain models; they are the contract you'll live inside.
Define one node model and prove the loop.
namespace App\Graph;
use GlobalInnovation\Neo4jLaravelEloquent\Model\Neo4jModel;
class Person extends Neo4jModel
{
protected $label = 'Person'; // confirm exact property name against your installed version
protected $fillable = ['name', 'email'];
}
$alice = Person::create(['name' => 'Alice']);
$bob = Person::create(['name' => 'Bob']);
$alice->follows()->save($bob); // or attach/create, depending on the relationship type
$alice->follows; // related People
$bob->followers; // inverse relationship, if defined
If that loop works, you have enough to build on. If it doesn't, stop and fix connection/config before writing more models — most early failures are connection or label mismatches, not ORM logic.
Model-per-label, with relationships as methods. Keep one class per node label and express edges as methods that return the package's relationship objects. The relationship type is a string (the Cypher edge name) and should be treated as part of your schema.
class Person extends Neo4jModel
{
protected $label = 'Person';
protected $fillable = ['name', 'email'];
public function follows()
{
return $this->hasMany(Person::class, 'FOLLOWS');
}
public function followers()
{
return $this->belongsToMany(Person::class, 'FOLLOWS'); // inverse direction
}
public function company()
{
return $this->hasOne(Company::class, 'WORKS_AT');
}
}
CRUD and simple querying. Use the Eloquent-style statics for straightforward node work, and chain where for property filters. Anything that involves multi-hop traversal, aggregation, or path finding should drop to raw Cypher.
$people = Person::where('name', 'Alice')->get();
$person = Person::find($id);
$person->update(['email' => '[email protected]']);
$person->delete();
Hybrid MySQL + Neo4j architecture. The most common production pattern with a package like this is: relational data stays in MySQL, the graph lives in Neo4j, and a thin sync layer mirrors the relevant fields. Do the mirroring in model observers or queued events, not in controllers.
class UserObserver
{
public function created(User $user): void
{
Person::create([
'uuid' => $user->uuid,
'name' => $user->name,
'email' => $user->email,
]);
}
}
Store a stable external key (UUID) on the graph node and never rely on Neo4j's internal id for cross-system references.
Repository/service layer for graph operations. Keep traversal and recommendation logic in a dedicated service, not in controllers or models. Models stay declarative; services own the Cypher and the transaction boundaries.
class RecommendationService
{
public function friendsOfFriends(Person $person): array
{
// raw Cypher via the underlying client is usually clearer here
return $this->client->run(
'MATCH (p:Person {uuid: $uuid})-[:FOLLOWS]->()-[:FOLLOWS]->(rec)
WHERE rec.uuid <> $uuid
RETURN DISTINCT rec.name AS name LIMIT 25',
['uuid' => $person->uuid]
)->toArray();
}
}
Bulk writes go through UNWIND, not save(). Looping create() is fine for seeds and tests, but for imports or sync jobs, batch into a single Cypher statement. The ORM's per-node round-trip cost will dominate otherwise.
UNWIND $rows AS row
MERGE (p:Person {uuid: row.uuid})
SET p.name = row.name, p.email = row.email
Testing pattern. Point the test suite at a dedicated Neo4j database, and clean up per test with MATCH (n) DETACH DELETE n or by using unique labels per test class. Neo4j has no migrations to roll back, so cleanup is entirely your responsibility.
CREATE CONSTRAINT person_uuid IF NOT EXISTS FOR (p:Person) REQUIRE p.uuid IS UNIQUE.$casts, timestamps, soft deletes, eager loading, and global scopes may behave differently or be absent. Verify each against the package source for your pinned version; don't assume parity with SQL Eloquent.$timestamps isn't honored automatically, set created_at/updated_at in model events or in the write path. The same applies to any "auto-updating" column you'd normally take for granted.MERGE rather than CREATE).FOLLOWS and FOLLOWED_BY are different edge types; decide the canonical direction, define inverses explicitly, and document it. Getting this wrong produces relationships that "disappear" when queried from the other side.EXPLAIN/PROFILE. Most "model doesn't work" bugs are actually "generated Cypher doesn't match the data."composer.json, test the upgrade path yourself, and be prepared to read the source. Keep an abstraction boundary (your repository layer) so you can swap the ORM without rewriting domain logic.How can I help you explore Laravel packages today?