Skip to content

06 · Working with JSON/APIs

JSON is the de facto data format for web APIs — lightweight, human-readable, and natively supported by PHP's json_encode()/json_decode() functions. This module covers converting between PHP values and JSON, calling external HTTP APIs to consume JSON, and building a small JSON endpoint of your own.

Encoding PHP values to JSON

<?php

$user = [
    "name" => "Ada Lovelace",
    "age" => 36,
    "active" => true,
    "tags" => ["math", "computing"],
];

$json = json_encode($user, JSON_PRETTY_PRINT);
echo $json;
// {
//     "name": "Ada Lovelace",
//     "age": 36,
//     "active": true,
//     "tags": [
//         "math",
//         "computing"
//     ]
// }

json_encode() maps PHP types onto JSON types fairly directly: arrays with sequential integer keys become JSON arrays ([...]), associative arrays become JSON objects ({...}), and true/false/null map onto their JSON equivalents unchanged.

Decoding JSON to PHP values

<?php

$json = '{"name":"Alan Turing","age":41,"skills":["logic","cryptography"]}';

// Second argument `true` -> associative arrays instead of stdClass objects
$data = json_decode($json, true);

echo $data["name"] . "\n";        // Alan Turing
echo $data["skills"][0] . "\n";   // logic

// Without `true`, you get stdClass objects with -> access instead of ['key']
$obj = json_decode($json);
echo $obj->name . "\n";           // Alan Turing
echo $obj->skills[1] . "\n";      // cryptography

Always check for decode failure

By default, json_decode() returns null on malformed input — which is easy to confuse with a JSON payload that legitimately is the value null. JSON_THROW_ON_ERROR turns a parse failure into a catchable exception instead, so a bug can't hide behind a silent null.

<?php

$badJson = '{"name": "Ada", }';   // trailing comma -- not valid JSON

try {
    $data = json_decode($badJson, true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
    echo "Invalid JSON: " . $e->getMessage() . "\n";
    // Invalid JSON: Syntax error
}

Always pass JSON_THROW_ON_ERROR (encoding or decoding) unless you have a specific reason not to — the alternative is manually checking json_last_error() !== JSON_ERROR_NONE after every single call, which is easy to forget even once.

Making objects JSON-serializable

By default, json_encode() on an object only picks up its public properties. To control exactly what an object's JSON looks like — hiding internals, renaming fields, computing derived values — implement JsonSerializable.

<?php

class Product implements JsonSerializable
{
    public function __construct(
        private string $name,
        private int $priceCents,   // stored as cents internally
    ) {}

    public function jsonSerialize(): array
    {
        return [
            "name" => $this->name,
            "price" => $this->priceCents / 100,   // exposed as dollars
        ];
    }
}

echo json_encode(new Product("Widget", 1999));
// {"name":"Widget","price":19.99}

Without JsonSerializable, json_encode() on this Product would produce {} — every property is private, and json_encode() never reaches into private/protected state on its own.

Consuming a JSON API with file_get_contents

For simple GET requests, file_get_contents() with a stream context is enough — no extra extension required.

<?php

$context = stream_context_create([
    "http" => [
        "method" => "GET",
        "header" => "Accept: application/json\r\n",
        "timeout" => 5,   // seconds -- never make a blocking call with no timeout
        "ignore_errors" => true,   // still return the body on 4xx/5xx instead of false
    ],
]);

$response = file_get_contents("https://api.example.com/users/1", context: $context);

if ($response === false) {
    echo "Request failed (network error, DNS, etc.)\n";
} else {
    // $http_response_header is a special variable PHP populates automatically
    // after an HTTP stream request, containing the raw response headers.
    $statusLine = $http_response_header[0] ?? "";
    echo "Status: $statusLine\n";

    $data = json_decode($response, true, flags: JSON_THROW_ON_ERROR);
    echo "Name: " . ($data["name"] ?? "unknown") . "\n";
}

Consuming a JSON API with cURL

cURL (the ext-curl extension) gives finer control — custom headers, POST/PUT/DELETE bodies, authentication, retries — and is the more common choice for anything beyond a quick GET.

<?php

function fetchJson(string $url, string $method = "GET", ?array $body = null): array
{
    $ch = curl_init($url);

    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,     // return the body as a string instead of printing it
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_TIMEOUT => 5,
        CURLOPT_HTTPHEADER => ["Accept: application/json", "Content-Type: application/json"],
    ]);

    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_THROW_ON_ERROR));
    }

    $response = curl_exec($ch);

    if ($response === false) {
        $error = curl_error($ch);
        curl_close($ch);
        throw new RuntimeException("cURL request failed: $error");
    }

    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($status >= 400) {
        throw new RuntimeException("API returned HTTP $status");
    }

    return json_decode($response, true, flags: JSON_THROW_ON_ERROR);
}

