A fluent, immutable, fully-typed SQL query builder for PHP 8.4+ with extensive support for PostgreSQL-specific features.
You compose a query from small, type-safe expression objects and render it to a parameterized SQL string with bound arguments — never by concatenating strings.
- Pure PostgreSQL focus — no compromises for lowest-common-denominator multi-database support.
- JSON-first design — first-class support for
json_build_objectandjson_aggto build hierarchical data directly in the database. - Complete feature set — CTEs, window functions, arrays, grouping sets,
ON CONFLICT,RETURNING, set-returning functions, and more. - Type-safe — builder methods only expose what is valid in the current context, so invalid queries are hard to express.
- Immutable — every builder method returns a new instance, so base queries can be shared and specialised without surprises.
- PHP 8.4 or newer
composer require flowpack/query-object-builderuse Flowpack\QueryObjectBuilder\PostgreSQL\Q;
$active = true;
$q = Q::select(Q::n('name'), Q::n('email'))
->from(Q::n('users'))
->where(Q::n('active')->eq(Q::arg($active)))
->orderBy(Q::n('name'));
[$sql, $args] = Q::build($q)->toSql();
echo $sql; // SELECT name,email FROM users WHERE active = $1 ORDER BY name
var_dump($args); // [true]Q::build($q)->toSql() returns a [$sql, $args] pair: a SQL string with
PostgreSQL numbered placeholders ($1, $2, …) and the positional argument
list to bind. See Executing queries for how to run it.
Q is the single entry point for building queries. It exposes the builder
package as a small set of static factory methods so you never reference the
underlying builder types directly:
- Statements:
Q::select(),Q::insertInto(),Q::update(),Q::deleteFrom(),Q::with(),Q::withRecursive() - Identifiers:
Q::n('table.column')for names/columns - Literals:
Q::string(),Q::int(),Q::float(),Q::bool(),Q::null(),Q::default(),Q::array(),Q::interval() - Parameters:
Q::arg()(positional),Q::bind()(named) - Composition:
Q::and(),Q::or(),Q::not(),Q::exists(),Q::case(),Q::coalesce(),Q::func(),Q::agg()
SQL functions live on the Q\Func facade: Q\Func::jsonBuildObject(),
Q\Func::jsonAgg(), Q\Func::count(), Q\Func::sum(), Q\Func::upper(),
Q\Func::rowNumber(), Q\Func::unnest(), and many more. It is named Func
(not Fn) because fn is a reserved keyword in PHP.
Every builder method returns a new builder — the original is never mutated. This makes base queries safe to reuse:
$base = Q::select(Q::n('*'))->from(Q::n('users'));
$active = $base->where(Q::n('active')->eq(Q::bool(true)));
$recent = $base->where(Q::n('created_at')->gt(Q::string('2024-01-01')));
// $base is unchangedExpressions returned by Q::n(), Q::arg(), literals, and functions carry the
SQL operators as fluent methods: ->eq(), ->neq(), ->lt(), ->gt(),
->like(), ->ilike(), ->in(), ->isNull(), ->isNotNull(), ->plus(),
->minus(), ->mult(), ->concat(), ->cast(), ->op('*', …), and more.
Parentheses are added automatically based on operator precedence.
The builder emits compact, single-line SQL. The SQL shown below is formatted for readability — it is otherwise exactly what each query renders.
$q = Q::select(Q::n('*'))->from(Q::n('users'));SELECT * FROM users$q = Q::select(Q::n('name'), Q::n('email'))
->from(Q::n('users'))
->where(Q::n('active')->eq(Q::bool(true)));SELECT name, email FROM users WHERE active = true$q = Q::select(Q::n('*'))
->from(Q::n('employees'))
->where(Q::and(
Q::or(
Q::n('firstname')->ilike(Q::arg('John%')),
Q::n('lastname')->ilike(Q::arg('John%')),
),
Q::n('active')->eq(Q::bool(true)),
));SELECT * FROM employees
WHERE (firstname ILIKE $1 OR lastname ILIKE $2) AND active = true$q = Q::select()->distinct()
->select(Q::n('department'))
->from(Q::n('employees'));SELECT DISTINCT department FROM employees$q = Q::select(Q::n('name'), Q::n('salary'))
->from(Q::n('employees'))
->orderBy(Q::n('salary'))->desc()->nullsLast()
->limit(Q::int(10))
->offset(Q::int(20));SELECT name, salary FROM employees
ORDER BY salary DESC NULLS LAST
LIMIT 10 OFFSET 20$q = Q::insertInto(Q::n('users'))
->columnNames('name', 'email', 'active')
->values(Q::string('John Doe'), Q::string('john@example.com'), Q::bool(true));INSERT INTO users (name, email, active)
VALUES ('John Doe', 'john@example.com', true)$q = Q::insertInto(Q::n('products'))
->columnNames('name', 'price', 'category')
->values(Q::string('Laptop'), Q::float(999.99), Q::string('Electronics'))
->values(Q::string('Book'), Q::float(19.99), Q::string('Literature'));INSERT INTO products (name, price, category) VALUES
('Laptop', 999.99, 'Electronics'),
('Book', 19.99, 'Literature')$q = Q::insertInto(Q::n('films'))
->setMap([
'code' => 'UA502',
'title' => 'Bananas',
'did' => 105,
]);INSERT INTO films (code,did,title) VALUES ($1, $2, $3)
-- args: ['UA502', 105, 'Bananas']$q = Q::insertInto(Q::n('archived_users'))
->query(Q::select(Q::n('*'))->from(Q::n('users'))->where(Q::n('active')->eq(Q::bool(false))));INSERT INTO archived_users SELECT * FROM users WHERE active = false$q = Q::insertInto(Q::n('users'))
->columnNames('name', 'email')
->values(Q::string('Jane Doe'), Q::string('jane@example.com'))
->returning(Q::n('id'), Q::n('created_at'));INSERT INTO users (name, email) VALUES ('Jane Doe', 'jane@example.com')
RETURNING id, created_at$q = Q::insertInto(Q::n('distributors'))
->columnNames('did', 'dname')
->values(Q::int(5), Q::string('Gizmo Transglobal'))
->onConflict(Q::n('did'))->doUpdate()
->set('dname', Q::n('EXCLUDED.dname'));INSERT INTO distributors (did, dname) VALUES (5, 'Gizmo Transglobal')
ON CONFLICT (did) DO UPDATE SET dname = EXCLUDED.dname$q = Q::update(Q::n('films'))
->set('kind', Q::string('Dramatic'))
->where(Q::n('kind')->eq(Q::string('Drama')));UPDATE films SET kind = 'Dramatic' WHERE kind = 'Drama'$q = Q::update(Q::n('employees'))->as('e')
->set('department_name', Q::n('d.name'))
->from(Q::n('departments'))->as('d')
->where(Q::n('e.department_id')->eq(Q::n('d.id')));UPDATE employees AS e SET department_name = d.name
FROM departments AS d
WHERE e.department_id = d.id$q = Q::deleteFrom(Q::n('films'))
->where(Q::n('kind')->neq(Q::string('Musical')));DELETE FROM films WHERE kind <> 'Musical'$q = Q::deleteFrom(Q::n('films'))
->using(Q::n('producers'))
->where(Q::and(
Q::n('producer_id')->eq(Q::n('producers.id')),
Q::n('producers.name')->eq(Q::string('foo')),
));DELETE FROM films USING producers
WHERE producer_id = producers.id AND producers.name = 'foo'$q = Q::select(Q::n('u.name'), Q::n('p.title'))
->from(Q::n('users'))->as('u')
->join(Q::n('posts'))->as('p')->on(Q::n('u.id')->eq(Q::n('p.user_id')));SELECT u.name, p.title FROM users AS u
JOIN posts AS p ON u.id = p.user_id$q = Q::select(Q::n('u.name'), Q::n('p.title'))
->from(Q::n('users'))->as('u')
->leftJoin(Q::n('posts'))->as('p')->on(Q::n('u.id')->eq(Q::n('p.user_id')));SELECT u.name, p.title FROM users AS u
LEFT JOIN posts AS p ON u.id = p.user_id$q = Q::select(Q::n('u.name'), Q::n('p.title'))
->from(Q::n('users'))->as('u')
->join(Q::n('posts'))->as('p')->using('user_id');SELECT u.name, p.title FROM users AS u
JOIN posts AS p USING (user_id)$q = Q::select(Q::n('department'))
->select(Q\Func::count(Q::n('*')))->as('employee_count')
->from(Q::n('employees'))
->groupBy(Q::n('department'));SELECT department, count(*) AS employee_count
FROM employees
GROUP BY department$q = Q::select(Q::n('department'))
->select(Q\Func::avg(Q::n('salary')))->as('avg_salary')
->from(Q::n('employees'))
->groupBy(Q::n('department'))
->having(Q\Func::avg(Q::n('salary'))->gt(Q::int(50000)));SELECT department, avg(salary) AS avg_salary
FROM employees
GROUP BY department
HAVING avg(salary) > 50000$q = Q::select(Q::n('department'), Q::n('job_title'), Q\Func::sum(Q::n('salary')))
->from(Q::n('employees'))
->groupBy()
->rollup(
Q::exps(Q::n('department')),
Q::exps(Q::n('job_title')),
);SELECT department, job_title, sum(salary)
FROM employees
GROUP BY ROLLUP (department, job_title)$q = Q::select(Q::n('department'), Q::n('job_title'), Q\Func::sum(Q::n('salary')))
->from(Q::n('employees'))
->groupBy()
->groupingSets(
Q::exps(Q::n('department')),
Q::exps(Q::n('job_title')),
Q::exps(),
);SELECT department, job_title, sum(salary)
FROM employees
GROUP BY GROUPING SETS (department, job_title, ())$q = Q::select(
Q::n('name'),
Q::n('salary'),
Q\Func::rowNumber()->over()->partitionBy(Q::n('department'))->orderBy(Q::n('salary'))->desc(),
)->from(Q::n('employees'));SELECT name, salary,
row_number() OVER (PARTITION BY department ORDER BY salary DESC)
FROM employees$q = Q::select(
Q\Func::sum(Q::n('salary'))->over('w'),
Q\Func::avg(Q::n('salary'))->over('w'),
)
->from(Q::n('empsalary'))
->window('w')->as()->partitionBy(Q::n('depname'))->orderBy(Q::n('salary'))->desc();SELECT sum(salary) OVER w, avg(salary) OVER w
FROM empsalary
WINDOW w AS (PARTITION BY depname ORDER BY salary DESC)$q = Q::select(
Q\Func::jsonBuildObject()
->prop('id', Q::n('id'))
->prop('name', Q::n('name'))
->prop('email', Q::n('email')),
)->from(Q::n('users'));SELECT json_build_object('id', id, 'name', name, 'email', email)
FROM users$q = Q::select(
Q::n('department'),
Q\Func::jsonAgg(
Q\Func::jsonBuildObject()
->prop('name', Q::n('name'))
->prop('salary', Q::n('salary')),
)->orderBy(Q::n('name')),
)
->from(Q::n('employees'))
->groupBy(Q::n('department'));SELECT department,
json_agg(json_build_object('name', name, 'salary', salary) ORDER BY name)
FROM employees
GROUP BY departmentWhen the query's primary output is a single JSON object, Q::selectJson()
makes it the first selection and lets you refine it later with
applySelectJson():
$q = Q::selectJson(
Q\Func::jsonBuildObject()
->prop('Title', Q::n('books.title'))
->prop('ID', Q::n('books.book_id')),
)
->from(Q::n('books'))
->where(Q::n('books.book_id')->eq(Q::arg(2)));SELECT json_build_object('Title', books.title, 'ID', books.book_id)
FROM books
WHERE books.book_id = $1$q = Q::select(Q::array(Q::string('a'), Q::string('b'), Q::string('c')));SELECT ARRAY['a','b','c']$q = Q::select(
Q\Func::arrayAppend(Q::array(Q::int(1), Q::int(2)), Q::int(3)),
Q\Func::arrayLength(Q::array(Q::int(1), Q::int(2), Q::int(3)), Q::int(1)),
);SELECT array_append(ARRAY[1,2], 3), array_length(ARRAY[1,2,3], 1)$q = Q::select(Q::n('*'))
->from(Q\Func::unnest(Q::array(Q::string('a'), Q::string('b'), Q::string('c'))))
->as('t')->columnAliases('value');SELECT * FROM unnest(ARRAY['a','b','c']) AS t (value)$q = Q::select(
Q::n('department'),
Q\Func::arrayAgg(Q::n('name'))->orderBy(Q::n('name')),
)
->from(Q::n('employees'))
->groupBy(Q::n('department'));SELECT department, array_agg(name ORDER BY name)
FROM employees
GROUP BY department$q = Q::select(Q::n('name'))
->from(Q::n('users'))
->where(Q::exists(
Q::select(Q::int(1))
->from(Q::n('posts'))
->where(Q::n('posts.user_id')->eq(Q::n('users.id'))),
));SELECT name FROM users
WHERE EXISTS (SELECT 1 FROM posts WHERE posts.user_id = users.id)$q = Q::select(Q::n('name'))
->from(Q::n('users'))
->where(Q::n('id')->in(
Q::select(Q::n('user_id'))
->from(Q::n('posts'))
->where(Q::n('published')->eq(Q::bool(true))),
));SELECT name FROM users
WHERE id IN (SELECT user_id FROM posts WHERE published = true)$ids = [1, 2, 3];
$q = Q::select(Q::n('username'))
->from(Q::n('accounts'))
->where(Q::n('id')->in(Q::args(...$ids)));SELECT username FROM accounts WHERE id IN ($1, $2, $3)
-- args: [1, 2, 3]$q = Q::select(Q::n('name'), Q::n('salary'))
->from(Q::n('employees'))->as('e1')
->where(Q::n('salary')->gt(
Q::select(Q\Func::avg(Q::n('salary')))
->from(Q::n('employees'))->as('e2')
->where(Q::n('e1.department')->eq(Q::n('e2.department'))),
));SELECT name, salary FROM employees AS e1
WHERE salary > (
SELECT avg(salary) FROM employees AS e2
WHERE e1.department = e2.department
)$q = Q::select(Q::n('avg_quantity'))
->from(
Q::select(Q\Func::avg(Q::n('quantity')))->as('avg_quantity')
->from(Q::n('sales'))
->groupBy(Q::n('brand')),
)->as('t');SELECT avg_quantity FROM (
SELECT avg(quantity) AS avg_quantity FROM sales GROUP BY brand
) AS t$q = Q::with('recent_orders')->as(
Q::select(Q::n('*'))
->from(Q::n('orders'))
->where(Q::n('created_at')->gt(Q::string('2023-01-01'))),
)
->select(Q::n('customer_name'), Q\Func::count(Q::n('*')))
->from(Q::n('recent_orders'))
->groupBy(Q::n('customer_name'));WITH recent_orders AS (
SELECT * FROM orders WHERE created_at > '2023-01-01'
)
SELECT customer_name, count(*) FROM recent_orders GROUP BY customer_name$q = Q::withRecursive('employee_recursive')
->columnNames('distance', 'employee_name', 'manager_name')->as(
Q::select(Q::int(1), Q::n('employee_name'), Q::n('manager_name'))
->from(Q::n('employee'))
->where(Q::n('manager_name')->eq(Q::string('Mary')))
->union()->all()
->select(Q::n('er.distance')->op('+', Q::int(1)), Q::n('e.employee_name'), Q::n('e.manager_name'))
->from(Q::n('employee_recursive'))->as('er')
->from(Q::n('employee'))->as('e')
->where(Q::n('er.employee_name')->eq(Q::n('e.manager_name'))),
)
->select(Q::n('distance'), Q::n('employee_name'))->from(Q::n('employee_recursive'));WITH RECURSIVE employee_recursive(distance, employee_name, manager_name) AS (
SELECT 1, employee_name, manager_name
FROM employee
WHERE manager_name = 'Mary'
UNION ALL
SELECT er.distance + 1, e.employee_name, e.manager_name
FROM employee_recursive AS er, employee AS e
WHERE er.employee_name = e.manager_name
)
SELECT distance, employee_name FROM employee_recursive$q = Q::select(
Q\Func::upper(Q::n('name')),
Q\Func::lower(Q::n('email')),
Q\Func::initcap(Q::n('title')),
)->from(Q::n('users'));SELECT upper(name), lower(email), initcap(title)
FROM users$q = Q::select(
Q\Func::extract('year', Q::n('created_at')),
Q::n('created_at')->plus(Q::interval('1 day')),
)->from(Q::n('orders'));SELECT EXTRACT(year FROM created_at), created_at + INTERVAL '1 day'
FROM orders$q = Q::select(Q::n('price')->op('*', Q::n('quantity')))->as('total')
->from(Q::n('order_items'));SELECT price * quantity AS total FROM order_items$q = Q::select(
Q::n('name'),
Q::case()
->when(Q::n('salary')->lt(Q::int(30000)))->then(Q::string('Low'))
->when(Q::n('salary')->lt(Q::int(70000)))->then(Q::string('Medium'))
->else(Q::string('High'))
->end(),
)->from(Q::n('employees'));SELECT name,
CASE
WHEN salary < 30000 THEN 'Low'
WHEN salary < 70000 THEN 'Medium'
ELSE 'High'
END
FROM employees$q = Q::select(Q::n('articles.content')->cast('text'))
->from(Q::n('articles'))
->where(Q::n('articles.content')->cast('text')->ilike(Q::arg('%foo%')));SELECT articles.content::text FROM articles WHERE articles.content::text ILIKE $1
-- args: ['%foo%']Each Q::arg() becomes a numbered placeholder in order of appearance:
$q = Q::select(Q::n('*'))
->from(Q::n('users'))
->where(Q::and(
Q::n('name')->like(Q::arg('John%')),
Q::n('active')->eq(Q::arg(true)),
));
[$sql, $args] = Q::build($q)->toSql();SELECT * FROM users WHERE name LIKE $1 AND active = $2
-- args: ['John%', true]Q::bind() declares a named placeholder; bind the values with
withNamedArgs(). Reusing the same name reuses its placeholder:
$q = Q::select(Q::n('*'))
->from(Q::n('users'))
->where(Q::and(
Q::n('name')->like(Q::bind('search')),
Q::n('active')->eq(Q::bind('is_active')),
));
[$sql, $args] = Q::build($q)
->withNamedArgs(['search' => 'John%', 'is_active' => true])
->toSql();SELECT * FROM users WHERE name LIKE $1 AND active = $2
-- args: ['John%', true]Named and positional parameters can be mixed in the same query.
The builder is driver-agnostic: it produces a SQL string with PostgreSQL
numbered placeholders ($1, $2, …) and a positional argument list. Feed both
to any layer that speaks PostgreSQL's native placeholders, for example the
pgsql extension:
use Flowpack\QueryObjectBuilder\PostgreSQL\Q;
$conn = pg_connect('host=localhost dbname=app user=app');
$q = Q::select(Q::n('name'), Q::n('email'))
->from(Q::n('users'))
->where(Q::n('active')->eq(Q::arg(true)));
[$sql, $args] = Q::build($q)->toSql();
$result = pg_query_params($conn, $sql, $args);
while ($row = pg_fetch_assoc($result)) {
printf("Name: %s, Email: %s\n", $row['name'], $row['email']);
}By default, identifiers are validated while building and an invalid name throws
a QueryBuilderException. Skip validation with
Q::build($q)->withoutValidation()->toSql() when you trust the input.
$userName = Q::n('users.name');
$userEmail = Q::n('users.email');
$q = Q::select($userName, $userEmail)->from(Q::n('users'));Builders expose applyIf() so optional clauses read top-to-bottom without
breaking the fluent chain:
$q = Q::update(Q::n('films'))
->set('kind', Q::string('Dramatic'))
->where(Q::n('kind')->eq(Q::string('Drama')))
->applyIf($onlyActive, fn ($q) => $q->where(Q::n('archived')->eq(Q::bool(false))));Break a large query into named, readable parts with Q::with() and chain
several CTEs with appendWith().
composer install
composer test # run the Pest test suite
composer analyse # run PHPStan (level max)Both must pass for any change.
Licensed under the GNU General Public License v3.0 or later.