Skip to content
Precious Lyson edited this page Nov 13, 2026 · 2 revisions

The Machinjiri HTTP component gives you three cooperating classes:

Class Role
HttpClient A cURL wrapper for outgoing HTTP calls. Handles retries, TLS, circuit-breaking, tracing, uploads, downloads, and concurrent requests.
HttpRequest An immutable-ish representation of an incoming HTTP request (or the basis for a client facade). Detects AJAX, parses JSON, negotiates content types, resolves client IP securely.
HttpResponse A structured outgoing response. Status codes, multi-value headers, cookies, JSON bodies, secure defaults, and safe emission.

They're designed to work standalone or together. HttpRequest conveniently wraps an HttpClient so you can both inspect the current request and make outbound calls from one object.


Requirements

  • ext-curl enabled
  • ext-json enabled
  • No Composer dependencies

Architecture at a Glance

┌─────────────────────────────────────────────────────────┐
│                    Your application                     │
└───────────────┬─────────────────────┬───────────────────┘
                │                     │
    ┌───────────▼────────┐  ┌─────────▼──────────┐
    │   HttpRequest      │  │    HttpResponse    │
    │ (incoming or gate) │  │   (to emit)        │
    │                    │  │                    │
    │  ┌──────────────┐  │  │  status/headers    │
    │  │  HttpClient  │──┼──▶  body / cookies    │
    │  │  (outbound)  │  │  │  security headers  │
    │  └──────────────┘  │  └────────────────────┘
    └────────────────────┘
             │
             ▼
     ┌───────────────┐
     │   ext-curl    │──▶ Remote server
     └───────────────┘

Part 1 — Beginner's Guide

Making your first request

use Mlangeni\Machinjiri\Core\Http\HttpClient;

$client = new HttpClient('https://api.example.com');

$response = $client->get('/users/42');

echo $response['http_code'];   // 200
echo $response['data'];        // {"id":42,"name":"Alice"}
echo $response['content_type']; // application/json

The get() method returns an array with these keys:

Key Meaning
data Raw response body (string)
http_code Status code (200, 404, …)
content_type Content-Type header
total_time Time taken in seconds
error cURL error message or null
errno cURL error number or null
retry_count How many retries occurred
headers Response headers as an associative array

Reading a response

$response = $client->get('/users/42');

if ($response['http_code'] === 200) {
    $user = json_decode($response['data'], true);
    echo "Hello, {$user['name']}";
}

Or, for full-fledged responses use toHttpResponse():

use Mlangeni\Machinjiri\Core\Http\HttpResponse;

$res = $client->toHttpResponse($client->get('/users/42'));

if ($res->isSuccess()) {
    $user = json_decode($res->getBody(), true);
}
echo $res->getStatusCode();     // 200
echo $res->getHeader('Content-Type');

Sending JSON

$response = $client->post('/users', [
    'name'  => 'Alice',
    'email' => 'alice@example.com',
]);

// Automatically:
// - json_encode()'s the array
// - sets Content-Type: application/json
// - sets method to POST

Form-encoded instead:

$response = $client->post('/login', [
    'username' => 'alice',
    'password' => 'secret',
], isJson: false);

Setting headers

$client->setHeaders([
    'Accept'       => 'application/json',
    'X-Api-Version' => '2',
]);

// Or with raw header strings
$client->setHeaders(['Accept: application/json', 'X-Trace: abc123']);

setHeaders() is replace-by-name — calling it twice with Accept won't duplicate the header.

To append rather than replace:

$client->addHeaders(['X-Extra: value']);

Authentication

// Bearer token
$client->setBearerToken('eyJhbGciOi...');

// Basic auth
$client->setBasicAuth('username', 'password');

Tokens are sent via Authorization header and won't be duplicated even if set repeatedly.


Part 2 — Intermediate Guide

Retries & backoff

The client retries idempotent verbs only by default (GET, HEAD, OPTIONS, PUT, DELETE, TRACE), and only on:

  • HTTP statuses: 408, 425, 429, 500, 502, 503, 504
  • cURL errors: COULDNT_CONNECT, COULDNT_RESOLVE_HOST, OPERATION_TIMEOUTED, GOT_NOTHING, RECV_ERROR, SEND_ERROR
