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 →