Skip to content

JSON ↔ CSV Converter — PHP source

Convert a JSON array of objects to CSV and back. Handles quoted fields, embedded commas, newlines and escaped quotes (RFC 4180). 100% in-browser.

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

<?php
// =============================================================================
// json-csv — PHP port
// =============================================================================
// Convert between JSON and RFC 4180 CSV in either direction:
//   • json_to_csv — serialize a JSON document (object or array of objects) to CSV
//   • csv_to_json — parse RFC 4180 CSV (with quoting) into a list of assoc arrays
//
// CosmoDev polyglot showcase port of the `json-csv` tool.
// Ported from src/lib/csv.ts (the canonical, live TypeScript lib).
//
// Pure and deterministic — depends only on its inputs. RFC 4180 quoting: any
// field containing a comma, double quote, carriage return, or line feed is
// wrapped in double quotes, and each embedded quote is doubled.
//
// This is display source — part of CosmoDev's polyglot tool pages.
// =============================================================================

declare(strict_types=1);

/**
 * Coerce a JSON-decoded value to its display string, replicating JavaScript's
 * String(): null -> "", booleans -> "true"/"false", numbers -> decimal form,
 * arrays -> elements joined by "," (so a comma-bearing cell re-quotes), and
 * objects -> "[object Object]".
 *
 * PHP's `(string)` on a float already drops a trailing ".0" (matching JS), and
 * json_decode keeps integer-typed values integral, so common numbers render the
 * same way as in the TS lib.
 */
function js_string(mixed $value): string
{
    if ($value === null) {
        return '';
    }
    if (is_bool($value)) {
        return $value ? 'true' : 'false';
    }
    if (is_int($value) || is_float($value)) {
        return (string) $value;
    }
    if (is_string($value)) {
        return $value;
    }
    if (is_array($value)) {
        // JS Array.prototype.toString joins elements with ",".
        return implode(',', array_map('js_string', $value));
    }
    // A stdClass (JSON object) renders as JS's "[object Object]".
    return '[object Object]';
}

/**
 * Quote a single CSV field per RFC 4180. We avoid a regex for the multi-byte
 * CR/LF case and test each structural character with str_contains instead.
 */
function csv_escape(mixed $field): string
{
    $s = js_string($field);
    foreach ([',', '"', "\n", "\r"] as $c) {
        if (str_contains($s, $c)) {
            return '"' . str_replace('"', '""', $s) . '"';
        }
    }
    return $s;
}

// ---------------------------------------------------------------------------
// JS-equivalent value semantics
// ---------------------------------------------------------------------------
// We decode JSON into stdClass (not assoc arrays) so that JSON objects and JSON
// arrays remain distinguishable: a JSON object becomes a stdClass instance and a
// JSON array becomes a plain PHP list. This mirrors JS exactly, where
// Array.isArray({}) is false, so a single object is wrapped as a one-row table.
// It also lets Object.keys expose array indices as string keys ("0","1",...).

/**
 * True for arrays and objects (JS `typeof === 'object' && != null`).
 */
function is_object_like(mixed $value): bool
{
    return is_array($value) || is_object($value);
}

/**
 * Object.keys parity: object properties in document order (PHP preserves the
 * order json_decode set them), or list indices as strings ("0", "1", ...).
 */
function keys_of(mixed $value): array
{
    if (is_object($value)) {
        // get_object_vars lists public properties in the order they were set;
        // json_decode populates stdClass in document order.
        $out = [];
        foreach (get_object_vars($value) as $k => $_) {
            $out[] = (string) $k;
        }
        return $out;
    }
    if (is_array($value)) {
        $out = [];
        foreach (array_keys($value) as $k) {
            // PHP stores numeric array keys as ints; JS exposes them as strings.
            $out[] = is_int($k) ? (string) $k : $k;
        }
        return $out;
    }
    return [];
}

/**
 * JS `obj[key]` parity: object property lookup, or list element at a key that
 * is a canonical numeric string. Returns null when absent (renders as "").
 */
