ennacx/php-simple-curl

PHP - Simple cURL wrapper library.

Maintainers

Package info

github.com/ennacx/php-simple-curl

pkg:composer/ennacx/php-simple-curl

Transparency log

Statistics

Installs: 143

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v2.0.0-rc2 2026-07-23 09:25 UTC

README

PHP Version Require Latest Stable Version Total Downloads Latest Unstable Version License

A small PHP 8.2+ cURL wrapper that builds typed request objects, executes them through single or multi clients, and returns response objects.

Philosophy

PHP's cURL extension is powerful, but its option-based API can become hard to read as requests grow. Additionally, the sheer number of granular cURL constants provided by PHP makes it challenging to select the most appropriate ones.

PHP Simple cURL keeps the core pieces explicit:

  • Request describes what to send.
  • CurlOptions describes how to send it.
  • Clients execute requests and return typed Response objects.

The library favors small value objects, immutable options, and predictable response helpers over a large fluent client with hidden state.

Requirements

  • PHP 8.2 or later
  • ext-curl
  • ext-openssl
  • Composer 2.x

Installation

composer require ennacx/php-simple-curl:^2.0@rc

Release Candidate Policy

Release Candidate versions are considered API complete.

During the RC phase:

  • No breaking changes.
  • No additions to the public API.
  • No changes to public method signatures or return types.
  • Only bug fixes, documentation improvements, tests, and internal implementation refinements are allowed.

This policy helps ensure that the stable release is a true continuation of the RC series.

💡 Refine, don't redefine.

Core Flow

  1. Create a Request with the HTTP method, URL, and request headers.
  2. Create CurlOptions for timeout, SSL, proxy, auth, redirect, and response capture settings.
  3. Pass the request to SingleClient::send() or MultiClient::sendAll().
  4. Read the returned Response object.

You may pass either a plain Request or the object returned by Request::prepare() to client send*() methods. When a plain Request is passed, the client prepares it internally with default CurlOptions.

Single Request

<?php

use Ennacx\SimpleCurl\Client\SingleClient;
use Ennacx\SimpleCurl\Option\CurlOptions;
use Ennacx\SimpleCurl\Request\Request;

$request = Request::get('https://www.php.net/')
    ->headers([
        'Accept' => 'text/html',
    ]);

$options = CurlOptions::create()
    ->timeout(10)
    ->followRedirects()
    ->userAgent('MyApp/1.0')
    ->captureBody()
    ->captureHeaders();

$client = new SingleClient();

// With custom CurlOptions.
$response = $client->send($request->prepare($options));

// With default CurlOptions.
$response = $client->send($request);

echo $response->statusCode;
echo $response->body;

foreach($response->rawHeaders() as $headerLine){
    echo $headerLine . PHP_EOL;
}

if($response->isSuccessful()){
    echo $response->header('content-type');
}

if($response->error !== null){
    echo $response->error;
    echo $response->errorMessage;
    echo $response->toCurlError()?->name;
}

Multiple Requests

MultiClient::sendAll() executes multiple requests with cURL multi and returns a Responses collection keyed by each request ID.

<?php

use Ennacx\SimpleCurl\Client\MultiClient;
use Ennacx\SimpleCurl\Option\CurlOptions;
use Ennacx\SimpleCurl\Request\Request;

$options = CurlOptions::create()
    ->timeout(10)
    ->followRedirects();

$phpRequest = Request::get('https://www.php.net/');
$packagistRequest = Request::get('https://packagist.org/');

$php = $phpRequest
    ->prepare($options);

$packagist = $packagistRequest
    ->prepare($options);

$client = new MultiClient();

// With custom CurlOptions.
$responses = $client->sendAll($php, $packagist);

// With default CurlOptions.
$responses = $client->sendAll($phpRequest, $packagistRequest);

$phpResponse = $responses->get($phpRequest->getId());
$packagistResponse = $responses->get($packagistRequest->getId());

echo $phpResponse->statusCode;
echo $packagistResponse->statusCode;

