๐จ Query
Note: While query builder are part of the Hector ORM ecosystem, they are available as a standalone package:
hectororm/query.
You can find it on
Packagist.
You can use them independently of the ORM, in any PHP application. ๐
The QueryBuilder in Hector ORM provides a fluent, object-oriented API to construct and execute SQL queries. It is
designed to streamline the building of SELECT, INSERT, UPDATE, DELETE, and UNION queries, while maintaining
control and readability in your codebase.
๐ Initialization
You can initialize the QueryBuilder using a Connection object. This setup allows you to immediately start crafting
your queries.
use Hector\Connection\Connection;
use Hector\Query\QueryBuilder;
$connection = new Connection('...');
$queryBuilder = new QueryBuilder($connection);
$result = $queryBuilder
->select('table')
->where('field1', 'foo')
->where('field2', '>=', 2)
->fetchAll();
๐งฑ Query Types
The library provides specific classes for each query type. Each of these classes implements the StatementInterface,
enabling you to manually build the query and bind parameters.
Hector\Query\SelectHector\Query\InsertHector\Query\UpdateHector\Query\DeleteHector\Query\Union
๐ Specifying the Table
Use from() to define the target table for your query:
$queryBuilder->from('users');
// SELECT * FROM users
$queryBuilder->from('users', 'u');
// SELECT * FROM users AS u
// Multiple tables (implicit join)
$queryBuilder
->from('users', 'u')
->from('profiles', 'p')
->where('u.id', '=', 'p.user_id');
๐งช Example
use Hector\Connection\Bind\BindParamList;
use Hector\Connection\Connection;
use Hector\Query\Select;
$select = new Select();
$select
->from('table')
->where('field', 'value');
$binds = new BindParamList();
$statement = $select->getStatement($binds);
$connection = new Connection('...');
$result = $connection->fetchAll($statement, $binds);
๐งฎ Conditions
Both WHERE and HAVING clauses are supported using the same API. Simply switch the method prefix.
Where / Having
Two-argument form: column, value (implicit โ=โ operator):
use Hector\Query\QueryBuilder;
$queryBuilder
->from('table', 'alias')
->where('field', 'value');
Three-argument form (column, operator, value) :
use Hector\Query\QueryBuilder;
$queryBuilder
->from('table', 'alias')
->where('field', '=', 'value')
->orWhere('field', '>=', 10);
Grouped Conditions
You can pass a Closure to where() or having() to create grouped conditions wrapped in parentheses. The closure
receives a Conditions object (Hector\Query\Statement\Conditions) as its first argument, which exposes the same
where() / having() methods:
use Hector\Query\Statement\Conditions;
$queryBuilder
->from('users')
->where(function (Conditions $conditions): void {
$conditions->where('age', '>=', 18);
$conditions->orWhere('role', 'admin');
})
->where('active', true);
// WHERE ( age >= ? OR role = ? ) AND active = ?
This is useful for combining OR conditions without affecting the rest of the query:
$queryBuilder
->from('products')
->where('in_stock', true)
->where(function (Conditions $conditions): void {
$conditions->where('category', 'electronics');
$conditions->orWhere('price', '<', 10);
});
// WHERE in_stock = ? AND ( category = ? OR price < ? )
The same pattern works with orWhere(), having(), and orHaving():
$queryBuilder
->from('orders')
->column('customer_id')
->column('SUM(amount)', 'total')
->groupBy('customer_id')
->having(function (Conditions $conditions): void {
$conditions->having('total', '>', 1000);
$conditions->orHaving('total', '<', 100);
});
// HAVING ( total > ? OR total < ? )
Tip: Type-hinting the closure parameter as Conditions enables full autocompletion in your IDE.
Condition Shortcuts
Several convenience methods are provided:
$queryBuilder->whereIn('id', [1, 2, 3]);
$queryBuilder->whereNotBetween('age', 18, 30);
$queryBuilder->whereGreaterThan('score', 50);
$queryBuilder->whereExists(new Select(...));
$queryBuilder->whereContains('name', 'john');
Full list:
whereIn($column, array $values)whereNotIn($column, array $values)whereBetween($column, $value1, $value2)whereNotBetween($column, $value1, $value2)whereGreaterThan($column, $value)whereGreaterThanOrEqual($column, $value)whereLessThan($column, $value)whereLessThanOrEqual($column, $value)whereExists($statement)whereNotExists($statement)whereContains($column, $string)whereStartsWith($column, $string)whereEndsWith($column, $string)
All where* methods have their having* counterparts for filtering grouped results:
$queryBuilder
->from('orders')
->column('customer_id')
->column('SUM(amount)', 'total')
->groupBy('customer_id')
->having('total', '>', 1000)
->orHaving('total', '<', 100);
Available:
having()orHaving()havingIn()havingNotIn()havingBetween()havingNotBetween(),havingGreaterThan()havingGreaterThanOrEqual()havingLessThan()havingLessThanOrEqual(),havingExists()havingNotExists()havingContains()havingStartsWith()havingEndsWith().
๐ Selecting Columns
You can customize the columns returned by your query:
$queryBuilder
->from('users')
->column('name')
->column('email', 'user_email');
// SELECT name, email AS user_email FROM users
$queryBuilder->resetColumns();
$queryBuilder->columns(['id', 'name', 'created_at']);
// SELECT id, name, created_at FROM users
๐งโ๐คโ๐ง Grouping Results
To group query results:
$queryBuilder
->from('products')
->column('category_id')
->column('COUNT(*)', 'count')
->groupBy('category_id')
->groupBy('status');
// SELECT category_id, COUNT(*) AS count FROM products GROUP BY category_id, status
$queryBuilder->resetGroups();
$queryBuilder->groupByWithRollup();
// Adds WITH ROLLUP modifier (MySQL)
๐ข Ordering Results
Sort your result set with orderBy():
$queryBuilder
->from('posts')
->orderBy('created_at', 'DESC')
->orderBy('name', 'ASC');
// SELECT * FROM posts ORDER BY created_at DESC, name ASC
$queryBuilder->resetOrder();
$queryBuilder->random();
// SELECT * FROM posts ORDER BY RAND()
๐ Sorting
Info: Since version 1.3
The Sort namespace provides type-safe, composable sorting objects. This is especially useful when sort parameters come
from user input (e.g. ?sort=title:desc) and need to be validated before being applied to a query.
Sort Objects
The SortInterface defines a single method: apply(QueryBuilder $builder): void. Two implementations are provided:
use Hector\Query\Sort\Sort;
use Hector\Query\Sort\MultiSort;
// Single sort
$sort = new Sort('title', 'ASC');
$sort->apply($builder);
// Multiple sorts
$sort = new MultiSort(
new Sort('title', 'ASC'),
new Sort('id', 'DESC'),
);
$sort->apply($builder);
The QueryBuilder also provides a fluent shortcut:
$builder->applySort($sort);
โน๏ธ
applySort()is additive: it appends the sort to the existing order. To replace the existing order, callresetOrder()first:$builder->resetOrder()->applySort($sort);
SortConfig
SortConfig parses and validates sort parameters from HTTP query strings. It lets you control which columns are
sortable, map public parameter names to real column names, and optionally lock sort directions.
use Hector\Query\Sort\SortConfig;
$sortConfig = new SortConfig(
allowed: ['title', 'created_at', 'id'],
default: ['title'],
);
// Resolve from query params: ?sort=created_at:desc
$sort = $sortConfig->resolve($request->getQueryParams());
$builder->resetOrder()->applySort($sort);
Constructor parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
allowed |
array |
required | Allowed sort columns (see Allowed formats) |
default |
array |
required | Default sort in column:dir format (e.g. ['title:desc']) |
defaultDir |
string |
ASC |
Direction used when not specified by the user or the mapping |
sortParam |
string |
sort |
Query parameter name to read from |
Allowed formats
The allowed parameter accepts several formats:
// Simple: column name as-is, user can choose direction
$config = new SortConfig(
allowed: ['title', 'created_at', 'id'],
default: ['title'],
);
// Column mapping: expose a public name, map to real column
$config = new SortConfig(
allowed: ['name' => 'user_name', 'date' => 'created_at'],
default: ['name'],
);
// Locked direction: the mapping includes :dir, user cannot override it
$config = new SortConfig(
allowed: ['status' => 'create_time:desc'],
default: ['status'],
);
// Multi-column mapping: one public name maps to multiple ORDER BY columns
$config = new SortConfig(
allowed: ['status' => ['status', 'create_time:desc']],
default: ['status'],
);
Direction control: free vs. locked
The direction is determined by the presence of :dir in the mapping value:
| Mapping value | Direction | Description |
|---|---|---|
'column' (no dir) |
Free | User chooses with ?sort=col:asc or :desc. Falls back to defaultDir if omitted. |
'column:desc' (with dir) |
Locked | Direction is fixed. Userโs requested direction is ignored. |
For multi-column mappings, each column follows its own rule:
$config = new SortConfig(
allowed: [
'create_time' => 'review_id', // free direction
'status' => ['status', 'create_time:desc'], // status=free, create_time=locked DESC
],
default: ['create_time:desc'],
defaultDir: 'asc',
);
| User request | Generated ORDER BY | Explanation |
|---|---|---|
?sort=create_time:asc |
ORDER BY review_id ASC |
Free: user direction applied |
?sort=create_time |
ORDER BY review_id ASC |
Free: defaultDir (asc) applied |
?sort=status:asc |
ORDER BY status ASC, create_time DESC |
status=free (asc), create_time=locked (desc) |
?sort=status:desc |
ORDER BY status DESC, create_time DESC |
status=free (desc), create_time=locked (desc) |
?sort=status |
ORDER BY status ASC, create_time DESC |
status=free (defaultDir), create_time=locked |
?sort=unknown |
ORDER BY review_id DESC |
Invalid: falls back to default |
| (no param) | ORDER BY review_id DESC |
No param: falls back to default |
Default sort format
Default sort items use the column:dir string format. Each item must match a key in allowed:
// Single default
$config = new SortConfig(allowed: ['title', 'id'], default: ['title']);
// With explicit direction
$config = new SortConfig(allowed: ['title', 'id'], default: ['title:desc']);
// Multi-column default
$config = new SortConfig(allowed: ['title', 'id'], default: ['title', 'id:desc']);
Supported URL formats
- Single:
?sort=title:asc - Multiple:
?sort[]=title:asc&sort[]=id:desc - Without direction (uses
defaultDir):?sort=title
Custom Sort Implementations
Implement SortInterface for custom sorting logic:
use Hector\Query\Sort\SortInterface;
use Hector\Query\QueryBuilder;
class RandomSort implements SortInterface
{
public function apply(QueryBuilder $builder): void
{
$builder->orderBy('RAND()');
}
}
class NullsLastSort implements SortInterface
{
public function __construct(
private string $column,
private string $dir = 'ASC',
) {}
public function apply(QueryBuilder $builder): void
{
$builder->orderBy(sprintf('%s IS NULL', $this->column), 'ASC');
$builder->orderBy($this->column, $this->dir);
}
}
๐ฆ Limiting Results
Control pagination using limit() and offset():
$queryBuilder->limit(10);
$queryBuilder->offset(20);
$queryBuilder->resetLimit();
๐ Assigning Values
When building INSERT or UPDATE queries, use:
$queryBuilder
->assign('name', 'Alice')
->assign('email', 'alice@example.com');
$queryBuilder->resetAssignments();
Or in bulk:
$queryBuilder->assigns([
'name' => 'Alice',
'email' => 'alice@example.com'
]);
You can also pass a StatementInterface to assigns() for more advanced use cases.
๐ Joins
Join tables with the following methods:
$queryBuilder->innerJoin('users', 'users.id = posts.user_id', 'u');
$queryBuilder->leftJoin('categories', 'categories.id = posts.category_id');
$queryBuilder->rightJoin('tags', 'tags.id = posts.tag_id');
$queryBuilder->resetJoin();
๐ Unions
The Union class allows combining multiple SELECT queries:
use Hector\Query\Select;
use Hector\Query\Union;
$select1 = (new Select())->from('customers')->column('name');
$select2 = (new Select())->from('suppliers')->column('name');
$union = new Union();
$union->addSelect($select1, $select2);
Union also implements StatementInterface, allowing you to bind and execute it like any other query.
Executing a Union
use Hector\Connection\Bind\BindParamList;
$binds = new BindParamList();
$statement = $union->getStatement($binds);
// Execute via connection
foreach ($connection->fetchAll($statement, $binds) as $row) {
echo $row['name'];
}
Union All (with duplicates)
$union = new Union();
$union->addSelect($select1, $select2);
$union->all(); // UNION ALL instead of UNION
๐ค Fetching Results
Use the following methods to retrieve data:
$queryBuilder->fetchOne(); // ?array - first row
$queryBuilder->fetchAll(); // Generator - all rows
$queryBuilder->fetchColumn(); // Generator - specific column
Tip: fetchAll() and fetchColumn() return a Generator. Refer to PHP
documentation: https://www.php.net/manual/en/class.generator.php
๐ข Counting Results
Quickly count the results of a query:
$count = $queryBuilder
->from('table')
->where('status', 'active')
->count();
$rows = $queryBuilder->fetchAll();
This method resets column selection, limit, and order, but does not mutate the original query builder.
๐ Distinct Values
Use distinct() to eliminate duplicates:
$queryBuilder
->from('users')
->distinct()
->fetchAll();
โ Existence Check
Determine if any row matches the conditions:
$exists = $queryBuilder
->from('users')
->where('email', 'alice@example.com')
->exists();
Does not alter the query builder instance.
โ๏ธ Shortcuts for Insert / Update / Delete
You can execute data manipulation directly:
$queryBuilder->insert([
'name' => 'Alice',
'email' => 'alice@example.com'
]);
$queryBuilder->update([
'status' => 'inactive'
]);
$queryBuilder->delete();
You may also use a subquery for insert:
$queryBuilder->insert((new Select())->from('source_table'));
Ignoring Duplicates
Use ignore() to skip rows that would cause duplicate key violations:
$queryBuilder
->from('users')
->ignore()
->insert([
'email' => 'alice@example.com',
'name' => 'Alice'
]);
// INSERT IGNORE INTO users ...
These shortcut methods do not affect the QueryBuilder instance, so it remains reusable for further operations.
๐ Locking Rows
Use the $lock parameter on fetch methods to acquire a FOR UPDATE lock on selected rows. This is useful for
preventing concurrent modifications in transactional contexts.
$connection->beginTransaction();
// Lock the row for update
$user = $queryBuilder
->from('users')
->where('id', 1)
->fetchOne(lock: true);
// Modify and save
$queryBuilder->from('users')->where('id', 1)->update(['balance' => $user['balance'] - 100]);
$connection->commit();
Available on:
fetchOne(bool $lock = false)fetchAll(bool $lock = false)fetchColumn(int $column = 0, bool $lock = false)
Warning: Locking requires an active transaction. The lock is released when the transaction is committed or rolled back.
Tip: On databases that support it (MySQL 8+, PostgreSQL), SKIP LOCKED is automatically added to avoid blocking
on already-locked rows.
๐ Integrated Pagination
Info: Since version 1.3
The QueryBuilder provides a paginate() method that integrates directly with the Pagination
component. It automatically handles limit/offset and returns a pagination object.
use Hector\Pagination\Request\OffsetPaginationRequest;
$request = new OffsetPaginationRequest(page: 3, perPage: 20);
$pagination = $queryBuilder
->from('users')
->where('active', true)
->orderBy('created_at', 'DESC')
->paginate($request);
// Access results
foreach ($pagination as $row) {
echo $row['name'];
}
// Pagination metadata
$pagination->getCurrentPage(); // 3
$pagination->hasMore(); // true/false
$pagination->getTotal(); // null (not computed by default)
With Total Count
Pass withTotal: true to compute the total count (requires an additional query):
$pagination = $queryBuilder
->from('users')
->paginate($request, withTotal: true);
$pagination->getTotal(); // 1523
$pagination->getTotalPages(); // 77
Supported Request Types
| Request Type | Returns |
|---|---|
OffsetPaginationRequest |
OffsetPagination |
CursorPaginationRequest |
CursorPagination |
RangePaginationRequest |
RangePagination |
use Hector\Pagination\Request\CursorPaginationRequest;
$request = new CursorPaginationRequest(
perPage: 20,
position: ['id' => 42],
);
$pagination = $queryBuilder
->from('users')
->orderBy('id')
->paginate($request);
$pagination->getNextPosition(); // ['id' => 62]
Tip: See the Pagination documentation for details on pagination types, navigators, and response preparation.