Skip to content

ER/Schema Visualizer — PHP source

Paste CREATE TABLE DDL and get an ER diagram as SVG: tables with typed columns, primary keys, and foreign-key arrows in a deterministic layered layout. Pan and zoom the live diagram; export the SVG.

This is the PHP implementation — the same logic the interactive tool runs, in a shareable, citable form.

<?php
// schema-visualizer — pure CREATE TABLE DDL → layered ER diagram as SVG.
// PHP port (canonical TS: src/lib/schema-visualizer.ts; Go twin:
// cli/schema-visualizer). Tolerant common subset of Postgres/MySQL/SQLite:
// unparseable statements degrade to notes, never throw. Integer geometry only
// (half-up rounding — JS Math.round parity), so every port draws the
// byte-identical diagram.

const SV_LAYOUT = ['rowHeight' => 24, 'charWidth' => 7, 'padding' => 8, 'layerGap' => 60, 'columnGap' => 40];
const SV_QUOTES = ["'" => "'", '"' => '"', '`' => '`', '[' => ']'];

function sv_round(float $v): int { return (int)($v + 0.5); } // half-up, JS parity

function sv_upper(string $s): string { return strtoupper($s); }

/** Split on `;` outside strings/quoted identifiers (depth-agnostic: an
 *  unterminated paren cannot swallow the statements after it). */
function sv_split_statements(string $ddl): array
{
    $out = [];
    $cur = '';
    $i = 0;
    $n = strlen($ddl);
    while ($i < $n) {
        $ch = $ddl[$i];
        if (isset(SV_QUOTES[$ch])) {
            $close = SV_QUOTES[$ch];
            $cur .= $ch;
            $i++;
            while ($i < $n) {
                $cur .= $ddl[$i];
                if ($ddl[$i] === $close) {
                    if ($close === "'" && $i + 1 < $n && $ddl[$i + 1] === "'") {
                        $cur .= $ddl[$i + 1];
                        $i += 2;
                        continue;
                    }
                    break;
                }
                $i++;
            }
            $i++;
            continue;
        }
        if ($ch === ';') { $out[] = $cur; $cur = ''; $i++; continue; }
        $cur .= $ch;
        $i++;
    }
    if (trim($cur) !== '') $out[] = $cur;
    return $out;
}

/** Tokens: [kind, text] — qident/string carry text with quotes stripped. */
function sv_tokenize(string $s): array
{
    $toks = [];
    $i = 0;
    $n = strlen($s);
    while ($i < $n) {
        $ch = $s[$i];
        if (ctype_space($ch)) { $i++; continue; }
        if (isset(SV_QUOTES[$ch])) {
            $close = SV_QUOTES[$ch];
            $text = '';
            $i++;
            while ($i < $n) {
                if ($s[$i] === $close) {
                    if ($close === "'" && $i + 1 < $n && $s[$i + 1] === "'") {
                        $text .= "'";
                        $i += 2;
                        continue;
                    }
                    break;
                }
                $text .= $s[$i++];
            }
            $i++;
            $toks[] = [$ch === "'" ? 'string' : 'qident', $text];
            continue;
        }
        if (strpos('(),.', $ch) !== false) { $toks[] = ['punct', $ch]; $i++; continue; }
        $j = $i;
        while ($j < $n && !ctype_space($s[$j]) && strpos("',().`[]", $s[$j]) === false) $j++;
        $toks[] = ['word', substr($s, $i, $j - $i)];
        $i = $j;
    }
    return $toks;
}

function sv_is_p(?array $t, string $p): bool
{
    return $t !== null && $t[0] === 'punct' && $t[1] === $p;
}

function sv_kw(?array $t, string $w): bool
{
    return $t !== null && $t[0] === 'word' && sv_upper($t[1]) === $w;
}

function sv_g(array $toks, int $i): ?array
{
    return $i < count($toks) ? $toks[$i] : null;
}

