Repository navigation
Http
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.
- ext-curl enabled
- ext-json enabled
- No Composer dependencies
┌─────────────────────────────────────────────────────────┐
│ Your application │
└───────────────┬─────────────────────┬───────────────────┘
│ │
┌───────────▼────────┐ ┌─────────▼──────────┐
│ HttpRequest │ │ HttpResponse │
│ (incoming or gate) │ │ (to emit) │
│ │ │ │
│ ┌──────────────┐ │ │ status/headers │
│ │ HttpClient │──┼──▶ body / cookies │
│ │ (outbound) │ │ │ security headers │
│ └──────────────┘ │ └────────────────────┘
└────────────────────┘
│
▼
┌───────────────┐
│ ext-curl │──▶ Remote server
└───────────────┘
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/jsonThe 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 |
$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');$response = $client->post('/users', [
'name' => 'Alice',
'email' => 'alice@example.com',
]);
// Automatically:
// - json_encode()'s the array
// - sets Content-Type: application/json
// - sets method to POSTForm-encoded instead:
$response = $client->post('/login', [
'username' => 'alice',
'password' => 'secret',
], isJson: false);$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']);// 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.
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.
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".
Two levels:
$client->setTimeout(10); // total request timeout, secondsThe 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.
$response = $client->uploadFile(
'/documents',
'file', // field name
'/tmp/report.pdf',
['title' => 'Q4 Report']
);- Uses
CURLFilefor multipart streaming - Suppresses
Expect: 100-continue(avoids stalls on large bodies)
$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.
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// Returns null on non-JSON or malformed (safe default)
$data = $request->getJsonBody();
// Throws MachinjiriException on non-JSON/malformed
$data = $request->getJsonBodyOrFail();$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-emptyif ($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 prefersif (!$request->verifyCsrf($sessionToken)) {
// reject
}Checks X-CSRF-Token, _token in POST, X-XSRF-Token in that order, using constant-time comparison.
$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-Typeuse Mlangeni\Machinjiri\Core\Http\HttpResponse;
$res = (new HttpResponse())
->setStatusCode(201)
->setHeader('Location', '/users/43')
->setJsonBody(['id' => 43]);
$res->send();$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
}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.
$res->redirect('/login', 302); // 302 (default)
$res->redirect('/permanent', 301);
$res->redirect('/temp-keep-method', 307);$client->setCookie('theme', 'dark');
$client->setCookie('lang', 'en');
// Values are URL-encoded — safe against header injection.For cookie jars:
$client->enableCookies('/tmp/cookies.txt');$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, orNone - Values are URL-encoded
$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
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 (
v4orv6) - 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.
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.
Three layers of safety:
-
Verb filter:
POST/PATCHnever retried by default - Status filter: Only transient error codes retried
- 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.
Defends against memory exhaustion from a malicious or misbehaving server:
$client->setMaxResponseBytes(10 * 1024 * 1024); // 10 MiBIf exceeded, the transfer aborts mid-stream (CURLE_WRITE_ERROR) — you don't download into memory first.
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
}$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.
$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'
);
}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):
-
X-Forwarded-For— left-most valid IP wins X-Real-IPX-Client-IP- 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.
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://tempstreams spill to disk past 2 MiB, so even large downloads don't exhaust PHP's memory limit.
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
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: nosniffX-Frame-Options: DENYReferrer-Policy: strict-origin-when-cross-originContent-Security-Policy: …Strict-Transport-Security: max-age=31536000; includeSubDomains
Pass any PSR-3–compatible logger:
$client->setLogger($psrLogger);The client logs:
-
warningon retries (attempt, status, errno, delay) -
warningwhen the retry budget is exhausted - Any
levelyou call from your own code vialog()
Context includes: attempt, max, status, errno, delay_ms, method, url.
| Method | Description |
|---|---|
__construct(string $baseUrl = '', ?Session = null, ?Cookie = null) |
Build a client bound to a base URL |
| 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 |
| 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 |
| 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 |
| Method | Description |
|---|---|
__construct(string $method, string $uri, …) |
Full control |
createFromGlobals() |
From $_SERVER, $_GET, etc. |
getMethod, getUri, getPath, getQueryParams, getQueryParam, getPostData, getPostParam, getFiles, getCookies, getCookie, getServer, getServerParam, getHeaders, getHeader, getBody, getJsonBody, getJsonBodyOrFail, getUserAgent, getReferer, getContentType, getContentLength, getAccept, getAcceptLanguage, getAcceptEncoding
isGet, isPost, isPut, isPatch, isDelete, isOptions, isSecure, isAjax, expectsJson, has, filled, hasAttribute
preferredContentType, verifyCsrf, input, only, except, all, getIp, withTrustedProxies, setAttribute, getAttribute
get, post, put, patch, delete, head, options, api, oauthApi, upload, download, batch, forward
withOptions, withTimeout, withAuth, withRetry, withProxy, withCookies, withOAuth, getClient, setClient
| Method | Description |
|---|---|
setStatusCode(int) |
Set code |
getStatusCode() |
Get code |
isInformational/ isSuccess/ isRedirect/ isClientError/ isServerError/ isEmpty |
Predicates |
redirect(string $url, int $code = 302) |
Set Location |
| 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 |
setBody, getBody, setJsonBody (JSON encoding with unicode/slash preservation + pretty-print)
withCookie(...) — validated name, SameSite enforcement
withSecurityHeaders(csp, hsts, frameOptions)
| 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 |
clear(), getStatusTexts(), isSent()
If you're upgrading from a previous version, review these:
- 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
-
getIp()/isSecure()only trustX-Forwarded-*from trusted proxies (previously spoofable) -
forward()strips hop-by-hop headers case-insensitively and dropsContent-Length -
api()no longer sends headers twice -
batch()runs concurrently viamultiRequest() - Cookie values must be strings (arrays are skipped)
-
send()throws if headers were already sent, adds Content-Length, callsfastcgi_finish_request() addHeader()supports multiple values per name-
withCookie()validates name and enforcesSecurewhenSameSite=None -
statusTextsis now a private const (was a mutable public property) -
sendEarlyHints()is new
| 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
|
Your backend has failed failureThreshold times in a row. Wait cooldownSeconds, or:
$client->reset(); // does NOT close circuitTo inspect:
// (No public getter for consecutiveFailures — call after cooldown)Either raise setTimeout() or the remote endpoint is genuinely slow. Timeouts are retried, so the total time = timeout × (maxRetries + 1).
Something wrote output before send(). Check for:
- Leading BOM or whitespace in included files
-
echo/printearlier in the request - PHP error notices emitted to the browser
Browsers reject SameSite=None on non-HTTPS. Either set secure: true, or use Lax/Strict for HTTP.
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 ✅You hit setMaxResponseBytes(). Raise the limit or disable with setMaxResponseBytes(null).
Check:
- Are you setting them on the same domain?
- If
SameSite=StrictorLax, are you on the same site? - If
Secure, is the request over HTTPS? - Are you using a cookie jar via
enableCookies()?
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.
Machinjiri Docs
- Home
- Learn
- Build
-
Tutorials
- Routing
- Controllers and Middleware
- Views and Templates
- Database ORM
- Eloquent Database ORM
- Errors and Exceptions
- Forms and Validation
- Http
- Events and Logging
- Services and DI
- Security
- Authentication
- Caching
- File System
- Date and Time
- Debugging
- Queue and Jobs
- Task Scheduling
- Transport (Mail, SMS)
- LDAP
- Notifications
- Webhooks
- UUID Generations
- Terminal
- Helper Function
- Reference