Search by

nolikein / curl-client

Nolikein

A centralized way to perform curl requests

Package info

gitlab.com/Come-Wasik/curl-client

Issues

pkg:composer/nolikein/curl-client

Statistics

Installs: 2 140

Dependents: 1

Suggesters: 0

Stars: 0

1.3.2 2026-11-03 00:12 UTC

This package is auto-updated.

Last update: 2026-11-03 21:02:12 UTC


README

Packagist Version Pipeline Status Gitlab Code Coverage Downloads

You would like to perform a http request ? Why not using curl ! I made a way to use curl with PHP language that is more accessible ! Respecting PHP-PSR 7 and SOLID principles.

First, I encapsulated the most useful curl functions into object to make it more practical. Every requests return an internal Response that use file stream to store the body. Besides, Curl logic is a bit hard to use for a beginner so that is why I created a "Curl Client". Every Client use the Curl object under the hood.

Then, you can manage multiple configurations for your client with the "Manager". The Manager is perfect to be used with a Framework by retrieving data from a configuration file then injecting your Curl Clients in a Service Container.

❓ Interested to use it with Laravel ? Here is the repository !

Installation

You need to use composer to install the library.

composer require nolikein/curl-client ^1

General use

The library has 3 main classes.

  • Curl which allows to manage curl native php functions but with OOP, basically it's a low level management.
  • Curl Client which allows to perform requests by using a "Curl" instance, basically it's a high level management.
  • Curl Client Manager which allows to register instances of "Curl Client" (that have themselves instances of "Curl").

Using Curl Client (High level)

Let us begin with the easier way to perform requests. Here is an example (with some secure things)

use Nolikein\CurlClient\CurlClient;
use Nolikein\CurlClient\Exception\CurlThrowable;

try {
    $client = new CurlClient();

    /** @var \Nolikein\CurlClient\Response\Response $response */
    $response = $client->get('https://custom-url.org');
    echo $response->getContent() ?? '';
} catch (CurlThrowable $e) {
    // CurlThrowable covers CurlError (curl_exec() failed) and every CurlException
    // report($e);
}

Here is a list of cool other things you can do:

use Nolikein\CurlClient\Curl;
use Nolikein\CurlClient\CurlClient;
use Nolikein\CurlClient\Dtos\AuthorityCertificate;
use Nolikein\CurlClient\Dtos\AuthToken;
use Nolikein\CurlClient\Dtos\Certificate;
use Nolikein\CurlClient\Dtos\Proxy;
use Nolikein\CurlClient\Enums\HttpMethod;

