wikibase-solutions/php-cypher-dsl
A fluent PHP DSL for building Cypher queries for Neo4j. Construct expressive, type-safe graph queries programmatically with an intuitive API, reducing string concatenation and making complex queries easier to read, maintain, and test. Ideal for PHP and Laravel apps working with graph databases.
composer require wikibase-solutions/php-cypher-dsl
There is nothing to register. It is a framework-agnostic DSL/value-object library — no service provider, no facade alias, no publishable config. That is the first thing to internalise: it builds a string plus a parameter map, and it does not talk to Neo4j. You still need a driver (Laudis neo4j-php-client, graphaware/neo4j-php-client, or the Bolt protocol directly).
Where to look first, in order:
src/Cypher.php — the static entry-point. This is where clause starters (match(), create(), merge(), unwind(), …) and expression factories (node(), relationship(), variable(), parameter(), literal()) live.src/Query.php — the fluent clause chain (where, with, return, set, delete, orderBy, limit, skip).tests/ — the README is thin and the PHPDoc is thinner; the test suite is the de-facto API reference. Budget an hour there, not in the docs.The smallest useful thing you can build is a parameterised MATCH … RETURN:
use WikibaseSolutions\CypherDSL\Cypher;
$person = Cypher::node('p')->withLabels('Person');
$query = Cypher::match($person)
->where($person->property('email')->equals(Cypher::parameter('email')))
->return($person);
(string) $query;
// MATCH (p:Person) WHERE p.email = $email RETURN p
Casting the query object to string is the intended consumption path. Do that inside a repository, never in a controller.
One repository class per aggregate. The DSL is a builder, not a data layer. Wrap it so callers never see Cypher:
final class UserRepository
{
public function __construct(private Client $client) {}
public function findByEmail(string $email): ?array
{
$u = Cypher::node('u')->withLabels('User');
$q = Cypher::match($u)
->where($u->property('email')->equals(Cypher::parameter('email')))
->return($u)
->limit(1);
$rows = $this->client->run((string) $q, ['email' => $email])->toArray();
return $rows[0] ?? null;
}
}
Extract reusable fragments into private methods. The real payoff of a DSL over string concatenation is that you can compose. A tenancy filter, an isDeleted guard, a visibility scope — write each once as a predicate expression and ->and(...) them into any query.
Treat parameters as the contract, not the string. Build the query with Cypher::parameter('name') everywhere, and keep the associative array passed to the driver in the same method that builds the query. Anything that interleaves user input into an identifier or label position is a bug — identifier positions need ->withLabels()/->withName() style helpers, not string concatenation.
Testing strategy. Two tiers: (a) unit-test the compiled Cypher string — cheap, catches regressions in predicate logic; (b) integration-test against a disposable Neo4j (Testcontainers or the community Docker image) for anything involving MERGE, ordering semantics, or WITH projections, which are easy to get subtly wrong in a builder. Assert on the string only for the parts you care about; whole-string equality snapshots become noisy as the DSL evolves.
Mapping. The driver returns plain arrays. Add a thin hydrator (User::fromNodeRow($row)) so the DSL's output type never leaks into your domain layer. This is also the seam where you decide how to flatten node properties versus relationship metadata.
Composition order. Build match → where → optional with → return, and let the builder validate the shape. Do not attempt to assemble a Query from independently-built clause strings; you lose the whole benefit.
git clone + vendor/ reading. Pin the version in composer.json and update deliberately; the API surface has moved between releases and there is no large consumer base to surface breaking changes for you.->run() or a connection. You get __toString() and (in most versions) a way to retrieve bound parameters — nothing else.return as a method name. PHP 7.0+ permits reserved keywords as method names, so $query->return(...) parses fine, while new Return() does not. This trips up IDE autocomplete and static analysis; add a @method annotation or ignore the squiggle.$email; if you forget to pass ['email' => …] to the driver you get a runtime Neo4j error, not a PHP error. Log the compiled Cypher and the parameter array together — that pair is the only useful debugging artefact.withLabels() / property() quote or validate their input; if you are passing user-controlled values there, sanitise upstream.WITH and aggregation are the weakest areas of most hand-rolled Cypher builders. If your queries lean on collect(), count(), or multi-stage projections, validate the generated output against the Neo4j browser before trusting it; aliasing bugs here are silent and return wrong-shaped rows rather than throwing.Expressions/ namespace is where to model it — implement the same interface the built-ins do so it composes inside where() and return(). This is worth doing once rather than sprinkling strings.src/Cypher.php in your installed version and confirm the spelling.How can I help you explore Laravel packages today?