HTTP Clients And APIs

JSON APIs

JSON APIs use HTTP to exchange structured data. The client sends and receives JSON instead of HTML.

A good JSON API is predictable. Clients should know what status codes mean, what successful responses look like, what error responses look like, and which fields can be missing or null.

Return JSON with the right content type

PHP example
<?php

declare(strict_types=1);

$response = [
    'data' => [
        'id' => 123,
        'type' => 'product',
        'name' => 'Notebook',
    ],
];

echo json_encode($response, JSON_THROW_ON_ERROR) . PHP_EOL;

// Prints:
// {"data":{"id":123,"type":"product","name":"Notebook"}}

In a real HTTP response, also send Content-Type: application/json and the correct status code.

Choose a response shape

Many APIs wrap successful data in a data key and errors in an errors key. The exact convention matters less than consistency.

PHP example
<?php

declare(strict_types=1);

function productResponse(int $id, string $name): array
{
    return [
        'data' => [
            'id' => $id,
            'type' => 'product',
            'attributes' => [
                'name' => $name,
            ],
        ],
    ];
}

echo json_encode(productResponse(123, 'Notebook'), JSON_THROW_ON_ERROR) . PHP_EOL;

// Prints:
// {"data":{"id":123,"type":"product","attributes":{"name":"Notebook"}}}

Avoid returning completely different shapes for similar endpoints. Clients become fragile when one success response returns { "id": 1 } and another returns { "data": { "id": 1 } } without a reason.

Error responses

Error responses should be machine-readable and safe to show or log. Do not expose stack traces, SQL, secrets, or internal class names.

PHP example
<?php

declare(strict_types=1);

function errorResponse(string $code, string $message): array
{
    return [
        'errors' => [
            [
                'code' => $code,
                'message' => $message,
            ],
        ],
    ];
}

echo json_encode(errorResponse('not_found', 'Product not found.'), JSON_THROW_ON_ERROR) . PHP_EOL;

// Prints:
// {"errors":[{"code":"not_found","message":"Product not found."}]}

Pair the body with the correct status code, such as 404 for missing resources or 422 for validation errors.

Validation errors

Validation errors should point to the fields that failed.

PHP example
<?php

declare(strict_types=1);

$response = [
    'errors' => [
        ['field' => 'email', 'message' => 'Enter a valid email address.'],
        ['field' => 'name', 'message' => 'Name is required.'],
    ],
];

echo json_encode($response, JSON_THROW_ON_ERROR) . PHP_EOL;

// Prints:
// {"errors":[{"field":"email","message":"Enter a valid email address."},{"field":"name","message":"Name is required."}]}

Clients can use this shape to attach messages to form fields or show a useful API error.

Missing and null are different

In JSON, a field can be absent or present with null.

Absent often means "not included in this response". null often means "known to be empty".

Be deliberate. If middle_name is optional, returning "middle_name": null can be clearer than sometimes omitting it, but the important thing is documenting and staying consistent.

Lists should stay lists

For collections, return arrays consistently. An empty result should be [], not {} or null.

PHP example
<?php

declare(strict_types=1);

$response = ['data' => []];

echo json_encode($response, JSON_THROW_ON_ERROR) . PHP_EOL;

// Prints:
// {"data":[]}

JSON Is A Representation, Not The Whole API

JSON describes the body format. HTTP still defines the method, target URL, headers, status, caching, authentication, and connection behavior. An endpoint can use JSON without being RESTful, and a REST-style resource can offer representations other than JSON.

Treat the complete response as a contract:

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, max-age=60

{"data":{"id":"prod_123","name":"Notebook"}}

The status tells the client the outcome, headers describe the representation and policy, and the body carries structured application data. A 200 body containing {"success":false} forces clients to ignore HTTP semantics and invent endpoint-specific rules.

Content-Type And Accept Have Different Jobs

For a request with a JSON body, Content-Type: application/json tells the server how to parse those bytes. A client sending JSON with text/plain creates ambiguity and should normally receive 415 Unsupported Media Type when the endpoint requires JSON.