$client = new CurlClient();
$client
    /**
     * Use different http methods
     */
    ->get(url: '', headers: [], curlConfig: [])
    ->post(url: '', data: [], headers: [], curlConfig: [])
    ->put(url: '', data: [], headers: [], curlConfig: [])
    ->patch(url: '', data: [], headers: [], curlConfig: [])
    ->delete(url: '', headers: [], curlConfig: [])
    // Each request starts from GET: the method of a previous request is never reused
    // But the curlConfig options stay on the curl instance for every later request, to any host:
    // never pass credentials (CURLOPT_USERPWD) or TLS settings meant for one request, use another client
    // You can choose your http method
    ->request(url: '', method: 'GET', data: [], headers: [], curlConfig: [])
    // You can also use the enumeration
    ->request(url: '', method: HttpMethod::GET, data: [], headers: [], curlConfig: [])

    /**
     * Header
     * They are just appended
     */
    // Set one
    ->withHeader('name', 'value1')
    ->withHeader('name', 'value2')
    // Set many
    ->withHeaders(['name' => 'value1'])
    ->withHeaders(['name' => ['value1', 'value2']])
    // Remove one
    ->withoutHeader('name')
    // Check existence
    ->hasHeader('name') //: bool
    // Get one
    ->getHeader('name') //: ?array<int, string>
    // Get all
    ->getHeaders() //: array<string, array<int, string>>

    /**
     * Cookies
     */
    // Set one
    ->withCookie('name', 'value1')
    // Set many
    ->withCookies(['name' => 'value1'])
    // Remove one
    ->withoutCookie('name')
    // Remove all
    ->withoutCookie()
    // Check existence
    ->hasCookie('name') //: bool
    // Check existence of any cookie
    ->hasAnyCookie() //: bool
    // Get one
    ->getCookie('name') //: ?string
    // Get all
    ->getCookies() //: array<string, string>

    /**
     * Files.
     * Files are a POST argument, a file postArgumentKey override a passed post argument to request().
     */
    ->withFile(postArgumentKey: 'file', filepath: '/path/to/file.txt', mime: 'text/txt', finalName: 'destinationName.txt')
    ->hasFile('file') //: bool
    ->getFile('file') //: \CURLFile
    // Remove one
    ->withoutFile('file')
    // Remove all
    ->withoutFile()

    /**
     * Proxy
     */
    ->withProxy(new Proxy(
        address: 'http://address.org',
        tunnel: false, // Using proxy tunneling. This is an optional argument
    ))
    ->isUsingProxy() //: bool
    ->getProxy() //: ?Proxy
    // Remove it: after a request that used it, the proxy is also removed from the curl instance,
    // which then ignores the proxy environment variables (http_proxy, HTTPS_PROXY, ALL_PROXY) too
    ->withProxy(null)

    /**
     * Auth token
     */
    ->withAuthToken(new AuthToken(type: 'Bearer', value: 'value'))
    ->isUsingAuthToken() //: bool

    /**
     * Certificate
     */
    // Set a certificate .pem or .crt (CURLOPT_SSLCERT)
    ->withCertificate(Certificate::createFromPath(
        __DIR__ . '/my/custom/path/cert.pem',
    ))
    ->isUsingCertificate() //: bool
    ->getCertificate() //: ?Certificate
    // Note: curl cannot remove a certificate from its handle. Once a request has sent it, withCertificate(null)
    // stops the client from setting it, but the next requests still send it: create a new client instead

    // Alternative ways
    // Set a certificate with private key (CURLOPT_SSLCERT + CURLOPT_SSLKEY)
    ->withCertificate(Certificate::createFromPath(
        __DIR__ . '/my/custom/path/cert.pem',
        __DIR__ . '/path/to/private-key.pem',
    ))
    // Set a certificate with private key and password (CURLOPT_SSLCERT + CURLOPT_SSLKEY + CURLOPT_KEYPASSWD)
    ->withCertificate(Certificate::createFromPath(
        __DIR__ . '/my/custom/path/cert.pem',
        __DIR__ . '/path/to/private-key.pem',
        'the-private-key-password',
    ))

    /**
     * Authority certificate, to verify the server certificate
     */
    // Set a certificate authority file (CURLOPT_CAINFO)
    // Note: the default directory of curl (e.g. /etc/ssl/certs) stays trusted
    ->withAuthorityCertificate(AuthorityCertificate::createFromPath(
        __DIR__ . '/path/to/ca.pem',
    ))
    ->isUsingAuthorityCertificate() //: bool
    ->getAuthorityCertificate() //: ?AuthorityCertificate
    // Remove it: after a request that used it, the curl instance trusts the default authorities of curl again
    // (when curl knows them: not with libcurl older than 7.84, nor with a build that uses the system store)
    ->withAuthorityCertificate(null)

    // Set a certificate authority file and directory (CURLOPT_CAINFO + CURLOPT_CAPATH)
    // The directory replaces the default one: to trust only your authority, give a directory
    // that holds only this authority, or an empty one
    ->withAuthorityCertificate(AuthorityCertificate::createFromPath(
        __DIR__ . '/path/to/ca.pem',
        __DIR__ . '/path/to/ca-directory',
    ))

    /**
     * Manage the curl instance
     */
    // Note: this also mean you can manage the \CurlHandle instance from the $curl->manage() method.
    ->manageCurl(function (Curl $curl): Curl {
        return $curl->manage(function (CurlHandle $handle): CurlHandle {
            return $handle;
        });
    })
    // Set a timeout, limit the response body to 10 MB, allow only http and https, require TLS 1.2 or more,
    // enable HSTS and reject unsafe headers, for every next request of this client (see "Security notes" below)
    ->manageCurl(fn(Curl $curl): Curl => $curl
        ->withTimeout(timeoutMs: 5000, connectTimeoutMs: 2000)
        ->withMaxBodySize(10 * 1024 * 1024)
        ->withAllowedProtocols()
        ->withMinimumTlsVersion()
        ->withHsts()
        ->withStrictHeaders()
    )
