friendsofouro/http-batch-contract
A PHP contract package for batching HTTP requests. It defines interfaces for grouping requests, handling batch responses, and keeping batch logic decoupled from implementation details, making it easier to build consistent, framework-agnostic HTTP batching across applications and services.
If you're adopting this batch HTTP client in a Laravel app (or upgrading a package that depends on it), the fastest path is to confirm you're on the right major version before touching any code. v3.0.0 renames two methods and changes the return type of a third, so a composer update can silently break batches that were working yesterday.
Start by checking what you actually have installed:
composer show | grep -i batch
# or, if you know the package namespace:
composer why php-http/httplug # example — depends on your actual package
Then look at three places, in this order:
getSuccessfulResults → getResponses, getFailedResults → getExceptions) are the first thing to grep for.ResponseBatchInterface source — it's the contract that changed, so it's the authoritative reference for what you're now allowed to call.->getSuccessfulResults() or ->getFailedResults() needs review.Your first useful use case is usually "fan out a handful of independent API calls and keep the successes separate from the failures with their underlying errors intact." In v3.x that's now:
use Psr\Http\Message\ResponseInterface;
$batch = $client->batch([
new Request('GET', 'https://api.example.com/users/1'),
new Request('GET', 'https://api.example.com/users/2'),
new Request('GET', 'https://api.example.com/users/999'), // 404
]);
$responses = $batch->getResponses(); // ResponseInterface[]
$exceptions = $batch->getExceptions(); // ClientExceptionInterface[]
foreach ($responses as $response) {
logger()->info('ok', ['status' => $response->getStatusCode()]);
}
foreach ($exceptions as $exception) {
report($exception); // PSR-18 exception, still Laravel-reportable
}
That's the smallest possible loop: dispatch, split, log/report. Everything else builds on it.
Wrap batches in a service, not a controller. Batch calls are I/O heavy and almost always want retry/timeout policy around them. A thin service keeps the PSR-7/PSR-18 types out of your controllers and lets you swap the client in tests.
final class UserFetcher
{
public function __construct(private HttpClient $client) {}
/** @return array<int, array{id:int, name:string}> */
public function fetchMany(array $ids): array
{
$batch = $this->client->batch(array_map(
fn (int $id) => new Request('GET', "https://api.example.com/users/{$id}"),
$ids
));
return collect($batch->getResponses())
->map(fn (ResponseInterface $r) => json_decode($r->getBody()->getContents(), true))
->values()
->all();
}
}
Use filter() for fluent narrowing instead of array_filter(). This is the most substantive ergonomic win in v3.x. Because filter() returns static, you can chain predicates and keep the batch API's own methods available:
$batch = $client->batch($requests);
$notFound = $batch
->filter(fn (ResponseInterface $r) => $r->getStatusCode() === 404)
->getResponses();
$serverErrors = $batch
->filter(fn (ResponseInterface $r) => $r->getStatusCode() >= 500)
->getExceptions();
Note the $batch variable is reused intentionally — because filter() returns a new batch-like instance rather than mutating, the original stays complete. Confirm this in your version if you rely on it.
Prefer getResponses() / getExceptions() over re-deriving. v3 gives you PSR-7 ResponseInterface bodies directly, which means $response->getBody()->getContents() and $response->getStatusCode() work out of the box. In v2 you had to dig through BatchItemInterface wrappers. If you were doing ->getResponse() on a batch item, collapse that step.
Pair with Laravel's concurrency limits. Batch clients usually cap parallel requests internally, but if you're building a job or command that fires hundreds of batches, use Bus::batch() (unrelated class, similar name — be careful when grepping) or chunk your input yourself:
collect($ids)->chunk(50)->each(function ($chunk) {
$batch = $this->client->batch($chunk->map(...)->all());
// handle
});
Testing: bind a fake HttpClient that returns a batch pre-seeded with specific responses and exceptions. Because getExceptions() now returns PSR-18 exceptions, you can assert on ClientExceptionInterface instances directly rather than inspecting wrapper objects — much less brittle in tests.
The method rename is the main migration risk. Grep for getSuccessfulResults and getFailedResults across app/, tests/, and any custom packages you've forked. phpstan/psalm with ResponseBatchInterface in scope will catch these immediately; a plain php -l won't.
filter() returning static changes call-site semantics. In v2, filter() returned array — if you were doing count($batch->filter(...)) or iterating it with foreach, that will now iterate the batch object rather than a plain array. If your code expected an array, call ->getResponses() or ->getExceptions() after filtering.
Don't assume getResponses() preserves request order. PSR-18/PSR-7 give you no ordering guarantee across a batch. If order matters, key the responses by URL or ID yourself; don't rely on index parity with your request array.
getExceptions() is empty on success, and getResponses() is empty on total failure. Neither throws — check both explicitly. The common bug is foreach ($batch->getResponses() as …) doing nothing because every request 4xx'd and you never looked at getExceptions().
Streams are consumable once. getContents() on a ResponseInterface body returns the remainder of the stream. If you read a body for logging, it's empty when your mapper reads it. Either call ->__toString() (which rewinds for in-memory streams) or cache the decoded payload on first read.
Config quirks live in the client, not the batch. Timeouts, concurrency, and defaults usually need to be set when constructing the HttpClient, not on batch(). If a batch is timing out, check the factory/binding in your service provider before the batch call.
Extension point worth knowing: if your app needs per-request retry or auth-refresh inside a batch, the cleanest place is a decorated HttpClient (or a PSR-18 client middleware) that handles a single sendRequest(). Batches fan out through the client, so decorating the client applies to every item without touching ResponseBatchInterface.
Pin the major version in composer.json ("^3.0") and run the migration in one PR. Because the contract changed, any package in your dependency tree that still calls getSuccessfulResults() will fatal on the upgrade — check composer why before bumping shared libraries.
How can I help you explore Laravel packages today?