A lightweight, multi-adapter HTTP client for PHP 8.1+ designed for maximum reliability across diverse hosting environments. It provides a clean, fluent API with native support for AWS Signature V4 and automatic adapter fallbacks.
Most modern PHP HTTP clients rely heavily on the cURL extension. However, in many shared hosting environments or restricted server setups, cURL may be disabled or outdated.
Callismart HTTP solves this by providing:
- Adapter Fallbacks: If
ext-curlisn't available, the client automatically fails over tofopenorsocketcommunication. - Zero Hard Dependencies: Keep your project footprint small—ideal for WordPress plugins and CLI tools.
- AWS SigV4: Native support for signing requests to AWS services (SES, S3, Lambda, etc.) without the weight of the full AWS SDK.
- Immutable Value Objects: Predictable request and response objects that are thread-safe and cacheable.
- Performance Tracking: Built-in latency measuring for every request.
- Smart Streaming: Download large files directly to disk without buffering in memory.
- Cookie Management: Automatic parsing and replay of session cookies across requests.
Install the package via Composer:
composer require callismart/httpuse Callismart\Http\HttpClient;
$client = new HttpClient();
$response = $client->get( 'https://api.example.com/v1/resource' );
if ( $response->ok() ) {
$data = $response->json();
print_r( $data );
}$response = $client->post_json( 'https://api.example.com/v1/users', [
'name' => 'Callistus',
'role' => 'Developer',
'email' => 'callistus@example.com',
] );
if ( $response->is_success() ) {
$user = $response->json();
echo "Created user ID: {$user['id']}";
}Stream a large file directly to disk without holding it in memory:
$response = $client->download(
'https://example.com/releases/plugin-2.1.0.zip',
'/var/www/downloads/plugin-2.1.0.zip'
);
if ( $response->is_success() ) {
echo "Saved {$response->file_size} bytes to {$response->sink_path}";
}Sign and send a request to AWS:
use Callismart\Http\HttpRequest;
use Callismart\Http\AwsSignatureV4;
$request = HttpRequest::post( 'https://email.us-east-1.amazonaws.com/', $body )
->with_header( 'Content-Type', 'application/x-www-form-urlencoded' );
$signer = new AwsSignatureV4( $access_key, $secret_key, 'us-east-1', 'ses' );
$signed = $signer->sign( $request );
$response = $client->send( $signed );The client automatically detects the best available adapter in this priority order:
- cURL (
ext-curl) — Highest performance, full feature support - fopen (
allow_url_fopen = On) — Fallback for shared hosting - Socket (
fsockopen) — Last resort, always available
$client = new HttpClient();
// Automatically uses the first available adapterIf you want to force a specific adapter:
use Callismart\Http\Adapters\CurlAdapter;
use Callismart\Http\Adapters\FopenAdapter;
use Callismart\Http\Adapters\SocketAdapter;
// Force cURL
$client = new HttpClient( new CurlAdapter() );
// Force fopen
$client = new HttpClient( new FopenAdapter() );
// Force socket (always available)
$client = new HttpClient( new SocketAdapter() );$adapter = $client->get_adapter();
echo "Using adapter: " . $adapter->get_id(); // "curl", "fopen", or "socket"Persistent headers applied to every request (useful for authentication):
$client->with_default_header( 'Authorization', "Bearer {$token}" )
->with_default_header( 'User-Agent', 'MyApp/1.0' );
// These headers are merged into every request sent via this client
$response = $client->get( 'https://api.example.com/user' );
// Automatically includes the Authorization and User-Agent headersPer-request headers always override client defaults.
Requests are immutable value objects built with a fluent API.
use Callismart\Http\HttpRequest;
// GET
$request = HttpRequest::get( 'https://api.example.com/posts' );
// POST
$request = HttpRequest::post( 'https://api.example.com/posts', $body );
// PUT
$request = HttpRequest::put( 'https://api.example.com/posts/123', $body );
// PATCH
$request = HttpRequest::patch( 'https://api.example.com/posts/123', $body );
// DELETE
$request = HttpRequest::delete( 'https://api.example.com/posts/123' );All withers return a new immutable request copy:
$request = HttpRequest::get( $url )
->with_header( 'Authorization', "Bearer {$token}" )
->with_header( 'X-Custom-Header', 'value' );Automatically sets Content-Type and Accept to application/json:
$request = HttpRequest::post( $url )
->with_json( [
'name' => 'Alice',
'email' => 'alice@example.com',
] );
// Equivalent to:
// $request = HttpRequest::post( $url, json_encode( [...] ) )
// ->with_header( 'Content-Type', 'application/json' )
// ->with_header( 'Accept', 'application/json' );$request = HttpRequest::get( $url )
->with_cookie( 'session_id', 'abc123' )
->with_cookie( 'user_pref', 'dark_mode' );
// Cookies are automatically formatted into the Cookie headerDownloads the response body directly to disk without buffering:
$request = HttpRequest::get( 'https://example.com/large-file.zip' )
->with_sink( '/tmp/large-file.zip' );
$response = $client->send( $request );
if ( $response->is_success() ) {
echo "File saved to: {$response->sink_path}";
echo "File size: {$response->file_size} bytes";
}The destination directory must exist and be writable. Throws InvalidArgumentException otherwise.
Use only in development or testing:
$request = HttpRequest::get( 'https://self-signed.example.com' )
->without_ssl_verification();$request = HttpRequest::get( $url )->with_options( [
'timeout' => 60, // seconds
'verify_ssl' => false, // boolean
'max_redirects' => 10, // integer
'cookies' => [ // array of name => value
'session_id' => 'xyz789',
],
] );$response = $client->send( $request );HttpClient provides shorthand methods for common patterns:
// GET
$response = $client->get( 'https://api.example.com/users' );
// POST with body
$response = $client->post( 'https://api.example.com/users', $json_body );
// POST with JSON (automatic encoding + headers)
$response = $client->post_json( 'https://api.example.com/users', [
'name' => 'Bob',
] );
// PUT
$response = $client->put( 'https://api.example.com/users/123', $body );
// PATCH
$response = $client->patch( 'https://api.example.com/users/123', $body );
// DELETE
$response = $client->delete( 'https://api.example.com/users/123' );
// Download (with automatic streaming)
$response = $client->download( $url, '/path/to/file' );Responses are immutable value objects with helper methods for common patterns.
$response = $client->get( $url );
// Shorthand for is_success()
if ( $response->ok() ) {
// 2xx status
}
// Detailed checks
if ( $response->is_success() ) {
// 200-299
}
if ( $response->is_client_error() ) {
// 400-499
}
if ( $response->is_server_error() ) {
// 500-599
}
if ( $response->is_error() ) {
// 400-599
}
if ( $response->is_redirect() ) {
// 300-399
}// JSON decoding
$data = $response->json(); // returns decoded array (or null if invalid)
// Check if body is valid JSON
if ( $response->is_json() ) {
$data = $response->json();
}
// Raw body as string
$raw = $response->body;// Get a single header (case-insensitive)
$content_type = $response->get_header( 'content-type' );
// Check if header exists
if ( $response->has_header( 'content-length' ) ) {
// ...
}
// Shorthand for Content-Type
$type = $response->content_type(); // e.g. "application/json; charset=utf-8"
// All headers as associative array
$headers = $response->headers; // keys are lowercase// Get a single cookie
$session_id = $response->get_cookie( 'session_id' );
// Check if cookie exists
if ( $response->has_cookie( 'session_id' ) ) {
// ...
}
// All cookies as associative array
$cookies = $response->cookies;// Check if any redirects were followed
if ( $response->was_redirected() ) {
echo "Original URL: {$response->original_url()}";
echo "Final URL: {$response->final_url()}";
}
// Complete redirect history (ordered list of URLs)
foreach ( $response->redirect_history as $url ) {
echo "Followed: {$url}\n";
}$response = $client->download( $url, '/tmp/file.zip' );
// Check if response was streamed to file
if ( $response->is_download() ) {
echo "File: {$response->sink_path}";
echo "Size: {$response->file_size} bytes";
echo "Body is empty: " . ( $response->body === '' ? 'yes' : 'no' );
}For buffered responses, save to file later:
$response = $client->get( $url ); // buffered in memory
if ( $response->is_success() ) {
$response->save_to( '/tmp/file.zip' ); // write to disk
}$response = $client->get( $url );
echo "Request took {$response->latency} seconds";Retrieve data from a server:
$response = $client->get( 'https://api.example.com/posts/42' );
if ( $response->is_success() ) {
$post = $response->json();
echo "Title: {$post['title']}";
}Submit data and create resources:
$response = $client->post_json( 'https://api.example.com/posts', [
'title' => 'Hello World',
'content' => 'This is my first post.',
'author' => 'Alice',
] );
if ( $response->is_success() ) {
$created = $response->json();
echo "Post created with ID: {$created['id']}";
}Custom headers:
$request = HttpRequest::post( $url, $body )
->with_header( 'Content-Type', 'application/x-www-form-urlencoded' )
->with_header( 'X-API-Key', 'secret' );
$response = $client->send( $request );Replace an entire resource:
$response = $client->put_json( 'https://api.example.com/posts/42', [
'title' => 'Updated Title',
'content' => 'Updated content.',
'author' => 'Alice',
] );Partially update a resource:
$response = $client->patch_json( 'https://api.example.com/posts/42', [
'title' => 'New Title Only',
] );Remove a resource:
$response = $client->delete( 'https://api.example.com/posts/42' );
if ( $response->is_success() ) {
echo "Post deleted";
}The with_json() method automatically encodes your data and sets the correct headers:
$request = HttpRequest::post( $url )
->with_json( [ 'key' => 'value' ] );
// Headers set automatically:
// Content-Type: application/json
// Accept: application/jsonThe json() method safely decodes JSON responses:
$response = $client->post_json( $url, $data );
// Returns decoded array (or null if invalid/empty)
$result = $response->json();
// Check validity first
if ( $response->is_json() ) {
$result = $response->json();
} else {
echo "Response is not valid JSON";
}Stream the response body directly to a file without buffering in memory:
$response = $client->download(
'https://example.com/releases/large-file-100mb.zip',
'/var/downloads/large-file.zip'
);
if ( $response->is_success() ) {
echo "Downloaded {$response->file_size} bytes";
} else {
echo "Download failed with status {$response->status_code}";
}The file is created at download time. On error, partially downloaded files are automatically cleaned up.
For small responses, buffer in memory and save later:
$response = $client->get( 'https://example.com/image.png' );
if ( $response->is_success() ) {
$response->save_to( '/var/downloads/image.png' );
}Manual sink configuration via request:
$request = HttpRequest::get( $url )->with_sink( '/tmp/download.zip' );
$response = $client->send( $request );
if ( $response->is_download() ) {
echo "Saved to {$response->sink_path}";
}Sign requests to AWS services without the full AWS SDK:
use Callismart\Http\AwsSignatureV4;
$signer = new AwsSignatureV4(
access_key: 'AKIAIOSFODNN7EXAMPLE',
secret_key: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
region: 'us-east-1',
service: 'ses', // or 's3', 'lambda', etc.
);The sign() method returns a new request with AWS signing headers applied:
$request = HttpRequest::post( 'https://email.us-east-1.amazonaws.com/', $body )
->with_header( 'Content-Type', 'application/x-www-form-urlencoded' );
$signed = $signer->sign( $request );
// Headers automatically added:
// - X-Amz-Date: 20250512T123456Z
// - X-Amz-Content-Sha256: (SHA256 hash of body)
// - Authorization: AWS4-HMAC-SHA256 Credential=..., SignedHeaders=..., Signature=...
$response = $client->send( $signed );The signer adds or overwrites three headers:
- X-Amz-Date: Request timestamp in
YYYYMMDDTHHmmssZformat (UTC) - X-Amz-Content-Sha256: SHA256 hash of the request body (hex-encoded)
- Authorization: AWS4-HMAC-SHA256 signature string containing credentials, signed headers, and signature
All other headers (including custom ones) are preserved and included in the signature.
Send an email via AWS SES:
$body = http_build_query( [
'Action' => 'SendEmail',
'Source' => 'noreply@example.com',
'Destination.ToAddresses.1' => 'user@example.com',
'Message.Subject.Data' => 'Hello!',
'Message.Body.Text.Data' => 'This is a test email.',
] );
$request = HttpRequest::post( 'https://email.us-east-1.amazonaws.com/', $body )
->with_header( 'Content-Type', 'application/x-www-form-urlencoded' );
$signer = new AwsSignatureV4( $key, $secret, 'us-east-1', 'ses' );
$signed = $signer->sign( $request );
$response = $client->send( $signed );
if ( $response->is_success() ) {
$xml = $response->body;
// Parse XML response...
}Sign a request to S3 (Get Object):
$request = HttpRequest::get( 'https://my-bucket.s3.amazonaws.com/config.json' );
$signer = new AwsSignatureV4( $key, $secret, 'us-east-1', 's3' );
$signed = $signer->sign( $request );
$response = $client->send( $signed );
if ( $response->is_success() ) {
$config = $response->json();
}The HTTP client uses a three-tier adapter system for maximum compatibility:
Availability: When ext-curl is loaded
Features: Full support — redirects, SSL verification, cookies, timeouts
Performance: Highest — uses native C implementation
use Callismart\Http\Adapters\CurlAdapter;
$adapter = new CurlAdapter();
if ( $adapter->is_available() ) {
echo "cURL is available";
}Availability: When allow_url_fopen = On in php.ini
Features: Redirects, SSL verification, cookies, timeouts
Limitation: May not work with restrictive open_basedir settings
use Callismart\Http\Adapters\FopenAdapter;
$adapter = new FopenAdapter();
if ( $adapter->is_available() ) {
echo "fopen streaming is available";
}Availability: Always (when fsockopen function exists)
Features: Redirects (manual), SSL via ssl:// wrapper, cookies
Limitation: No proxy support, manual socket handling
use Callismart\Http\Adapters\SocketAdapter;
$adapter = new SocketAdapter();
if ( $adapter->is_available() ) {
echo "Socket fallback is available";
}The HttpClient tests adapters in priority order and uses the first available:
$client = new HttpClient();
// Automatically selects cURL → fopen → socket
// If none are available, throws HttpRequestException:
// "HttpClient: no HTTP adapter is available in this environment.
// Enable cURL, allow_url_fopen, or fsockopen."Force a specific adapter:
$client = new HttpClient( new SocketAdapter() );
// Uses socket even if cURL is availableRuntimeException
└─ HttpRequestException
└─ HttpTimeoutException
use Callismart\Http\Exceptions\HttpRequestException;
use Callismart\Http\Exceptions\HttpTimeoutException;
try {
$response = $client->get( $url );
} catch ( HttpTimeoutException $e ) {
// Request exceeded timeout
echo "Timeout: {$e->getMessage()}";
} catch ( HttpRequestException $e ) {
// Connection failed, DNS error, socket error, etc.
echo "Error: {$e->getMessage()}";
}| Error | Cause |
|---|---|
HttpTimeoutException |
Request exceeded configured timeout |
HttpRequestException (DNS) |
Domain could not be resolved |
HttpRequestException (connection) |
TCP connection refused or timeout |
HttpRequestException (SSL) |
SSL certificate verification failed (when verify_ssl=true) |
InvalidArgumentException |
Invalid URL, unsupported HTTP method, missing sink directory |
$request = HttpRequest::get( $url )->with_options( [
'timeout' => 30, // seconds (default: 30)
'verify_ssl' => true, // boolean (default: true)
'max_redirects' => 5, // integer (default: 5)
'cookies' => [], // array of name => value
'sink' => null, // absolute path for streaming
] );$client = new HttpClient();
// Set default headers
$client->with_default_header( 'Authorization', "Bearer {$token}" );
$client->with_default_header( 'User-Agent', 'MyApp/1.0' );
// Check current adapter
$adapter_id = $client->get_adapter()->get_id(); // "curl", "fopen", or "socket"- PHP: 8.1 or higher
- ext-openssl: Recommended for HTTPS support
- At least one of:
ext-curl(preferred)allow_url_fopen = Onin php.inifsockopen()function enabled
-
Use Streaming for Large Files: Use
with_sink()ordownload()to avoid buffering entire files in memory. -
Reuse HttpClient Instances: Create once, send many requests through it to benefit from connection pooling (cURL).
-
Set Appropriate Timeouts: Balance between catching truly broken connections and allowing slow networks:
$request = HttpRequest::get( $url )->with_options( [ 'timeout' => 60 ] );
-
Disable SSL Verification Only in Development: Use
without_ssl_verification()only in trusted environments. -
Check Redirect Loops: Monitor
redirect_historyto detect infinite redirects:if ( count( $response->redirect_history ) > 10 ) { // Possible redirect loop }
This project is licensed under the MIT License. See LICENSE file for details.
Callistus Nwachukwu
Contributions are welcome. Please ensure all code follows WordPress PHP Coding Standards (K&R braces, spaces inside parentheses, tab indentation).
For issues, feature requests, or questions, please visit the project repository.