primd/fluidgraph

A Doctrine-inspired OGM for Bolt/Cypher Graph Databases

dev-master 2025-06-13 02:04 UTC

This package is auto-updated.

Last update: 2025-06-13 02:04:07 UTC


README

FluidGraph is an Object Graph Manager (OGM) for memgraph (though in principle could also work with Neo4j). It borrows a lot of concepts from Doctrine (a PHP ORM for relational databases), but aims to "rethink" many of those concepts in the graph paradigm.

This project is part of the Primd application stack and is copyright Primd Cooperative, licensed MIT. Primd Cooperative is a worker-owned start-up aiming to revolutionize hiring, learning, and work itself. For more information, or to support our work:

Installation

composer require primd/fluidgraph

Basic Concepts

FluidGraph borrows a bit of a "spiritual ontology" in order to talk about its core concepts. The Entity world is effectively the natural world. These are your concrete models and the things you interact with.

The Element world is the spiritual world. Entities are fastened to an Element. A single Element can be expressed in one or more Entities. These are the underlying graph data and not generally meant to be interacted with (unless you're doing more advanced development).

Both Entities and Elements have different forms, namely Nodes and Edges, i.e. there is a "Node Element" as well as a "Node Entity."

NOTE: FluidGraph is still alpha and is subject to fairly rapid changes, though we'll try not to break documented APIs.

Basic Usage

Instantiating a graph:

use Bolt\Bolt;
use Bolt\connection\StreamSocket;

$graph = new FluidGraph\Graph(
    [
        'scheme'      => 'basic',
        'principal'   => 'memgraph',
        'credentials' => 'password'
    ],
    new Bolt(new StreamSocket())
);

Create a Node class:

class Person extends FluidGraph\Node
{
	use FluidGraph\Entity\Id\Uuid7;

	public function __construct(
		public ?string $firstName = NULL,
		public ?string $lastName = NULL,
	) {}
}

Traits are used to provide built-in common functionality and usually represent hooks. The example above uses the Uuid7 trait to identify the entity. This will provide a key of id and automatically generate the UUID onCreate.

Create an Edge class:

class FriendsWith extends FluidGraph\Edge
{
	use FluidGraph\Entity\DateCreated;
	use FluidGraph\Entity\DateModified;

	public string $description;
}

Add a relationship between people:

class Person extends FluidGraph\Node
{
	use FluidGraph\Entity\Id\Uuid7;

    public protected(set) FluidGraph\Relationship\ToMany $friends;

	public function __construct(
		public ?string $firstName = NULL,
		public ?string $lastName = NULL,
	) {
		$this->friendships = new FluidGraph\Relationship\ToMany(
			$this,
			FriendsWith::class,
			[
				self::class
			]
		);
	}
}

Note: All properties on your entities MUST be publicly readable. They can have protected(set) or private(set), however, note that you CANNOT use property hooks. FluidGraph avoids reflection where possible, but due to how it uses per-property references, hooks are not viable.

Instantiate nodes:

$matt = new Person(firstName: 'Matt');
$jill = new Person(firstName: 'Jill');

Set the relationship between them:

$matt->friendships->set($jill, [
	'description' => 'Best friends forever!'
]);

Attach, merge changes into the queue, and execute it:

$graph->attach($matt)->queue->merge()->run();

Note: There is no need to attach $jill or the FriendsWith edge, as these are cascaded from $matt being attached. Without the relationship, $jill would need to be attached separately to persist.

Find a person:

$matt = $graph->query->matchOne(Person::class, ['firstName' => 'Matt']);

Get their friends:

$friends = $matt->friendships->get(Person::class);

Get the friendships (The actual FriendsWith edges):

$friendships = $matt->friendships->all();

Note: The available methods and return values depend on the relationship type. A ToMany has all() while a ToOne has any() for example.

Working with Entities and Elements

Status

To determine the status of an Entity or Element you can use the status() method which, with no arguments will return a FluidGraph\Status or NULL if somehow an Entity has not been fastened.

$entity_or_element->status()

Status types:

FluidGraph\Status::* Description
FASTENED The entity or element is bound to its other half, that's it.
INDUCTED The entity or element is ready and waiting to be merged with the graph.
ATTACHED The entity or element has been merged with and is attached to the graph
RELEASED The entity or element is ready and waiting to be removed from the graph.
DETACHED The entity or element has been merged with and is detached from the graph

You can easily check if the status is of one or more types by passing arguments, in which case status() will return TRUE if the status is any one of the types, FALSE otherwise:

$entity_or_element->status(FluidGraph\Status::ATTACHED, ...)

Is

Determine whether or not an Entity or Element is the same as another-ish:

$entity_or_element->is($entity_or_element_or_class);

This returns TRUE in given the following modes and outcomes:

Entities share the same Element

$entity->is($entity);

Entity expresses a given Element

$entity->is($element);

Element is the same as another Element

$element->is($element);

Entity's Element is Labeled as a Class

$entity->is(Person::class);

Element is Labeled as a Class

$element->is(Person::class);

Because Entities can express the same Element without the need for polymorphism you can, for example, have a totally different Node class, such as Author and check whether or not they are the same as a Person:

if ($person->is(Author::class)) {
	// Do things knowing the person is an author
}
Like

Available for Nodes only (as they can have more than one label), is the like() methods which will observe both classes as well as arbitrary labels that may be common. Like will also accept Nodes and Node Elements:

$entity->like(Archivable::ARCHIVED);

It is strongly recommended that you use constants for labels. How or where you implement them depends on how they are shared across Nodes. In the example above we have a separate Archivable Trait which could be used by various classes.

As

As mentioned before, different Entities can express the same Element. Because Nodes can carry multiple distinct labels, this effectively means that you can transform one Node into another (adding properties and relationships) in a dynamic an horizontal fashion.

A person becomes an author:

$author = $person->as(Author::class, ['penName' => 'Hairy Poster']);

$author->is(Person::class); // TRUE
$person->is(Author::class); // TRUE

NOTE: The Person object is not changed, rather, in this example a new Author object is created and the person/author share the same graph Node, the same Element (in FluidGraph). When working with a Person you only have access to the properties and relationships of a Person. The as() method allows you to gracefully change the Entity type.

When using as() to create a new expression of an existing Node, you need to pass any required arguments for instantiations (required by it's __construct() method) as the second parameter. If no properties are required, this can be excluded. If the Author object is already fastened to the underlying Node Element, then you can simply switch between them.

A subsequent merge/run of the queue will persist the Author Label as well as the properties to the database, assuming the original $person is already attached:

$graph->queue->merge()->run();

NOTE: At present as() exists on Edges as well, however, edges cannot have more than one Label, so the behavior is not particularly defined. On approach that may be taken is to allow an $edge->as() call to create a new type of Edge between the same source and target Entities. Another would be to change the type entirely.

Working with Relationships

Relationships are collections of Edges. To understand these better, we'll give a bit more definition to our Author class:

<?php

class Author extends FluidGraph\Node
{
	public FluidGraph\Relationship\ToMany $writings;

	public function __construct(
		public string $penName
	) {
		$this->writings = new FluidGraph\Relationship\ToMany(
			$this,
			Wrote::class,
			[
				Book::class
			]
		);
	}
}

Relationships have a subject (the Node from which they originate, $this when defined), a kind (the class of their Edge Entities), and a list of concerns (the classes of their related Node Entities).

In order to add this relationship, we need to define our Edge Entity Wrote. Edges can have their own properties, but in this case we'll keep it simple:

<?php

use FluidGraph\Edge;

class Wrote extends FluidGraph\Edge {}

We can add our corresponding Book node, as well:

class Book extends FluidGraph\Node
{
	public function __construct(
		public string $name,
		public int $pages
	) { }
}

With these options in place, we can now define books on our Author:

$book = new Book(name: 'The Cave of Blunder', pages: 13527);

$author->writings->set($book);

If our Edge had properties, we could define those properties when we set the $book:

$author->writings->set($book, [
    'dateStarted'  => new DateTime('September 17th, 1537'),
    'dateFinished' => new DateTime('June 1st, 1804')
]);

Similar to using as(), when we set an Entity on a given relationship, any arguments required to __construct() the edge would need to be passed. You can update the existing edge using the same method.

To get Nodes out of a relationship you use the corresponding get() method. For example, to get every Book written by an Author:

foreach($author->writings->get(Book::class) as $book) {
    // Do things with Books
}

When you unset() on a relationship the corresponding Edge is Released (and if the relationship is an owning relationship, related Nodes can be Released automatically):

$author->writings->unset($book);

If the Relationship is a ToOne the Entity argument is excluded.

NOTE: It's possible to have multiple Edges to/from the same nodes. While not yet supported, there would be additional methods for releasing individual edges. Which leads us to our next subject...

Getting Edges

Because Edges can have their own properties and/or you may need to remove a specific Edge from a relationship without destroying all relationships between two Nodes you occasionally may need to be able to obtain the edges themselves. In our running example these would be the Wrote object(s).

If you need to get all Edge Entities from a ToMany or FromMany relationships you can use the all() method:

foreach($author->writings->all() as $wrote) {
    // Do things with $wrote
}

NOTE: the return value of all() is a FluidGraph\Result objects, which has additional filtering and other abilities, but generally speaking operates like an array by extending ArrayObject.

To get the Edge Entity from a ToOne or FromOne relationship you can use the any() method which will return either the Edge Entity or NULL if there is no relationship.

if ($edge = $entity->relationship->any()) {
	// Do things with the $edge
}

In order to discover the Edges associated only with specific Nodes, Node Types, and Labels, you can use the of() and for() method. Both take multiple arguments of either Node Entities, Node Elements, or strings and collected the Edges that correspond to Nodes like() the argument. The only distinctions are as follows:

  1. The of() method only returns Edges whose Node corresponds to all arguments.
  2. The for() method returns Edges whose Node corresponds to any arguments.

Accordingly, for a single argument, these methods are effectively equivalent.

Finding Edges for a specific $person:

foreach($person->friendships->of($person) as $friends_with) {
	// Working with an Edge to a specific friend
}

Finding Edges to all friends who are of type Author:

foreach($person->friendships->of(Author::class) as $friends_with) {
    // Working with an Edge to a friend who's like() an Author
}

Note: No validation is done against the relationships concerns, because even though it will allow setting Nodes of the supported types, different Nodes Entities can share a common Node Element.

Finding Edges to all friends who are of type Author and labeled as Archived using of():

foreach($person->friendships->of(Author::class, Archivable::ARCHIVED) as $friends_with) {
    // Working with an edge to friend who's like() an Author AND like() ARCHIVED
}

Using the same argument with for() would result in finding Edges to all friends who are of type Author or labeled as Archived:

foreach($person->friendships->for(Author::class, Archivable::ARCHIVED) as $friends_with) {
    // Working with an edge to friend who's like() an Author OR like() ARCHIVED
}

Generally speaking, of() is likely what you want most of the time.