simsoft / http-client
A simple CURL HTTP Client.
Requires
- php: ^8
- ext-curl: *
- league/oauth2-client: ^2.8
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1|^2.0
Requires (Dev)
- phpmd/phpmd: ^2
- phpstan/phpstan: ^1
- phpunit/phpunit: ^11
- squizlabs/php_codesniffer: ^3.7
This package is auto-updated.
Last update: 2026-04-24 23:24:31 UTC
README
A fluent, production-grade PHP HTTP client built directly on the curl_*
extension.
It combines a zero-dependency core with full PSR compliance — giving you precise
control over cURL behavior while remaining interoperable with any PSR-7/PSR-18
compatible framework or library.
Prerequisites
- PHP 8.1 or higher
- The
ext-curlextension (enabled by default in most PHP distributions) - Composer
Strengths
- Zero runtime overhead — no deep object nesting, no hidden abstraction layers. The execution path from your call to the cURL handle is direct and auditable.
- Full PSR compliance — implements PSR-7 (HTTP messages), PSR-18 (HTTP client), and supports PSR-17 factories and PSR-3 logging out of the box.
- Fluent API — chainable methods read like natural language and require no configuration objects or builder classes.
- Production-ready resilience — built-in retry with exponential backoff, customizable retry conditions, connection reuse, and HTTP/2 support.
- Precise cURL control — every cURL option is accessible via
withOptions(), buffer size is tunable, DNS cache timeout is configurable, and download resumption is handled automatically. - Memory-efficient streaming — large uploads and downloads use PSR-7
StreamInterfaceobjects and cURL's nativeCURLOPT_READFUNCTION/CURLOPT_FILErather than buffering the entire body in memory. - Extensible middleware pipeline — named, ordered middleware closures intercept both the request and response, enabling auth injection, caching, circuit breaking, and error normalization without touching core logic.
Comparison
| Feature | Simsoft HttpClient | Guzzle | Symfony HttpClient | Laravel HTTP Client |
|---|---|---|---|---|
| PHP requirement | 8.1+ | 7.2.5+ | 5.4+ | 8.1+ (framework) |
| Dependencies | ext-curl only |
psr/http-*, psr/log, optional adapters |
None (native PHP streams or curl) | Wraps Guzzle |
| PSR-18 | ✅ | ✅ | ✅ (adapter) | ❌ (Guzzle underneath) |
| PSR-7 | ✅ (response) | ✅ (full) | ❌ (own contracts) | ❌ (own contracts) |
| Transport | cURL directly | cURL or stream | cURL, stream, amphp | Guzzle (cURL) |
| HTTP/2 | ✅ native | ✅ via cURL | ✅ native | ✅ via Guzzle |
| Fluent API | ✅ | ❌ (options array) | ✅ | ✅ |
| Middleware pipeline | ✅ named closures | ✅ HandlerStack | ✅ event listeners | ✅ (limited) |
| Retry built-in | ✅ + custom callback | Via middleware | ✅ | ✅ |
| Async / concurrent | ❌ | ✅ promises | ✅ native | ✅ via Guzzle |
| Streaming upload | ✅ StreamInterface | ✅ | ✅ | ✅ |
| Streaming download | ✅ sink / CURLOPT_FILE | ✅ | ✅ | ✅ |
| File attachments | ✅ CURLFile, path, resource, string | ✅ | ✅ | ✅ |
| Request mocking / testing | ❌ built-in | ✅ MockHandler | ✅ MockHttpClient | ✅ Http::fake() |
| Standalone | ✅ | ✅ | ✅ | ❌ requires Laravel |
| Memory footprint | ⭐ Minimal | Moderate | Low | Moderate + framework |
| Learning curve | Low | Medium | Medium | Low (Laravel only) |
When to choose Simsoft HttpClient
- You want full cURL control — buffer sizes, DNS timeouts, resume downloads, HTTP/2 — without routing through abstraction layers.
- You are building a standalone microservice, CLI tool, or library that must not pull in a framework.
- You need PSR-18 interoperability with a minimal dependency footprint.
- You want a readable fluent API without Guzzle's options-array style.
Usage Guide
- Installation
- Basic Usage
- Sending Request
- Post Request
- Set Headers
- Set CURL options
- Useful Methods
- Upload File
- Download File
- Retry Failed Request
- Logging
- Middleware Usage
- Response Handling
- Response Body
- Create Custom SDK
- PSR-18 Usage
- Macro
Install
composer require simsoft/http-client
Basic Usage
require "vendor/autoload.php"; use Simsoft\HttpClient\HttpClient; $response = HttpClient::make() ->withBaseUrl('https://api.domain.com/api') ->withBearerToken('YOUR_TOKEN') ->get('/users', [ 'page' => 1, 'limit' => 10, ]); echo $response->getStatusCode() . PHP_EOL; if ($response->ok()) { //{"status": 200, "data": [{"name": "John Doe","gender": "m"},{"name": "Jane Doe","gender": "f"}]} echo $response->data('status') . PHP_EOL; echo $response->data('data.0.name') . PHP_EOL; echo $response->data('data.1.name') . PHP_EOL; } else { // {"errors": {"status": 404, "title": "The resource was not found"}} echo $response->data('errors.status') . PHP_EOL; echo $response->data('errors.title') . PHP_EOL; } // Output: 200 John Doe Jane Doe
Sending Requests
use Simsoft\HttpClient\HttpClient; $client = new HttpClient(); $client->withBaseUrl('https://api.domain.com/api'); $response = $client->get('/resource'); // Perform GET request. $response = $client->get('/resource', ['foo' => 'bar', 'foo1' => 'bar2']); // Perform GET request with query params. ?foo=bar&foo1=bar2 $response = $client->patch('/resource', ['id' => 1]); // Perform PATCH request. $response = $client->delete('/resource', ['id' => 2]); // Perform DELETE request.
Post Requests
use Simsoft\HttpClient\HttpClient; $client = new HttpClient(); $client->withBaseUrl('https://api.domain.com'); $response = $client->withMultipart(['foo' => 'bar', 'baz' => 'qux'])->post('/user'); // Perform form-data post $response = $client->asMultipart()->post('/user', ['foo' => 'bar', 'baz' => 'qux']); // Perform form-data post $response = $client->post('/user', ['foo' => 'bar', 'baz' => 'qux']); // Perform form-data post $response = $client->withForm(['foo' => 'bar', 'baz' => 'qux'])->post('/user'); // Perform x-www-form-urlencoded post $response = $client->asForm()->post('/user', ['foo' => 'bar', 'baz' => 'qux']); // Perform x-www-form-urlencoded post $response = $client->withJson(['foo' => 'bar', 'baz' => 'qux'])->post('/user'); // JSON content request $response = $client->asJson()->post('/user', ['foo' => 'bar', 'baz' => 'qux']); // JSON content request $response = $client->withRaw('hello world')->post('/user'); // Raw text/plain content request $response = $client->withRaw('<xml>data</xml>', 'application/xml')->post('/user'); // Raw XML content request $response = $client->asRaw()->post('/user', 'hello world'); // Raw text/plain content request // Note: The client takes ownership of the stream and will close it after the request is complete. // Do not reuse the stream after this call. // For streams, you want to manage yourself, use withBody() instead. $response = $client->withBodyStream(new MyStream())->post('/user'); // Post a stream. $response = $client->withBodyStream(new MyPdfStream(), 'application/pdf')->post('/user'); // Post a PDF stream $response = $client->withBodyStream(new MyVideoStream(), 'video/mp4')->post('/user'); // Post a video stream. // GraphQL request. $response = $client->withGraphQL(' query GetUser($id: ID!) { user(id: $id) { id name } }', ['id' => 123]) ->post('/resource');
Set Headers
use Simsoft\HttpClient\HttpClient; $response = HttpClient::make() ->withBaseUrl('https://domain.com/api') ->withHeader('x-Author', 'John Doe') ->withHeaders([ 'Accept' => 'application/json', 'X-App-Version' => '1.0.0', ]) ->post('/resource', ['foo' => 'bar']);
Set CURL options
use Simsoft\HttpClient\HttpClient; $client = new HttpClient(); $response = $client ->withBaseUrl('https://domain.com/api') ->withOptions([ CURLOPT_CONNECTTIMEOUT_MS => 2000, // 2 seconds CURLOPT_TIMEOUT_MS => 5000, // 5 seconds ]) ->post('/resource', ['foo' => 'bar']);
Useful methods
use Simsoft\HttpClient\HttpClient; $response = HttpClient::make() ->withBaseUrl('https://domain.com/api') ->withBearerToken('YOUR_TOKEN') // set header Bearer YOUR_TOKEN ->withoutVerifying() // Disable TLS certificates verify. ->withoutReturnTransfer() // Disable return transfer. ->verbose() // Enable verbose mode ->post('/resource', ['foo' => 'bar']);
dump() vs dd() for Debugging
// dd() — dumps current state and immediately exits. Use during development. HttpClient::make() ->withBaseUrl('https://domain.com/api') ->withJson(['foo' => 'bar']) ->dd() ->post('/resource', ['foo' => 'bar']); // triggers execution — dumps full state then exits before request send. // dump() — dumps state inside the request pipeline after prepareHandle(), // then continues and completes the request. Use to inspect the fully built state. $response = HttpClient::make() ->withBaseUrl('https://domain.com/api') ->dump() ->post('/resource', ['foo' => 'bar']); // request still fires
Upload File
Upload a single file
use Simsoft\HttpClient\HttpClient; $client = HttpClient::make()->withBaseUrl('https://domain.com/api/upload'); // Attach CURLFile object. (Recommended) $response = $client->attach('file', new CURLFile('path/to/file.pdf'))->post(); // Note: Upload with a custom filename & MIME type. // attach (field name, file path|CURLFile, file name, mime type) // In practice, use only one per request unless your API accepts multiple fields. // Upload a file from a stream resource $response = $client->attach('attachment', fopen('path/to/file.pdf', 'r'), 'file.pdf', 'application/pdf')->post(); // Upload a file via a path $response = $client->attach('document', 'path/to/file.pdf', 'file.pdf', 'application/pdf')->post(); // Upload from string. $response = $client->attach('file', 'Hello world, file content here', 'note.txt')->post();
Upload multiple files.
use Simsoft\HttpClient\HttpClient; $client = HttpClient::make()->withBaseUrl('https://domain.com/api/upload') // Upload CURLFile objects. (Recommended) $response = $client->attach('files', [ new CURLFile('path/to/file1.pdf'), new CURLFile('path/to/file2.pdf'), ])->post(); // or upload files from resources $response = $client->attach('documents', [ fopen('path/to/file1.pdf', 'r'), fopen('path/to/file2.pdf', 'r'), ], 'file.pdf')->post(); // or upload files from paths $response = $client->attach('attachments', [ 'path/to/file1.pdf', 'path/to/file2.pdf', ], 'file.pdf')->post();
Download File
Download a file to disk.
use Simsoft\HttpClient\HttpClient; HttpClient::make() ->sink('path/to/file.zip') ->get('https://example.com/file.zip');
Stream download to a file handle.
use Simsoft\HttpClient\HttpClient; $fp = fopen('php://output', 'wb'); // or $fp = fopen('path/to/file.zip', 'wb'); HttpClient::make() ->sink($fp, streamOnly: true) ->get('https://example.com/file.zip'); fclose($fp);
Retry Failed Request
use Simsoft\HttpClient\HttpClient; $client = new HttpClient(); $client->withBaseUrl('https://domain.com/api/endpoint'); $response = $client->retry(3)->get(); // Retry 3 times. No wait in between attempts. $response = $client->retry(3, after: 500)->get(); // Retry 3 times, wait 500ms between attempts. $response = $client->retry(3, after: 2000)->get(); // Retry 3 times, wait 2 seconds between attempts. // Note: the second argument is in milliseconds.
Using retryWhen() to customize retry logic.
Example: Retry only network-level errors, never double-submit on 5xx.
use Simsoft\HttpClient\Response; HttpClient::make() ->retry(3, after: 500) ->retryWhen(function(Response $response, string $method, int $attempt): bool { // Only retry network-level failures, never server errors on POST return $response->isRetryableNetworkError(); }) ->withJson(['order_id' => 123]) ->post('https://api.example.com/orders');
Example: Exponential backoff with jitter
use Simsoft\HttpClient\Response; HttpClient::make() ->retry(5) ->retryWhen(function(Response $response, string $method, int $attempt): bool { if (!$response->isServerError() && !$response->isRetryableNetworkError()) { return false; } // Exponential backoff: 100ms, 200ms, 400ms, 800ms... $delay = (int) (100 * (2 ** ($attempt - 1))); // Add jitter ±20% to avoid thundering herd $jitter = (int) ($delay * 0.2); $sleep = $delay + random_int(-$jitter, $jitter); usleep($sleep * 1000); return true; }) ->get('https://api.example.com/reports/summary');
Example: Retry on specific HTTP status codes (e.g., 429 Too Many Requests)
use Simsoft\HttpClient\Response; HttpClient::make() ->withBaseUrl('https://api.example.com') ->retry(4) ->retryWhen(function(Response $response, string $method, int $attempt): bool { // Respect Retry-After header on 429 if ($response->getStatusCode() === 429) { $retryAfter = (int) $response->getHeaderLine('retry-after'); sleep(max(1, $retryAfter)); return true; } return $response->isRetryableNetworkError(); }) ->get('/search');
Logging
Set logger Psr\Log\LoggerInterface;
use Simsoft\HttpClient\HttpClient; use Monolog\Logger; $logger = new Logger('app'); $response = HttpClient::make() ->withLogger($logger) // Log with LoggerInterface. ->post('https://domain.com/api/endpoint', ['foo' => 'bar']);
Middleware Usage
Add middleware to the request pipeline. The middleware must be a callable that accepts a request instance and a closure and returns a response instance. More examples can be found in Middleware Examples
use Closure; use Simsoft\HttpClient\HttpClient; use Simsoft\HttpClient\Response; $client = HttpClient::make() ->withBaseUrl('https://api.example.com') // withMiddleware(Closure, middleware_name) middleware_name is optional // middleware function is expecting 2 arguments. 1st is the request object, 2nd is the next middleware function. // Middleware function should return a response object. ->withMiddleware(function (HttpClient $request,Closure $next): Response { // do something with the request $request->withHeader('X-Custom-Header', 'Custom Value'); $response = $next(); // do something with the response return $response; }) ->get('/users');
Response Handling
use Simsoft\HttpClient\HttpClient; $response = HttpClient::make() ->withBaseUrl('https://domain.com/api') ->post('/users', ['foo' => 'bar']); print_r($response->getHeaders()); // Get all headers. // output [ 'content-type' => 'application/json', 'cache-control' => 'no-cache', ] echo $response->getHeaderLine('content-type'); // output: application/json echo $response->getStatusCode(); // output: 200. echo $response->getTotalTime(); // output: 0.0112 (seconds, e.g. 11.2ms). if ($response->ok()) { // Or $response->successful() for 2xx status codes. // Output: {"status": 200, "total_records": 2034 "data": [{"name": "John Doe","gender": "m"},{"name": "Jane Doe","gender": "f"}]} echo (string) $response->getBody(); // Get raw body // Convert to object $users = $response->object(); echo $users->status . PHP_EOL; echo $users->total_records . PHP_EOL; foreach($users->data as $user) { echo $user->name . PHP_EOL; echo $user->gender . PHP_EOL; } // {"status": 200, "data": [{"name": "John Doe","gender": "m"},{"name": "Jane Doe","gender": "f"}]} $data = $response->data(); // Get full decoded array. Equivalent to $response->toArray() echo $data['status'] . PHP_EOL; echo $data['data'][0]['name'] . PHP_EOL; echo $data['data'][1]['name'] . PHP_EOL; // Support dot notation. echo $response->data('status') . PHP_EOL; // 200 echo $response->data('data.0.name') . PHP_EOL; // 'John Doe' echo $response->data('data.1.name') . PHP_EOL; // 'Jane Doe' // output all names using wildcard. foreach($response->data('data.*.name') as $name) { echo $name . PHP_EOL; } } elseif ($response->failed()) { // for 4xx or 5xx or network error. echo $response->isNetworkError() ? 'Network Error' : 'Not Network Error'; echo $response->isServerError() ? 'Server Error' : 'Not Server Error'; echo $response->isClientError() ? 'Client Error' : 'Not Client Error'; echo $response->getMessage() . PHP_EOL; // {"errors": {"status": 404, "title": "The resource was not found"}} echo $response->data('errors.status') . PHP_EOL; echo $response->data('errors.title') . PHP_EOL; }
Response Body
The response body is an instance of Psr\Http\Message\StreamInterface.
// 3 ways to get a raw body. $raw = (string) $response->getBody(); $raw = $response->body(); $raw = $response->getRaw(); $body = $response->getBody(); echo $body->getSize(); // Get the size before reading. echo $body->getContents(); // Read body full contents. // Rewind the body to the beginning before read again. $body->rewind(); echo $body->getContents(); // Incrementally read the body. while (!$body->eof()) { echo $body->read(1024); }
License
The Simsoft HttpClient is licensed under the MIT License. See the LICENSE file for details