$client->setRetryOptions(
    maxRetries: 3,          // total attempts beyond the initial
    retryBaseDelayMs: 500,  // starting backoff
    retryMaxDelayMs: 30000, // cap per retry
    retryBudgetMs: 120000   // total wall-clock budget
);

Jitter: Backoff is full-jitter exponential — delays are randomized between [delay/2, delay] to prevent thundering herds.

Retry-After: If the server sends Retry-After (seconds or HTTP-date), it's honoured up to retryMaxDelayMs.

Budgets: Even if attempts remain, the client won't start a retry that would push past retryBudgetMs — it stops cleanly with the last response.

Opting non-idempotent methods into retries

POST and PATCH are never retried unless you explicitly ask:

$client->setRetryOnNonIdempotent(true);

Only do this if the remote server is idempotent for the operation — e.g., you're sending an Idempotency-Key header, or the endpoint is a "create-or-update".

Timeouts

Two levels:

$client->setTimeout(10); // total request timeout, seconds

The default connect timeout is 10s and TCP keepalive is enabled. For real-world APIs, 30s end-to-end is usually fine.

Timeouts are retried (they're idempotent-safe). When exhausted, HttpTimeoutException is thrown.

File upload & download

Upload

$response = $client->uploadFile(
    '/documents',
    'file',               // field name
    '/tmp/report.pdf',
    ['title' => 'Q4 Report']
);
  • Uses CURLFile for multipart streaming
  • Suppresses Expect: 100-continue (avoids stalls on large bodies)

Download

$response = $client->downloadFile(
    '/exports/data.csv',
    '/var/tmp/data.csv'
);

Atomic: The file is written to data.csv.part-<random>, then renamed on success. If anything fails, the temp file is deleted — you never get a half-written destination file.

Working with incoming requests

HttpRequest reads from PHP's superglobals or is fully constructible:

use Mlangeni\Machinjiri\Core\Http\HttpRequest;

$request = HttpRequest::createFromGlobals();

$request->getMethod();       // 'GET'
$request->getPath();         // '/users/42'
$request->getQueryParam('q');// 'string' or default
$request->getHeader('Accept');
$request->getBody();         // raw string

JSON body

// Returns null on non-JSON or malformed (safe default)
$data = $request->getJsonBody();

// Throws MachinjiriException on non-JSON/malformed
$data = $request->getJsonBodyOrFail();

Input helpers

$request->input('q');         // $_POST then $_GET
$request->query('q');         // (available as getQueryParam)
$request->only(['name', 'email']);
$request->except(['password']);
$request->has('name');        // present
$request->filled('name');     // non-empty

Content negotiation

if ($request->expectsJson()) {
    // client sent Accept: application/json or X-Requested-With: XMLHttpRequest
}

$preferred = $request->preferredContentType(
    ['application/json', 'text/html'],
    'text/html'
);
// → 'application/json' if that's what the client prefers

CSRF

if (!$request->verifyCsrf($sessionToken)) {
    // reject
}

Checks X-CSRF-Token, _token in POST, X-XSRF-Token in that order, using constant-time comparison.

Forwarding to a backend

$request->forward('https://internal-api/users', 'POST');
// - Strips hop-by-hop headers (Connection, Keep-Alive, Transfer-Encoding, Host, Content-Length…)
// - Forwards cookies and OAuth bearer token
// - Preserves JSON/form-encoding based on incoming Content-Type

Building responses

use Mlangeni\Machinjiri\Core\Http\HttpResponse;

$res = (new HttpResponse())
    ->setStatusCode(201)
    ->setHeader('Location', '/users/43')
    ->setJsonBody(['id' => 43]);

$res->send();

Convenience senders

$res->sendJson($data, 200);
$res->sendError('Not found', 404);
$res->sendSuccess(['id' => 43], 'Created', 201);

sendError() and sendSuccess() wrap your data in a consistent envelope:

{
  "success": true,
  "message": "Created",
  "data": { "id": 43 },
  "code": 201,
  "timestamp": 1719000000
}

Multi-value headers

Set-Cookie, WWW-Authenticate, Link often need multiple values:

$res->addHeader('Set-Cookie', 'a=1; Path=/');
$res->addHeader('Set-Cookie', 'b=2; Path=/');
$res->getHeaderAll('Set-Cookie');  // ['a=1; Path=/', 'b=2; Path=/']

Each becomes a separate header line on emission — no comma-joining.

Redirects

$res->redirect('/login', 302);           // 302 (default)
$res->redirect('/permanent', 301);
$res->redirect('/temp-keep-method', 307);

Cookies

On outgoing requests

$client->setCookie('theme', 'dark');
$client->setCookie('lang', 'en');

// Values are URL-encoded — safe against header injection.

For cookie jars:

$client->enableCookies('/tmp/cookies.txt');

On responses

$res->withCookie(
    name: 'session',
    value: $sessionId,
    expire: time() + 3600,
    path: '/',
    secure: true,
    httponly: true,
    samesite: 'Lax'
);

withCookie() validates:

  • Name must be an RFC 6265 token
  • SameSite=None requires Secure=true (browsers reject otherwise)
  • SameSite must be Lax, Strict, or None
  • Values are URL-encoded

Batch / concurrent requests

$responses = $request->batch([
    'users'    => ['method' => 'GET',  'url' => 'https://api.example.com/users'],
    'posts'    => ['method' => 'GET',  'url' => 'https://api.example.com/posts'],
    'create'   => [
        'method'  => 'POST',
        'url'     => 'https://api.example.com/users',
        'data'    => ['name' => 'Alice'],
        'headers' => ['X-Idempotency-Key' => 'abc'],
    ],
]);

foreach ($responses as $key => $res) {
    if ($res->isSuccess()) {
        // …
    }
}
  • Each request uses its own cURL handle with isolated headers
  • Requests run concurrently via curl_multi_*
  • OAuth bearer token is automatically applied to each request

Part 3 — Advanced Guide

TLS & transport hardening

TLS verification is on by default, even for non-local hosts.

$client->setSslOptions(
    verifyPeer: true,
    verifyHost: 2,              // must match CN/SAN
    certPath: '/etc/ssl/cert.pem',
    keyPath: '/etc/ssl/key.pem'
);

Localhost exception: For localhost, 127.0.0.1, ::1, and *.localhost, the client automatically:

  • Disables TLS verification (self-signed dev certs just work)
  • Sets IP resolve family (v4 or v6)
  • Disables DNS caching
  • Adds Expect: suppression

HTTP/2: Negotiated via ALPN over TLS, falls back to HTTP/1.1 automatically. Nothing to configure.

Connection pooling: reset() clears state but keeps the handle — the TCP/TLS connection stays warm for the next request.

Circuit breaker

Protects against cascading failures to a flapping backend:

$client->setCircuitBreaker(
    failureThreshold: 5,   // consecutive failures before opening
    cooldownSeconds: 30    // seconds before half-open retry
);

States:

State Behaviour
Closed Normal operation
Open Throws HttpTransportException immediately — no network call
Half-open After cooldown, one request is allowed through. Success → closed; failure → open again

Failures that count: cURL errors, HTTP 5xx when failOnHttpError is on, timeouts.

Idempotency-aware retries

Three layers of safety:

  1. Verb filter: POST/PATCH never retried by default
  2. Status filter: Only transient error codes retried
  3. Budget filter: Won't start a retry that would exceed total time budget

Recommended pattern for POST retries:

$client
    ->setRetryOnNonIdempotent(true)
    ->setHeaders(['Idempotency-Key' => bin2hex(random_bytes(16))]);

$response = $client->post('/orders', $orderData);

The server de-dupes retries, so the client can safely retry.

Response size caps & fail-on-status

Size cap

Defends against memory exhaustion from a malicious or misbehaving server:

$client->setMaxResponseBytes(10 * 1024 * 1024); // 10 MiB

If exceeded, the transfer aborts mid-stream (CURLE_WRITE_ERROR) — you don't download into memory first.

Fail-on-status

Normally the client returns regardless of HTTP status. If you want exceptions on 4xx/5xx:

$client->failOnHttpError(true);

try {
    $client->get('/users/999');
} catch (HttpStatusException $e) {
    echo $e->getCode();  // 404
    echo $e->getBody();  // error response body
}

Distributed tracing

W3C Trace Context

$client->withTrace(
    traceId: '0af7651916cd43dd8448eb211c80319c',
    spanId:  'b7ad6b7169203331',
    sampled: true,
    requestId: 'req-abc'  // optional
);

Sets:

traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
X-Request-ID: req-abc

If requestId is omitted, a random 32-hex-char ID is generated.

Reading from an incoming request

$incoming = HttpRequest::createFromGlobals();

// Parse traceparent to correlate logs
$traceparent = $incoming->getHeader('traceparent');
if (preg_match('/^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/i', $traceparent, $m)) {
    $outgoing->withTrace(
        traceId:   $m[1],
        spanId:    bin2hex(random_bytes(8)), // new child span
        sampled:   $m[3] === '01'
    );
}

Trusted proxies & client IP

getIp() only trusts forwarded headers when the immediate peer is in the trusted proxy list. Otherwise, REMOTE_ADDR is authoritative.

$request->withTrustedProxies([
    '10.0.0.0/8',
    '172.16.0.0/12',
    '192.168.1.10',
]);

$clientIp = $request->getIp();

Resolution order (only if REMOTE_ADDR is trusted):

  1. X-Forwarded-For — left-most valid IP wins
  2. X-Real-IP
  3. X-Client-IP
  4. Fallback: REMOTE_ADDR

CIDR matching: Both IPv4 and CIDR notation (192.168.0.0/16) are supported.

isSecure() similarly: X-Forwarded-Proto / X-Forwarded-SSL are only consulted when the peer is trusted. Otherwise, only HTTPS=on and port 443 count.

Streaming responses

For large response bodies you don't want to buffer:

$stream = $client->streamRequest('GET', 'https://example.com/big.csv');

while (!feof($stream)) {
    echo fread($stream, 8192);
}

fclose($stream);

Or, from an incoming request point of view:

$res = (new HttpResponse())
    ->setHeader('Content-Type', 'text/csv')
    ->setHeader('Content-Disposition', 'attachment; filename="report.csv"');

$res->send();                    // sends headers
$stream = $client->streamRequest('GET', $backendUrl);
fpassthru($stream);

Note: php://temp streams spill to disk past 2 MiB, so even large downloads don't exhaust PHP's memory limit.

Early hints (HTTP 103)

Preload assets before the response body is ready:

$res = new HttpResponse();
$res->sendEarlyHints([
    '/styles/app.css'  => 'style',
    '/scripts/app.js'  => 'script',
    '/fonts/inter.woff2' => 'font',
]);
// … work …
$res->send();

No-op if headers were already sent. Each hint is emitted as:

Link: </styles/app.css>; rel=preload; as=style

Security headers

One call, comprehensive hardening:

$res->withSecurityHeaders(
    csp: "default-src 'self'; script-src 'self' https://cdn.example.com",
    hsts: true,
    frameOptions: 'DENY'
);

Sets:

  • X-Content-Type-Options: nosniff
  • X-Frame-Options: DENY
  • Referrer-Policy: strict-origin-when-cross-origin
  • Content-Security-Policy: …
  • Strict-Transport-Security: max-age=31536000; includeSubDomains

Logging

Pass any PSR-3–compatible logger:

$client->setLogger($psrLogger);

The client logs:

  • warning on retries (attempt, status, errno, delay)
  • warning when the retry budget is exhausted
  • Any level you call from your own code via log()

Context includes: attempt, max, status, errno, delay_ms, method, url.


Class Reference

HttpClient

Construction

Method Description
__construct(string $baseUrl = '', ?Session = null, ?Cookie = null) Build a client bound to a base URL

Request methods

Method Returns
get(string $endpoint, array $query = []) array
post(string $endpoint, $data = [], bool $isJson = true) array
put(string $endpoint, $data = [], bool $isJson = true) array
patch(string $endpoint, $data = [], bool $isJson = true) array
delete(string $endpoint, $data = []) array
head(string $endpoint) array
options(string $endpoint) array
uploadFile(string $endpoint, string $field, string $path, array $extra = []) array
downloadFile(string $endpoint, string $savePath) array
streamRequest(string $method, string $url, array $headers = [], $body = null) resource
multiRequest(array $requests) array of arrays

Builder methods

Method Purpose
setHeaders(array $headers) Replace-by-name
addHeaders(array $headers) Append
setBearerToken(string $token) Authorization: Bearer …
setBasicAuth(string $u, string $p) Authorization: Basic …
setCookie(string $name, string $value) Append cookie
enableCookies(?string $file) Cookie jar
setTimeout(int $s) Total timeout
setMaxRedirects(int $n) Redirect limit
setUserAgent(string $ua) User-Agent
setReferer(string $r) Referer
setProxy(...) HTTP proxy
setSslOptions(...) TLS config
setMaxResponseBytes(?int) Size cap
failOnHttpError(bool) Throw on 4xx/5xx
setRetryOptions(...) Retry tuning
setRetryOnNonIdempotent(bool) Enable POST retries
setCircuitBreaker(int $threshold, int $cooldown) Circuit breaker
enableCompression() Accept gzip
setCustomRequest(string $method) Override verb
withTrace(...) W3C traceparent
setLogger(object $logger) PSR-3 logger

Introspection

Method Returns
getInfo($option = null) cURL info
getError() string
getErrorCode() int
getResponseHeaders() array
getResponseHeader(string $name) ?string
toHttpResponse(array $response) HttpResponse
createFromHttpRequest(HttpRequest $req) self

HttpRequest

Construction

Method Description
__construct(string $method, string $uri, …) Full control
createFromGlobals() From $_SERVER, $_GET, etc.

Accessors

getMethod, getUri, getPath, getQueryParams, getQueryParam, getPostData, getPostParam, getFiles, getCookies, getCookie, getServer, getServerParam, getHeaders, getHeader, getBody, getJsonBody, getJsonBodyOrFail, getUserAgent, getReferer, getContentType, getContentLength, getAccept, getAcceptLanguage, getAcceptEncoding

Predicates

isGet, isPost, isPut, isPatch, isDelete, isOptions, isSecure, isAjax, expectsJson, has, filled, hasAttribute

Helpers

preferredContentType, verifyCsrf, input, only, except, all, getIp, withTrustedProxies, setAttribute, getAttribute

Client shortcuts

get, post, put, patch, delete, head, options, api, oauthApi, upload, download, batch, forward

Client config passthrough

withOptions, withTimeout, withAuth, withRetry, withProxy, withCookies, withOAuth, getClient, setClient

HttpResponse

Status

Method Description
setStatusCode(int) Set code
getStatusCode() Get code
isInformational/ isSuccess/ isRedirect/ isClientError/ isServerError/ isEmpty Predicates
redirect(string $url, int $code = 302) Set Location

Headers

Method Notes
setHeader(name, value) Replace
addHeader(name, value) Append (multi-value)
getHeader(name) First value
getHeaderAll(name) All values
removeHeader(name) Remove
setContentType(string) Shorthand
getHeaders() All

Body

setBody, getBody, setJsonBody (JSON encoding with unicode/slash preservation + pretty-print)

Cookies

withCookie(...) — validated name, SameSite enforcement

Security

withSecurityHeaders(csp, hsts, frameOptions)

Emission

Method Notes
send() Emit status + headers + body; auto Content-Length
sendJson($data, $code = 200) JSON envelope
sendError(string $msg, int $code = 500) Error envelope
sendSuccess($data, string $msg, int $code = 200) Success envelope
sendEarlyHints(array $links) 103 Early Hints

Utilities

clear(), getStatusTexts(), isSent()


Behaviour Changes vs. Earlier Versions

If you're upgrading from a previous version, review these:

HttpClient

  • TLS verification is now ON by default (was disabled for non-localhost — a MITM vuln)
  • close() actually closes the handle (was inverted in the original)
  • setHeaders() is idempotent — replaces by name (was append, causing duplicates)
  • POST/PATCH aren't retried unless setRetryOnNonIdempotent(true) is called
  • Retries are wall-clock–bounded by retryBudgetMs, not just attempt count
  • multiRequest() isolates headers per handle
  • downloadFile() writes atomically

HttpRequest

  • getIp() / isSecure() only trust X-Forwarded-* from trusted proxies (previously spoofable)
  • forward() strips hop-by-hop headers case-insensitively and drops Content-Length
  • api() no longer sends headers twice
  • batch() runs concurrently via multiRequest()
  • Cookie values must be strings (arrays are skipped)

HttpResponse

  • send() throws if headers were already sent, adds Content-Length, calls fastcgi_finish_request()
  • addHeader() supports multiple values per name
  • withCookie() validates name and enforces Secure when SameSite=None
  • statusTexts is now a private const (was a mutable public property)
  • sendEarlyHints() is new

Security Notes

Concern Mitigation
MITM TLS verification on by default
Header injection setCookie() and withCookie() URL-encode values; withCookie() validates name
Proxy spoofing getIp() and isSecure() require trusted proxy peer
Header duplication setHeaders() replaces by name; OAuth applied at one layer
Memory exhaustion setMaxResponseBytes() aborts mid-stream
Cascading failures Circuit breaker
Retry storms Idempotency filter + jitter + budget
XSS withSecurityHeaders() sets CSP, X-Content-Type-Options: nosniff
Clickjacking X-Frame-Options: DENY
Downgrade HSTS via withSecurityHeaders()
SameSite misconfig withCookie() rejects SameSite=None without Secure

Troubleshooting

HttpTransportException: Circuit breaker open

Your backend has failed failureThreshold times in a row. Wait cooldownSeconds, or:

$client->reset(); // does NOT close circuit

To inspect:

// (No public getter for consecutiveFailures — call after cooldown)

CURLE_OPERATION_TIMEOUTED after retries

Either raise setTimeout() or the remote endpoint is genuinely slow. Timeouts are retried, so the total time = timeout × (maxRetries + 1).

Headers already sent when calling send()

Something wrote output before send(). Check for:

  • Leading BOM or whitespace in included files
  • echo / print earlier in the request
  • PHP error notices emitted to the browser

SameSite=None requires Secure=true

Browsers reject SameSite=None on non-HTTPS. Either set secure: true, or use Lax/Strict for HTTP.

JSON not being sent

post()'s second argument takes a bool $isJson = true third parameter. If you pass false, it form-encodes. Double-check:

$client->post('/endpoint', $data);              // JSON ✅
$client->post('/endpoint', $data, false);       // form ✅

CURLE_WRITE_ERROR unexpectedly

You hit setMaxResponseBytes(). Raise the limit or disable with setMaxResponseBytes(null).

Cookies not appearing on the server

Check:

  1. Are you setting them on the same domain?
  2. If SameSite=Strict or Lax, are you on the same site?
  3. If Secure, is the request over HTTPS?
  4. Are you using a cookie jar via enableCookies()?

FAQ

Q: Does this support Guzzle-style middleware? A: Not directly — but HttpRequest::batch() and the builder methods give you comparable composability. For heavy middleware needs, layer this on top of PSR-18 via an adapter.

Q: Can I use it with a PSR-3 logger? A: Yes. setLogger($logger) accepts any object with warning(), error(), info(), debug() methods.

Q: Does it support HTTP/3? A: Not yet. HTTP/2 is negotiated automatically; HTTP/1.1 fallback works. HTTP/3 requires a non-cURL transport in PHP's current ecosystem.

Q: How do I share cookies across requests? A: Use enableCookies('/path/to/jar.txt') — the cURL engine handles persistence.

Q: What's the difference between HttpRequest and HttpClient? A: HttpClient is the outbound transport. HttpRequest is a value object for the current inbound request, and it happens to hold an HttpClient instance so you can do $request->post(...).

Q: Can I use HttpResponse outside of a web request (CLI)? A: Yes. send() writes to stdout. In CLI, fastcgi_finish_request() simply doesn't exist — no problem.

Q: Is multiRequest() safe for auth sharing? A: Yes. Each handle gets its own header sink. OAuth tokens applied via batch() are applied to each request individually.

Q: What does reset() actually do? A: Clears responseHeaders, responseBuffer, and retryCount. Preserves the cURL handle for connection pooling. Does not reset builder options.

Q: How do I clear builder options between requests? A: Create a new HttpClient, or reset specific options via setOption($opt, $value).

Q: Is StreamRequest's return value seekable? A: Yes — it returns a rewound php://temp stream. You can rewind() and re-read. For very large responses, prefer downloadFile() to avoid temp-file overhead.


Clone this wiki locally