// $user = fetchJson("https://api.example.com/users/1");
// $created = fetchJson("https://api.example.com/users", "POST", ["name" => "New User"]);

Checking curl_exec()'s return value AND the HTTP status code are both necessary — a false return means the request itself never completed (timeout, DNS failure, refused connection), while a 200-vs-404-vs-500 status means it completed but the server is reporting success or failure.

Building a small JSON API endpoint

<?php
// api/user.php -- returns JSON instead of HTML
header("Content-Type: application/json");

$id = (int) ($_GET["id"] ?? 0);

$users = [1 => ["id" => 1, "name" => "Ada Lovelace"]];

if (!isset($users[$id])) {
    http_response_code(404);
    echo json_encode(["error" => "User not found"]);
    exit;
}

echo json_encode($users[$id]);

Setting the Content-Type: application/json header tells the client (browser, curl, another service) how to interpret the body, and setting an accurate http_response_code() lets API consumers branch on status without having to parse the error message text.

JSON cheat sheet

Function / flag Purpose
json_encode($value, $flags) PHP value → JSON string
json_decode($json, true, flags: ...) JSON string → associative array
json_decode($json) (no true) JSON string → stdClass object(s)
JSON_THROW_ON_ERROR Throw JsonException instead of failing silently
JSON_PRETTY_PRINT Human-readable, indented output
JsonSerializable Interface to control exactly how an object encodes
CURLOPT_RETURNTRANSFER Return the response body instead of printing it
http_response_code() Set the HTTP status code of your own response

How It Actually Works

json_encode() walks your PHP value's internal representation recursively — for an array it's the HashTable bucket-by-bucket, for an object it's the visible (public, or all if you implement JsonSerializable) property table — building a JSON text string as it goes, using an internal string buffer that grows dynamically rather than repeated concatenation (avoiding the O(n²) cost naive string-building would incur). json_decode() runs a real recursive-descent JSON parser that tokenizes the input and builds either stdClass objects or associative arrays (true for the second argument) depending on how you call it; because that parser is strict about JSON grammar, malformed input doesn't throw by default — it returns null and sets an internal error code retrievable via json_last_error(), which is exactly why "always check for decode failure" is not optional advice but a consequence of PHP's error-signaling design for this specific function. When you implement JsonSerializable, json_encode() checks for that interface before falling back to default property-walking, calling your jsonSerialize() method and encoding whatever array or scalar it returns instead — a compile-time interface check translated into a runtime dispatch decision. Consuming an API with cURL involves a real TCP handshake, TLS negotiation, and HTTP request/response cycle handled by libcurl (a C library PHP's cURL extension binds to) entirely outside the Zend Engine — curl_exec() blocks the current process until the full response is read, which is why a slow upstream API stalls your entire PHP process for that request's duration.

Exercise

Write a function fetchJsonSafely(string $url): array that wraps file_get_contents() with a stream context (5-second timeout), returns ["error" => "..."] on a network failure, and otherwise decodes the JSON body with JSON_THROW_ON_ERROR, catching JsonException and returning an ["error" => "..."] array for malformed responses too — so callers never have to distinguish "network failed" from "bad JSON" from "success," just check isset($result["error"]). Then write a small Order class implementing JsonSerializable that stores an internal DateTime for placedAt but exposes it as an ISO-8601 string ($date->format(DATE_ATOM)) in its JSON output.