function sv_take_name(array $toks, int $i): ?array
{
    $first = sv_g($toks, $i);
    if ($first === null || ($first[0] !== 'qident' && $first[0] !== 'word')) return null;
    $name = $first[1];
    $j = $i + 1;
    while (sv_is_p(sv_g($toks, $j), '.') && sv_g($toks, $j + 1) !== null
        && (sv_g($toks, $j + 1)[0] === 'qident' || sv_g($toks, $j + 1)[0] === 'word')) {
        $name .= '.' . $toks[$j + 1][1];
        $j += 2;
    }
    return [$name, $j];
}

function sv_paren_list(array $toks, int $i): ?array
{
    if (!sv_is_p(sv_g($toks, $i), '(')) return null;
    $names = [];
    $j = $i + 1;
    for (;;) {
        $name = sv_take_name($toks, $j);
        if ($name === null) return null;
        $names[] = $name[0];
        $j = $name[1];
        if (sv_is_p(sv_g($toks, $j), ',')) { $j++; continue; }
        if (sv_is_p(sv_g($toks, $j), ')')) return [$names, $j + 1];
        return null;
    }
}

function sv_is_modifier(array $t): bool
{
    static $mods = ['NOT', 'NULL', 'PRIMARY', 'KEY', 'UNIQUE', 'DEFAULT', 'REFERENCES',
        'AUTO_INCREMENT', 'AUTOINCREMENT', 'ON', 'COMMENT', 'CHECK', 'CONSTRAINT'];
    return $t[0] === 'word' && in_array(sv_upper($t[1]), $mods, true);
}

function sv_join_type(array $toks): string
{
    $raw = implode(' ', array_map(fn ($t) => $t[1], $toks));
    $raw = preg_replace('/\s*\(\s*/', '(', $raw);
    $raw = preg_replace('/\s*\)\s*/', ')', $raw);
    $raw = preg_replace('/\s*,\s*/', ',', $raw);
    return sv_upper(trim($raw));
}

function sv_parse_column(array $line, string $tableName, array &$fks): ?array
{
    $name = sv_take_name($line, 0);
    if ($name === null) return null;
    $i = $name[1];
    $typeToks = [];
    while ($i < count($line) && !sv_is_modifier($line[$i])) $typeToks[] = $line[$i++];
    $nullable = true;
    $pk = false;
    while ($i < count($line)) {
        $t = $line[$i];
        if (sv_kw($t, 'NOT') && sv_kw(sv_g($line, $i + 1), 'NULL')) { $nullable = false; $i += 2; continue; }
        if (sv_kw($t, 'NULL')) { $i++; continue; }
        if (sv_kw($t, 'PRIMARY') && sv_kw(sv_g($line, $i + 1), 'KEY')) { $pk = true; $nullable = false; $i += 2; continue; }
        if (sv_kw($t, 'UNIQUE') || sv_kw($t, 'AUTO_INCREMENT') || sv_kw($t, 'AUTOINCREMENT')) { $i++; continue; }
        if (sv_kw($t, 'DEFAULT')) {
            $i++;
            if (sv_is_p(sv_g($line, $i), '(')) {
                $depth = 0;
                while ($i < count($line)) {
                    if (sv_is_p($line[$i], '(')) $depth++;
                    if (sv_is_p($line[$i], ')')) $depth--;
                    $i++;
                    if ($depth === 0) break;
                }
            } elseif ($i < count($line)) $i++;
            continue;
        }
        if (sv_kw($t, 'COMMENT')) {
            $i++;
            if (sv_g($line, $i) !== null && $line[$i][0] === 'string') $i++;
            continue;
        }
        if (sv_kw($t, 'ON')) {
            $i += 2;
            if (sv_kw(sv_g($line, $i), 'SET') || sv_kw(sv_g($line, $i), 'NO')) $i += 2;
            elseif ($i < count($line)) $i++;
            continue;
        }
        if (sv_kw($t, 'REFERENCES')) {
            $i++;
            $target = sv_take_name($line, $i);
            if ($target !== null) {
                $i = $target[1];
                $toCol = null;
                if (sv_is_p(sv_g($line, $i), '(')) {
                    $list = sv_paren_list($line, $i);
                    if ($list !== null) { $toCol = $list[0][0]; $i = $list[1]; }
                }
                $fks[] = ['fromTable' => $tableName, 'fromColumn' => $name[0],
                    'toTable' => $target[0], 'toColumn' => $toCol];
            }
            continue;
        }
        $i++; // unknown modifier tolerated
    }
    return ['name' => $name[0], 'type' => sv_join_type($typeToks), 'nullable' => $nullable, 'isPrimaryKey' => $pk];
}