;

AuthToken accepts only the Bearer type by default: another type throws InvalidTokenType. Add a type before you use it. It is accepted for the rest of the PHP process, in any case (types are stored with a first upper case letter):

use Nolikein\CurlClient\Dtos\AuthToken;

AuthToken::addType('token');
AuthToken::isTypeAccepted('TOKEN'); //: true

$client->withAuthToken(new AuthToken(type: 'Token', value: 'value')); // Authorization: Token value

You can also build a configured client in one call with the factory. Every argument is optional:

use Nolikein\CurlClient\Curl;
use Nolikein\CurlClient\CurlClientFactory;
use Nolikein\CurlClient\Dtos\AuthorityCertificate;
use Nolikein\CurlClient\Dtos\AuthToken;
use Nolikein\CurlClient\Dtos\Certificate;
use Nolikein\CurlClient\Dtos\Proxy;

$client = CurlClientFactory::make(
    certificate: Certificate::createFromPath(__DIR__ . '/my/custom/path/cert.pem'),
    token: new AuthToken(type: 'Bearer', value: 'value'),
    proxy: new Proxy(address: 'http://address.org'),
    curlInstance: new Curl(),
    authorityCertificate: AuthorityCertificate::createFromPath(__DIR__ . '/path/to/ca.pem'),
); //: CurlClient

Using Curl instance (Low level)

You saw the High level class, you now have to know what happen under the hood.

Remember the basic example ? Here is the equivalent.

use Nolikein\CurlClient\Curl;
use Nolikein\CurlClient\Exception\CurlThrowable;

try {
    $curl = new Curl('https://custom-url.org');

    /** @var \Nolikein\CurlClient\Response\Response $response */
    $response = $curl->request();
    echo $response->getContent() ?? '';
} catch (CurlThrowable $e) {
    // report($e);
}

The only thing that really changed it that we pass the url in the constructor instead of using a ->get() method.

That is because the Curl instance is just a wrapper of the php native \CurlHandle instance. In other words, it is what you get from using curl_init() So... what could we do with this instance? Here is many examples !

use Nolikein\CurlClient\Curl;
use Nolikein\CurlClient\Exception\CurlError;
use Nolikein\CurlClient\Enums\HttpMethod;
use Nolikein\CurlClient\Enums\TlsVersion;
use Nolikein\CurlClient\Dtos\AuthorityCertificate;
use Nolikein\CurlClient\Dtos\Certificate;
use Nolikein\CurlClient\Dtos\Proxy;

