Userland mysqli compatibility shim over ePHPm's
in-process DB bridge (ephpm_db_query() / ephpm_db_execute()). Code
written against the mysqli API talks straight to the embedded litewire
SQLite session — no MySQL server, no socket, no wire protocol.
The activation rule — read this first. A userland shim cannot override a loaded extension. The global
mysqli/mysqli_result/mysqli_stmtclasses, themysqli_*functions, and theMYSQLI_*constants are defined only when ext-mysqli is NOT loaded (every definition is guarded). ePHPm's own SDK builds currently compile mysqli in, so under a stock ePHPm binary the real extension wins and the global surface of this package is inert. The namespaced API (Ephpm\Mysqli\Connectionetc.) works regardless — the global definitions are thin aliases onto it.
Who this is for:
- PHP embed builds without mysqli — slimmed SDK variants, other
embedders of the ePHPm SAPI — that want existing mysqli code (e.g.
WordPress'
wpdb) to run on the bridge unmodified. - Code that wants the bridge behind a mysqli-shaped API under any build, via the always-available namespaced classes.
// Global surface (only when ext-mysqli is absent):
$db = mysqli_connect(); // host args accepted & ignored
$db->query("INSERT INTO t (name) VALUES ('a')");
$row = $db->query('SELECT * FROM t')->fetch_assoc();
// Namespaced surface (works everywhere, even with ext-mysqli loaded):
$db = new \Ephpm\Mysqli\Connection();
$stmt = $db->prepare('SELECT * FROM t WHERE id = ?');
$stmt->bind_param('i', $id);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();- Requirements
- Install
- How it connects (it doesn't)
- Error reporting
- Statement routing
- Coverage matrix
- Fidelity limits
- Transactions
- Testing without ePHPm
- License
- PHP 8.2+
- ePHPm v0.6.3 or newer (current release: v0.10.2) — the
ephpm_db_*bridge merged in ephpm#257 and first shipped in the v0.6.3 release. [db.sqlite]active in your ePHPm config. The bridge only registers when an embedded SQLite backend is running; without it the natives throwephpm_db: no embedded database is active.- For the global mysqli surface: a PHP build without ext-mysqli
(see the activation rule above). Check with
php -m | grep mysqli— or at runtime:
var_dump(extension_loaded('mysqli')); // false → shim globals active
var_dump(function_exists('ephpm_db_query')); // true → bridge availableePHPm packages are distributed via their GitHub repositories, not
Packagist. Add this repo as a Composer vcs repository, then require
the package (ephpm/mysqli-shim is tagged v0.1.0, so ^0.1
resolves):
composer config repositories.ephpm/mysqli-shim vcs https://github.com/ephpm/mysqli-shim
composer require ephpm/mysqli-shim:^0.1src/compat/mysqli.php (the guarded global surface) is loaded through
composer's autoload.files on every request; when ext-mysqli is
loaded it returns immediately without defining anything.
There is no connection. Host, user, password, database, port, and
socket arguments on __construct / mysqli_connect() /
real_connect() are accepted and ignored; ping() is always true;
connect_errno is always 0. Every statement runs on ePHPm's per-thread
litewire session — the same backend the MySQL wire frontend serves, so
SHOW/DESCRIBE emulation, SET NAMES no-ops, and transaction handling
behave exactly as they do over a socket to the same server.
get_server_info() returns 8.0.36-litewire, mirroring what
litewire's MySQL wire frontend advertises in its handshake. Note that
SELECT VERSION() through the same backend answers 8.0.0-litewire
(a translate-layer constant) — real litewire over a socket shows the
same pair.
The shim honors mysqli's report-mode semantics with the PHP 8.1+
default (MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT):
| Mode | Behavior on SQL error |
|---|---|
ERROR | STRICT (default) |
throws Ephpm\Mysqli\SqlException |
ERROR only |
E_USER_WARNING + returns false |
OFF |
returns false silently |
In every mode, errno / error / sqlstate / error_list are set on
the connection (and statement). Errors carry the bridge's MySQL errno
(e.g. 1062) and SQLSTATE.
Exception class caveat: the real mysqli_sql_exception is final
(PHP 8.4), so the shim cannot extend it. When ext-mysqli is absent,
mysqli_sql_exception is aliased to Ephpm\Mysqli\SqlException and
catch (mysqli_sql_exception $e) works as expected. When ext-mysqli
IS loaded (namespaced usage), catch Ephpm\Mysqli\SqlException or
\RuntimeException — both the real and the shim exception extend
RuntimeException. getSqlState() is available on both.
Setting the mode: the global mysqli_report() is part of the guarded
surface, so it only exists (and only affects the shim) when ext-mysqli
is absent. Namespaced users call Ephpm\Mysqli\Report::set() — when
the real extension is loaded, the real mysqli_report() controls the
real driver, not this shim.
mysqli_query() is one entry point; the shim runs every statement through
the bridge's unified ephpm_db_run(), which executes once and reports
has_rowset (read from the executed statement), the rows, the column
metadata, and the affected-rows / last-insert-id metadata. There is no
first-keyword classification: a WITH … INSERT hybrid, INSERT … RETURNING, and CALL all classify correctly for free, and a rowset with
zero rows still carries its column names.
On an ePHPm too old to have ephpm_db_run() the backend transparently
falls back to the previous ephpm_db_query() / ephpm_db_execute() split
(routing by first keyword, with ephpm_db_columns() filling in zero-row
column names when present), preserving the v0.6.3 minimum.
After a rowset statement insert_id is 0 and affected_rows equals
num_rows (the mysqlnd buffered behavior).
Legend: impl = implemented over the bridge · no-op = accepted,
does nothing, returns success · throws = not implemented, throws
Ephpm\Mysqli\NotImplementedException (a BadMethodCallException).
| Member | Status |
|---|---|
__construct / connect / real_connect |
no-op — all connection args accepted and ignored |
query (returns mysqli_result|bool), real_query + store_result / use_result |
impl (all results buffered; $result_mode ignored) |
prepare |
impl — but no server-side validation; errors surface at execute |
real_escape_string / escape_string |
impl — backslash-escapes NUL, \n, \r, \, ", Ctrl-Z; the single quote is doubled (''), which litewire accepts where it rejects \' (db-wordpress #1) |
errno, error, error_list, sqlstate, insert_id, affected_rows, field_count |
impl |
autocommit, begin_transaction, commit, rollback |
impl — as SQL through the bridge; see Transactions |
savepoint, release_savepoint |
pass-through SQL — support depends on the backend |
close |
impl — later use throws Error, like the real class |
ping |
impl — always true |
select_db, set_charset, options / set_opt, ssl_set |
no-op true |
character_set_name |
impl — 'utf8mb4' |
get_charset |
impl — static utf8mb4 charset object |
get_server_info / server_info / server_version |
impl — 8.0.36-litewire / 80036 |
get_client_info, host_info, protocol_version |
impl — static shim values |
thread_id |
impl — stable per-connection fake (no real threads exposed) |
warning_count, get_warnings |
always 0 / false |
more_results, next_result |
always false (no multi-query) |
multi_query |
throws |
change_user, kill, refresh, stat, dump_debug_info, debug, stmt_init |
throws |
async (MYSQLI_ASYNC, poll, reap_async_query) |
not defined |
| Member | Status |
|---|---|
fetch_assoc, fetch_row, fetch_array (ASSOC/NUM/BOTH), fetch_object, fetch_all, fetch_column |
impl — native int/float/null typing from the bridge |
num_rows, field_count, data_seek, free / close / free_result, iteration (foreach) |
impl |
fetch_field, fetch_fields, fetch_field_direct, field_seek, current_field |
impl, best-effort — see Fidelity limits |
lengths |
impl — byte lengths of the last-fetched row |
| Member | Status |
|---|---|
bind_param (i/d/s/b, by reference, coerced at execute) |
impl |
execute (incl. PHP 8.1-style execute([$params]), sent as strings) |
impl |
get_result |
impl — Result for rowsets, false otherwise; consumed once per execute |
bind_result + fetch |
impl — cheap over the buffered rowset, so it's in |
affected_rows, insert_id, num_rows, param_count, field_count |
impl |
errno, error, error_list, sqlstate |
impl |
store_result |
no-op true (always buffered) |
free_result, close |
impl |
reset, send_long_data, result_metadata, attr_set, attr_get |
throws |
Every implemented member above has its mysqli_* procedural wrapper
(mysqli_connect, mysqli_query, mysqli_fetch_assoc,
mysqli_stmt_bind_param, mysqli_report, …) — defined only when
ext-mysqli is absent. mysqli_connect_errno() / mysqli_connect_error()
return 0 / null. See src/compat/mysqli.php for the exact list.
The MYSQLI_* constants the shim defines (fetch modes, report flags,
field types, field flags, client/option/transaction flags) live in
Ephpm\Mysqli\Compat::CONSTANTS with values asserted against the real
extension in CI. MYSQLI_TYPE_VARCHAR is deliberately absent (the real
extension doesn't define it) and so are MYSQLI_NO_DATA /
MYSQLI_DATA_TRUNCATED (deprecated in PHP 8.4; never produced here).
Honest list of where the shim differs from real mysqli:
- Field metadata is inferred, not declared.
fetch_field()reportsname/orgnamecorrectly;typeis guessed from PHP value types (int →LONGLONG, float →DOUBLE, string →VAR_STRING, all-null →NULL);table/orgtable/dbare empty,lengthis 0,flagsis at mostNUM_FLAG. Code that branches on precise column types (e.g. distinguishingTINYINTfromBIGINT) will not get that here. prepare()does not validate SQL — the bridge has no server-side prepare, so syntax errors throw/fail atexecute()time.insert_idis per-statement: 0 after any rowset statement.affected_rowsafter SELECT equalsnum_rows(mysqlnd buffered behavior); it does not go to -1.- No multi-statement support (
multi_query), no async, nomysqli_driver/mysqli_warningclasses. fetch_object()assigns properties after construction, not before, as the real extension does.mysqli_report()is per-process (a static mode), not per-driver instance.
begin_transaction / commit / rollback / autocommit(false) run
BEGIN / COMMIT / ROLLBACK through the bridge as plain SQL, and
the shim tracks statements it has seen to make commit() with no open
transaction a no-op and to emulate autocommit-off (implicit BEGIN
before the next data statement).
Abandoned transactions roll back at request end. The bridge
session lives for the worker thread, but transactions do not: if a
script leaves an explicit transaction open when the request finishes
(forgotten COMMIT, or a mid-transaction fatal), ePHPm's per-request
teardown issues a server-side ROLLBACK and logs a warning — the open
transaction cannot leak into the next request on the same thread. This
is a safety net, not an API: scripts should still COMMIT or
ROLLBACK explicitly.
Ephpm\Mysqli\Connection takes an optional DbOpsInterface, and
Ephpm\Mysqli\SqliteDbOps emulates the bridge with the sqlite3
extension (native REAL binding, bridge-shaped errors with best-effort
MySQL errnos, SET … no-ops). This is how this repo's own test suite
runs on a stock PHP CLI:
use Ephpm\Mysqli\Connection;
use Ephpm\Mysqli\SqliteDbOps;
$db = new Connection(ops: new SqliteDbOps()); // per-connection
Connection::setDefaultOpsFactory(fn () => new SqliteDbOps()); // process-wideSqliteDbOps is for tests only: it speaks SQLite dialect directly —
no MySQL translation, no SHOW/DESCRIBE emulation — and its error-code
mapping is a heuristic.
Because a normal dev PHP has the real ext-mysqli loaded, the test suite
targets the namespaced classes directly and exercises the guarded
global surface in a child php -n process (see
tests/CompatSurfaceTest.php).
MIT — see LICENSE.