function sv_parse_ddl(string $ddl): array
{
    if (trim($ddl) === '') return ['tables' => [], 'foreignKeys' => [], 'notes' => ['No DDL input.']];
    $tables = [];
    $fks = [];
    $notes = [];
    foreach (sv_split_statements($ddl) as $stmt) {
        if (trim($stmt) === '') continue;
        $toks = sv_tokenize($stmt);
        try {
            $i = 0;
            if (!sv_kw(sv_g($toks, $i), 'CREATE')) throw new RuntimeException('bad');
            $i++;
            while (sv_kw(sv_g($toks, $i), 'TEMP') || sv_kw(sv_g($toks, $i), 'TEMPORARY') || sv_kw(sv_g($toks, $i), 'UNLOGGED')) $i++;
            if (!sv_kw(sv_g($toks, $i), 'TABLE')) {
                $notes[] = 'Skipped non-table statement.';
                continue;
            }
            $i++;
            if (sv_kw(sv_g($toks, $i), 'IF') && sv_kw(sv_g($toks, $i + 1), 'NOT') && sv_kw(sv_g($toks, $i + 2), 'EXISTS')) $i += 3;
            $name = sv_take_name($toks, $i);
            if ($name === null || !sv_is_p(sv_g($toks, $name[1]), '(')) throw new RuntimeException('bad');
            $i = $name[1] + 1;
            $body = [];
            $depth = 0;
            for (; $i < count($toks); $i++) {
                if (sv_is_p($toks[$i], '(')) $depth++;
                if (sv_is_p($toks[$i], ')')) {
                    if ($depth === 0) break;
                    $depth--;
                }
                $body[] = $toks[$i];
            }
            if ($i >= count($toks)) throw new RuntimeException('bad');
            $lines = [];
            $line = [];
            $depth = 0;
            foreach ($body as $t) {
                if (sv_is_p($t, '(')) $depth++;
                if (sv_is_p($t, ')')) $depth--;
                if (sv_is_p($t, ',') && $depth === 0) { $lines[] = $line; $line = []; continue; }
                $line[] = $t;
            }
            if ($line) $lines[] = $line;
            $table = ['name' => $name[0], 'columns' => []];
            $tables[] = &$table;
            unset($table);
            $tidx = count($tables) - 1;
            foreach ($lines as $t2) {
                if (!$t2) continue;
                $first = $t2[0];
                $u = $first[0] === 'word' ? sv_upper($first[1]) : '';
                if ($u === 'PRIMARY' && sv_kw(sv_g($t2, 1), 'KEY')) {
                    $list = sv_paren_list($t2, 2);
                    if ($list) {
                        foreach ($list[0] as $cn) {
                            foreach ($tables[$tidx]['columns'] as &$col) {
                                if ($col['name'] === $cn) { $col['isPrimaryKey'] = true; $col['nullable'] = false; }
                            }
                            unset($col);
                        }
                    }
                    continue;
                }
                if ($u === 'FOREIGN' && sv_kw(sv_g($t2, 1), 'KEY')) {
                    $from = sv_paren_list($t2, 2);
                    if ($from !== null && sv_kw(sv_g($t2, $from[1]), 'REFERENCES')) {
                        $target = sv_take_name($t2, $from[1] + 1);
                        if ($target !== null) {
                            $toCols = null;
                            if (sv_is_p(sv_g($t2, $target[1]), '(')) {
                                $to = sv_paren_list($t2, $target[1]);
                                if ($to !== null) $toCols = $to[0];
                            }
                            foreach ($from[0] as $idx => $fc) {
                                $toCol = null;
                                if ($toCols !== null) {
                                    $toCol = $idx < count($toCols) ? $toCols[$idx] : $toCols[count($toCols) - 1];
                                }
                                $fks[] = ['fromTable' => $tables[$tidx]['name'], 'fromColumn' => $fc,
                                    'toTable' => $target[0], 'toColumn' => $toCol];
                            }
                        }
                    }
                    continue;
                }
                if (in_array($u, ['UNIQUE', 'KEY', 'INDEX', 'CHECK', 'EXCLUDE', 'CONSTRAINT'], true)) continue;
                $col = sv_parse_column($t2, $tables[$tidx]['name'], $fks);
                if ($col !== null) $tables[$tidx]['columns'][] = $col;
            }
        } catch (RuntimeException $e) {
            $notes[] = 'Skipped unparseable statement.';
        }
    }
    $foreignKeys = array_map(function ($fk) use ($tables) {
        if ($fk['toColumn'] !== null) return $fk;
        $target = null;
        foreach ($tables as $t) if ($t['name'] === $fk['toTable']) { $target = $t; break; }
        $pk = null;
        if ($target !== null) foreach ($target['columns'] as $c) if ($c['isPrimaryKey']) { $pk = $c; break; }
        $fk['toColumn'] = $pk !== null ? $pk['name'] : 'id';
        return $fk;
    }, $fks);
    return ['tables' => $tables, 'foreignKeys' => $foreignKeys, 'notes' => $notes];
}