$curl = new Curl();
$curl
    // Change url, nothing weird here
    ->withUrl('https://custom-url.org')
    // Set \CurlHandle headers
    ->withHeaders([
        'content-type' => ['text/html'],
    ])

    /**
     * Since the default http method is GET, select the http method
     * and give body arguments to the request.
     */
    // Set method as POST
    ->usePost()
    // With arguments
    ->usePost(data: ['a' => 'b'])

    // Set method back to GET, clearing a previous usePost() or setMethod()
    ->useGet()

    // Set method as PATCH
    ->setMethod(
        method: HttpMethod::PATCH,
        data: ['a' => 'b'],
    )
    // Note: useRequestWithPostFields() is now deprecated

    // Set method as DELETE
    ->setMethod(HttpMethod::DELETE)
    // Note: useRequest() is now deprecated

    /**
     * Added cool things
     */
    // Set a certificate .pem or .crt (CURLOPT_SSLCERT)
    ->withCertificate(Certificate::createFromPath(
        __DIR__ . '/my/custom/path/cert.pem',
    ))

    // Alternative ways
    // Set a certificate with private key (CURLOPT_SSLCERT + CURLOPT_SSLKEY)
    ->withCertificate(Certificate::createFromPath(
        __DIR__ . '/my/custom/path/cert.pem',
        __DIR__ . '/path/to/private-key.pem',
    ))
    // Set a certificate with private key and password (CURLOPT_SSLCERT + CURLOPT_SSLKEY + CURLOPT_KEYPASSWD)
    ->withCertificate(Certificate::createFromPath(
        __DIR__ . '/my/custom/path/cert.pem',
        __DIR__ . '/path/to/private-key.pem',
        'the-private-key-password',
    ))

    // Set a certificate authority file (CURLOPT_CAINFO), with an optional directory (CURLOPT_CAPATH)
    // Without a directory, the default directory of curl (e.g. /etc/ssl/certs) stays trusted
    ->withAuthorityCertificate(AuthorityCertificate::createFromPath(
        __DIR__ . '/path/to/ca.pem',
        __DIR__ . '/path/to/ca-directory', // Optional
    ))
    // Trust the default authorities of curl again (CURLOPT_CAINFO and CURLOPT_CAPATH back to their defaults).
    // Throws UnsupportedCurlOption when curl has no known default (libcurl older than 7.84, or the system store)
    ->withoutAuthorityCertificate()

    // Set a proxy (CURLOPT_PROXY and CURLOPT_HTTPPROXYTUNNEL)
    ->withProxy(new Proxy(
        address: 'http://address.org',
        tunnel: false, // Using proxy tunneling. This is an optional argument
    ))
    // Remove the proxy. The proxy environment variables (http_proxy, HTTPS_PROXY, ALL_PROXY) are ignored too
    ->withoutProxy()
    /**
     * Safety limits (opt-in, see "Security notes" below)
     */
    // Abort the request after 5 seconds (CURLOPT_TIMEOUT_MS), and the connection after 2 seconds
    // (CURLOPT_CONNECTTIMEOUT_MS, optional). Values are in milliseconds, 1 or more.
    ->withTimeout(timeoutMs: 5000, connectTimeoutMs: 2000)
    // Allow only http and https, for the url and for redirects (CURLOPT_PROTOCOLS_STR and
    // CURLOPT_REDIR_PROTOCOLS_STR)
    ->withAllowedProtocols()
    // Or give the curl names of the protocols you need
    ->withAllowedProtocols(['https'])
    // Or give another list for redirects: here a redirect can only lead to https
    ->withAllowedProtocols(['http', 'https'], redirectProtocols: ['https'])
    // Follow redirects (CURLOPT_FOLLOWLOCATION), 5 at most by default (CURLOPT_MAXREDIRS).
    // The limit is 0 or more: with 0, any redirect fails with a CurlError.
    ->withRedirects()
    ->withRedirects(maxRedirects: 3)
    // Refuse a server that offers only a TLS version older than 1.2 (CURLOPT_SSLVERSION)
    ->withMinimumTlsVersion()
    // Or require TLS 1.3
    ->withMinimumTlsVersion(TlsVersion::Tls13)
    // Refuse an HTTPS proxy (an address that starts with https://) that offers only a TLS version
    // older than 1.2 (CURLOPT_PROXY_SSLVERSION). It also takes TlsVersion::Tls13.
    ->withProxyMinimumTlsVersion()
    // Remember the hosts that send a Strict-Transport-Security header, and request their next
    // http:// URLs with https:// (CURLOPT_HSTS_CTRL). The list lasts as long as this instance.
    ->withHsts()
    // Or keep the list in a file between runs (CURLOPT_HSTS): curl reads it at the next request
    // and saves it when this instance is closed or destroyed. Its directory must exist and be writable.
    ->withHsts('/var/cache/my-app/hsts.txt')
    // Make withHeaders() throw InvalidHeader for a header name that is not a token (RFC 9110),
    // or a value that holds CR, LF or NUL. Call it before withHeaders().
    ->withStrictHeaders()

    // Attach file (example for POST http method)
    ->usePost([
        'my_file' => Curl::createFile(
            filepath: __DIR__ . 'here.txt',
            mime: 'text/txt',
            finalName: 'dummy.txt',
        ),
    ])

    /**
     * \CurlHandle configuration and management
     */
    ->withConfiguration([
        CURLOPT_HEADER => true,
    ])
    // withConfiguration() ignores an option that curl refuses (curl_setopt() returns false).
    // withConfigurationOrFail() throws InvalidCurlOption instead; the options before it stay set.
    // In 2.0, withConfiguration() will throw too and replace withConfigurationOrFail().
    ->withConfigurationOrFail([
        CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,
    ])
    // If you do not find what you're looking for, use manage() to handle the \CurlHandle instance.
    // Return statement is optional in the callback, manage() return the current instance.
    ->manage(function (CurlHandle $handle): CurlHandle {
        return $handle;
    })

    /**
     * Other methods
     */
    // Check the stream of the \CurlHandle instance is open
    ->isOpen()
    // Close the stream of the \CurlHandle instance
    ->close()
    // Perform a request then close the stream of the \CurlHandle instance.
    // Note, the Curl Client down there DO NOT use it. It is because of the nature of a "Service Container"
    // on recent Frameworks, we should keep an open stream otherwise consecutive requests won't be possible.
    ->requestAndClose()
