anlutro/curl
anlutro/curl is a simple, chainable cURL wrapper for PHP and Laravel. It offers an intuitive interface for GET/POST requests, headers, cookies, redirects, timeouts, and error handling, making HTTP calls cleaner and easier to test than raw cURL.
Install with Composer:
composer require anlutro/curl
There is no config publishing, no service container requirement, and no bootstrap step. Instantiate the client directly and make a request:
$curl = new anlutro\cURL\cURL;
$response = $curl->get('https://api.example.com/users');
$response->statusCode; // int
$response->body; // string
That is essentially the whole "hello world" for this package — a cURL factory object, a Request object it creates, and a Response value object returned from send().
Where to look first. The library is three small classes; read them in this order:
src/cURL.php — the factory (get, post, put, patch, delete, head, newRequest) plus the default headers and default curl options that get applied to every request.src/Request.php — the fluent per-request builder: URL, method, payload, headers, cookies, and raw CURLOPT_* options, terminated by send().src/Response.php — body, statusCode, headers, cookies, info, and the curl error fields.First real use case. The package pays off when you're hitting one or two third-party JSON APIs from a small app or a CLI script and don't want to pull in Guzzle plus PSR-7/PSR-18. A typical first task is "POST some JSON to a webhook and check the status code":
$curl = new anlutro\cURL\cURL;
$curl->setDefaultHeader('Content-Type', 'application/json');
$request = $curl->newRequest('post', 'https://hooks.example.com/notify',
json_encode(['event' => 'deploy', 'sha' => $sha]));
$response = $request->send();
if ($response->statusCode !== 200) {
throw new RuntimeException("Webhook failed: {$response->statusCode} {$response->body}");
}
If you're on Laravel, the repo ships a service provider and a cURL facade under the anlutro\cURL\Laravel namespace; register the provider in config/app.php (or rely on package discovery if your version supports it) and inject/resolve the client rather than new-ing it inline.
Wrap the client once, inject it everywhere. The biggest day-to-day win is treating cURL as a configured singleton — base headers, auth, timeouts, user agent — and injecting it:
// App\Services\ApiClient
public function __construct(private \anlutro\cURL\cURL $curl) {}
public function fetchOrder(int $id): array
{
$res = $this->curl->get("https://api.example.com/orders/{$id}");
if ($res->statusCode !== 200 || $res->error) {
throw new ApiException($res->error ?: $res->body, $res->statusCode);
}
return json_decode($res->body, true, 512, JSON_THROW_ON_ERROR);
}
Bind it in the container ($this->app->singleton(cURL::class, fn () => (new cURL)->setDefaultHeader(...)->setDefaultOption(CURLOPT_TIMEOUT, 10));) so config lives in one place.
Use the shortcuts for simple calls, newRequest() for anything nontrivial. $curl->get($url, $query) and $curl->post($url, $data) cover the 80% case. The moment you need a custom header, a cookie, or a raw option, switch to the fluent builder — it's the same object underneath, just more explicit:
$response = $curl->newRequest('get', 'https://api.example.com/search', ['q' => 'laravel'])
->setHeader('Authorization', 'Bearer ' . $token)
->setHeader('Accept', 'application/json')
->setOption(CURLOPT_TIMEOUT, 5)
->setOption(CURLOPT_CONNECTTIMEOUT, 2)
->send();
Cookies for session-based scraping. When you need to persist a login across requests, use a cookie jar file rather than manual header juggling:
$file = storage_path('app/curl-cookies.txt');
$curl->newRequest('post', $loginUrl, ['user' => $u, 'pass' => $p])
->setCookieFile($file)
->send();
$dashboard = $curl->newRequest('get', $dashboardUrl)
->setCookieFile($file)
->send();
Centralize response handling. Since the package never throws on HTTP errors, put the "status check + decode + throw" logic in a helper or a small response decorator. Every call site doing its own if ($statusCode >= 400) is how inconsistent error handling creeps in.
Testing. There is no mock handler or transport abstraction. The pragmatic pattern is to define a thin interface (HttpClient with get/post) implemented by a class wrapping anlutro\cURL\cURL, then fake that interface in tests. Alternatively, point the base URL at a local test server — but don't try to subclass Request to intercept curl_exec, it's more pain than it's worth.
Laravel-specific. Resolve the client from the container (app('curl') or the facade) so tests can swap the binding. If the bundled provider is too thin for your needs, ignore it and register your own singleton — the provider only wires up the facade.
HTTP error codes do not throw. A 500 response is a perfectly successful send() call with $response->statusCode === 500. You must check status codes yourself. Conversely, a transport failure (DNS, timeout, refused connection) shows up as $response->error being non-empty and statusCode typically 0. Check both — and note that $response->error is falsy on success, so a bare if ($response->error) works.
$response->body can be the error page, not your JSON. CURLOPT_FAILONERROR is not enabled by default, so on a 404 you get the server's HTML body. Always branch on status before json_decode, otherwise you'll get cryptic null results.
POSTing an array is multipart/form-data. If you hand a PHP array to post() or setData(), curl sends multipart/form-data — not application/x-www-form-urlencoded, and definitely not JSON. If the remote API is strict (many are), explicitly encode:
$curl->newRequest('post', $url, http_build_query(['a' => 1]))
->setHeader('Content-Type', 'application/x-www-form-urlencoded');
// or JSON:
$curl->newRequest('post', $url, json_encode($payload))
->setHeader('Content-Type', 'application/json');
Nested query parameters on GET. The GET-data path builds the query string for you, but nested arrays hit PHP's http_build_query limits (a[b][c] style). If the API wants a[]=1&a[]=2 or comma-separated values, build the URL string yourself and pass no data.
No timeouts by default. Straight libcurl behaviour applies: without CURLOPT_TIMEOUT / CURLOPT_CONNECTTIMEOUT a hung endpoint blocks your worker indefinitely. Set both as client defaults; this is the single most common production incident with thin curl wrappers.
Default headers/options are copied at request creation, not at send time. Set your defaults on the client before calling get/newRequest, not between building the request and calling send(). Read cURL::newRequest() in the source once to confirm the snapshot semantics for your pinned version, since it's changed across releases.
Cookie file needs to be writable and persistent. A missing directory silently degrades to no cookie persistence. Verify the file exists after the first request and check your web user's permissions.
Debugging is raw curl. When something fails opaquely, ->setOption(CURLOPT_VERBOSE, true) (write to a log stream with CURLOPT_STDERR if you don't want it in output) plus dumping $response->info gives you timing, redirect chain, and effective URL. That's usually faster than instrumenting the wrapper.
Header casing in $response->headers is whatever the server sent (plus curl's parsing). Dump it once per new API before writing $response->headers['Content-Type'] — better yet, if the class exposes a getHeader() accessor, use it and avoid the guessing.
No PSR-18, no middleware, no retries, no async. It doesn't implement Psr\Http\Client\ClientInterface, so it won't drop into framework code typed against PSR-18, and there's no curl_multi concurrency. If you need retry/backoff, write a small loop around send() yourself — or accept that at that point Guzzle is the cheaper answer. This package's niche is "small, readable, zero-dependency curl," and it's good at exactly that.
Extending. You can subclass cURL to change how Request objects are created (handy for stamping a base URL or auth header on every request), but the more durable extension point is composition: your own thin client class owning an anlutro\cURL\cURL, so you can swap the underlying transport later without touching call sites.
How can I help you explore Laravel packages today?