laravel-freelancer-nl/fluentaql
A fluent SQL builder for Laravel that lets you compose queries through a chainable PHP API instead of raw strings, keeping statements readable, parameterized, and easy to maintain while retaining full control over the generated SQL.
This is a standalone, framework-agnostic fluent builder that emits AQL strings plus a bind-variable array — it does not talk to ArangoDB for you. You pair it with the official PHP driver (triagens/arangodb).
Install and pin. The package is archived (last release 2022-05-16), so pin an exact version and commit the lockfile:
composer require laravel-freelancer-nl/fluentaql:0.4.* # confirm latest tag with `composer show -a`
Where to look first, in this order:
README.md — the fluent surface, one example per AQL clause.tests/ — for an archived package this is the real documentation; each test shows a builder call next to its expected AQL output.src/ — specifically the main AQL entry class and the clause classes, to confirm method names and argument order before you commit to them in your codebase.First use case — a filtered, paginated read. Build the query, then hand the string and binds to the driver:
use LaravelFreelancerNL\FluentAQL\AQL;
$aqb = new AQL();
$aqb->for('user', 'users') // FOR user IN users
->filter('user.status == @status') // FILTER ...
->sort('user.createdAt DESC')
->limit(2, 20) // LIMIT 20, 2 (offset, count)
->return('user'); // RETURN user
$query = $aqb->getQuery(); // "FOR user IN users FILTER user.status == @status ..."
$bindVars = $aqb->getBindVars(); // ['status' => 'active', ...]
Method names and bind-registration style (
bind()vs. values passed alongside a clause) vary between minor versions. Verify againstsrc/at your pinned tag — the examples here follow the README's fluent style.
Wire it up once. Create a thin service that owns the connection and a factory method for builders, then inject that instead of new AQL() everywhere:
final class AqlClient
{
public function __construct(private Connection $connection) {}
public function query(): AQL
{
return new AQL();
}
public function all(AQL $aqb): array
{
return $this->connection
->query($aqb->getQuery(), $aqb->getBindVars())
->getAll();
}
}
Repository wrapper per collection. Don't leak builders into controllers. One repository per document collection, each method returning domain objects:
final class UserRepository
{
public function __construct(private AqlClient $aql) {}
public function activeSince(DateTimeInterface $since, int $limit = 50): array
{
$aqb = $this->aql->query()
->for('u', 'users')
->filter('u.status == @status AND u.createdAt >= @since')
->sort('u.createdAt DESC')
->limit($limit)
->return('u');
return array_map(
fn ($row) => User::fromDocument($row),
$this->aql->all($aqb->bind(['status' => 'active', 'since' => $since->format('c')]))
);
}
}
Conditional filter composition. The builder's value is in assembling optional predicates without string concatenation:
$aqb->for('u', 'users');
if ($status = $criteria->status()) {
$aqb->filter('u.status == @status')->bind(['status' => $status]);
}
if ($search = $criteria->search()) {
$aqb->filter('LIKE(u.name, @search, true)')->bind(['search' => "%{$search}%"]);
}
$aqb->sort('u.name ASC')->limit($perPage)->return('u');
Keep every filter() call a complete boolean expression; don't try to open a parenthetical in one call and close it in the next.
Bind everything that is a value. Values go through binds so AQL compiles once and the server caches the plan. Identifiers (collections, attribute paths) are not bindable in AQL — those must be validated against an allowlist in your code, never interpolated from request input.
Raw AQL escape hatch. For clauses the builder doesn't model well (complex COLLECT ... AGGREGATE, PRUNE, some graph options), use the raw-AQL method to splice in a fragment while still handing off your binds. Treat any raw fragment as a code-level constant — review it in PRs like SQL.
Testing without a database. The builder is pure string generation, so a unit suite that asserts getQuery() and getBindVars() catches regressions in milliseconds. Follow the package's own test style: assert the full query string plus the bind array, not just assertStringContainsString. Keep a separate, smaller integration suite against a disposable ArangoDB (Docker or arangodb/test image) for the queries where the compiler matters.
Reuse and composition. A builder instance is mutable, so treat it as a one-shot value object: create, compose, execute, discard. If you need to share a common base (FOR u IN users FILTER u.tenantId == @tenant), export a closure that takes a fresh builder and returns it, rather than storing a half-built builder in a service property.
Write operations. Use the builder's insert/update/replace/remove/upsert clauses so you get the RETURN NEW / RETURN OLD handling and OPTIONS { ignoreErrors: true } in one place, and keep the document payload as a bind variable rather than an inline object literal.
Graph traversals. Model them as a dedicated repository method returning raw paths; mapping traversal results to domain objects generically tends to obscure the vertices/edges shape you actually need.
DB-style connection management. You register the driver connection and the AqlClient yourself in a service provider.@0 or @value1; always read getBindVars() as the source of truth and pass the pair together. Mixing a manually written bind name with the builder's generated one is a common cause of "bind parameter 'x' was not declared."FOR → FILTER → SORT → LIMIT → RETURN and LET/COLLECT in specific positions. If the builder lets you append out of order you'll get a server-side parse error, not a builder exception — so keep your composition code in clause order and don't rely on the builder to reorder.getQuery() and getBindVars() side by side, then paste the query into the ArangoDB web UI's AQL editor with the binds as JSON. Isolate whether the failure is your string, your binds, or your data before touching PHP.'created' => 'u.createdAt' rather than passing request values through.FILTER results vs. errors. A filter that references a missing attribute silently drops documents in AQL. When a query "returns nothing," check attribute spelling (including case) and whether the value type matches — AQL is strict about 1 vs. "1".^7.4|^8.0; if you're on 8.3+ you'll need --ignore-platform-req or a fork. Verify before designing around it.How can I help you explore Laravel packages today?