foreach($responses as $requestId => $response){
    echo $requestId . ': ' . $response->statusCode . PHP_EOL;
}

Request

Request describes the HTTP request itself. It owns the URL, HTTP method, request ID, and request headers. It does not execute cURL and does not own transport options.

<?php

use Ennacx\SimpleCurl\Enum\ContentType;
use Ennacx\SimpleCurl\Request\Request;

$request = Request::post('https://api.example.com/users')
    ->accept(ContentType::Json)
    ->headers([
        'Content-Type' => 'application/json',
    ]);

Accept Header

Use accept() or accepts() to add an Accept header from common ContentType values, MediaRange values, or custom media type strings.

<?php

use Ennacx\SimpleCurl\Enum\ContentType;
use Ennacx\SimpleCurl\Enum\MediaRange;
use Ennacx\SimpleCurl\Request\Request;

$request = Request::get('https://api.example.com/users')
    ->accept(ContentType::Json);

$request = Request::get('https://api.example.com/users')
    ->accepts(ContentType::Json, 'application/vnd.api+json');

$request = Request::get('https://api.example.com/users')
    ->accepts(
        ContentType::Json,
        ContentType::Html->withQuality(0.8),
        MediaRange::Any->withQuality(0.1),
    );

The third request sends:

Accept: application/json, text/html;q=0.8, */*;q=0.1

Use withQuality() only for values that need an explicit Quality Value. You do not need to add it to every accepted type, and q=1 can usually be omitted because it is the HTTP default.

If the same media type is added more than once, the first value wins. For example:

$request = Request::get('https://api.example.com/users')
    ->accepts(
        ContentType::Json,
        ContentType::Json->withQuality(0.5),
    );

The request above sends only:

Accept: application/json

You can still set a fully custom Accept header with headers():

$request = Request::get('https://api.example.com/users')
    ->headers([
        'Accept' => 'application/json;q=1.0, text/html;q=0.8',
    ]);

An explicitly provided Accept header is kept and is not overwritten by accept() or accepts().

Header names, header values, and Accept values must not contain line breaks. Invalid header or Accept input throws InvalidRequestException.

Query Parameters

Request can manage GET query parameters separately from the base URL.

Existing query strings are parsed when the request is created:

$request = Request::get('https://api.example.com/users?page=1')
    ->param('q', 'php curl')
    ->param('page', 2);

The request above is executed as:

https://api.example.com/users?page=2&q=php+curl

Use params() to add multiple query parameters:

$request = Request::get('https://api.example.com/users')
    ->params([
        'page'  => 1,
        'limit' => 20,
        'sort'  => 'name',
    ]);

Passing null removes an existing query parameter when overwriting is enabled:

$request = Request::get('https://api.example.com/users?page=1&debug=1')
    ->param('debug', null);

Use overwrite: false to keep existing values:

$request = Request::get('https://api.example.com/users?page=1')
    ->param('page', 2, overwrite: false)
    ->param('limit', 20);

URL fragments are preserved and appended after the rebuilt query string:

$request = Request::get('https://example.com/docs?lang=en#install')
    ->param('version', '2.x');

This is executed as:

https://example.com/docs?lang=en&version=2.x#install

Request Body

Request can also hold a request body. The body is converted into CURLOPT_POSTFIELDS when the request is executed.

ContentType represents common HTTP media types. In the current request body API, it is used to choose the default Content-Type header for body() and bodyFromFile().

Use body() when you already have a raw string payload:

<?php

use Ennacx\SimpleCurl\Enum\ContentType;
use Ennacx\SimpleCurl\Request\Request;

$request = Request::post('https://api.example.com/messages')
    ->body('plain text message', ContentType::PlainText);

Use bodyFromFile() when the request body should be read from a local file:

$request = Request::put('https://api.example.com/documents/1')
    ->bodyFromFile(__DIR__ . '/payload.txt', ContentType::PlainText);

bodyFromFile() reads the file contents and sends them as the request body. It is useful for APIs that expect raw text, JSON, XML, or binary-like payloads in the body. It is not a multipart file upload helper.

Use json() to encode an array as JSON. The default Content-Type is application/json.

$request = Request::post('https://api.example.com/users')
    ->accept(ContentType::Json)
    ->json([
        'name'  => 'Taro',
        'email' => 'taro@example.com',
    ]);

You can also pass a pre-encoded JSON string. The string is validated before it is stored as the request body.

$request = Request::post('https://api.example.com/users')
    ->json('{"name":"Taro","email":"taro@example.com"}');

Use form() for application/x-www-form-urlencoded payloads:

$request = Request::post('https://api.example.com/token')
    ->form([
        'grant_type' => 'client_credentials',
        'client_id'  => 'example-client',
    ]);

When a body helper is used, PHP Simple cURL sets the matching Content-Type automatically unless you explicitly provide one with headers().

$request = Request::post('https://api.example.com/users')
    ->headers([
        'Content-Type' => 'application/vnd.api+json',
    ])
    ->json([
        'name' => 'Taro',
    ]);

Use attach() to send files as multipart/form-data:

<?php

use Ennacx\SimpleCurl\Request\Request;
use Ennacx\SimpleCurl\Request\RequestAttachment;

$request = Request::post('https://api.example.com/upload')
    ->form([
        'description' => 'Profile image',
    ])
    ->attach(new RequestAttachment(
        name: 'file',
        path: __DIR__ . '/avatar.png',
        filename: 'avatar.png',
        mimeType: 'image/png',
    ));

When attachments are present, Content-Type is managed by cURL. Any user-defined Content-Type header is removed so cURL can generate the required multipart boundary.

If a multipart field name conflicts with a form field name or another attachment, the attachment overwrites the existing field by default. Pass allowOverwrite: false to attach() to reject duplicate names instead:

$request = Request::post('https://api.example.com/upload')
    ->form(['file' => 'already used'])
    ->attach(new RequestAttachment('file', __DIR__ . '/avatar.png'), allowOverwrite: false);

Use attachFile() when the default filename and MIME handling are enough:

$request = Request::post('https://api.example.com/upload')
    ->attachFile(__DIR__ . '/avatar.png');

The multipart field name defaults to the local filename without extension. Pass name when the API expects a specific field name:

$request = Request::post('https://api.example.com/upload')
    ->attachFile(__DIR__ . '/avatar.png', name: 'file');

Attachments can be combined with form() fields only. JSON or raw body payloads cannot be mixed with file attachments.

Stream-based request bodies are planned for a later implementation. They are intentionally not part of the current public request body API yet.

Supported request factory methods:

  • Request::get()
  • Request::post()
  • Request::put()
  • Request::delete()
  • Request::patch()
  • Request::head()
  • Request::options()

Curl Options

CurlOptions describes how cURL should execute the request. It owns timeout, SSL, proxy, auth, redirect, and response capture settings.

CurlOptions is immutable. Fluent helper methods return a new instance, so remember to keep the returned value.

<?php

use Ennacx\SimpleCurl\Config\AuthConfig;
use Ennacx\SimpleCurl\Config\ProxyConfig;
use Ennacx\SimpleCurl\Config\RedirectConfig;
use Ennacx\SimpleCurl\Config\SslConfig;
use Ennacx\SimpleCurl\Config\TimeoutConfig;
use Ennacx\SimpleCurl\Option\CurlOptions;
use Ennacx\SimpleCurl\Option\RawCurlOptions;

$options = CurlOptions::create(
    AuthConfig::bearer('token'),
    SslConfig::verified(),
    ProxyConfig::http('proxy.example.com', port: 3128),
    TimeoutConfig::seconds(timeoutSec: 15, connectTimeoutSec: 5),
    RedirectConfig::enabled(maxRedirects: 5),
    RawCurlOptions::create([
        CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS,
    ]),
);

Fluent helpers are also available:

$options = CurlOptions::create()
    ->timeout(10)
    ->followRedirects()
    ->userAgent('MyApp/1.0')
    ->referer('https://example.com')
    ->captureBody()
    ->captureHeaders();

Because options are immutable, this does not change the original instance:

$baseOptions     = CurlOptions::create()->timeout(10);
$redirectOptions = $baseOptions->followRedirects();

Raw cURL Options

Use RawCurlOptions when you need a CURLOPT_* setting that is not covered by a dedicated config object yet.

use Ennacx\SimpleCurl\Option\CurlOptions;
use Ennacx\SimpleCurl\Option\RawCurlOptions;

$options = CurlOptions::create(
    RawCurlOptions::create([
        CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS,
        CURLOPT_ACCEPT_ENCODING => '',
    ]),
);

The fluent shortcut is also available:

$options = CurlOptions::create()
    ->raw([
        CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS,
    ]);

Raw options are applied after generated options and config objects. By default, they can overwrite existing cURL options. Pass overwrite: false to keep generated values and only add missing options.

Raw options are a low-level escape hatch. Pass only trusted values, especially when setting security-sensitive options such as SSL verification, redirects, protocols, or custom headers.

Sending Requests

Clients accept either Request or PreparedRequest.

Use Request::prepare() when you want to attach custom CurlOptions explicitly:

$response = $client->send($request->prepare($options));

If no custom options are required, pass Request directly. The client converts it to PreparedRequest internally with default CurlOptions.

$response  = $singleClient->send($request);
$responses = $multiClient->sendAll($requestA, $requestB);

sendAll() returns a Responses collection. Use get() when the response must exist, or find() when a missing response should return null.

Responses implements ArrayAccess, so you can also read responses with $responses[$request->getId()]. The collection is read-only; write and unset operations throw ImmutableCollectionException. Use filter() when you want a new Responses collection containing only matching responses.

$response     = $responses->get($requestA->getId());
$sameResponse = $responses[$requestA->getId()]; // array-style

$maybeResponse = $responses->find($requestB->getId());

$errors = $responses->filter(
    static fn($response, string $requestId): bool => $response->isError() && $requestId !== '',
);

foreach($responses as $requestId => $response){
    echo $response->statusCode;
}

Config Objects

Config objects own their own cURL option mapping. The client passes them through CurlOptionsFactory before execution.

Client

Use client settings to add User-Agent and Referer headers through CurlOptions. Pass only the header value, not a full header line.

$options = CurlOptions::create()
    ->userAgent('MyApp/1.0')
    ->referer('https://example.com');

If the same headers are explicitly set with Request::headers(), those request headers are kept.

$request = Request::get('https://api.example.com')
    ->headers([
        'User-Agent' => 'CustomAgent/2.0',
    ]);

$options = CurlOptions::create()
    ->userAgent('MyApp/1.0');

SSL

use Ennacx\SimpleCurl\Config\SslConfig;

$ssl = SslConfig::verified();
$insecure = SslConfig::insecure();

Proxy

use Ennacx\SimpleCurl\Config\ProxyConfig;

$httpProxy  = ProxyConfig::http('proxy.example.com', port: 3128);
$socksProxy = ProxyConfig::socks5('127.0.0.1', port: 1080);

Authentication

use Ennacx\SimpleCurl\Config\AuthConfig;

$basic  = AuthConfig::basic('user', 'password');
$bearer = AuthConfig::bearer('token');

AuthConfig::bearer() expects the token only. Do not include Authorization: or the Bearer scheme.

Timeout

use Ennacx\SimpleCurl\Config\TimeoutConfig;

$timeout   = TimeoutConfig::seconds(timeoutSec: 10, connectTimeoutSec: 3);
$timeoutMs = TimeoutConfig::milliseconds(timeoutMs: 1500, connectTimeoutMs: 500);

Redirects

use Ennacx\SimpleCurl\Config\RedirectConfig;

$redirect   = RedirectConfig::enabled(maxRedirects: 10, autoReferer: true);
$noRedirect = RedirectConfig::disabled();

Response

Both clients return Response objects.

echo $response->statusCode;       // int
echo $response->body;             // string|null
print_r($response->rawHeaders()); // raw response header lines
print_r($response->headers());    // parsed response headers
print_r($response->info);         // curl_getinfo() result

if($response->isOk()){
    // HTTP 200 and no cURL error.
}

if($response->isSuccessful()){
    // HTTP 2xx and no cURL error.
}

if($response->isRedirect()){
    // HTTP 3xx.
}

if($response->isError()){
    // cURL error, HTTP 4xx, or HTTP 5xx.
}

if($response->hasHeader('content-type')){
    echo $response->header('content-type');
}

$json = $response->json();

if($response->error !== null){
    echo $response->error;
    echo $response->errorMessage;
    echo $response->toCurlError()?->name;
}

Response status helpers

  • isInformational() returns true for HTTP 1xx.
  • isOk() returns true for HTTP 200 with no cURL error.
  • isSuccessful() returns true for HTTP 2xx with no cURL error.
  • isRedirect() returns true for HTTP 3xx.
  • isClientError() returns true for HTTP 4xx.
  • isServerError() returns true for HTTP 5xx.
  • isError() returns true for a cURL error, HTTP 4xx, or HTTP 5xx.

Header helpers

  • $response->rawHeaders() returns raw header lines.
  • $response->headers() returns parsed headers keyed by lower-case header name.
  • $response->header('content-type') returns a header value, an array of values, or null.
  • $response->hasHeader('content-type') checks whether the header exists.

JSON helper

  • $response->json() decodes Response::$body as JSON.
  • $response->json(throw: false) returns null when the body is empty.

Exceptions

PHP Simple cURL throws library-specific exceptions for invalid API usage and execution setup failures.

Network-level cURL errors such as connection failures or timeouts are returned as Response::$error and Response::$errorMessage when a response object can be created. They are not thrown by default. Use Response::toCurlError() when you want the raw cURL error code as a CurlError enum.

<?php

use Ennacx\SimpleCurl\Exception\SimpleCurlErrorInterface;

try{
    $response = $client->send($request);
} catch(SimpleCurlErrorInterface $e){
    echo $e->getMessage();
}

Exception classes

  • InvalidRequestException is thrown when the request itself is invalid, such as an invalid URL, header, query parameter, or Accept value.
  • RequestBodyException is thrown when the request body cannot be built, such as invalid JSON, unreadable body files, invalid attachments, or unsupported body and attachment combinations.
  • InvalidConfigurationException is thrown when cURL options or config objects are invalid.
  • CurlExecutionException is thrown when cURL cannot be initialized or the cURL multi execution loop fails.
  • InvalidResponseException is thrown when response data cannot be interpreted, such as JSON decode failure from Response::json().
  • ResponseNotFoundException is thrown when a response is not found in a Responses collection.
  • ImmutableCollectionException is thrown when an immutable collection is modified.

Security Notes

PHP Simple cURL is designed primarily for HTTP and HTTPS requests, but Request does not currently restrict URL schemes to http and https.

If request URLs are built from external input, validate allowed schemes, hosts, ports, and network ranges in your application before creating a Request. This is especially important to avoid SSRF-style issues or unintended access to local/internal resources.

RawCurlOptions is a low-level escape hatch. Options passed through raw() are applied directly to cURL and may override generated options, including headers, SSL verification, redirects, protocols, and other security-sensitive behavior. Pass only trusted values.

For vulnerability reporting and supported versions, see Security Policy.

Notes

  • captureBody controls whether the response body is stored in Response::$body.
  • captureHeaders controls whether response header lines are available through Response::rawHeaders() and Response::headers().
  • Internally, CURLOPT_RETURNTRANSFER is enabled when either body or headers need to be captured.
  • MultiClient::sendAll() returns a Responses collection, keyed by Request::getId().
  • API naming follows a small convention: immutable value objects use with() / without(), in-place helpers use add() / remove(), required lookups use get(), optional lookups use find(), existence checks use has(), full list access uses all(), and conversions use toXxx().

License

MIT