The Accept request header tells the server which response media types the client can handle. An API that only returns JSON may reject an incompatible request with 406 Not Acceptable, although many APIs document JSON as the only response and keep negotiation simple.

Do not parse JSON merely because the URL starts with /api. Check the method and content type expected by the operation. Requests such as GET commonly have no body, while POST, PUT, or PATCH may require one.

Media types can include parameters such as application/json; charset=utf-8. Parse the media type rather than comparing the entire header as one exact string.

PHP example
<?php

declare(strict_types=1);

function mediaType(string $contentType): string
{
    return strtolower(trim(explode(';', $contentType, 2)[0]));
}

foreach (['application/json', 'application/json; charset=utf-8', 'text/plain'] as $value) {
    echo mediaType($value) . PHP_EOL;
}

// Prints:
// application/json
// application/json
// text/plain

Separate Request Shapes From Response Shapes

A create request, stored domain object, and response are related but not identical. Clients should not be allowed to set server-owned fields such as IDs, permissions, audit timestamps, or calculated totals simply because those fields appear in responses.

PHP example
<?php

declare(strict_types=1);

$createRequest = [
    'name' => 'Notebook',
    'price_pennies' => 1299,
];

$createdResponse = [
    'data' => [
        'id' => 'prod_123',
        'name' => 'Notebook',
        'price_pennies' => 1299,
        'created_at' => '2026-06-10T09:30:00Z',
    ],
];

Use explicit input DTOs, validators, and response mappers rather than mass-assigning decoded JSON into an ORM model. This prevents undocumented fields from becoming writable and keeps database changes from silently changing the public API.

Field Names And Types Are Public Contracts

Choose one naming convention, such as snake_case or camelCase, and apply it consistently. PHP property names do not have to match JSON keys if a response mapper owns the conversion.

Once clients depend on a field's type, changing it can be breaking. Returning 42 today and "42" tomorrow affects strict clients, generated code, sorting, and validation. Keep booleans as JSON booleans, numbers as numbers where their range is safe, objects as objects, and collections as arrays.

JavaScript numbers cannot precisely represent every 64-bit integer. APIs with very large database IDs often expose opaque string identifiers. This also discourages clients from treating IDs as quantities or inferring record counts.

Money should not be a floating-point amount without a documented rule. Integer minor units plus currency are explicit:

PHP example
<?php

declare(strict_types=1);

function priceResponse(string $productId, int $amountMinor, string $currency): array
{
    return [
        'data' => [
            'id' => $productId,
            'price' => [
                'amount_minor' => $amountMinor,
                'currency' => $currency,
            ],
        ],
    ];
}

echo json_encode(
    priceResponse('prod_9007199254740993', 1299, 'GBP'),
    JSON_THROW_ON_ERROR,
) . PHP_EOL;

// Prints:
// {"data":{"id":"prod_9007199254740993","price":{"amount_minor":1299,"currency":"GBP"}}}

Dates And Times Need One Documented Format

Use a documented ISO 8601/RFC 3339-style timestamp with an offset or Z for instants. Avoid locale-dependent strings such as 10/06/26 9:30, whose ordering and timezone are unclear.

PHP example
<?php

declare(strict_types=1);

$createdAt = new DateTimeImmutable('2026-06-10 09:30:00', new DateTimeZone('UTC'));

echo $createdAt->format(DateTimeInterface::RFC3339_EXTENDED) . PHP_EOL;

// Prints:
// 2026-06-10T09:30:00.000+00:00

A date without a time, such as a birthday, should stay a date string like 2026-06-10 rather than being forced into midnight UTC. Durations, local appointment times, and instants are different concepts and should have different documented representations.

Null, Missing, Empty, And Zero Are Different

These values carry different meanings:

  • a missing field may mean "not requested" or "leave unchanged"
  • null may mean "known to have no value" or "clear this value"
  • [] means an included empty list
  • "" is an included empty string
  • 0 is a number and must not be treated as absent
  • false is a boolean and must not be removed by truthy filtering

This is critical for partial updates. In a PATCH request, omission commonly means no change while an explicit null may clear a nullable property.

PHP example
<?php

declare(strict_types=1);

function updateMiddleName(array $request, ?string $current): ?string
{
    if (!array_key_exists('middle_name', $request)) {
        return $current;
    }

    $value = $request['middle_name'];

    if ($value !== null && !is_string($value)) {
        throw new InvalidArgumentException('middle_name must be a string or null.');
    }

    return $value;
}

echo updateMiddleName([], 'Lee') . PHP_EOL;
var_dump(updateMiddleName(['middle_name' => null], 'Lee'));

// Prints:
// Lee
// NULL

Use array_key_exists() when null is a meaningful present value. isset() returns false for both missing and null.

Error Codes Should Be Stable And Machine-Readable

A human message can improve logs or UI, but clients should make decisions using a documented code, status, field pointer, or type. Human text may be reworded or translated.

PHP example
<?php

declare(strict_types=1);

function validationErrors(): array
{
    return [
        'errors' => [
            [
                'code' => 'invalid_email',
                'field' => 'email',
                'message' => 'Enter a valid email address.',
            ],
            [
                'code' => 'out_of_range',
                'field' => 'quantity',
                'message' => 'Quantity must be from 1 to 100.',
            ],
        ],
    ];
}

Separate categories clients may handle differently:

  • malformed JSON
  • unsupported media type
  • authentication failure
  • authorization failure
  • field validation failure
  • resource not found
  • state conflict
  • rate limit
  • temporary dependency failure
  • unexpected server error

Do not return raw exception messages. Log the internal exception with a request or correlation ID, then return a safe public code and message.

Status Codes And Bodies Must Agree

Common API outcomes include:

  • 200 OK for a successful read or update with a response body
  • 201 Created for creation, often with a Location header
  • 202 Accepted when work is queued and not complete
  • 204 No Content for success with no response body
  • 400 Bad Request for malformed request syntax
  • 401 Unauthorized for missing or invalid authentication
  • 403 Forbidden for an authenticated caller lacking permission
  • 404 Not Found for an unavailable resource
  • 409 Conflict for a state or uniqueness conflict
  • 415 Unsupported Media Type for the wrong request format
  • 422 Unprocessable Content for semantically invalid input where that convention is chosen
  • 429 Too Many Requests for rate limiting
  • 500 or 503 for appropriate server-side failures

Document the project's convention because some frameworks and APIs differ on 400 versus 422. Consistency across endpoints is more useful than debating one code without context.

A 204 response must not contain JSON. If the client needs a confirmation object or updated resource, use an appropriate status that allows a body.

Collections Need Metadata Without Changing Item Shape

A list response should remain a JSON array under its documented key, including when empty. Pagination data belongs beside it rather than replacing the list.

PHP example
<?php

declare(strict_types=1);

$response = [
    'data' => [],
    'meta' => [
        'page_size' => 25,
        'has_more' => false,
    ],
    'links' => [
        'next' => null,
    ],
];

echo json_encode($response, JSON_THROW_ON_ERROR) . PHP_EOL;

// Prints:
// {"data":[],"meta":{"page_size":25,"has_more":false},"links":{"next":null}}

The dedicated pagination lesson covers offset and cursor behavior. At the JSON level, keep the shape stable so clients do not branch between an object, null, and an array based on item count.

Avoid Encoding Domain Objects Directly

Passing an entity directly to json_encode() can expose public properties accidentally, omit private state unpredictably, trigger JsonSerializable behavior, or couple the API to internal names. Lazy ORM relationships may cause database queries during serialization.

Use a mapper or response resource that selects fields deliberately:

PHP example
<?php

declare(strict_types=1);

final readonly class Product
{
    public function __construct(
        public string $id,
        public string $name,
        public int $costPennies,
        public int $salePricePennies,
    ) {
    }
}

function productApiData(Product $product): array
{
    return [
        'id' => $product->id,
        'name' => $product->name,
        'price_pennies' => $product->salePricePennies,
    ];
}

$product = new Product('prod_123', 'Notebook', 700, 1299);
echo json_encode(['data' => productApiData($product)], JSON_THROW_ON_ERROR) . PHP_EOL;

// Prints:
// {"data":{"id":"prod_123","name":"Notebook","price_pennies":1299}}

The internal cost is intentionally absent. A database or domain refactor can now occur without automatically changing the API.

Compatibility Requires Additive Thinking

Clients update on their own schedules. Renaming or removing a field, changing its type, changing nullability, or reusing an error code with a new meaning can break them.

Adding an optional field is often compatible when clients ignore unknown fields. Adding a new enum value can still break clients that assumed the old set was exhaustive. Document whether consumers must tolerate unknown values.

When a breaking change is unavoidable, use the project's versioning and deprecation process. Measure old-field or old-version usage where possible. Do not maintain two subtly different response shapes under the same contract without a negotiation rule.

Test The Contract, Not Only The PHP Array

Endpoint tests should assert the actual status, headers, encoded JSON, types, nullability, and error shape. A unit test of a response-array helper does not prove the HTTP layer sent Content-Type, rejected malformed input, or avoided debug output.

Useful tests include:

  • valid create and read responses
  • empty collection
  • omitted optional field and explicit null
  • malformed JSON
  • wrong content type
  • field validation errors
  • unauthorized and forbidden requests
  • not-found and conflict outcomes
  • large integer IDs represented as documented
  • timestamps and timezone offsets
  • accidental sensitive fields

Schema and contract tests can supplement examples, but a schema does not prove authorization or business behavior. Test both representation and outcome.

What To Check In A Project

Check that request parsing verifies the expected content type and uses JSON_THROW_ON_ERROR or equivalent error handling.

Check that request DTOs and response mappers are separate from persistence entities.

Check that field names, types, identifiers, money, timestamps, nullability, and enum evolution are documented.

Check that status codes, headers, success envelopes, and machine-readable errors agree across endpoints.

Check that empty lists remain arrays, partial updates distinguish missing from null, and sensitive internals never enter encoded output.

Check the tests against the actual HTTP response rather than only helper return values.

What You Should Be Able To Do

After this lesson, you should be able to design a JSON request and response as part of a complete HTTP contract, enforce Content-Type, and distinguish request input from server-owned response data.

You should also be able to choose stable representations for identifiers, money, dates, lists, nulls, and errors; map domain objects deliberately; assess compatibility when fields evolve; and test the encoded status, headers, and body that clients really receive.

Use Status Codes for protocol semantics and API Status Code Design for endpoint outcome matrices and retry behavior.

Practice

Task: Build JSON API Responses

Write a small PHP script that builds success and error response arrays for a JSON API.

Requirements

  • Use declare(strict_types=1);.
  • Create a success response with a data key.
  • Create an error response with an errors key.
  • Include a validation error with a field name.
  • Encode the responses with json_encode(..., JSON_THROW_ON_ERROR).
  • Print the encoded output.

Check Your Work

Run the script and confirm the success and error responses have different, predictable shapes.

Show solution

This solution keeps response shapes explicit and easy for a client to consume.

PHP example
<?php

declare(strict_types=1);

function productCreatedResponse(int $id, string $name): array
{
    return [
        'data' => [
            'id' => $id,
            'type' => 'product',
            'attributes' => [
                'name' => $name,
            ],
        ],
    ];
}

function validationErrorResponse(string $field, string $message): array
{
    return [
        'errors' => [
            [
                'field' => $field,
                'message' => $message,
            ],
        ],
    ];
}

echo json_encode(productCreatedResponse(123, 'Notebook'), JSON_THROW_ON_ERROR) . PHP_EOL;
echo json_encode(validationErrorResponse('name', 'Name is required.'), JSON_THROW_ON_ERROR) . PHP_EOL;

// Prints:
// {"data":{"id":123,"type":"product","attributes":{"name":"Notebook"}}}
// {"errors":[{"field":"name","message":"Name is required."}]}

In a real API response, the first body would likely use status 201 Created, while the validation body would use a 4xx status such as 422.

Why This Works

The success response has a data object. The validation failure has an errors list that tells the client which field failed and why.

Practice: Map Domain Data To JSON

Create an explicit API mapper for an order without exposing its internal cost or database representation.

Task

Build a readonly Order containing:

  • a string ID larger than JavaScript's safe integer range
  • customer email
  • subtotal and tax in integer minor units
  • currency
  • DateTimeImmutable creation time
  • an internal fraud score that must not be returned

Write orderApiData(Order $order): array and encode it under a data key. Return a nested money object and an RFC 3339 timestamp.

Check Your Work

Confirm the ID remains a JSON string, money is not a float, the timestamp includes an offset, and the fraud score is absent.

Show solution

The mapper selects and converts fields intentionally instead of encoding the domain object directly.

PHP example
<?php

declare(strict_types=1);

final readonly class Order
{
    public function __construct(
        public string $id,
        public string $customerEmail,
        public int $subtotalMinor,
        public int $taxMinor,
        public string $currency,
        public DateTimeImmutable $createdAt,
        public float $fraudScore,
    ) {
    }
}

function orderApiData(Order $order): array
{
    return [
        'id' => $order->id,
        'customer_email' => $order->customerEmail,
        'total' => [
            'amount_minor' => $order->subtotalMinor + $order->taxMinor,
            'currency' => $order->currency,
        ],
        'created_at' => $order->createdAt->format(DateTimeInterface::RFC3339_EXTENDED),
    ];
}

$order = new Order(
    id: '9007199254740993',
    customerEmail: 'ada@example.com',
    subtotalMinor: 2000,
    taxMinor: 400,
    currency: 'GBP',
    createdAt: new DateTimeImmutable('2026-06-10T09:30:00+00:00'),
    fraudScore: 0.17,
);

echo json_encode(['data' => orderApiData($order)], JSON_THROW_ON_ERROR) . PHP_EOL;

// Prints:
// {"data":{"id":"9007199254740993","customer_email":"ada@example.com","total":{"amount_minor":2400,"currency":"GBP"},"created_at":"2026-06-10T09:30:00.000+00:00"}}

The fraud score remains internal, and the response format is independent of how the order is stored.

Practice: Validate A JSON Request Boundary

Build a framework-independent parser for a product-create endpoint.

Task

Write parseProductRequest(string $contentType, string $rawBody): array that:

  • accepts application/json with optional parameters such as charset=utf-8
  • rejects other media types with a dedicated exception
  • decodes with JSON_THROW_ON_ERROR
  • requires the top-level JSON value to be an object/associative array
  • requires a non-empty string name
  • requires integer price_pennies greater than zero
  • returns only normalized name and price_pennies

Demonstrate one valid request and one wrong media type.

Show solution

The parser treats media type, JSON syntax, shape, and field rules as separate boundary checks.

PHP example
<?php

declare(strict_types=1);

final class UnsupportedMediaType extends RuntimeException
{
}

function parseProductRequest(string $contentType, string $rawBody): array
{
    $mediaType = strtolower(trim(explode(';', $contentType, 2)[0]));

    if ($mediaType !== 'application/json') {
        throw new UnsupportedMediaType('Content-Type must be application/json.');
    }

    $decoded = json_decode($rawBody, flags: JSON_THROW_ON_ERROR);

    if (!is_object($decoded)) {
        throw new InvalidArgumentException('The JSON body must be an object.');
    }

    $data = get_object_vars($decoded);
    $name = $data['name'] ?? null;
    $price = $data['price_pennies'] ?? null;

    if (!is_string($name) || trim($name) === '') {
        throw new InvalidArgumentException('name must be a non-empty string.');
    }

    if (!is_int($price) || $price <= 0) {
        throw new InvalidArgumentException('price_pennies must be a positive integer.');
    }

    return [
        'name' => trim($name),
        'price_pennies' => $price,
    ];
}

$valid = parseProductRequest(
    'application/json; charset=utf-8',
    '{"name":" Notebook ","price_pennies":1299,"admin":true}',
);

echo json_encode($valid, JSON_THROW_ON_ERROR) . PHP_EOL;

try {
    parseProductRequest('text/plain', '{}');
} catch (UnsupportedMediaType $exception) {
    echo $exception->getMessage() . PHP_EOL;
}

// Prints:
// {"name":"Notebook","price_pennies":1299}
// Content-Type must be application/json.

The unrecognized admin field is not returned, so mass assignment cannot make it part of the application input accidentally.