- Introduction
- Making Requests
- Concurrent Requests
- Connections
- Restricting Destinations
- Macros
- Testing
- Events
Hypervel provides an expressive, minimal API around the Guzzle HTTP client, allowing you to quickly make outgoing HTTP requests to communicate with other web applications. Hypervel's wrapper around Guzzle is focused on its most common use cases and a wonderful developer experience.
Before sending a real request, Hypervel returns the current execution's idle database sessions to their pools. Transactions and explicitly pinned sessions remain held. Code that needs the same database session across an HTTP call may use DB::withPinnedSession(). See releasing and pinning database connections.
To make requests, you may use the head, get, post, put, patch, and delete methods provided by the Http facade. First, let's examine how to make a basic GET request to another URL:
use Hypervel\Support\Facades\Http;
$response = Http::get('http://example.com');The get method returns an instance of Hypervel\Http\Client\Response, which provides a variety of methods that may be used to inspect the response:
$response->body() : string;
$response->json($key = null, $default = null, $flags = null) : mixed;
$response->object($flags = null) : mixed;
$response->collect($key = null, $flags = null) : Hypervel\Support\Collection;
$response->fluent($key = null, $flags = null) : Hypervel\Support\Fluent;
$response->decodeUsing($callback) : Hypervel\Http\Client\Response;
$response->resource() : resource;
$response->status() : int;
$response->successful() : bool;
$response->redirect(): bool;
$response->failed() : bool;
$response->clientError() : bool;
$response->header($header) : string;
$response->headers() : array;
$response->reason() : string;
$response->effectiveUri() : ?Psr\Http\Message\UriInterface;
$response->handlerStats() : array;
$response->toPsrResponse() : Psr\Http\Message\ResponseInterface;You may also use the response's tap method to inspect it without interrupting a method chain.
The Hypervel\Http\Client\Response object also implements the PHP ArrayAccess interface, allowing you to access JSON response data directly on the response:
return Http::get('http://example.com/users/1')['name'];The json, object, collect, and fluent methods accept an optional flags argument, which is passed to json_decode:
$value = $response->json('value', flags: JSON_BIGINT_AS_STRING);When flags is omitted or null, these methods use Response::$defaultJsonDecodingFlags. You may configure this default in a service provider's boot method:
use Hypervel\Http\Client\Response;
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Response::$defaultJsonDecodingFlags = JSON_BIGINT_AS_STRING;
}The default is shared by all requests handled by the worker, so configure it only during startup. Pass flags: 0 to use no flags for an individual call.
If you set a custom decoder using decodeUsing, that callback replaces JSON decoding and the flags do not apply. The callback receives the response body and a boolean indicating whether object was called.
In addition to the response methods listed above, the following methods may be used to determine if the response has a specific status code:
$response->ok() : bool; // 200 OK
$response->created() : bool; // 201 Created
$response->accepted() : bool; // 202 Accepted
$response->noContent() : bool; // 204 No Content
$response->movedPermanently() : bool; // 301 Moved Permanently
$response->found() : bool; // 302 Found
$response->notModified() : bool; // 304 Not Modified
$response->badRequest() : bool; // 400 Bad Request
$response->unauthorized() : bool; // 401 Unauthorized
$response->paymentRequired() : bool; // 402 Payment Required
$response->forbidden() : bool; // 403 Forbidden
$response->notFound() : bool; // 404 Not Found
$response->requestTimeout() : bool; // 408 Request Timeout
$response->conflict() : bool; // 409 Conflict
$response->unprocessableContent() : bool; // 422 Unprocessable Content
$response->unprocessableEntity() : bool; // 422 Unprocessable Entity
$response->tooManyRequests() : bool; // 429 Too Many Requests
$response->serverError() : bool; // >= 500 Server ErrorThe HTTP client also allows you to construct request URLs using the URI template specification. To define the URL parameters that can be expanded by your URI template, you may use the withUrlParameters method:
Http::withUrlParameters([
'endpoint' => 'https://hypervel.org',
'page' => 'docs',
'version' => '13.x',
'topic' => 'validation',
])->get('{+endpoint}/{page}/{version}/{topic}');URI-template expansion is enabled only after calling withUrlParameters. Literal braces are otherwise treated as ordinary URL characters instead of template expressions.
If you would like to dump the outgoing request instance before it is sent and terminate the script's execution, you may add the dd method to the beginning of your request definition:
return Http::dd()->get('http://example.com');To process a response as it arrives, you may enable the stream option. The jsonLines method allows you to iterate over newline-delimited JSON without loading the entire response into memory:
$response = Http::withOptions(['stream' => true, 'read_timeout' => 30])
->get('https://example.com/events');
try {
foreach ($response->jsonLines() as $event) {
// Process the event...
}
} finally {
$response->close();
}The jsonLines method skips blank lines and throws a JsonException if a record contains invalid JSON. Like json, it uses Response::$defaultJsonDecodingFlags unless you pass flags. For example, $response->jsonLines(flags: JSON_BIGINT_AS_STRING) preserves large integers as strings. The decodeUsing callback applies to the whole body, not individual lines.
For plain text or a custom JSON decoder, you may use the lines method instead. It removes LF and CRLF line endings, preserves empty lines, and includes the final line even when it has no newline. For binary data and other formats, you may read the underlying PSR-7 response body directly.
Both methods continue reading from the body's current position. They do not rewind it. Streaming bodies cannot be rewound, so choose between processing their lines and reading the entire body with body or json. Memory usage grows with the longest line, not the total response size.
Inside a Swoole coroutine with native cURL hooks enabled, the HTTP client receives chunks as they arrive, buffers a limited amount, and pauses network reads when your application falls behind. Named connections reuse idle connections between requests while keeping request headers, credentials, and cookies separate. Each active stream owns its transport; concurrent streams do not multiplex onto the same connection.
For streaming requests, timeout bounds the wait for response headers. After headers arrive, a long-running stream may continue beyond that timeout. The read_timeout option limits idle gaps during headers and body reads, defaults to 60 seconds, and accepts 0 to wait without an idle limit. connect_timeout continues to limit connection establishment. Closing a response or canceling its consuming coroutine releases its active transfer.
When reading a streamed body has to wait for more data, Hypervel first returns idle database sessions to their pools, so database work done between reads does not hold a connection while the stream is idle. As with sending, transactions and pinned sessions remain held.
If cURL negotiates authentication for a streamed request through the CURLOPT_HTTPAUTH or CURLOPT_PROXYAUTH cURL options, the response is returned when its first body bytes arrive or the transfer completes. The timeout option also covers that wait.
For streamed responses, on_stats runs when the response is returned. The on_trailers callback runs once the transfer has completed, after on_headers. Small responses may already be complete when returned; for longer responses, trailers arrive as the body is consumed.
Outside a hooked coroutine, or when you provide stream_context or a custom stream_factory, the client uses Guzzle's PHP-stream handler. This fallback requires allow_url_fopen, cannot use cURL destination pins or required transport sharing, and retains Guzzle's timeout behavior. Custom handlers and clients remain responsible for their own streaming support; faked responses do not need a transport.
Of course, it is common when making POST, PUT, and PATCH requests to send additional data with your request, so these methods accept an array of data as their second argument. By default, data will be sent using the application/json content type:
use Hypervel\Support\Facades\Http;
$response = Http::post('http://example.com/users', [
'name' => 'Steve',
'role' => 'Network Administrator',
]);You may use the query method to send a QUERY request with structured request data. By default, the data will be sent as JSON:
$response = Http::query('http://example.com/search', [
'filters' => ['status' => 'active'],
]);When making GET requests, you may either append a query string to the URL directly or pass an array of key / value pairs as the second argument to the get method:
$response = Http::get('http://example.com/users', [
'name' => 'Taylor',
'page' => 1,
]);Alternatively, the withQueryParameters method may be used:
Http::retry(3, 100)->withQueryParameters([
'name' => 'Taylor',
'page' => 1,
])->get('http://example.com/users');If you would like to send data using the application/x-www-form-urlencoded content type, you should call the asForm method before making your request:
$response = Http::asForm()->post('http://example.com/users', [
'name' => 'Sara',
'role' => 'Privacy Consultant',
]);You may use the withBody method if you would like to provide a raw request body when making a request. The content type may be provided via the method's second argument:
$response = Http::withBody(
base64_encode($photo), 'image/jpeg'
)->post('http://example.com/photo');If the request already provides the correct headers or should not include a content type, you may pass null as the second argument:
$response = Http::withBody($stream, null)
->post('http://example.com/import');If you would like to send files as multi-part requests, you should call the attach method before making your request. This method accepts the name of the file and its contents. If needed, you may provide a third argument which will be considered the file's filename, while a fourth argument may be used to provide headers associated with the file:
$response = Http::attach(
'attachment', file_get_contents('photo.jpg'), 'photo.jpg', ['Content-Type' => 'image/jpeg']
)->post('http://example.com/attachments');Instead of passing the raw contents of a file, you may pass a stream resource:
$photo = fopen('photo.jpg', 'r');
$response = Http::attach(
'attachment', $photo, 'photo.jpg'
)->post('http://example.com/attachments');Headers may be added to requests using the withHeaders method. This withHeaders method accepts an array of key / value pairs:
$response = Http::withHeaders([
'X-First' => 'foo',
'X-Second' => 'bar'
])->post('http://example.com/users', [
'name' => 'Taylor',
]);You may use the accept method to specify the content type that your application is expecting in response to your request:
$response = Http::accept('application/json')->get('http://example.com/users');For convenience, you may use the acceptJson method to quickly specify that your application expects the application/json content type in response to your request:
$response = Http::acceptJson()->get('http://example.com/users');The withHeaders method merges new headers into the request's existing headers. If needed, you may replace values for matching header keys while retaining unrelated headers using the replaceHeaders method:
$response = Http::withHeaders([
'X-Original' => 'foo',
'X-Keep' => 'bar',
])->replaceHeaders([
'X-Original' => 'replacement',
])->post('http://example.com/users', [
'name' => 'Taylor',
]);You may specify basic and digest authentication credentials using the withBasicAuth and withDigestAuth methods, respectively:
// Basic authentication...
$response = Http::withBasicAuth('johndoe@example.com', 'secret')->post(/* ... */);
// Digest authentication...
$response = Http::withDigestAuth('johndoe@example.com', 'secret')->post(/* ... */);Built-in NTLM authentication is not provided. Integrations requiring it must supply their own authentication middleware.
If you would like to quickly add a bearer token to the request's Authorization header, you may use the withToken method:
$response = Http::withToken('token')->post(/* ... */);The withUserAgent method may be used to specify the request's user agent:
$response = Http::withUserAgent('Hypervel/1.0')->get(/* ... */);You may also attach cookies to a request using the withCookies method:
$response = Http::withCookies([
'session' => 'abc123',
], 'example.com')->get(/* ... */);To specify a cookie's path or other attributes, pass a Guzzle SetCookie instance to withCookie. The cookie must include a domain:
use GuzzleHttp\Cookie\SetCookie;
$response = Http::withCookie(new SetCookie([
'Name' => 'session',
'Value' => 'abc123',
'Domain' => 'api.example.com',
'Path' => '/api',
'Secure' => true,
'HostOnly' => true,
]))->get('https://api.example.com/api/users');By default, redirects will be followed. You may configure the maximum number of redirects using the maxRedirects method, or disable redirects entirely using the withoutRedirecting method:
$response = Http::maxRedirects(3)->get(/* ... */);
$response = Http::withoutRedirecting()->get(/* ... */);If you would like to disable TLS certificate verification, you may use the withoutVerifying method:
$response = Http::withoutVerifying()->get(/* ... */);The sink method may be used to write the response body directly to a file path or stream resource:
$response = Http::sink(storage_path('app/example.txt'))->get(/* ... */);The timeout method may be used to specify the maximum number of seconds to wait for a response. By default, the HTTP client will timeout after 30 seconds:
$response = Http::timeout(3)->get(/* ... */);If the given timeout is exceeded, an instance of Hypervel\Http\Client\ConnectionException will be thrown.
You may specify the maximum number of seconds to wait while trying to connect to a server using the connectTimeout method. The default is 10 seconds:
$response = Http::connectTimeout(3)->get(/* ... */);If you would like the HTTP client to automatically retry the request if a client or server error occurs, you may use the retry method. The retry method accepts the maximum number of times the request should be attempted and the number of milliseconds that Hypervel should wait in between attempts:
$response = Http::retry(3, 100)->post(/* ... */);If you would like to manually calculate the number of milliseconds to sleep between attempts, you may pass a closure as the second argument to the retry method:
use Throwable;
$response = Http::retry(3, function (int $attempt, Throwable $exception) {
return $attempt * 100;
})->post(/* ... */);For convenience, you may also provide an array as the first argument to the retry method. This array will be used to determine how many milliseconds to sleep between subsequent attempts:
$response = Http::retry([100, 200])->post(/* ... */);If needed, you may pass a third argument to the retry method. The third argument should be a callable that determines if the retries should actually be attempted. For example, you may wish to only retry the request if the initial request encounters a ConnectionException:
use Hypervel\Http\Client\ConnectionException;
use Hypervel\Http\Client\PendingRequest;
use Throwable;
$response = Http::retry(3, 100, function (?Throwable $exception, PendingRequest $request) {
return $exception instanceof ConnectionException;
})->post(/* ... */);The callback also receives the HTTP method as a third argument, or null when the method is unavailable.
If a request attempt fails, you may wish to make a change to the request before a new attempt is made. You can achieve this by modifying the request argument provided to the callable you provided to the retry method. For example, you might want to retry the request with a new authorization token if the first attempt returned an authentication error:
use Hypervel\Http\Client\PendingRequest;
use Hypervel\Http\Client\RequestException;
use Throwable;
$response = Http::withToken($this->getToken())->retry(2, 0, function (?Throwable $exception, PendingRequest $request) {
if (! $exception instanceof RequestException || $exception->response->status() !== 401) {
return false;
}
$request->withToken($this->getNewToken());
return true;
})->post(/* ... */);If all of the requests fail, an instance of Hypervel\Http\Client\RequestException will be thrown. If you would like to disable this behavior, you may provide a throw argument with a value of false. When disabled, the last response received by the client will be returned after all retries have been attempted:
$response = Http::retry(3, 100, throw: false)->post(/* ... */);Warning
If all of the requests fail because of a connection issue, a Hypervel\Http\Client\ConnectionException will still be thrown even when the throw argument is set to false.
Unlike Guzzle's default behavior, Hypervel's HTTP client wrapper does not throw exceptions on client or server errors (400 and 500 level responses from servers). You may determine if one of these errors was returned using the successful, clientError, or serverError methods:
// Determine if the status code is >= 200 and < 300...
$response->successful();
// Determine if the status code is >= 400...
$response->failed();
// Determine if the response has a 400 level status code...
$response->clientError();
// Determine if the response has a 500 level status code...
$response->serverError();
// Immediately execute the given callback if there was a client or server error...
$response->onError(callable $callback);If you have a response instance and would like to throw an instance of Hypervel\Http\Client\RequestException if the response status code indicates a client or server error, you may use the throw or throwIf methods:
use Hypervel\Http\Client\Response;
$response = Http::post(/* ... */);
// Throw an exception if a client or server error occurred...
$response->throw();
// Throw an exception if an error occurred and the given condition is true...
$response->throwIf($condition);
// Throw an exception if an error occurred and the given closure resolves to true...
$response->throwIf(fn (Response $response) => true);
// Throw an exception if an error occurred and the given condition is false...
$response->throwUnless($condition);
// Throw an exception if an error occurred and the given closure resolves to false...
$response->throwUnless(fn (Response $response) => false);
// Throw an exception if the response has a specific status code...
$response->throwIfStatus(403);
// Throw an exception unless the response has a specific status code...
$response->throwUnlessStatus(200);
// Throw an exception if a server error occurred (status >= 500)...
$response->throwIfServerError();
// Throw an exception if a client error occurred (status >= 400 and < 500)...
$response->throwIfClientError();
return $response['user']['id'];The Hypervel\Http\Client\RequestException instance has a public $response property which will allow you to inspect the returned response.
The throw method returns the response instance if no error occurred, allowing you to chain other operations onto the throw method:
return Http::post(/* ... */)->throw()->json();If you would like to perform some additional logic before the exception is thrown, you may pass a closure to the throw method. The exception will be thrown automatically after the closure is invoked, so you do not need to re-throw the exception from within the closure:
use Hypervel\Http\Client\Response;
use Hypervel\Http\Client\RequestException;
return Http::post(/* ... */)->throw(function (Response $response, RequestException $e) {
// ...
})->json();By default, RequestException messages are truncated to 120 characters when logged or reported. To customize or disable this behavior, you may utilize the truncateAt and dontTruncate methods when configuring your application's registered behavior in your bootstrap/app.php file:
use Hypervel\Http\Client\RequestException;
->registered(function (): void {
// Truncate request exception messages to 240 characters...
RequestException::truncateAt(240);
// Disable request exception message truncation...
RequestException::dontTruncate();
})Alternatively, you may customize the exception truncation behavior per request using the truncateExceptionsAt method:
return Http::truncateExceptionsAt(240)->post(/* ... */);Since Hypervel's HTTP client is powered by Guzzle, you may take advantage of Guzzle Middleware to manipulate the outgoing request or inspect the incoming response. To manipulate the outgoing request, register a Guzzle middleware via the withRequestMiddleware method:
use Hypervel\Support\Facades\Http;
use Psr\Http\Message\RequestInterface;
$response = Http::withRequestMiddleware(
function (RequestInterface $request) {
return $request->withHeader('X-Example', 'Value');
}
)->get('http://example.com');Likewise, you can inspect the incoming HTTP response by registering a middleware via the withResponseMiddleware method:
use Hypervel\Support\Facades\Http;
use Psr\Http\Message\ResponseInterface;
$response = Http::withResponseMiddleware(
function (ResponseInterface $response) {
$header = $response->getHeader('X-Example');
// ...
return $response;
}
)->get('http://example.com');The withMiddleware method appends a complete Guzzle middleware callable to the pending request. If an integration must run before all existing middleware, including global middleware, use prependMiddleware:
use Hypervel\Support\Facades\Http;
$traceId = 'request-id';
$response = Http::prependMiddleware(
fn (callable $handler) => fn ($request, array $options) => $handler(
$request->withHeader('X-Trace-ID', $traceId),
$options,
)
)->get('http://example.com');Sometimes, you may want to register a middleware that applies to every outgoing request and incoming response. To accomplish this, you may use the globalRequestMiddleware and globalResponseMiddleware methods. Typically, these methods should be invoked in the boot method of your application's AppServiceProvider:
use Hypervel\Support\Facades\Http;
Http::globalRequestMiddleware(fn ($request) => $request->withHeader(
'User-Agent', 'Example Application/1.0'
));
Http::globalResponseMiddleware(fn ($response) => $response->withHeader(
'X-Finished-At', now()->toDateTimeString()
));Global middleware remains active for the lifetime of the worker, so it should be registered during application boot rather than request handling.
You may register a callback that is invoked before a request is sent using the beforeSending method. The callback receives the outgoing request, the request options, and the pending request instance:
use Hypervel\Http\Client\PendingRequest;
use Hypervel\Http\Client\Request;
$response = Http::beforeSending(function (Request $request, array $options, PendingRequest $pendingRequest) {
// ...
})->get('http://example.com');Likewise, you may register a callback that is invoked after a response is received using the afterResponse method:
use Hypervel\Http\Client\Response;
$response = Http::afterResponse(function (Response $response) {
// ...
return $response;
})->get('http://example.com');You may specify additional Guzzle request options for an outgoing request using the withOptions method. The withOptions method accepts an array of key / value pairs:
$response = Http::withOptions([
'debug' => true,
])->get('http://example.com/users');To configure default options for every outgoing request, you may utilize the globalOptions method. Typically, this method should be invoked from the boot method of your application's AppServiceProvider:
use Hypervel\Support\Facades\Http;
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Http::globalOptions([
'allow_redirects' => false,
]);
}Global options remain active for the lifetime of the worker, so they should not be changed during request handling.
To create requests without global options or middleware, use the withoutGlobalConfiguration method. HTTP fakes and stray-request protection still apply:
$response = Http::withoutGlobalConfiguration(
fn () => Http::get('https://example.com/users')
);The callback affects only the current coroutine. For parallel requests, call withoutGlobalConfiguration inside each task.
To send outgoing requests through a proxy, configure the proxy option using Http::globalOptions during application boot. Requests made through the HTTP client will inherit this configuration:
Http::globalOptions([
'proxy' => 'http://egress-proxy.internal:8080',
]);Alternatively, Guzzle reads the HTTP_PROXY, HTTPS_PROXY, and NO_PROXY environment variables. NO_PROXY lists destinations that should be reached directly. These are outgoing proxies, separate from the reverse proxies that forward incoming requests to your application.
If your application downloads URLs supplied by users, the proxy must reject loopback, private, link-local, and reserved IPv4 and IPv6 destinations after resolving their hostnames and connect to the address it checked. Blocking hostnames is not enough, because a public hostname can resolve to a private address. A filtering proxy such as Smokescreen provides destination filtering; an unrestricted forwarding proxy does not.
Requests using withDestinationPolicy reject a proxy configured for their scheme. Select their proxy through the policy instead, as described in Egress Proxies.
If your application uses Telescope, HTTP client requests are automatically recorded by the HTTP client watcher. The watcher also records third-party SDKs that use Guzzle; see HTTP client watcher for credential masking and direct Guzzle options. You may exclude an individual request from being recorded by chaining the withoutTelescope method:
$response = Http::withoutTelescope()->get('http://example.com');To attach tags to a recorded request — for example, to identify which downstream service or feature initiated the call — use the withTelescopeTags method. You can pass raw strings, or enum cases:
$response = Http::withTelescopeTags(['billing', 'stripe'])->get('http://example.com');enum BillingTag: string
{
case Billing = 'billing';
case Stripe = 'stripe';
}
$response = Http::withTelescopeTags([BillingTag::Billing, BillingTag::Stripe])
->get('http://example.com');Enum cases are normalized to strings before storage — backed enums use their ->value, unit enums use their ->name.
These methods are safe to call regardless of Telescope's state. They simply set Guzzle option keys that the Telescope watcher reads when present; if Telescope is disabled or not installed, the keys are ignored and the request runs as normal with no overhead.
When OpenTelemetry tracing is enabled, outgoing requests are traced automatically and send the current trace context to the server they call. You may skip tracing a request using the withoutTrace method, or keep the trace without automatically adding trace headers using the withoutTracePropagation method. This method leaves any headers you supplied yourself unchanged. Use withoutTracePropagation for third-party and customer-supplied URLs, which should not receive your internal trace identifiers:
$response = Http::withoutTracePropagation()->post($webhookUrl, $payload);Like the Telescope methods, these methods are safe to call whether or not OpenTelemetry is installed or enabled.
Sometimes, you may wish to make multiple HTTP requests concurrently. In other words, you want several requests to be dispatched at the same time instead of issuing the requests sequentially. This can lead to substantial performance improvements when interacting with slow HTTP APIs.
In Hypervel, the simplest way to dispatch multiple HTTP requests concurrently is the parallel helper from Hypervel\Coroutine. It accepts an array of closures, runs each in its own coroutine, and returns the results once all of them have completed:
use Hypervel\Support\Facades\Http;
use function Hypervel\Coroutine\parallel;
$responses = parallel([
fn () => Http::get('http://localhost/first'),
fn () => Http::get('http://localhost/second'),
fn () => Http::get('http://localhost/third'),
]);
return $responses[0]->ok() &&
$responses[1]->ok() &&
$responses[2]->ok();If the parent coroutine has already used the database, call DB::releaseIdleConnections() before parallel() to make its idle sessions available while it waits. The HTTP requests run in child coroutines and cannot release the parent's connections. Transactions and pinned sessions still remain held; see database connection pooling.
Each response can be accessed based on the order it was added to the array. If you wish, you can name the requests using array keys, which allows you to access the corresponding responses by name:
use Hypervel\Support\Facades\Http;
use function Hypervel\Coroutine\parallel;
$responses = parallel([
'first' => fn () => Http::get('http://localhost/first'),
'second' => fn () => Http::get('http://localhost/second'),
'third' => fn () => Http::get('http://localhost/third'),
]);
return $responses['first']->ok();Each closure may use the full Http API, including chained methods like withHeaders, withToken, and timeout:
use Hypervel\Support\Facades\Http;
use function Hypervel\Coroutine\parallel;
$responses = parallel([
fn () => Http::withToken($token)->get('http://localhost/first'),
fn () => Http::withHeaders(['X-Trace' => $id])->get('http://localhost/second'),
]);By default, parallel dispatches every closure simultaneously. If you need to cap how many requests are in flight at once — for example, to avoid overwhelming a downstream service — pass a maximum to the second argument:
use Hypervel\Support\Facades\Http;
use function Hypervel\Coroutine\parallel;
$responses = parallel([
fn () => Http::get('http://localhost/first'),
fn () => Http::get('http://localhost/second'),
// ...
], concurrent: 5);For finer control — such as collecting partial results when some requests fail without throwing — instantiate Parallel directly and call wait(throw: false). After the call, the returned array holds the successful results, and the failures may be inspected via getThrowables(), hasFailures(), and failedCount() on the same instance:
use Hypervel\Coroutine\Parallel;
use Hypervel\Support\Facades\Http;
$parallel = new Parallel(concurrent: 5);
foreach ($urls as $key => $url) {
$parallel->add(fn () => Http::get($url), $key);
}
$results = $parallel->wait(throw: false);
if ($parallel->hasFailures()) {
foreach ($parallel->getThrowables() as $key => $error) {
$logger->error('Request failed', ['key' => $key, 'error' => $error->getMessage()]);
}
}When you dispatch concurrent requests with parallel, any closure may throw — typically a ConnectionException for a network failure, or a RequestException raised by calling throw() on the response. If at least one closure throws, parallel collects every failure and throws a single Hypervel\Coroutine\Exceptions\ParallelExecutionException. The exception exposes the successful results via getResults() and the failures via getThrowables(), both keyed by your closure keys:
use Hypervel\Coroutine\Exceptions\ParallelExecutionException;
use Hypervel\Support\Facades\Http;
use function Hypervel\Coroutine\parallel;
try {
$responses = parallel([
'github' => fn () => Http::get('https://api.github.com/user'),
'gitlab' => fn () => Http::get('https://gitlab.com/api/v4/user'),
]);
} catch (ParallelExecutionException $e) {
foreach ($e->getThrowables() as $key => $error) {
$logger->error('Request failed', ['key' => $key, 'error' => $error->getMessage()]);
}
// Successful responses from the same call are still available...
$partial = $e->getResults();
}If you would rather always receive partial results without an exception being thrown, instantiate Parallel directly and call wait(throw: false). The non-throwing path exposes failures via getThrowables() / hasFailures() / failedCount() on the Parallel instance, as shown in Limiting Concurrency.
To run logic as each individual request completes — for example, to log progress or update a counter — place that logic at the end of the closure for that request:
use Hypervel\Support\Facades\Http;
use function Hypervel\Coroutine\parallel;
$responses = parallel([
'github' => function () use ($logger) {
$response = Http::get('https://api.github.com/user');
$logger->info('Completed', ['key' => 'github', 'status' => $response->status()]);
return $response;
},
'gitlab' => function () use ($logger) {
$response = Http::get('https://gitlab.com/api/v4/user');
$logger->info('Completed', ['key' => 'gitlab', 'status' => $response->status()]);
return $response;
},
]);If the same logic should run after every request completes, factor it out into a wrapper closure that takes the request key and the underlying request callable:
use Closure;
use Hypervel\Support\Facades\Http;
use function Hypervel\Coroutine\parallel;
$track = fn (string $key, Closure $request) => function () use ($key, $request, $logger) {
$response = $request();
$logger->info('Completed', ['key' => $key, 'status' => $response->status()]);
return $response;
};
$responses = parallel([
'github' => $track('github', fn () => Http::get('https://api.github.com/user')),
'gitlab' => $track('gitlab', fn () => Http::get('https://gitlab.com/api/v4/user')),
]);To dispatch a batch of HTTP requests after the current HTTP response has been sent to the user, wrap the call in Hypervel's defer helper. This keeps the response feeling fast while the requests run in the background:
use Hypervel\Support\Facades\Http;
use function Hypervel\Coroutine\parallel;
defer(function () {
parallel([
fn () => Http::get('http://localhost/first'),
fn () => Http::get('http://localhost/second'),
fn () => Http::get('http://localhost/third'),
]);
});By default, deferred callbacks only run when the response is successful (a status code below 400). If you want the requests to run even when the response indicates an error, chain the always method:
defer(function () {
parallel([
fn () => Http::get('http://localhost/first'),
fn () => Http::get('http://localhost/second'),
]);
})->always();When using Guzzle's asynchronous APIs directly, create and finish each operation in the same coroutine. This keeps its callbacks, request context, and database transactions together. The default client may be shared across coroutines:
use GuzzleHttp\Client;
use function Hypervel\Coroutine\parallel;
$client = new Client;
$responses = parallel([
fn () => $client->getAsync('https://example.com/users')->wait(),
fn () => $client->getAsync('https://example.com/orders')->wait(),
]);Guzzle throws a LogicException if another coroutine tries to use a pending promise or drive a cURL multi-handler with active work. Completed results may be shared. When a coroutine exits, Hypervel cancels its unfinished native transfers and runs its queued callbacks in that same coroutine, allowing cancellation to reach dependent promises. Finish any work you need before returning from the coroutine.
Hypervel installs hypervel/guzzle in place of guzzlehttp/guzzle and hypervel/guzzle-promises in place of guzzlehttp/promises, so these checks also cover third-party SDKs using Guzzle. Custom transports and other promise implementations remain responsible for their own coroutine safety. When supplying a custom handler, keep each active multi-handler within one coroutine. Once all its transfers finish, it may be reused by another coroutine.
Hypervel's HTTP client reuses connections for both synchronous and asynchronous requests. Requests without a connection share one transport, and each named connection has its own. Every pending request still receives a fresh Guzzle client, middleware stack, and cookie jar, so request-specific middleware, callbacks, and options stay with that request.
Asynchronous requests borrow a cURL multi-handler for their coroutine while it has work. Once idle, the handler returns to a pool so another coroutine can reuse its connections. Each transport retains up to 64 idle multi-handlers by default. For streamed responses, each transport keeps up to 32 idle connections per worker. These limits apply to idle resources, not concurrent requests; connections are opened as needed.
To register a connection, typically in the boot method of your application's AppServiceProvider, call the registerConnection method:
use Hypervel\Support\Facades\Http;
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Http::registerConnection('github', [
'base_uri' => 'https://api.github.com',
'timeout' => 10,
'headers' => [
'Accept' => 'application/vnd.github+json',
],
]);
}The second argument is a request-option preset. It accepts normal Guzzle request options except for options whose ownership belongs to a dedicated Hypervel API:
cookiesis rejected. Every pending request owns an isolated cookie jar; add cookies usingwithCookie()orwithCookies().handleris rejected. UsesetHandler()for a request-specific handler.poolis rejected. HTTP clients are not object-pooled.max_host_connectionsandmax_total_connectionsare rejected. Use bounded coroutine fan-out or the rate limiter instead.transport_sharingconfigures the connection's shared and isolated handlers. It accepts Guzzle'sTransportSharingmodes and is never passed into request options.max_idle_handlessets how many idle cURL easy handles each buffered handler keeps (256 for the synchronous handler and 50 per asynchronous multi-handler by default; 0 keeps none). Synchronous easy handles keep their own connections; asynchronous ones use their multi-handler's connection cache. This is not a limit on concurrent requests or open sockets. Streamed responses use the separate retention limit described above.max_idle_multi_handlerssets how many idle asynchronous multi-handlers the connection retains (64 by default; 0 keeps none). Active handlers are additional. Increase this when recurring bursts benefit from more warm connections, or decrease it to retain fewer idle resources.multiplexremains a request option. TheMultiplexing::NONEmode also configures the connection handler so its guarantee applies to every request using that handler.
The dedicated connection-cap options are rejected at every option layer. pool, handler, cookies, transport_sharing, max_idle_handles, and max_idle_multi_handlers are also rejected from global options, per-call connection overrides, fluent withOptions() calls, and raw send() options. This keeps cookie and handler ownership consistent regardless of which option layer supplied a value.
Requests with raw CURLOPT_RESOLVE DNS pins use an isolated transport so their pins cannot affect later requests. Connections that require persistent transport sharing can't send these requests, and PERSISTENT_PREFER falls back to handler-level sharing.
Once registered, select a connection for a request by chaining the connection method:
$response = Http::connection('github')
->withToken($token)
->get('/user');Option precedence is deterministic and does not depend on chaining order: factory global options are the lowest layer, followed by the registered connection preset, an optional per-call connection override, and finally fluent request options. Options with named keys, such as headers, are merged key by key. Nested lists, such as a header's values, and other list options, such as auth credentials, replace the lower layer's list as a whole. Top-level payload lists, such as multipart parts or a JSON list, combine the entries from every layer. An empty array leaves an option with named keys, such as curl, unchanged, but replaces a list, so 'auth' => [] removes earlier credentials. Passing an empty per-call array intentionally clears the registered request preset. The connection's handler settings remain unchanged:
// Uses the registered GitHub preset...
Http::connection('github')->get('/user');
// Replaces the preset with an empty option layer for this request...
Http::connection('github', [])->get('https://example.com');Requests sent without data, including get($url), head($url) and delete($url), still inherit a configured body. Set a payload option to null to remove it; for example, withOptions(['json' => null]) removes the configured JSON payload.
setHandler() replaces only the low-level handler beneath that request's fresh middleware stack; its global, connection, and fluent options still apply. setClient() gives the caller complete ownership of the client and bypasses the connection preset. Global and fluent request options still apply to the request sent through that client.
Each pending request constructs one cookie jar and reuses it only across that request's redirects and retry attempts. Concurrent requests never share cookies, including requests made without connection().
Calling registerConnection() or setConnectionConfig() again invalidates the connection's cached low-level handler. Subsequent requests lazily create a handler from the new configuration, while in-flight requests safely finish on their existing handler reference.
When your application sends requests to URLs supplied by its users, such as webhook endpoints, link previews, or file imports, a malicious URL could point the request at your internal network, a cloud metadata service, or another private address. To prevent this, pass a destination policy to the withDestinationPolicy method. The PublicDestinationPolicy only allows public addresses:
use Hypervel\Http\Client\Destinations\PublicDestinationPolicy;
use Hypervel\Support\Facades\Http;
$response = Http::withDestinationPolicy(new PublicDestinationPolicy)
->post($webhookUrl, $payload);The policy resolves the destination's hostname, rejects the request if any of its addresses are private or reserved, and then connects only to the addresses it checked. Since the connection is pinned to those addresses, a hostname whose DNS answer changes after the check cannot send the request somewhere else. Each redirect is checked the same way before it is followed.
If a destination is not allowed, a Hypervel\Http\Client\Destinations\DisallowedDestinationException is thrown, and the request is never retried. If the destination's hostname cannot be resolved, a Hypervel\Http\Client\Destinations\DestinationResolutionException is thrown instead. It extends ConnectionException, so it is reported like any other connection failure and retried when you configure retries using retry(). The time spent resolving the hostname counts against the request's timeout and connect timeout.
You may check a URL before storing it, such as when a user saves a webhook endpoint, using the policy's validate method. It throws a DisallowedDestinationException for a URL the policy would never send to, including a private address written as an IP address. Hostnames are not resolved, since their addresses may change before a request is sent; each request checks them when it resolves them:
(new PublicDestinationPolicy)->validate($request->input('url'));During local development, or when some known internal services should be reachable, you may allow specific addresses or CIDR ranges:
$policy = new PublicDestinationPolicy(allowedNetworks: ['10.20.0.0/16']);For more specific rules, you may extend PublicDestinationPolicy and override its allowsAddress method, which receives the destination URI and each address it resolved to. You may also write a policy of your own by implementing the Hypervel\Http\Client\Destinations\DestinationPolicy interface.
To send restricted requests through an egress proxy, extend the policy and return the proxy's URL from its proxyFor method:
use Hypervel\Http\Client\Destinations\PublicDestinationPolicy;
use Psr\Http\Message\UriInterface;
class EgressProxyPolicy extends PublicDestinationPolicy
{
protected function proxyFor(UriInterface $uri): ?string
{
return 'http://egress-proxy.internal:4750';
}
}A proxy URL without a port uses its scheme's default port: 80 for http and 443 for https. The policy then checks and pins the proxy's address instead of the destination's. Since proxies usually run on private addresses, allow the proxy's address using allowedNetworks or allowsAddress. Destinations written as IP addresses are still checked, but hostnames are resolved by the proxy, which must enforce the destination filtering requirements.
Each request through a proxy given by hostname, including each redirect, uses an isolated transport so an older connection cannot bypass the newly approved proxy address. Connections that require persistent transport sharing can't send these requests, and PERSISTENT_PREFER falls back to handler-level sharing. A proxy given by IP address has only that address, so requests through it reuse connections. Direct policy requests reuse connections when the approved addresses match.
If the proxy cannot be resolved or refuses the connection, a Hypervel\Http\Client\Destinations\ProxyConnectionException is thrown. It extends ConnectionException, and lets you tell a failure of your own egress path apart from a failure of the destination.
Pinning relies on Guzzle's cURL transport and libcurl 7.75 or newer. Streaming is supported inside a Swoole coroutine with native cURL hooks enabled. Restricted requests may not use the PHP-stream fallback, set raw curl options, or use a custom handler from the setHandler method. A proxy configured for the request's scheme is also rejected, including through global options or HTTP_PROXY / HTTPS_PROXY; select it through the policy instead. These requests throw a DisallowedDestinationException. NO_PROXY on its own does not prevent restricted requests. Other environment proxy fallbacks, such as all_proxy, do not override the policy's route. To write a large response directly to a file, use the sink method.
If the installed cURL transport lacks a required capability, a Hypervel\Http\Client\Destinations\DestinationPolicyException is thrown. These failures are never retried, and your retry callback is not called, because another attempt cannot change the installed transport.
A client supplied using the setClient method owns its whole handler stack, so the policy does not apply to its requests. Faked requests never reach the policy either, so Http::fake() works as usual in your tests.
If you extend the HTTP client factory to replace its connection handler, your transport must honor the policy's cURL pinning options. A transport that ignores those options bypasses destination enforcement.
The Hypervel HTTP client allows you to define "macros", which can serve as a fluent, expressive mechanism to configure common request paths and headers when interacting with services throughout your application. To get started, you may define the macro within the boot method of your application's App\Providers\AppServiceProvider class:
use Hypervel\Support\Facades\Http;
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Http::macro('github', function () {
return Http::withHeaders([
'X-Example' => 'example',
])->baseUrl('https://github.com');
});
}Once your macro has been configured, you may invoke it from anywhere in your application to create a pending request with the specified configuration:
$response = Http::github()->get('/');Macros are stored globally for the life of the PHP process. Hypervel automatically flushes framework-owned HTTP client macros after every test. If your application or package defines its own macroable class, add it to your test-state cleanup.
Many Hypervel services provide functionality to help you easily and expressively write tests, and Hypervel's HTTP client is no exception. The Http facade's fake method allows you to instruct the HTTP client to return stubbed / dummy responses when requests are made.
For example, to instruct the HTTP client to return empty, 200 status code responses for every request, you may call the fake method with no arguments:
use Hypervel\Support\Facades\Http;
Http::fake();
$response = Http::post(/* ... */);Alternatively, you may pass an array to the fake method. The array's keys should represent URL patterns that you wish to fake and their associated responses. The * character may be used as a wildcard character. You may use the Http facade's response method to construct stub / fake responses for these endpoints:
Http::fake([
// Stub a JSON response for GitHub endpoints...
'github.com/*' => Http::response(['foo' => 'bar'], 200, $headers),
// Stub a string response for Google endpoints...
'google.com/*' => Http::response('Hello World', 200, $headers),
]);Any requests made to URLs that have not been faked will actually be executed. If you would like to specify a fallback URL pattern that will stub all unmatched URLs, you may use a single * character:
Http::fake([
// Stub a JSON response for GitHub endpoints...
'github.com/*' => Http::response(['foo' => 'bar'], 200, ['Headers']),
// Stub a string response for all other endpoints...
'*' => Http::response('Hello World', 200, ['Headers']),
]);For convenience, simple string, JSON, and empty responses may be generated by providing a string, array, or integer as the response:
Http::fake([
'google.com/*' => 'Hello World',
'github.com/*' => ['foo' => 'bar'],
'chatgpt.com/*' => 200,
]);Fake response bodies may also be PHP stream resources or PSR-7 stream instances:
use GuzzleHttp\Psr7\Utils;
Http::fake([
'example.com/*' => Http::response(Utils::streamFor('Hello World')),
]);Sometimes you may need to test your application's behavior if the HTTP client encounters a Hypervel\Http\Client\ConnectionException when attempting to make a request. You can instruct the HTTP client to throw a connection exception using the failedConnection method:
Http::fake([
'github.com/*' => Http::failedConnection(),
]);To test your application's behavior if a Hypervel\Http\Client\RequestException is thrown, you may use the failedRequest method:
$this->mock(GithubService::class)
->shouldReceive('getUser')
->andThrow(Http::failedRequest(['code' => 'not_found'], 404));Sometimes you may need to specify that a single URL should return a series of fake responses in a specific order. You may accomplish this using the Http::sequence method to build the responses:
Http::fake([
// Stub a series of responses for GitHub endpoints...
'github.com/*' => Http::sequence()
->push('Hello World', 200)
->push(['foo' => 'bar'], 200)
->pushStatus(404),
]);The push method accepts the same response bodies as Http::response, including PHP stream resources and PSR-7 streams.
When all the responses in a response sequence have been consumed, any further requests will cause the response sequence to throw an exception. If you would like to specify a default response that should be returned when a sequence is empty, you may use the whenEmpty method:
Http::fake([
// Stub a series of responses for GitHub endpoints...
'github.com/*' => Http::sequence()
->push('Hello World', 200)
->push(['foo' => 'bar'], 200)
->whenEmpty(Http::response()),
]);If you would like to fake a sequence of responses but do not need to specify a specific URL pattern that should be faked, you may use the Http::fakeSequence method:
Http::fakeSequence()
->push('Hello World', 200)
->whenEmpty(Http::response());If you require more complicated logic to determine what responses to return for certain endpoints, you may pass a closure to the fake method. This closure will receive an instance of Hypervel\Http\Client\Request and should return a response instance. Within your closure, you may perform whatever logic is necessary to determine what type of response to return:
use Hypervel\Http\Client\Request;
Http::fake(function (Request $request) {
return Http::response('Hello World', 200);
});To distinguish requests sent to the same URL, you may attach attributes using the withAttributes method. These attributes are available to fake callbacks and request assertions through the request's attributes method and are not sent to the remote server:
use Hypervel\Http\Client\Request;
use Hypervel\Support\Facades\Http;
Http::fake(fn (Request $request) => match ($request->attributes()['name'] ?? null) {
'products' => Http::response(['products' => []]),
default => Http::response(),
});
$response = Http::withAttributes(['name' => 'products'])
->get('https://example.com/graphql');When faking responses, you may occasionally wish to inspect the requests the client receives in order to make sure your application is sending the correct data or headers. You may accomplish this by calling the Http::assertSent method after calling Http::fake.
If a test makes real requests, call Http::record() before sending them to enable the same request assertions without faking their responses.
The assertSent method accepts a closure which will receive a Hypervel\Http\Client\Request instance and should return a boolean value indicating if the request matches your expectations. In order for the test to pass, at least one request must have been issued matching the given expectations:
use Hypervel\Http\Client\Request;
use Hypervel\Support\Facades\Http;
Http::fake();
Http::withHeaders([
'X-First' => 'foo',
])->post('http://example.com/users', [
'name' => 'Taylor',
'role' => 'Developer',
]);
Http::assertSent(function (Request $request) {
return $request->hasHeader('X-First', 'foo') &&
$request->url() === 'http://example.com/users' &&
$request['name'] === 'Taylor' &&
$request['role'] === 'Developer';
});The Hypervel\Http\Client\Request instance provides a variety of methods that may be used to inspect the request, including method, url, uri, headers, body, data, query, hasFile, isJson, isForm, and isMultipart.
If needed, you may assert that a specific request was not sent using the assertNotSent method:
use Hypervel\Http\Client\Request;
use Hypervel\Support\Facades\Http;
Http::fake();
Http::post('http://example.com/users', [
'name' => 'Taylor',
'role' => 'Developer',
]);
Http::assertNotSent(function (Request $request) {
return $request->url() === 'http://example.com/posts';
});You may use the assertSentCount method to assert how many requests were "sent" during the test:
Http::fake();
Http::assertSentCount(5);You may use the assertSentInOrder method to assert that requests were sent in a given order:
Http::fake();
Http::get('https://hypervel.org');
Http::get('https://api.hypervel.org');
Http::assertSentInOrder([
'https://hypervel.org',
'https://api.hypervel.org',
]);Or, you may use the assertNothingSent method to assert that no requests were sent during the test:
Http::fake();
Http::assertNothingSent();If your test uses response sequences, you may use the assertSequencesAreEmpty method to assert that every created response sequence has been fully consumed:
Http::fake([
'github.com/*' => Http::sequence()
->push('Hello World', 200),
]);
Http::get('https://github.com/hypervel/hypervel');
Http::assertSequencesAreEmpty();You may use the recorded method to gather all requests and their corresponding responses. The recorded method returns a collection of arrays that contains instances of Hypervel\Http\Client\Request and Hypervel\Http\Client\Response:
Http::fake([
'https://hypervel.org' => Http::response(status: 500),
'https://api.hypervel.org/' => Http::response(),
]);
Http::get('https://hypervel.org');
Http::get('https://api.hypervel.org/');
$recorded = Http::recorded();
[$request, $response] = $recorded[0];Additionally, the recorded method accepts a closure which will receive an instance of Hypervel\Http\Client\Request and Hypervel\Http\Client\Response and may be used to filter request / response pairs based on your expectations:
use Hypervel\Http\Client\Request;
use Hypervel\Http\Client\Response;
Http::fake([
'https://hypervel.org' => Http::response(status: 500),
'https://api.hypervel.org/' => Http::response(),
]);
Http::get('https://hypervel.org');
Http::get('https://api.hypervel.org/');
$recorded = Http::recorded(function (Request $request, Response $response) {
return $request->url() !== 'https://hypervel.org' &&
$response->successful();
});If you would like to ensure that all requests sent via the HTTP client have been faked throughout your individual test or complete test suite, you can call the preventStrayRequests method. After calling this method, any requests that do not have a corresponding fake response will throw an exception rather than making the actual HTTP request:
use Hypervel\Support\Facades\Http;
Http::preventStrayRequests();
Http::fake([
'github.com/*' => Http::response('ok'),
]);
// An "ok" response is returned...
Http::get('https://github.com/hypervel/hypervel');
// An exception is thrown...
Http::get('https://hypervel.org');Sometimes, you may wish to prevent most stray requests while still allowing specific requests to execute. To accomplish this, you may pass an array of URL patterns to the allowStrayRequests method. Any request matching one of the given patterns will be allowed, while all other requests will continue to throw an exception:
use Hypervel\Support\Facades\Http;
Http::preventStrayRequests();
Http::allowStrayRequests([
'http://127.0.0.1:5000/*',
]);
// This request is executed...
Http::get('http://127.0.0.1:5000/generate');
// An exception is thrown...
Http::get('https://hypervel.org');Hypervel fires three events during the process of sending HTTP requests. The RequestSending event is fired prior to a request being sent, while the ResponseReceived event is fired after a response is received for a given request. The ConnectionFailed event is fired if no response is received for a given request.
The RequestSending and ConnectionFailed events both contain a public $request property that you may use to inspect the Hypervel\Http\Client\Request instance. Likewise, the ResponseReceived event contains a $request property as well as a $response property which may be used to inspect the Hypervel\Http\Client\Response instance. You may create event listeners for these events within your application:
use Hypervel\Http\Client\Events\RequestSending;
class LogRequest
{
/**
* Handle the event.
*/
public function handle(RequestSending $event): void
{
// $event->request ...
}
}