Migration field guide

Classifying and decoding compressed HTML

A byte-level walkthrough for moving historically double-encoded zlib data from utf8mb4 MEDIUMTEXT to MEDIUMBLOB without breaking old reads or new writes.

1. Start with the right mental model

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.

Canonical value
8906 0000 789C

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.

Write 1 PHP creates binary length + zlib
Write 2 Connection declares latin1 MySQL interprets every byte as a character.
Storage Column encodes UTF-8 Some source bytes expand to two or three bytes.
Old read latin1 reverses it The historical connection hid the stored expansion.
Stored in MEDIUMTEXT
E280 B006 0000 78C5 93

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.

What changes after ALTER: converting the column to MEDIUMBLOB preserves these stored bytes but stops MySQL from transcoding them on reads. The shim must now perform the historical reverse mapping itself.

2. Recognize the four possible formats

New canonical

Raw, prefixed

XX XX XX XX 78 9C …

What the type writes after the column becomes binary.

Legacy fallback

Raw, naked

78 9C …

Older application data written before the MySQL-compatible prefix was added.

Historical

Double-encoded, prefixed

[4 encoded characters] 78 C5 93 …

The four original prefix bytes occupy between four and twelve stored bytes.

Historical

Double-encoded, naked

78 C5 93 …

An older naked zlib stream after the same MySQL character conversion.

Why the encoded prefix is at most 12 bytes: each of its four original MySQL-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.

3. Classify using header bytes only

Do not speculatively decompress the entire value. Check prefixed forms first, because the four arbitrary length bytes could coincidentally begin with zlib-like bytes.

Are bytes 4–5 78 9C?
Raw + prefix
After four UTF-8 characters, are the next bytes 78 C5 93?
Double-encoded + prefix
Does the value start with 78 9C?
Raw + naked
Does the value start with 78 C5 93?
Double-encoded + naked

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;
}
Classification is not integrity validation. The envelope has no dedicated magic value before its length. After classification, zlib checksum validation must succeed; prefixed values must also decompress to exactly the declared byte length.
What about zlib headers other than 78 9C?

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.

4. Reverse MySQL’s exact latin1 mapping

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.

Decode 1 Read one UTF-8 sequence Reject malformed or non-canonical UTF-8.
Decode 2 Produce a Unicode code point For example, C5 93 → U+0153.
Decode 3 Look up the MySQL byte U+0153 → 9C.
Decode 4 Append one raw byte Repeat until the full binary value is recovered.
Unicode range or valueRecovered byteRule
U+0000–U+007F00–7FIdentity
U+00A0–U+00FFA0–FFIdentity
CP1252 symbols such as U+20AC, U+2030, U+015380–9FLookup table
U+0081, U+008D, U+008F, U+0090, U+009D81, 8D, 8F, 90, 9DMySQL gap mapping
Anything elseNoneReject: MySQL latin1 could not have produced it
Complete special Unicode-to-byte map
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,
];
Strictness is useful: if a historical candidate contains invalid UTF-8 or a Unicode character outside this mapping, do not substitute or discard it. Reject that candidate and preserve the original value for the existing fallback behavior.

5. Normalize, decompress once, and validate

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.

ClassificationNormalizationDecompression input
Raw, prefixedNoneDrop exactly four bytes
Raw, nakedNoneWhole value
Double-encoded, prefixedReverse MySQL-latin1 mappingDrop four recovered bytes
Double-encoded, nakedReverse MySQL-latin1 mappingWhole 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.

Why validation matters: 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.

6. Put the shim together

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);
}

Recommended unit-test matrix

  • Raw prefixed value returns its original content and validates the declared length.
  • Raw naked value remains readable for legacy compatibility.
  • Double-encoded prefixed value with a one-, two-, and three-byte encoded length character is recovered.
  • Double-encoded naked value is recovered.
  • A payload containing MySQL's special gap byte, such as 8F, round-trips through the custom decoder.
  • Malformed UTF-8, an unmappable Unicode character, bad zlib data, and a wrong declared length fail safely.
  • New writes use pack('V'), include the prefix, and can be read back.
  • Valid decompressed values "" and "0" are not mistaken for failure.
End state: historical rows remain readable without rewriting the table, while every new write is raw binary in MySQL-compatible length + zlib form. As historical rows age out or are rewritten, the double-decoding branch naturally becomes cold cleanup code.