PHP: Wie gibt man JSON zurück?

Zwei Zeilen, und die Sache ist erledigt. Fast — denn json_encode() kann scheitern, und wenn du das nicht prüfst, schickst du einen leeren Body mit Status 200 los. Der Client sieht dann keinen Fehler, sondern nichts.

Die zwei Zeilen

<?php
  header('Content-Type: application/json');
  echo json_encode($daten);
?>

Der charset=utf-8, den man oft dahinter sieht, ist überflüssig. JSON ist laut RFC 8259 immer UTF-8, und der Media Type application/json definiert gar keinen charset-Parameter.

Wichtig ist dagegen, dass vor dem header() keine Ausgabe steht — sonst bekommst du “headers already sent”. Dazu gibt es einen eigenen Artikel.

Der Punkt, der in den meisten Antworten fehlt

json_encode() gibt bei einem Fehler false zurück. Der häufigste Auslöser ist ungültiges UTF-8 in den Daten:

Rueckgabe          : false
json_last_error()  : 5
json_last_error_msg: Malformed UTF-8 characters, possibly incorrectly encoded

echo false gibt einen leeren String aus. Deine Antwort ist also: Status 200, Content-Type application/json, Body leer. Für den Client sieht das aus wie ein Serverfehler, den es nicht gibt — und in deinem Log steht nichts.

Woher kommt ungültiges UTF-8? Meistens aus der Datenbank. Eine Spalte in latin1, ein Import aus einer alten Anwendung, ein kopierter Text aus Word. Ein einziger falscher Datensatz reicht, und die ganze Antwort ist leer.

Zwei Wege, das zu behandeln. Entweder du willst davon wissen:

<?php
  try {
    $json = json_encode($daten, JSON_THROW_ON_ERROR);
  } catch (JsonException $e) {
    http_response_code(500);
    error_log('JSON-Fehler: ' . $e->getMessage());
    exit;
  }
?>

Oder du willst, dass die Antwort trotzdem herausgeht:

mit JSON_INVALID_UTF8_SUBSTITUTE : {"text":"gueltig �1 kaputt"}

JSON_INVALID_UTF8_SUBSTITUTE ersetzt die kaputten Bytes durch das Ersatzzeichen U+FFFD. Für eine Suchergebnisliste ist das die bessere Wahl — lieber ein Fragezeichen im Text als gar keine Ergebnisse.

Ich nehme JSON_THROW_ON_ERROR als Standard und weiche nur dort ab, wo ich einen guten Grund habe.

Array oder Objekt?

Das ist die zweite Sache, die in der Praxis Ärger macht:

json_encode(['a','b'])          : ["a","b"]
nach unset($l[1])               : {"0":"a","2":"c"}   <- Objekt!
mit array_values()              : ["a","c"]

Ein einziges unset() verändert den Typ deiner Antwort. Der Client, der ein Array erwartet und darüber iterieren will, bekommt ein Objekt. In JavaScript scheitert dann .map(), in einer typisierten Sprache scheitert schon das Parsen.

Und der Fehler tritt nur auf, wenn tatsächlich etwas gefiltert wurde — in deinen Tests mit vollständigen Daten also nicht.

Deshalb: Wenn eine Liste herauskommen soll, array_values() davor. Immer.

Der umgekehrte Fall ist ebenfalls einen Blick wert:

json_encode([])                  : []   <- leeres Array
json_encode(new stdClass())      : {}   <- leeres Objekt
json_encode([], JSON_FORCE_OBJECT): {}

Wenn deine Schnittstelle ein Objekt verspricht und du bei “keine Daten” ein leeres Array zurückgibst, ändert sich der Typ. Für diesen Fall gibt es JSON_FORCE_OBJECT oder eben ein new stdClass().

Umlaute und Schrägstriche

ohne Flags             : {"ort":"München","url":"https:\/\/example.com\/a\/b"}
JSON_UNESCAPED_UNICODE : {"ort":"München","url":"https:\/\/example.com\/a\/b"}
JSON_UNESCAPED_SLASHES : {"ort":"München","url":"https://example.com/a/b"}
beide                  : {"ort":"München","url":"https://example.com/a/b"}

Beide Formen sind gültiges JSON, und jeder Parser versteht beide. Es geht also nicht um Korrektheit, sondern um zwei praktische Dinge.

Erstens die Größe. Jeder Umlaut braucht escaped sechs Byte statt zwei. Bei einer Antwort mit viel deutschem Text sind das schnell 20 Prozent mehr.

Zweitens die Lesbarkeit. Wenn du dir die Antwort im Browser oder in einem Log ansiehst, ist München deutlich angenehmer als München.

Ich setze beide Flags standardmäßig:

