Raw, prefixed
XX XX XX XX 78 9C …
What the type writes after the column becomes binary.
Migration field guide
A byte-level walkthrough for moving historically double-encoded zlib data from
utf8mb4 MEDIUMTEXT to MEDIUMBLOB without breaking old reads or new writes.
PHP's gzcompress() produces a zlib stream, not a gzip stream.
With the default compression level, the zlib header is 78 9C. To make that stream
compatible with MySQL's UNCOMPRESS(), the application prepends a four-byte,
little-endian length of the uncompressed content.
Here, 89 06 00 00 is little-endian 0x00000689, or 1,673 bytes.
The trouble begins when these arbitrary binary bytes cross a MySQL latin1
connection into an utf8mb4 character column.
length + zlib
MySQL maps original byte 89 to Unicode U+2030 (‰), whose UTF-8
encoding is E2 80 B0. It maps byte 9C to U+0153
(œ), encoded as C5 93. The logical four-byte prefix now occupies six
physical bytes.
MEDIUMBLOB
preserves these stored bytes but stops MySQL from transcoding them on reads. The shim must now
perform the historical reverse mapping itself.
XX XX XX XX 78 9C …
What the type writes after the column becomes binary.
78 9C …
Older application data written before the MySQL-compatible prefix was added.
[4 encoded characters] 78 C5 93 …
The four original prefix bytes occupy between four and twelve stored bytes.
78 C5 93 …
An older naked zlib stream after the same MySQL character conversion.
latin1 bytes becomes one, two, or three UTF-8 bytes. Four times three is twelve.
Including the three-byte encoded zlib marker, classification needs at most the first 15 bytes.
Do not speculatively decompress the entire value. Check prefixed forms first, because the four arbitrary length bytes could coincidentally begin with zlib-like bytes.
78 9C?78 C5 93?78 9C?78 C5 93?To locate the double-encoded prefixed marker, walk exactly four canonical UTF-8 sequences from the beginning. Those four Unicode characters represent the original four single-byte length values. The resulting byte offset is between 4 and 12.
private function encodedPrefixLength(string $value): ?int
{
$offset = 0;
for ($character = 0; $character < 4; ++$character) {
$sequenceLength = $this->mysqlLatin1Utf8SequenceLength($value, $offset);
if (null === $sequenceLength) {
return null;
}
$offset += $sequenceLength;
}
return $offset;
}
Normal gzcompress() headers include 78 01, 78 5E,
78 9C, and 78 DA, depending on compression level. This application
calls gzcompress($data) with the default level, so 78 9C is the
observed invariant. If support is broadened, note that double encoding leaves
78 01 and 78 5E visually unchanged; header bytes alone cannot tell
their raw and historical forms apart.
For either historical format, decode the entire value. This is not ordinary
ISO-8859-1 conversion. MySQL's latin1 is based on CP1252 and assigns a Unicode
character to every possible byte, including CP1252's five gaps.
C5 93 → U+0153.
U+0153 → 9C.
| Unicode range or value | Recovered byte | Rule |
|---|---|---|
U+0000–U+007F | 00–7F | Identity |
U+00A0–U+00FF | A0–FF | Identity |
CP1252 symbols such as U+20AC, U+2030, U+0153 | 80–9F | Lookup table |
U+0081, U+008D, U+008F, U+0090, U+009D | 81, 8D, 8F, 90, 9D | MySQL gap mapping |
| Anything else | None | Reject: MySQL latin1 could not have produced it |
private const UNICODE_TO_MYSQL_LATIN1 = [
0x20ac => 0x80, 0x0081 => 0x81, 0x201a => 0x82, 0x0192 => 0x83,
0x201e => 0x84, 0x2026 => 0x85, 0x2020 => 0x86, 0x2021 => 0x87,
0x02c6 => 0x88, 0x2030 => 0x89, 0x0160 => 0x8a, 0x2039 => 0x8b,
0x0152 => 0x8c, 0x008d => 0x8d, 0x017d => 0x8e, 0x008f => 0x8f,
0x0090 => 0x90, 0x2018 => 0x91, 0x2019 => 0x92, 0x201c => 0x93,
0x201d => 0x94, 0x2022 => 0x95, 0x2013 => 0x96, 0x2014 => 0x97,
0x02dc => 0x98, 0x2122 => 0x99, 0x0161 => 0x9a, 0x203a => 0x9b,
0x0153 => 0x9c, 0x009d => 0x9d, 0x017e => 0x9e, 0x0178 => 0x9f,
];
Classification determines which normalization is required. After normalization, every candidate is either a naked zlib stream or the canonical four-byte prefix followed by zlib.
| Classification | Normalization | Decompression input |
|---|---|---|
| Raw, prefixed | None | Drop exactly four bytes |
| Raw, naked | None | Whole value |
| Double-encoded, prefixed | Reverse MySQL-latin1 mapping | Drop four recovered bytes |
| Double-encoded, naked | Reverse MySQL-latin1 mapping | Whole recovered value |
private function decompressPrefixed(string $value): string|false
{
if (strlen($value) < 6) {
return false;
}
$expectedLength = unpack('Vlength', substr($value, 0, 4))['length'];
$decompressed = @gzuncompress(substr($value, 4));
if (false === $decompressed || strlen($decompressed) !== $expectedLength) {
return false;
}
return $decompressed;
}
Use V, not L, for the length header. V explicitly means an
unsigned 32-bit little-endian value, matching MySQL's format on every PHP host.
gzuncompress() validates the zlib stream and
its Adler-32 checksum. Comparing strlen($decompressed) with the declared length gives
prefixed values a second independent structural check.
public function convertToPHPValue($value, AbstractPlatform $platform): mixed
{
if (null === $value) {
return null;
}
$format = $this->classifyCompressionFormat($value);
if ($format->isDoubleEncoded()) {
$decoded = $this->reverseMysqlLatin1Encoding($value);
if (false === $decoded) {
return parent::convertToPHPValue($value, $platform);
}
$value = $decoded;
}
$decompressed = $format->hasLengthPrefix()
? $this->decompressPrefixed($value)
: @gzuncompress($value);
return parent::convertToPHPValue(
false !== $decompressed ? $decompressed : $value,
$platform,
);
}
public function convertToDatabaseValue($value, AbstractPlatform $platform): mixed
{
$data = parent::convertToDatabaseValue($value, $platform);
if (null === $data) {
return null;
}
return pack('V', strlen($data)) . gzcompress($data);
}
8F, round-trips through the custom decoder.pack('V'), include the prefix, and can be read back."" and "0" are not mistaken for failure.length + zlib form. As historical
rows age out or are rewritten, the double-decoding branch naturally becomes cold cleanup code.