;

Using the Response

Every request returns a Nolikein\CurlClient\Response\Response. Its body is stored in a temporary file, deleted when the stream is closed.

use Nolikein\CurlClient\CurlClient;

$response = (new CurlClient())->get('https://custom-url.org');

/**
 * Body
 */
$response->getContent(); //: ?string, null when the response has no body
$response->getBody(); //: ?StreamInterface, the stream of the temporary file
// getContent() reads the whole body into memory: it throws StreamTooLargeForMemory when the body
// does not fit in the memory left under memory_limit, while (string) $response->getBody() returns ''.
// Read a large body in chunks instead:
$body = $response->getBody();
$body?->rewind();
while (null !== $body && ! $body->eof()) {
    $chunk = $body->read(8192); // handle each chunk of 8 KB
}

/**
 * Status
 */
$response->getStatusCode(); //: int, e.g. 200
$response->getReasonPhrase(); //: string, e.g. 'OK'
$response->getProtocolVersion(); //: ?string, e.g. '1.1' or '2.0'

// Status helpers
$response->isSuccessful(); //: bool, 2xx
$response->isRedirection(); //: bool, 3xx
$response->isClientError(); //: bool, 4xx
$response->isServerError(); //: bool, 5xx
$response->isOk(); //: bool, 200
$response->isUnauthorized(); //: bool, 401
$response->isForbidden(); //: bool, 403
$response->isNotFound(); //: bool, 404
// 1xx: an informational status, not an error, despite the name
$response->isInvalid(); //: bool

/**
 * Headers
 * Names are stored in lowercase and can be read in any case.
 */
$response->getHeaders(); //: array<string, array<int, string>>, e.g. ['content-type' => ['application/json']]
$response->hasHeader('Content-Type'); //: bool
// getHeader() and getHeaderLine() throw HeaderNotFound for a missing header: call hasHeader() first
$response->getHeader('Content-Type'); //: array<int, string>
$response->getHeaderLine('Content-Type'); //: string, the values joined with '; '

A Response is immutable: withStatus(), withHeader(), withHeaders(), withAddedHeader(), withoutHeader(), withBody(), withProtocolVersion() and withException() return a modified copy, and the original stays unchanged.

A response can carry an exception. Curl::request() never attaches one: it is for your own code, for example a custom client that builds a response with Response::make(), or a test.

use Nolikein\CurlClient\Response\Response;

$response = Response::make(
    content: '{"error": "maintenance"}',
    status: 503,
    reason: 'Service Unavailable',
    headers: ['content-type' => 'application/json'],
    exception: new \RuntimeException('The API is in maintenance'),
);