function sv_layout_schema(array $schema): array
{
    $o = SV_LAYOUT;
    if (!count($schema['tables'])) return ['width' => 0, 'height' => 0, 'tables' => [], 'edges' => []];
    $index = [];
    foreach ($schema['tables'] as $i => $t) if (!isset($index[$t['name']])) $index[$t['name']] = $i;
    $boxes = [];
    foreach ($schema['tables'] as $t) {
        $lens = [mb_strlen($t['name'])];
        foreach ($t['columns'] as $c) $lens[] = mb_strlen($c['name'] . ' ' . $c['type']);
        $lens[] = 1;
        $boxes[] = ['x' => 0, 'y' => 0,
            'w' => sv_round(max($lens) * $o['charWidth'] + 2 * $o['padding']),
            'h' => sv_round($o['rowHeight'] * (1 + count($t['columns'])) + $o['padding'])];
    }
    $layerOf = array_fill(0, count($schema['tables']), 0);
    for ($pass = 0; $pass < count($schema['tables']); $pass++) {
        $changed = false;
        foreach ($schema['foreignKeys'] as $fk) {
            $ti = $index[$fk['fromTable']] ?? null;
            $tj = $index[$fk['toTable']] ?? null;
            if ($ti === null || $tj === null || $ti === $tj) continue;
            if ($layerOf[$ti] < $layerOf[$tj] + 1) { $layerOf[$ti] = $layerOf[$tj] + 1; $changed = true; }
        }
        if (!$changed) break;
    }
    $layers = [];
    foreach ($layerOf as $i => $l) $layers[$l][] = $i;
    ksort($layers);
    $y = 0; $width = 0; $height = 0;
    foreach ($layers as $layer) {
        $x = 0; $layerH = 0;
        foreach ($layer as $i) {
            $boxes[$i]['x'] = $x;
            $boxes[$i]['y'] = $y;
            $x += $boxes[$i]['w'] + $o['columnGap'];
            $layerH = max($layerH, $boxes[$i]['h']);
        }
        $width = max($width, $x - $o['columnGap']);
        $height = max($height, $y + $layerH);
        $y += $layerH + $o['layerGap'];
    }
    $edges = [];
    foreach ($schema['foreignKeys'] as $fk) {
        $frm = $index[$fk['fromTable']] ?? null;
        $to = $index[$fk['toTable']] ?? null;
        if ($frm === null || $to === null) continue;
        $x1 = $boxes[$to]['x'] + sv_round($boxes[$to]['w'] / 2);
        $y1 = $boxes[$to]['y'] + $boxes[$to]['h'];
        $x2 = $boxes[$frm]['x'] + sv_round($boxes[$frm]['w'] / 2);
        $y2 = $boxes[$frm]['y'];
        $midY = sv_round(($y1 + $y2) / 2);
        $edges[] = ['fk' => $fk, 'path' => "M $x1 $y1 V $midY H $x2 V $y2",
            'label' => $fk['fromColumn'] . ' → ' . $fk['toColumn']];
    }
    $laid = [];
    foreach ($schema['tables'] as $i => $t) {
        $rows = [];
        foreach ($t['columns'] as $ci => $_) {
            $rows[] = ['x' => $boxes[$i]['x'], 'y' => $boxes[$i]['y'] + $o['rowHeight'] * (1 + $ci),
                'w' => $boxes[$i]['w'], 'h' => $o['rowHeight']];
        }
        $laid[] = ['table' => $t, 'box' => $boxes[$i],
            'titleBar' => ['x' => $boxes[$i]['x'], 'y' => $boxes[$i]['y'], 'w' => $boxes[$i]['w'], 'h' => $o['rowHeight']],
            'columnRows' => $rows];
    }
    return ['width' => $width, 'height' => $height, 'tables' => $laid, 'edges' => $edges];
}

