sobhanmohammadi / arxaron
Arxaron — a secure, robust, high-performance PHP database library built directly on mysqli (no PDO), with a fluent query builder, active-record models, transactions, and optional Redis (predis) caching.
Requires
- php: >=8.3
- ext-json: *
- ext-mysqli: *
Requires (Dev)
- phpunit/phpunit: ^11.0 || ^12.0
- predis/predis: ^2.2 || ^3.0
Suggests
- predis/predis: Required if you want Redis-backed query caching (^2.2 or ^3.0).
README
Package: sobhanmohammadi/arxaron
A secure, robust, and performant database library for PHP 8.3, 8.4, and 8.5 —
built directly on mysqli (never PDO), with a fluent query builder,
an optional active-record layer, real transactions with savepoints, and
first-class Redis query caching via predis/predis.
📖 See
docs/GUIDE.mdfor the complete, in-depth usage guide — every method, every config option, transactions, caching, security model, troubleshooting, and a full end-to-end example. This README is just a quick start.
Why this exists
mysqli-only. No PDO anywhere in the stack, including for prepared statements — everything goes throughmysqli_stmt.- Security by construction. All values are bound as prepared-statement
parameters. Table/column names are validated against a strict whitelist
pattern and backtick-quoted.
update()/delete()refuse to run without aWHEREclause unless you explicitly opt out. - Robust. Automatic, bounded reconnect-and-retry on transient errors
("MySQL server has gone away"), nested transactions via
SAVEPOINT, deadlock-aware transaction retries, and connection error messages that never leak credentials. - Fast. Prepared statement reuse per call, buffered or streamed
(
cursor()) result fetching, and an optional Redis cache layer with tag-based invalidation that fails open (a cache outage never breaks your app — it just stops caching). - PHP 8.3 – 8.5 compatible. Uses only stable, non-deprecated APIs
(readonly properties, enums-free,
match, named arguments) so it runs unmodified across the 8.3/8.4/8.5 line.
Installation
composer require sobhanmohammadi/arxaron
# Optional, only if you want Redis-backed caching:
composer require predis/predis
Quick start
use Arxaron\Config; use Arxaron\Database; Database::addConnection(Config::fromArray([ 'host' => '127.0.0.1', 'username' => 'app', 'password' => getenv('DB_PASSWORD'), 'database' => 'shop', 'charset' => 'utf8mb4', ])); // Optional: enable Redis-backed query caching (uses predis/predis). Database::useRedisCache(new Predis\Client([ 'scheme' => 'tcp', 'host' => '127.0.0.1', 'port' => 6379, ])); // Fluent query builder $activeUsers = Database::table('users') ->select('id', 'name', 'email') ->where('active', '=', 1) ->orderBy('created_at', 'DESC') ->limit(20) ->get(); foreach ($activeUsers as $row) { echo $row['name'], "\n"; } // Raw parameterized SQL (positional or named placeholders) $row = Database::query('SELECT * FROM users WHERE email = :email', [ 'email' => 'ada@example.com', ])->first();
Query builder
Database::table('orders') ->select('orders.id', 'orders.total', 'customers.name') ->join('customers', 'orders.customer_id', '=', 'customers.id') ->where('orders.status', '=', 'paid') ->whereBetween('orders.created_at', $start, $end) ->whereIn('orders.region', ['EU', 'UK']) ->orWhere('orders.priority', '=', 'high') ->groupBy('customers.id') ->having('SUM(orders.total)', '>', 1000) ->orderBy('orders.total', 'DESC') ->paginate(page: 2, perPage: 25) ->get();
Grouped conditions:
Database::table('products') ->where('active', '=', 1) ->whereGroup(function ($q) { $q->where('price', '<', 10)->orWhere('on_sale', '=', 1); }) ->get();
Writes:
$id = Database::table('users')->insert([ 'name' => 'Ada Lovelace', 'email' => 'ada@example.com', ]); Database::table('users')->insertMany([ ['name' => 'Grace', 'email' => 'grace@example.com'], ['name' => 'Alan', 'email' => 'alan@example.com'], ]); Database::table('users')->where('id', '=', $id)->update(['active' => 0]); Database::table('users')->where('id', '=', $id)->delete();
update() and delete() throw a QueryException if you forget a WHERE
clause — use ->whereRaw('1=1') to explicitly confirm a full-table
operation.
Transactions
Database::transaction(function ($conn) { $conn->execute('UPDATE accounts SET balance = balance - ? WHERE id = ?', [100, 1]); $conn->execute('UPDATE accounts SET balance = balance + ? WHERE id = ?', [100, 2]); });
Nested transaction() calls automatically use SAVEPOINTs. Deadlocks
(MySQL error 1213) and lock-wait timeouts (1205) at the outermost level are
retried automatically with a small randomized backoff.
Redis query caching
$topProducts = Database::table('products') ->where('active', '=', 1) ->orderBy('sales', 'DESC') ->limit(10) ->cache(ttlSeconds: 60) // cached under a key tagged with "products" ->get(); // Any insert/update/delete on `products` automatically invalidates // everything cached under that tag.
Cache reads/writes are fail-open: if Redis is unreachable, Arxaron transparently falls back to querying MySQL directly rather than throwing.
Active record (optional)
use Arxaron\Model; final class User extends Model { protected static string $table = 'users'; protected static array $fillable = ['name', 'email']; protected static ?int $cacheTtl = 30; } $user = User::create(['name' => 'Ada', 'email' => 'ada@example.com']); $user = User::find(1); $user->name = 'Ada Lovelace'; $user->save(); $user->delete(); foreach (User::query()->where('active', '=', 1)->get() as $row) { // ... }
Streaming large result sets
foreach (Database::connection()->cursor('SELECT * FROM events') as $row) { // processed one row at a time, not buffered in memory }
Multiple connections
Database::addConnection(Config::fromArray([...]), name: 'analytics'); Database::table('events', connection: 'analytics')->get();
Configuration reference (Config)
| Option | Default | Notes |
|---|---|---|
host |
— | Prefix with p: (or set persistent: true) for a persistent connection. |
username / password |
— | |
database |
— | |
port |
3306 |
|
socket |
null |
Unix socket path, if used instead of TCP. |
charset |
utf8mb4 |
|
ssl_enabled |
false |
Plus ssl_key, ssl_cert, ssl_ca_cert, ssl_ca_path, ssl_cipher. |
connect_timeout |
5 |
Seconds. |
read_timeout |
0 (system default) |
Seconds. |
strict_mode |
true |
Sets strict sql_mode + isolation level on connect. |
isolation_level |
REPEATABLE READ |
One of the four standard SQL levels. |
max_reconnect_attempts |
2 |
Bounded automatic retry on transient connection errors. |
Security notes
- Every value ever placed into SQL is sent via
mysqli_stmt::bind_param. There is no string-concatenation path for values. - Table/column identifiers passed as plain strings are validated against
^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$and backtick-quoted (with embedded backticks doubled) before use. whereRaw()/selectRaw()are explicit, clearly named escape hatches for expressions that can't be expressed otherwise (e.g.COUNT(*)); callers are responsible for never interpolating untrusted input into them.- Connection errors are sanitized so
(using password: YES/NO)-style fragments never leak into logs or exceptions. - Cached values are
serialize()d butunserialize()d withallowed_classes => false, preventing PHP object-injection even if a cache backend were ever compromised.
Requirements
- PHP 8.3, 8.4, or 8.5
ext-mysqlipredis/predis^2.2 or ^3.0 (only if you use Redis caching)
License
MIT