$response->getException(); //: ?\Throwable
// Throw the attached exception, or do nothing when there is none
$response->throwResponse();

Using Curl Manager

Here is how to use the manager. We add how much client instance we need, then we retrieve the client instance that interrest us.

use Nolikein\CurlClient\Curl;
use Nolikein\CurlClient\CurlClient;
use Nolikein\CurlClient\CurlClientManager;

$manager = new CurlClientManager();
$manager->setClient(
    identifier: 'base',
    client: $manager->makeClientFromConfiguration(
        key: 'base', // It is a name in case of error message
        clientData: [
            'curl' => Curl::class,
            'client' => CurlClient::class,
        ],
    ),
);

// Get the client
$client = $manager->getClient('base');
// Do the things you want...
echo $client->get('http://address.org')->getContent();

The manager checks the curl and client classes before it builds them. A curl class that does not extend Curl throws InvalidCurlClass, and a client class that does not implement CurlClientInterface throws InvalidCurlClientClass (namespace Nolikein\CurlClient\Exception). Both extend \InvalidArgumentException.

But set a client is not just selecting a curl and a client, you can add certificate, authority certificate, proxy, auth token and even manage your curl client instance from it:

use Nolikein\CurlClient\Curl;
use Nolikein\CurlClient\CurlClient;
use Nolikein\CurlClient\CurlClientManager;

$manager = new CurlClientManager();
$manager->setClient('base', $manager->makeClientFromConfiguration(
    'base',
    [
        'curl' => Curl::class,
        'client' => CurlClient::class,
        'authentication' => [
            // Supported type: Bearer. An empty type or value throws EmptyToken
            'type' => 'Bearer',
            'value' => 'false-token',
        ],
        'certificate' => [
            'path' => 'path/to/certificate.pem',
            'private_key_path' => 'path/to/private-key.pem', // Optional
            'password' => 'private-key-password', // Optional
        ],
        // Needs a client class that extends CurlClient
        'authority_certificate' => [
            'path' => 'path/to/ca.pem',
            'directory_path' => 'path/to/ca-directory', // Optional
        ],
        'proxy' => [
            'address' => 'http://address.org',
            'tunnel' => true, // Optional. Using proxy tunneling
        ],
        // You can perform actions that apply for all your future requests with this client.
        'manage' => function (CurlClient $client): CurlClient {
            return $client;
        },
    ],
));

// Remove client
$manager->unsetClient('base');
// Unexisting client return null
$client = $manager->getClient('base'); //: null

A good example of "how to use" with a Framework is the configuration file of the Laravel wrapper.

Security notes

Security model, protections by OWASP category and how to report a vulnerability: see SECURITY.md.