function sv_esc(string $s): string
{
    return htmlspecialchars($s, ENT_QUOTES | ENT_XML1, 'UTF-8', false);
}

function sv_render_svg(array $geo): string
{
    $out = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' . $geo['width'] . ' ' . $geo['height']
        . '" class="sv-root" role="img"><title>Schema diagram</title>';
    $boxOf = [];
    foreach ($geo['tables'] as $t) if (!isset($boxOf[$t['table']['name']])) $boxOf[$t['table']['name']] = $t['box'];
    foreach ($geo['edges'] as $e) {
        $frm = $boxOf[$e['fk']['fromTable']] ?? null;
        if ($frm === null) continue;
        $ax = $frm['x'] + sv_round($frm['w'] / 2);
        $out .= '<path class="sv-edge" d="' . $e['path'] . '"/>'
            . '<polygon class="sv-arrow" points="' . ($ax - 5) . ',' . ($frm['y'] - 8) . ' '
            . ($ax + 5) . ',' . ($frm['y'] - 8) . ' ' . $ax . ',' . $frm['y'] . '"/>';
    }
    foreach ($geo['tables'] as $t) {
        $b = $t['box'];
        $tb = $t['titleBar'];
        $out .= '<g class="sv-table"><rect class="sv-box" x="' . $b['x'] . '" y="' . $b['y']
            . '" width="' . $b['w'] . '" height="' . $b['h'] . '" rx="6"/>'
            . '<rect class="sv-titlebar" x="' . $tb['x'] . '" y="' . $tb['y'] . '" width="' . $tb['w']
            . '" height="' . $tb['h'] . '" rx="6"/>'
            . '<text class="sv-title" x="' . ($b['x'] + 8) . '" y="' . ($tb['y'] + 17) . '">'
            . sv_esc($t['table']['name']) . '</text>';
        foreach ($t['table']['columns'] as $ci => $c) {
            $row = $t['columnRows'][$ci];
            $cls = $c['isPrimaryKey'] ? 'sv-pk' : 'sv-col';
            $out .= '<text class="' . $cls . '" x="' . ($row['x'] + 8) . '" y="' . ($row['y'] + 17) . '">'
                . sv_esc($c['name']) . ' ' . sv_esc($c['type']) . '</text>';
        }
        $out .= '</g>';
    }
    return $out . '</svg>';
}

function sv_ddl_to_svg(string $ddl): array
{
    $schema = sv_parse_ddl($ddl);
    return ['svg' => sv_render_svg(sv_layout_schema($schema)), 'schema' => $schema];
}

// Example:
//   sv_ddl_to_svg('CREATE TABLE users (id INT PRIMARY KEY);' .
//                 'CREATE TABLE posts (id INT PRIMARY KEY, user_id INT REFERENCES users(id), title TEXT);')['svg']
// → users box on layer 0, posts below, one FK edge — byte-identical to the
//   TS/Go/… ports (integer geometry, same defaults).

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →