http clients and apis
Headers
Headers are the named metadata fields sent with an HTTP request or response. The body carries the main data; headers describe how that data should be understood, whether the caller is authorised, how clients may cache it, how much quota remains, and how the request can be traced in logs.
They matter because many API bugs are not in the JSON body. A request may have the right URL and body but fail because the Authorization header is missing, the Content-Type is wrong, the client forgot to send Accept: application/json, or a proxy stripped a forwarding header.
Request headers
Request headers tell the server about the caller and the data being sent.
Common request headers include:
Authorization: credentials such as a bearer token or API key.Content-Type: the format of the request body, such asapplication/json.Accept: the response format the client wants.User-Agent: the client application making the request.Idempotency-Key: a unique key for safely retrying a create/payment-style operation.X-Request-IdorTraceparent: a correlation value used in logs and distributed tracing.
In PHP, request headers often arrive through framework request objects. In plain PHP you may see them through getallheaders() on web SAPIs, or through server variables such as $_SERVER['HTTP_AUTHORIZATION']. For learning and tests, it is usually clearer to pass headers as arrays.
<?php
declare(strict_types=1);
function normaliseHeaderName(string $name): string
{
return strtolower($name);
}
function getHeader(array $headers, string $name): ?string
{
$wanted = normaliseHeaderName($name);
foreach ($headers as $headerName => $value) {
if (normaliseHeaderName((string) $headerName) === $wanted) {
return is_array($value) ? implode(', ', $value) : (string) $value;
}
}
return null;
}
$headers = [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer token_123',
];
var_dump(getHeader($headers, 'content-type'));
var_dump(getHeader($headers, 'Accept'));
// Prints:
// string(16) "application/json"
// NULL
Header names are case-insensitive, so Content-Type, content-type, and CONTENT-TYPE refer to the same header. Header values are not generally case-insensitive; treat them according to the specific header's rules.
Response headers
Response headers tell the client how to handle the response.
<?php
declare(strict_types=1);
function jsonResponse(array $payload, int $status = 200): array
{
return [
'status' => $status,
'headers' => [
'Content-Type' => 'application/json; charset=utf-8',
'Cache-Control' => 'no-store',
'X-Request-Id' => 'req_7f3a',
],
'body' => json_encode($payload, JSON_THROW_ON_ERROR),
];
}
$response = jsonResponse(['ok' => true]);
print_r($response);
// Prints:
// [status] => 200
// [Content-Type] => application/json; charset=utf-8
// [body] => {"ok":true}
Real applications may call header() directly, but returning a response object or array is easier to test. Frameworks such as Laravel, Symfony, Slim, and Laminas all provide response APIs so code can set status, headers, and body together.
Content-Type and Accept
Content-Type describes the request or response body that is actually present. Accept describes the response format the client would prefer.
For a JSON API:
- A request with a JSON body should usually include
Content-Type: application/json. - A client that expects JSON should usually send
Accept: application/json. - A JSON response should include
Content-Type: application/json; charset=utf-8.
Do not decode the body as JSON just because the URL belongs to an API. Check the content type first when the endpoint requires JSON.
<?php
declare(strict_types=1);
function acceptsJson(array $headers): bool
{
$accept = getHeader($headers, 'Accept');
if ($accept === null || trim($accept) === '') {
return true;
}
return str_contains(strtolower($accept), 'application/json')
|| str_contains($accept, '*/*');
}
var_dump(acceptsJson(['Accept' => 'application/json']));
var_dump(acceptsJson(['Accept' => 'text/html']));
// Prints:
// bool(true)
// bool(false)
In production code, content negotiation can become more detailed because Accept may contain multiple media types with quality values. For junior PHP work, the important skill is recognising that the header exists and not assuming every client wants the same format.
Authentication headers
APIs commonly use Authorization: Bearer <token> or a custom API key header. The important rule is to parse the header deliberately and avoid logging secrets.
<?php
declare(strict_types=1);
function bearerTokenFromHeaders(array $headers): ?string
{
$authorization = getHeader($headers, 'Authorization');
if ($authorization === null) {
return null;
}
if (!preg_match('/^Bearer\s+(.+)$/i', trim($authorization), $matches)) {
return null;
}
return $matches[1];
}
var_dump(bearerTokenFromHeaders(['Authorization' => 'Bearer abc.def.ghi']));
var_dump(bearerTokenFromHeaders(['Authorization' => 'Basic abc123']));
// Prints:
// string(11) "abc.def.ghi"
// NULL
When an auth header is invalid, return a suitable error such as 401 Unauthorized; do not include the token in the response or logs.
Caching and rate limit headers
Headers also control operational behaviour.
Cache-Control: no-store tells clients and proxies not to store sensitive responses. Public, cacheable responses may use directives such as public, max-age=300, but user-specific data should not be cached publicly.
Rate limit headers vary between APIs, but you may see names like:
RateLimit-Limit: the total allowed requests in the window.RateLimit-Remaining: how many requests are left.RateLimit-Reset: when the window resets.Retry-After: how long the client should wait before retrying.
Clients should treat these as hints for better behaviour, not as permission to ignore status codes.
Multiple values
Some headers can appear more than once. The common example is Set-Cookie, where joining values with commas is not safe. Many other repeated headers can be combined, but cookie handling is special enough that real applications should use the framework's cookie API instead of hand-building those strings.
<?php
declare(strict_types=1);
$responseHeaders = [
'Set-Cookie' => [
'session=abc; HttpOnly; Secure; SameSite=Lax',
'theme=dark; Max-Age=2592000; SameSite=Lax',
],
];
foreach ($responseHeaders['Set-Cookie'] as $cookieHeader) {
echo $cookieHeader . PHP_EOL;
}
// Prints:
// session=abc; HttpOnly; Secure; SameSite=Lax
// theme=dark; Max-Age=2592000; SameSite=Lax
What to check
Before moving on, make sure you can:
- Explain the difference between request headers and response headers.
- Read a header without depending on exact casing.
- Choose sensible JSON response headers.
- Recognise
Authorization,Content-Type,Accept, cache headers, rate limit headers, and request IDs. - Avoid logging sensitive header values.
Practice
Practice: Inspect API Headers
Write a small PHP function that receives request headers and returns a decision for a JSON-only API endpoint.
Requirements
- Accept header names in any casing.
- Require
Content-Type: application/jsonfor requests with a body. - Accept clients that send
Accept: application/jsonorAccept: */*. - Extract a bearer token from
Authorization: Bearer .... - Return a status code and message for missing or invalid headers.
- Include at least three sample requests: valid, wrong content type, and missing bearer token.
Show solution
One good solution is to normalise names when reading headers, then make each API requirement explicit.
<?php
declare(strict_types=1);
function headerValue(array $headers, string $name): ?string
{
$wanted = strtolower($name);
foreach ($headers as $headerName => $value) {
if (strtolower((string) $headerName) === $wanted) {
return trim((string) $value);
}
}
return null;
}
function bearerToken(array $headers): ?string
{
$authorization = headerValue($headers, 'Authorization');
if ($authorization === null) {
return null;
}
if (!preg_match('/^Bearer\s+(.+)$/i', $authorization, $matches)) {
return null;
}
return $matches[1];
}
function checkApiHeaders(array $headers, bool $hasBody): array
{
if ($hasBody && headerValue($headers, 'Content-Type') !== 'application/json') {
return ['status' => 415, 'message' => 'Request body must be JSON.'];
}
$accept = headerValue($headers, 'Accept') ?? '*/*';
if (!str_contains(strtolower($accept), 'application/json') && !str_contains($accept, '*/*')) {
return ['status' => 406, 'message' => 'Client must accept JSON responses.'];
}
if (bearerToken($headers) === null) {
return ['status' => 401, 'message' => 'Bearer token is required.'];
}
return ['status' => 200, 'message' => 'Headers are valid.'];
}
$examples = [
'valid' => [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
'Authorization' => 'Bearer token_123',
],
'wrong content type' => [
'Content-Type' => 'text/plain',
'Accept' => 'application/json',
'Authorization' => 'Bearer token_123',
],
'missing bearer token' => [
'content-type' => 'application/json',
'accept' => '*/*',
],
];
foreach ($examples as $label => $headers) {
$result = checkApiHeaders($headers, true);
echo $label . ': ' . $result['status'] . ' ' . $result['message'] . PHP_EOL;
}
// Prints:
// valid: 200 Headers are valid.
// wrong content type: 415 Request body must be JSON.
// missing bearer token: 401 Bearer token is required.
This keeps the rules visible and testable. In a real controller, the same checks might be provided by framework middleware, but the underlying decisions are the same.