function get_field(mixed $value, string $key): mixed
{
    if (is_object($value)) {
        // property_exists is true even for a null-valued property, matching JS
        // where a present-but-null key differs from an absent key.
        return property_exists($value, $key) ? $value->{$key} : null;
    }
    if (is_array($value)) {
        // PHP coerces a canonical numeric-string key to its integer when
        // accessing a list, so array_key_exists covers both forms uniformly.
        return array_key_exists($key, $value) ? $value[$key] : null;
    }
    return null;
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/**
 * Serialize a JSON document to CSV.
 *
 * Accepts a single object or an array of objects. Returns null on invalid
 * JSON, or when the document yields no object rows (and thus no column
 * headers) — e.g. a bare array of primitives such as `[1, 2, 3]`.
 */
function json_to_csv(string $json): ?string
{
    // Decode into stdClass (objects) + arrays (lists) to keep the two
    // distinguishable, matching JS Array.isArray semantics.
    $data = json_decode($json, false);
    if ($data === null && json_last_error() !== JSON_ERROR_NONE) {
        return null;
    }

    // A JSON array is many rows; anything else (object, scalar, null) is one.
    $rows = is_array($data) ? $data : [$data];

    // Header union across object-like rows, first-seen order, de-duplicated.
    $headers = [];
    $seen = [];
    foreach ($rows as $row) {
        foreach (keys_of($row) as $k) {
            if (!array_key_exists($k, $seen)) {
                $seen[$k] = true;
                $headers[] = $k;
            }
        }
    }
    if ($headers === []) {
        return null;
    }

    $lines = [implode(',', array_map('csv_escape', $headers))];
    foreach ($rows as $row) {
        // A non-object row (null, number, string) yields an empty line: every
        // header lookup on it returns null -> the empty field.
        $cells = [];
        foreach ($headers as $h) {
            $cells[] = csv_escape(get_field($row, $h));
        }
        $lines[] = implode(',', $cells);
    }
    return implode("\n", $lines);
}

/**
 * Parse RFC 4180 CSV into a list of row assoc-arrays keyed by the first row.
 *
 * Handles quoted fields, doubled-quote escapes, and embedded commas/newlines;
 * bare carriage returns outside quotes are ignored. Returns [] for empty
 * input, or for input that is only a header row.
 */
function csv_to_json(string $csv): array
{
    // Single-pass character-state machine over bytes. CSV structural
    // characters are all single-byte ASCII, so byte iteration is safe and
    // keeps multi-byte field content intact.
    $rows = [];
    $field = '';
    $row = [];
    $inQuotes = false;
    $len = strlen($csv);

    for ($i = 0; $i < $len; $i++) {
        $ch = $csv[$i];
        if ($inQuotes) {
            if ($ch === '"') {
                // Doubled quote -> one literal quote; lone quote -> close field.
                if ($i + 1 < $len && $csv[$i + 1] === '"') {
                    $field .= '"';
                    $i++;
                } else {
                    $inQuotes = false;
                }
            } else {
                $field .= $ch;
            }
        } elseif ($ch === '"') {
            $inQuotes = true;
        } elseif ($ch === ',') {
            $row[] = $field;
            $field = '';
        } elseif ($ch === "\n") {
            $row[] = $field;
            $rows[] = $row;
            $row = [];
            $field = '';
        } elseif ($ch !== "\r") {
            $field .= $ch;
        }
    }

    // Flush a trailing row only when there is pending content. Input that ended
    // with a newline already flushed; this guard avoids an empty final row.
    if ($field !== '' || $row !== []) {
        $row[] = $field;
        $rows[] = $row;
    }

    if ($rows === []) {
        return [];
    }

    $headers = $rows[0];
    $out = [];
    $rowCount = count($rows);
    for ($r = 1; $r < $rowCount; $r++) {
        $cells = $rows[$r];
        $obj = [];
        foreach ($headers as $i => $h) {
            $obj[$h] = $cells[$i] ?? '';
        }
        $out[] = $obj;
    }
    return $out;
}

/*
 * Example usage (this file is a library; uncomment to run as a script):
 *
 * $raw = '[{"name":"Doe, John","note":"say \\"hi\\""},{"name":"Jane","note":"plain"}]';
 * $csv = json_to_csv($raw);
 * echo $csv . "\n";
 * var_export(csv_to_json($csv));
 */

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 →