Writing a database dump in pure PHP is not hard. Writing one that actually restores is, because every way it fails is silent. These are the ones that cost real time on Invision Community specifically.
Why pure PHP at all
Invision Community's own Configuration Error notification lists exec, system, passthru, pcntl_exec, popen, proc_open and shell_exec as dangerous and tells administrators to disable them. Take that advice — as you should — and mysqldump becomes unreachable. Any backup approach that shells out is broken on exactly the servers that followed the security guidance.
1. The suite connects with ANSI_QUOTES
IPS\Db issues this on every connection:
SET sql_mode='STRICT_ALL_TABLES,ONLY_FULL_GROUP_BY,ANSI_QUOTES'
Two consequences, both fatal and neither obvious.
SHOW CREATE TABLE returns double-quoted identifiers.
CREATE TABLE "core_members" ( "member_id" int unsigned NOT NULL AUTO_INCREMENT, ...
That only parses in a session that is also in ANSI_QUOTES. Restore it from any ordinary MySQL client — or from a standalone script that does not boot the suite — and every single CREATE TABLE dies with a syntax error pointing at the table name. Drop the mode for that one statement and put it straight back:
$previous = $db->query( 'SELECT @@SESSION.sql_mode AS m' )->fetch_assoc()['m'];
$db->query( "SET SESSION sql_mode = '" . str_replace( ',ANSI_QUOTES', '', $previous ) . "'" );
$create = $db->query( "SHOW CREATE TABLE `{$table}`" );
$db->query( "SET SESSION sql_mode = '{$previous}'" );
Double-quoted string literals stop being strings. This is a perfectly ordinary statement that fails under ANSI_QUOTES:
SHOW FULL TABLES WHERE Table_type = "BASE TABLE" -- ERROR: Unknown column 'BASE TABLE' in 'where clause'
MySQL read it as an identifier. Use single quotes for every literal in code that runs against this connection.
2. Appending to a gzip file makes it multi-member, and gzdecode() only reads the first part
A chunked dump opens the archive, appends, and closes it once per chunk. That is a multi-member gzip stream: several concatenated deflate members in one file. It is completely valid, and gunzip, zcat and PHP's gzopen all handle it.
gzdecode() does not. It returns the first member and reports no error:
gzopen loop read : 26526 bytes gzdecode read : 254 bytes
Nothing throws. Nothing warns. You get a backup containing its first few hundred rows and no indication anything is wrong until you need it. Always read these with a loop:
$out = '';
$h = gzopen( $path, 'rb' );
while ( !gzeof( $h ) ) { $out .= gzread( $h, 262144 ); }
gzclose( $h );
And write a footer marker as the last thing in the archive. Its presence on read-back is the only cheap proof the stream was not truncated.
3. An empty binary value becomes a bare 0x
Binary columns have to be hex-encoded — a BLOB can hold a NUL byte or an invalid UTF-8 sequence, and escaping it stores something subtly different. But:
'0x' . bin2hex( '' ) // "0x" -- ERROR: Unknown column '0x' in 'field list'
MySQL reads a bare 0x as an identifier. Every empty BLOB in the database fails to restore. Special-case it:
if ( $value === '' ) { return "''"; }
return '0x' . bin2hex( $value );
Detect binary columns by SHOW FULL COLUMNS where Collation is NULL — more reliable than matching type names, since VARBINARY and BLOB report differently but both have no character set.
4. Statement splitting: strip leading comments, do not skip the chunk
If the dump writes a banner before each table:
-- -- core_members -- DROP TABLE IF EXISTS `core_members`; CREATE TABLE `core_members` ( ... );
then splitting the archive on a statement terminator produces a chunk that starts with comment lines and ends with the DROP. The obvious guard —
if ( str_starts_with( $sql, '--' ) ) { continue; } // WRONG
— throws away every DROP. Every CREATE then fails with "table already exists", and the restore reports hundreds of errors while changing nothing. Strip the comments instead:
$sql = preg_replace( "/^(?:[ \t]*--[^\n]*\n)+/", '', $sql );
5. Two more worth setting
SET SQL_MODE='NO_AUTO_VALUE_ON_ZERO'in the dump header. Without it a legitimate0in an AUTO_INCREMENT column is replaced by a fresh id on restore, breaking every row that referenced it.SET FOREIGN_KEY_CHECKS=0around the replay, or table order decides whether the restore succeeds.
Consistency: say which kind of backup it is
A dump that finishes in one request can run inside START TRANSACTION WITH CONSISTENT SNAPSHOT and is a genuine point-in-time copy. A dump split across requests cannot be — no transaction survives the end of a request — so its first table is read minutes before its last, while people are posting. That is the same limitation as mysqldump without --single-transaction, and it is fine, but the resulting archive can contain a reply whose topic is missing. Record which kind each archive is and show it, rather than letting an administrator assume.
And verify by reading, not by remembering
Comparing the dumper's row counter against the dumper's manifest proves nothing — both come from the same variable. Open the finished file, count what is in it, and compare that. Because real_escape_string converts newlines inside values to a literal \n and binary is hex, no value can contain a raw newline, so a tuple is reliably one line and the count is exact rather than a guess.
Related application: Backup & Restore — Backup & Restore writes its dumps in pure PHP with these cases handled, verifies each archive by reading the finished file back, and restores without needing a shell.
Recommended Comments