<?php
  echo json_encode($daten,
      JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
?>

Das Escapen der Schrägstriche stammt übrigens daher, dass </script> in einem HTML-Dokument den Script-Block beenden würde. Wenn du JSON direkt in eine HTML-Seite schreibst, lass JSON_UNESCAPED_SLASHES weg oder nimm gleich JSON_HEX_TAG.

Zahlen

Ein paar Dinge, die überraschen können:

['n' => 1.0]                        : {"n":1}       <- der Float ist weg
mit JSON_PRESERVE_ZERO_FRACTION     : {"n":1.0}
['n' => 0.1 + 0.2]                  : {"n":0.30000000000000004}

Aus 1.0 wird 1. Wenn dein Client den Typ auswertet — etwa weil eine statisch typisierte Sprache dort einen float erwartet — brauchst du JSON_PRESERVE_ZERO_FRACTION.

Und dann gibt es noch einen Flag, den ich ausdrücklich nicht empfehle:

['id' => '9223372036854775807']    : {"id":"9223372036854775807"}
dasselbe mit JSON_NUMERIC_CHECK    : {"id":9223372036854775807}
['plz' => '007'] mit NUMERIC_CHECK : {"plz":7}   <- führende Null weg

JSON_NUMERIC_CHECK wandelt jeden numerisch aussehenden String in eine Zahl. Das zerstört Postleitzahlen mit führender Null, Artikelnummern, Telefonnummern, IBANs und Kontonummern. Der Flag wird gelegentlich empfohlen, um “saubere” Typen zu bekommen — dabei richtet er mehr Schaden an, als er nützt. Wenn ein Feld eine Zahl sein soll, dann sorg dafür, dass es schon vor json_encode() eine ist.

Große Zahlen beim Einlesen

Die andere Richtung hat ihr eigenes Problem:

{"id": 9223372036854775808}
Typ                   : float
Wert                  : 9.223372036854776E+18   <- Genauigkeit verloren
JSON_BIGINT_AS_STRING : '9223372036854775808' (string)

Zahlen über PHP_INT_MAX werden zu Floats, und damit ist die Genauigkeit weg. Das trifft man bei IDs aus Systemen mit 64-Bit-Snowflake-IDs — Discord, X und einige andere. Mit JSON_BIGINT_AS_STRING bleibt der Wert erhalten:

<?php
  $daten = json_decode($json, true, 512, JSON_BIGINT_AS_STRING);
?>

Fehler beim Einlesen erkennen

Auch hier lohnt der genaue Blick:

'null'    -> NULL       error=0 (No error)
'{"a":1}' -> array      error=0 (No error)
'{a:1}'   -> NULL       error=4 (Syntax error)
''        -> NULL       error=4 (Syntax error)

Die erste Zeile ist der Punkt: json_decode('null') liefert null und ist dabei völlig erfolgreich. null ist ja gültiges JSON. Ein Test auf === null unterscheidet also nicht zwischen “kaputtes JSON” und “die Antwort war null”.

Deshalb entweder json_last_error() prüfen oder gleich:

<?php
  try {
    $daten = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
  } catch (JsonException $e) {
    // ungültiges JSON
  }
?>

Der dritte Parameter, den man dabei mitschleppen muss, ist die maximale Verschachtelungstiefe:

Tiefe 512 (Standard) : true
Tiefe 3              : NULL error=Maximum stack depth exceeded

Bei Daten von außen ist ein niedrigerer Wert eine billige Absicherung gegen absichtlich tief verschachtelte Dokumente.

Eigene Objekte ausgeben

Zum Schluss noch etwas, das man einmal einrichtet und dann nie wieder anfasst:

ohne JsonSerializable : {}
mit JsonSerializable  : {"betrag":4.99,"waehrung":"EUR"}

json_encode() nimmt nur die öffentlichen Eigenschaften mit. Bei einer Klasse mit private oder protected bekommst du also ein leeres Objekt — ohne jede Fehlermeldung.

Die Lösung ist das Interface JsonSerializable:

<?php
class Preis implements JsonSerializable {
  public function __construct(private int $cent) {}

  public function jsonSerialize(): array {
    return ['betrag' => $this->cent / 100, 'waehrung' => 'EUR'];
  }
}
?>

Damit bestimmst du selbst, was in der Antwort steht. Das ist auch der richtige Ort, um Dinge herauszuhalten, die niemanden etwas angehen — Passwort-Hashes, interne IDs, Zeitstempel aus der Datenbank.

Zusammenfassung

  • header('Content-Type: application/json') — der charset ist überflüssig.
  • json_encode() kann false liefern; ohne Prüfung sendest du einen leeren Body mit Status 200.
  • JSON_THROW_ON_ERROR als Standard, JSON_INVALID_UTF8_SUBSTITUTE wenn die Antwort trotzdem heraus soll.
  • array_values() vor der Ausgabe, sonst wird aus dem Array ein Objekt.
  • JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES für kürzere und lesbarere Ausgaben.
  • JSON_NUMERIC_CHECK nicht verwenden — es zerstört Postleitzahlen und IBANs.
  • Beim Einlesen JSON_BIGINT_AS_STRING für große IDs.
  • json_decode('null') ist erfolgreich — null allein taugt nicht als Fehlertest.
  • Eigene Klassen brauchen JsonSerializable, sonst fehlen alle nicht-öffentlichen Eigenschaften.

Hinweis zu Netcup (Werbung)

Der deutsche Hoster Netcup bietet unter anderem günstige und zugleich leistungsstarke Webhosting Pakete, KVM-basierte Root Server und dezidierte Server an. Mit unseren Gutscheincodes kannst du noch mehr Geld sparen (6€ bei deiner ersten Bestellung, 30% Rabatt auf alle KVM-basierten Root Server, ...).