Requests are sent as you configure them. These practices keep them safe:

  • Allow only the protocols you need. Call withAllowedProtocols() to accept http and https, or give your own list.
  • Set a timeout. Call withTimeout() so a request ends when the server takes too long.
  • Require TLS 1.2 or more. Call withMinimumTlsVersion() so the request fails with a CurlError when the server offers only an older version, or give TlsVersion::Tls13 (namespace Nolikein\CurlClient\Enums) to require TLS 1.3. For an HTTPS proxy (an address that starts with https://), also call withProxyMinimumTlsVersion().
  • Enable HSTS. Call withHsts() so curl remembers the hosts that send a Strict-Transport-Security header, and requests their later http:// URLs with https://. Without a file, the list lasts as long as the Curl instance: a client reuses it for all its requests. Give a file, e.g. withHsts('/var/cache/my-app/hsts.txt'), to keep the list between runs: curl reads it at the next request and saves it when the instance is closed or destroyed. HSTS needs libcurl 7.74 or later: with an older one, withHsts() throws UnsupportedCurlOption.
  • Keep TLS verification on. The library never turns it off. Never set CURLOPT_SSL_VERIFYPEER to false or CURLOPT_SSL_VERIFYHOST below 2 through withConfiguration(): anyone between you and the server could then read and change the request, credentials included.
  • Keep one-request options out of a shared client. The curlConfig of a CurlClient request stays on its curl instance for every later request, to any host. A CURLOPT_USERPWD or a CURLOPT_SSL_VERIFYPEER => false given for one request is sent or applied to all the next ones: use a separate client for them.
  • Validate URLs that come from users. The library requests any host it is given: check the host first, then pin the checked IP with CURLOPT_RESOLVE through withConfiguration(), so the request goes to the address you checked.
  • Reject unsafe headers. Call withStrictHeaders() so a header name that is not a token, or a value with a line break or a NUL byte, throws InvalidHeader instead of being sent. This covers cookies and the Authorization header. Each cookie is also checked against RFC 6265: one name=value pair per value of the Cookie header (give several cookies as several values, as withCookie() does), a token name, and a value without ;, ,, space, double quote or backslash, so withCookie('lang', 'en; admin=1') cannot add a cookie. To check a header or a cookie that comes from a user before you add it, call CheckHeaderSafety::isSafeName(), isSafeValue(), isSafeCookieName() and isSafeCookieValue() (namespace Nolikein\CurlClient\Services). Still accept only the characters you expect in header and cookie values that come from users.
  • Follow redirects only when you need them, with a limit. Redirects are off by default. Call withRedirects() to follow up to 5 of them, or give your own limit.
  • Allow only https for redirects. A redirect can lead to any URL the server chooses. Give the redirect protocols as the second argument of withAllowedProtocols(), e.g. withAllowedProtocols(['http', 'https'], ['https']).
  • Send credentials in the Authorization header. When you follow redirects, curl removes this header when a redirect leads to another host.
  • Give a directory to withAuthorityCertificate() to trust only your authority. With the file alone, the default certificate directory of curl stays trusted. Use a directory that holds only your authority certificate.
  • Use one client per client certificate. curl cannot remove a client certificate from its handle: once a request of a CurlClient has sent one, withCertificate(null) does not stop the next requests from sending it. Create a new client for requests that must not send it. A proxy or an authority certificate removed with withProxy(null) or withAuthorityCertificate(null) is removed from the handle too, except an authority certificate when curl has no known default (libcurl below 7.84, or a build that uses the system store): the handle then keeps it.
  • Limit the response size. The response body is written to a temporary file, with no size limit by default. Call withMaxBodySize(), e.g. withMaxBodySize(10 * 1024 * 1024) for 10 MB, so request() throws a CurlError when the body goes over the limit. A response whose Content-Length is over the limit is refused before its body is read. Any other body is counted as it is written to the file, after decoding: a small compressed body that expands past the limit is stopped too, and the file never grows past the limit. getContent() reads the whole body into a string: keep the limit under memory_limit, or read the body in chunks with getBody()->read(). Over the memory left, getContent() throws StreamTooLargeForMemory instead of stopping the script.
  • Keep secrets out of logs and error pages. The parameters that carry a URL, headers, cookies, a body, a curl configuration, a password or a token are marked #[\SensitiveParameter], so the frames of the library hide them in stack traces. A body field that curl cannot send (an object other than CURLFile, CURLStringFile or Stringable) throws InvalidBodyField (to check a body before you send it, call CheckBodyField::isSendable() for one field or CheckBodyField::isDataSendableOrFail() for the whole body, namespace Nolikein\CurlClient\Services), and a curl option that curl rejects (unknown option, wrong value type, NUL byte) throws InvalidCurlOption: both name the field or the option id, never the value. Still pass each curl option with the type curl expects: when an error handler turns warnings into exceptions, a value that curl converts with a warning (an array for a string option) is shown in the stack trace frame of curl_setopt(). var_dump(), print_r() and Symfony dump() show Curl, CurlClient, Certificate, Proxy, AuthToken and Response with their secrets masked: passwords, tokens, URL credentials and query values, and the values of every header whose name contains auth, token, key, secret, session, cookie, passw or signature. Put secrets in a header or in the query, never in the URL path: the path is shown as given. When you extend these classes or implement CurlClientInterface, mark the parameters of your methods #[\SensitiveParameter] too: PHP does not inherit the attribute. In production, also set zend.exception_ignore_args = On in php.ini, so no argument reaches stack traces. To log a URL or headers yourself, mask them first:
use Nolikein\CurlClient\Services\MaskSecrets;

MaskSecrets::inUrl('https://user:password@example.com/path?api_key=abc');
// 'https://********@example.com/path?api_key=********'

MaskSecrets::inHeaders(['X-Api-Key' => ['abc'], 'Accept' => ['application/json']]);
// ['X-Api-Key' => ['********'], 'Accept' => ['application/json']]

Customization

Having custom requests

You can extends the Nolikein\CurlClient\CurlClient class to create your own client with your own requests.

use Nolikein\CurlClient\CurlClient;
use Nolikein\CurlClient\Response\Response;

class CustomClient extends CurlClient
{
    /**
     * Perform a ping request to my favorite api
     */
    public function ping(): Response
    {
        return $this->get('http://my-api.org/ping');
    }
}

// Instantiate the custom client
$client = new CustomClient();
$response = $client->ping();
// ...

The constructor might also be overridden to add custom properties. Just do not forget to use parent::__construct() and pass the $curl argument.

You can also recreate your whole implementation of the "Curl Client" by implementing the Nolikein\CurlClient\CurlClientInterface interface which is compatible with the "Curl Manager".

Having custom instance of Curl

You can extends the Nolikein\CurlClient\Curl class to create your own client with your own requests.

use Nolikein\CurlClient\Curl;
use Nolikein\CurlClient\CurlClient;
use Nolikein\CurlClient\Response\Response;

class CustomCurl extends Curl
{
    /**
     * Perform a ping request to my favorite api
     */
    public function setSomething(): self
    {
        curl_setopt($this->handle, CURLOPT_ACCEPT_ENCODING, 'my accepted encoding');

        return $this;
    }
}

// Instantiate the client with our custom curl instance
$client = new CurlClient(
    curl: new CustomCurl(),
);
$response = $client->get('http://test.org');
// ...

Testing

Install dependencies

docker compose run --rm php composer install

Run tests

docker compose run --rm php ./vendor/bin/pest

Run tests with coverage

The output is created as html in the local tests/Coverage directory

docker compose run --rm php ./vendor/bin/pest --coverage-html tests/Coverage
sudo chown $USER:$USER -R tests/Coverage

Creating your own tests

Everything is testable and for that, Mockable. You can be inspired from existing content found in the gitlab repository.

We will take an example. Look about the CustomClient we created before. We will test the ping() method, but this means we should trigger some methods of the Curl instance. This is why we Mock it.

The following example use the Pest testing Framework:

use Nolikein\CurlClient\Curl;
use Nolikein\CurlClient\Response\Response;

it('can perform a ping request', function (): void {
    // We create the response that the Curl instance should return when request() is called.
    $fakedResponse = Response::make(
        content: json_encode(['services_available' => true]),
        status: 200,
        reason: 'OK',
        headers: [
            'content-type' => 'application/json',
        ],
    );

    // Create a Mock of the Curl instance that will fake the request
    /** @var \Mockery\MockInterface&Curl $curl */
    $curl = \Mockery::mock(Curl::class);
    $curl->shouldReceive('withUrl', 'withHeaders', 'withConfiguration')
        ->once()
        ->andReturnSelf()
    ;
    $curl->shouldReceive('when')->times(3)->andReturnSelf();
    $curl->shouldReceive('request')->andReturn($fakedResponse);

    // Instantiate the custom client with our mocked instance
    $client = new CustomClient(
        curl: $curl,
    );
    $response = $client->ping();

    // We check the response correspondance
    expect($response->getContent())->toBe($fakedResponse->getContent());
    expect($response->getStatusCode())->toBe($fakedResponse->getStatusCode());
});

Licence

MIT

Contributing

You can open a ticket, make a pull request or contact me. To report a vulnerability, follow SECURITY.md.