A PHP extension powered by Rust 🦀 and SQLx, enabling safe, fast, and expressive database access with additional SQL syntax. It's built using the ext-php-rs crate.
Postgres, MySQL and Mssql are supported.
The project's goals are centered on providing a secure and ergonomic way to interact with SQL-based DBM systems without any compromise on performance. The author's not big on PHP, but as a security researcher he understood the necessity of modernizing the toolkit of great many PHP developers. The idea came up, and bish bash bosh, a couple of weekends later the project was all but done. More to come.
The project is still kind of experimental, so any feedback/ideas will be greatly appreciated!
- AST-based SQL augmentation (e.g., conditional blocks)
- Named parameters with
$param
,:param
, or positional:1
syntax - Automatic result conversion to PHP arrays or objects
- Painless
IN (?)
/NOT IN (?)
clauses expansion and collapse - Safe and robust
ORDER BY
/GROUP BY
clauses - Pagination with
PAGINATE
- Safe and robust
SELECT
- SQL transactions are supported in full
- Native JSON and bigint support
- Optional persistent connections (with connection pooling)
This extension introduces a powerful SQL preprocessor that supports conditional blocks, optional fragments, and named parameters.
Wrap parts of your query with double braces {{ ... }}
to make them conditional:
SELECT *
FROM users
WHERE TRUE
{{ AND name = $name }}
{{ AND status = $status }}
If a named parameter used inside the block is not provided, the entire block is omitted from the final query.
Nested conditional blocks are supported:
SELECT *
FROM logs
WHERE TRUE {{ AND date > $since {{ AND level = $level }} }}
In the above example the level
condition will only be rendered when both $level
and $since
are set.
SELECT *
FROM logs
WHERE date > $since {{ AND level = $level }}
The above example will throw an exception if $since
is not set.
Passing an array as a parameter to a single placeholder automatically expands it:
// Expands to: SELECT * FROM people WHERE name IN (?, ?, ?)
// with values ['Peter', 'John', 'Jane']
$rows = $driver->queryAll(
'SELECT * FROM people WHERE name IN :names', [
'names' => ['Peter', 'John', 'Jane']
]
);
Omitting the parameter or passing an empty array will make IN
collapse into boolean FALSE
.
var_dump($driver->dry(
'SELECT * FROM people WHERE name IN :names', [
'names' => []
]
));
array(2) {
[0]=>
string(53) "SELECT * FROM people WHERE FALSE /* name IN :names */"
[1]=>
array(0) {
}
}
Same goes for NOT IN
, except it will collapse into boolean TRUE
.
var_dump($driver->dry(
'SELECT * FROM people WHERE name NOT IN :names', [
'names' => []
]
));
array(2) {
[0]=>
string(56) "SELECT * FROM people WHERE TRUE /* name NOT IN :names */"
[1]=>
array(0) {
}
}
Makes sense, right? Given that
x IN (1, 2, 3)
is sugar for(x = 1 OR x = 2 OR x = 3)
andx NOT IN (1, 2, 3)
is sugar for(x != 1 AND x != 2 AND x != 3)
.
Keep in mind that you can not only use it in WHERE
, but also in ON
clauses when joining.
It is true that in simpler cases of
IN :empty
like the above example you could just immediately return an empty result set without sending it to DBMS, but there could be aJOIN
or aUNION
.
Sql\ByClause
helper class for safe ORDER BY
/ GROUP BY
clauses from user input.
SAFETY CONCERNS
- 🟢 You can safely pass any user input as sorting settings.
- 🔴 Do NOT pass unsanitized user input into
ByClause
constructor to avoid SQL injection vulnerabilities. If you absolutely have to, then applyarray_values()
to the argument to avoid SQL injections.
Examples:
// Let's define allowed columns
$orderBy = new Sqlx\ByClause([
'name',
'created_at',
'random' => 'RANDOM()'
]);
// Equivalent to: SELECT * FROM users ORDER BY `name` ASC, RANDOM()
$driver->queryAll('SELECT * FROM users ORDER BY :order_by', [
'order_by' => $orderBy([
['name', Sqlx\ByClause::ASC],
'random'
])
]);
Field names are case-sensitive, but they get trimmed.
Sql\PaginateClause
helper class for safe pagination based on user input.
// Let's define pagination rules
$pagination = new Sqlx\PaginateClause;
$pagination->perPage(5);
$pagination->maxPerPage(20);
// Equivalent to: SELECT * FROM people ORDER by id LIMIT 5 OFFSET 500
$rows = $driver->queryAll(
'SELECT * FROM people ORDER by id PAGINATE :pagination', [
'pagination' => $pagination(100)
]
);
// Equivalent to: SELECT * FROM people ORDER by id LIMIT 10 OFFSET 1000
$rows = $driver->queryAll(
'SELECT * FROM people ORDER by id PAGINATE :pagination', [
'pagination' => $pagination(100, 10)
]
);
// Equivalent to: SELECT * FROM people ORDER by id LIMIT 5 OFFSET 0
$rows = $driver->queryAll(
'SELECT * FROM people ORDER by id PAGINATE :pagination', [
'pagination' => $pagination()
]
);
You can safely pass any unsanitized values as arguments, but keep in mind that perPage()
/maxPerPage()
/
defaultPerPage()
functions take a positive integer and throw an exception otherwise.
A helper class for safe SELECT
clauses from user input.
SAFETY CONCERNS
- 🟢 You can safely pass any user input as invocation argument.
- 🔴 Do NOT pass unsanitized user input into
SelectClause
constructor to avoid SQL injection vulnerabilities. If you absolutely have to, then applyarray_values()
to the argument to avoid SQL injections.
Examples:
$select = new Sqlx\SelectClause([
'id',
'created_at',
'name' => ,
'num_posts' => 'COUNT(posts.*)'
]);
// Equivalent to: SELECT `id`, `name`, COUNT(posts.*) AS `num_posts` FROM users
$rows = $driver->queryAll('SELECT :select FROM users', [
'select' => $select(['id','name', 'num_posts])
]);
Note that column names are case-sensitive, but they get trimmed.
$driver->begin(function($driver) {
// All queries inside this function will be wrapped in a transaction.
// You can use all driver functions here.
$driver->insert('users', ['name' => 'John', 'age' => 25]);
$driver->insert('users', ['name' => 'Mary', 'age' => 20]);
// return false;
});
A ROLLBACK
happens if the closure returns false
or throws an exception.
Otherwise, a COMMIT
gets sent when functions finishes normally.
Additional supported methods to be called from inside a closure:
savepoint(name: String)
rollbackToSavepoint(name: String)
releaseSavepoint(name: String)
PostgreSQL/MySQL JSON types are automatically decoded into PHP arrays or objects.
var_dump($driver->queryValue(
'SELECT $1::json',
['{"foo": ["bar", "baz"]}']
));
/* Output:
object(stdClass)#2 (1) {
["foo"]=>
array(2) {
[0]=>
string(3) "bar"
[1]=>
string(3) "baz"
}
}*/
var_dump($driver->queryRow(
'SELECT $1::json AS col',
['{"foo": ["bar", "baz"]}']
)->col->foo[0]);
// Output: string(3) "bar"
Install with cargo-php
:
cargo install cargo-php --locked
cd php-sqlx-cdylib
For macOS:
export MACOSX_DEPLOYMENT_TARGET=$(sw_vers -productVersion | tr -d '\n')
export RUSTFLAGS="-C link-arg=-undefined -C link-arg=dynamic_lookup"
#export RUSTFLAGS="-Zmacro-backtrace -Zproc-macro-backtrace -Clink-arg=-undefined -Clink-arg=dynamic_lookup"
cargo install cargo-php --locked
cd php-sqlx-cdylib
cargo php install --release --yes
$driver = new Sqlx\PgDriver("postgres://user:pass@localhost/db");
Or with options:
$driver = new Sqlx\PgDriver([
Sqlx\DriverOptions::OPT_URL => 'postgres://user:pass@localhost/db',
Sqlx\DriverOptions::OPT_ASSOC_ARRAYS => true, // return arrays instead of objects
Sqlx\DriverOptions::OPT_PERSISTENT_NAME => 'main_db'
Sqlx\DriverOptions::OPT_MAX_CONNECTIONS => 5,
Sqlx\DriverOptions::OPT_MIN_CONNECTIONS => 0,
//Sqlx\DriverOptions::OPT_MAX_LIFETIME => "30 min",
Sqlx\DriverOptions::OPT_IDLE_TIMEOUT => 120,
]);
assocArrays(): bool
– returns true if the driver is currently set to produce associative arrays instead of objects.prepare(string $query): Sqlx\PreparedQuery
– returns a reusable prepared query object bound to the same driver.
Method | Returns | Notes |
---|---|---|
queryRow() |
first row (array | object) | exception if no rows returned |
queryRowAssoc() |
first row (array) | ∟ enforces array mode |
queryRowObj() |
first row (object) | ∟ enforces object mode |
queryMaybeRow() |
first row (array | object | null) | null if no rows returned |
queryMaybeRowAssoc() |
first row (array | null) | ∟ enforces array mode |
queryMaybeRowObj() |
first row (object | null) | ∟ enforces object mode |
Method | Returns | Notes |
---|---|---|
queryValue() |
first row column value | exception if no rows returned |
queryValueAssoc() |
↑ | ∟ enforces array mode for JSON objects |
queryValueObj() |
↑ | ∟ enforces object mode for JSON objects |
queryMaybeValue() |
first row column value or null | null if no rows returned |
queryMaybeValueAssoc() |
↑ | ∟ enforces array mode for JSON objects |
queryMaybeValueObj() |
↑ | ∟ enforces object mode for JSON objects |
Method | Returns | Notes |
---|---|---|
queryColumn() |
array of column's values from each row | exception if no rows returned |
queryColumnAssoc() |
↑ | ∟ enforces array mode for JSON objects |
queryColumnObj() |
↑ | ∟ enforces object mode for JSON objects |
Method | Returns |
---|---|
queryAll() |
array of rows |
queryAllAssoc() |
array of assoc arrays |
queryAllObj() |
array of objects |
execute(string $query, array $parameters = null): int
– run INSERT/UPDATE/DELETE and return affected count.insert(string $table, array $row): int
– convenience wrapper aroundINSERT
.
dry(string $query, array $parameters = null): array
– render final SQL + bound parameters without executing. Handy for debugging.
Prepared queries expose exactly the same surface as the driver, but without the SQL argument:
$query = $driver->prepare("SELECT * FROM logs WHERE level = $level");
$rows = $query->queryAll(['level' => 'warn']);
All helpers listed above have their prepared-query counterparts:
execute()
queryRow()
/queryRowAssoc()
/queryRowObj()
queryAll()
/queryAllAssoc()
/queryAllObj()
queryDictionary()
/queryDictionaryAssoc()
/queryDictionaryObj()
queryGroupedDictionary()
/queryGroupedDictionaryAssoc()
/queryGroupedDictionaryObj()
queryColumnDictionary()
/queryColumnDictionaryAssoc()
/queryColumnDictionaryObj()
Method | Returns | Notes |
---|---|---|
queryDictionary() |
array<string | int, array | object> |
key = first column, value = entire row |
queryDictionaryAssoc() |
array<string | int, array> |
∟ forces associative arrays |
queryDictionaryObj() |
array<string | int, object> |
∟ forces objects |
⚠️ First column must be scalar, otherwise an exception will be thrown.- 🔀 The iteration order is preserved.
var_dump($driver->queryGroupedColumnDictionary(
'SELECT department, name FROM employees WHERE department IN (?)',
[['IT', 'HR']]
));
/* Output:
array(2) {
["IT"]=> array("Alice", "Bob")
["HR"]=> array("Eve")
}
*/
Method | Returns | Notes |
---|---|---|
queryColumnDictionary() |
array<string | int, mixed> |
key = first column, value = second column |
queryColumnDictionaryAssoc() |
↑ | ∟ enforces array mode for second column if it's a JSON |
queryColumnDictionaryObj() |
↑ | ∟ enforces object mode for second column if it's a JSON |
var_dump($driver->queryColumnDictionary(
'SELECT name, age FROM people WHERE name IN (?)',
[["Peter", "John", "Jane"]]
));
/* Output:
array(1) {
["John"]=>
int(22)
}
*/
Method | Returns | Notes |
---|---|---|
queryGroupedDictionary() |
array<string, array<array | object>> |
key = first column, value = list of matching rows |
queryGroupedDictionaryAssoc() |
array<string, array<array> |
∟ forces associative arrays |
queryGroupedDictionaryObj() |
array<string, array<object>> |
∟ forces objects |
var_dump($driver->queryGroupedDictionary(
'SELECT department, name FROM employees WHERE department IN (?)',
[['IT', 'HR']]
));
/* Output:
array(1) {
["IT"]=>
array(2) {
[0]=>
object(stdClass)#2 (2) {
["department"]=>
string(2) "IT"
["name"]=>
string(5) "Alice"
}
[1]=>
object(stdClass)#3 (2) {
["department"]=>
string(2) "IT"
["name"]=>
string(3) "Bob"
}
}
}
*/
Supported parameter types:
"text"
123
3.14
true
[1, 2, 3]
Nested arrays are automatically flattened and bound in order.
PostgreSQL BIGINT
values are safely mapped to PHP integers:
var_dump($driver->queryValue('SELECT ((1::BIGINT << 62) - 1) * 2 + 1');
// Output: int(9223372036854775807)
- The AST cache reduces repeated parsing overhead and speeds up query rendering.
- Supports both positional
?
,$1
,:1
and named$param
,:param
placeholders automatically.
Benchmarking pure Rust performance is more useful for optimizing the backend.
Command:
cargo bench
Here are M1 Max results for parsing and rendering a hefty query. No caching involved.
Running benches/benchmark.rs (target/release/deps/benchmark-eaed67cfaa034b35)
Gnuplot not found, using plotters backend
Ast::parse_small time: [2.5877 µs 2.5928 µs 2.5981 µs]
change: [−0.4151% −0.0902% +0.2146%] (p = 0.57 > 0.05)
No change in performance detected.
Found 3 outliers among 100 measurements (3.00%)
1 (1.00%) low mild
1 (1.00%) high mild
1 (1.00%) high severe
Ast::parse_big time: [7.2626 µs 7.2785 µs 7.2958 µs]
change: [+0.1364% +0.4485% +0.7694%] (p = 0.00 < 0.05)
Change within noise threshold.
Found 3 outliers among 100 measurements (3.00%)
2 (2.00%) high mild
1 (1.00%) high severe
Ast::render_big time: [1.9188 µs 1.9215 µs 1.9243 µs]
Found 5 outliers among 100 measurements (5.00%)
5 (5.00%) high mild
Command:
cd benches
curl -s https://raw.githubusercontent.com/composer/getcomposer.org/f3108f64b4e1c1ce6eb462b159956461592b3e3e/web/installer | php -- --quiet
./composer.phar require phpbench/phpbench --dev
./vendor/bin/phpbench run benchmark.php
Or use Docker:
docker build . -t php-sqlx-benches
docker run php-sqlx-benches
M1 Max results:
benchDrySmall...........................I0 - Mo2.009μs (±0.00%)
benchDryBig.............................I0 - Mo5.105μs (±0.00%)
benchSelect1kRows.......................I0 - Mo927.888μs (±0.00%)
MIT