HTTP Client¶
A transport-abstraction layer for making HTTP requests. The Application layer defines PSR-7
message factories, a transport contract, and a promise interface; the Adapter layer provides
a Guzzle implementation and a PSR-3 logging decorator. An HttpService facade combines
transport + all factories into a single dependency.
Application\HttpClient
├── HttpService — Facade: HttpClient + MessageFactory +
│ StreamFactory + UriFactory
├── Transport\
│ └── HttpClient (interface) — send(), sendAsync()
├── Message\
│ ├── MessageFactory (interface) — createRequest(), createResponse()
│ ├── StreamFactory (interface) — createStream()
│ ├── UriFactory (interface) — createUri()
│ └── Promise (interface) — then(), getState(), getResponse(),
│ getException(), wait()
└── Exception\
├── Exception (interface) — Marker
├── TransferException — Base runtime exception
├── RequestException — Has getRequest()
├── HttpException — Has getResponse(), getStatusCode()
└── NetworkException — Connection-level failure
Adapter\HttpClient
├── Guzzle\
│ ├── GuzzleClient — HttpClient → Guzzle ClientInterface
│ ├── GuzzlePromise — Promise → Guzzle PromiseInterface
│ ├── GuzzleMessageFactory — MessageFactory → Guzzle PSR-7
│ ├── GuzzleStreamFactory — StreamFactory → Guzzle PSR-7
│ └── GuzzleUriFactory — UriFactory → Guzzle PSR-7
└── Logging\
└── LoggingHttpClient — Decorator: logs request/response then delegates
Application\HttpFoundation
├── HttpMethod — String constants (GET, POST, ...)
└── HttpStatus — Integer constants (OK, NOT_FOUND, ...)
Table of Contents¶
- HttpClient (Transport)
- HttpService (Facade)
- Message Factories
- Promise
- Guzzle Adapter
- LoggingHttpClient
- Exception Hierarchy
- HttpFoundation Primitives
- Installation
- Symfony Configuration
- Usage Examples
HttpClient (Transport)¶
Fight\Common\Application\HttpClient\Transport\HttpClient
interface HttpClient
{
/** @throws Exception */
public function send(RequestInterface $request, array $options = []): ResponseInterface;
/** @throws Exception */
public function sendAsync(RequestInterface $request, array $options = []): Promise;
}
Implementations¶
| Implementation | Namespace | Purpose |
|---|---|---|
GuzzleClient |
Adapter\HttpClient\Guzzle |
Production — wraps Guzzle ClientInterface |
LoggingHttpClient |
Adapter\HttpClient\Logging |
Dev — logs request/response then delegates |
HttpService (Facade)¶
Fight\Common\Application\HttpClient\HttpService
Implements HttpClient, MessageFactory, StreamFactory, and UriFactory, delegating
every method to its injected dependency. This is the recommended way to depend on HTTP in
application services — one dependency gives you transport, message creation, streams, and
URI parsing.
final readonly class HttpService implements HttpClient, MessageFactory, StreamFactory, UriFactory
{
public function __construct(
private HttpClient $httpClient,
private MessageFactory $messageFactory,
private StreamFactory $streamFactory,
private UriFactory $uriFactory,
) {}
}
class UserApiService
{
public function __construct(private HttpService $http) {}
public function fetchUser(int $id): array
{
$request = $this->http->createRequest('GET', "/users/{$id}");
$response = $this->http->send($request);
return json_decode((string) $response->getBody(), true);
}
}
Message Factories¶
MessageFactory¶
Fight\Common\Application\HttpClient\Message\MessageFactory
interface MessageFactory
{
public function createRequest(
string $method,
string|UriInterface $uri,
array $headers = [],
mixed $body = null,
string $protocol = '1.1'
): RequestInterface;
public function createResponse(
int $status = 200,
array $headers = [],
mixed $body = null,
string $protocol = '1.1',
?string $reason = null
): ResponseInterface;
}
StreamFactory¶
Fight\Common\Application\HttpClient\Message\StreamFactory
interface StreamFactory
{
/** @throws DomainException */
public function createStream(mixed $body = null): StreamInterface;
}
UriFactory¶
Fight\Common\Application\HttpClient\Message\UriFactory
interface UriFactory
{
/** @throws DomainException */
public function createUri(string $uri): UriInterface;
}
Adapter Implementations¶
All three factories have a single Guzzle adapter:
| Factory | Implementation | PSR-7 Library |
|---|---|---|
MessageFactory |
GuzzleHttp\Psr7\Request / Response |
guzzlehttp/psr7 |
StreamFactory |
GuzzleHttp\Psr7\Utils::streamFor() |
guzzlehttp/psr7 |
UriFactory |
GuzzleHttp\Psr7\Utils::uriFor() |
guzzlehttp/psr7 |
$factory = new GuzzleMessageFactory();
$request = $factory->createRequest('POST', '/api/orders', [
'Content-Type' => 'application/json',
], json_encode($orderData));
Promise¶
Fight\Common\Application\HttpClient\Message\Promise
Represents the eventual result of an asynchronous HTTP operation.
interface Promise
{
public const PENDING = 'pending';
public const FULFILLED = 'fulfilled';
public const REJECTED = 'rejected';
public function then(?callable $onFulfilled = null, ?callable $onRejected = null): static;
public function getState(): string;
/** @throws MethodCallException */
public function getResponse(): ResponseInterface;
/** @throws MethodCallException */
public function getException(): Throwable;
public function wait(): void;
}
| Method | Returns | Notes |
|---|---|---|
then() |
static |
Returns a NEW promise with chained callbacks |
getState() |
string |
One of PENDING, FULFILLED, REJECTED |
getResponse() |
ResponseInterface |
Throws MethodCallException unless FULFILLED |
getException() |
Throwable |
Throws MethodCallException unless REJECTED |
wait() |
void |
Synchronously resolves / rejects |
Guzzle Adapter¶
GuzzleClient¶
Fight\Common\Adapter\HttpClient\Guzzle\GuzzleClient
final class GuzzleClient implements HttpClient
{
public function __construct(protected ClientInterface $client) {}
}
Wraps any Guzzle ClientInterface. send() calls sendAsync() then wait(), re-throwing
any non-Fight exception as TransferException.
GuzzlePromise¶
Fight\Common\Adapter\HttpClient\Guzzle\GuzzlePromise
Wraps a Guzzle PromiseInterface. On rejection, converts Guzzle exceptions to the Fight
exception hierarchy:
| Guzzle Exception | Fight Exception |
|---|---|
ConnectException |
NetworkException |
RequestException (has response) |
HttpException |
RequestException (no response) |
RequestException |
Other GuzzleException |
TransferException |
Non-Guzzle Throwable |
RuntimeException |
GuzzleMessageFactory, GuzzleStreamFactory, GuzzleUriFactory¶
Simple adapters that delegate to guzzlehttp/psr7 classes:
$messageFactory = new GuzzleMessageFactory();
$streamFactory = new GuzzleStreamFactory();
$uriFactory = new GuzzleUriFactory();
LoggingHttpClient¶
Fight\Common\Adapter\HttpClient\Logging\LoggingHttpClient
A decorator that logs every request and response via PSR-3 before delegating to the inner
client. Configurable log level (default LogLevel::DEBUG).
final readonly class LoggingHttpClient implements HttpClient
{
public function __construct(
private HttpClient $httpClient,
private LoggerInterface $logger,
private string $logLevel = LogLevel::DEBUG,
) {}
}
Logged data:
- Request: method, URI, protocol version, headers, body content
- Response (on fulfill): status code, reason phrase, protocol version, headers, body content (stream is rewound after reading)
- Exception (on reject): exception message and full exception object
$client = new LoggingHttpClient(
new GuzzleClient(new GuzzleHttp\Client()),
$logger,
LogLevel::INFO
);
Exception Hierarchy¶
Throwable
└── Domain\Exception\SystemException
└── Domain\Exception\RuntimeException
└── TransferException ──── implements Exception (marker)
└── RequestException ── has getRequest()
├── HttpException ── has getResponse(), getStatusCode(), create()
└── NetworkException
| Exception | When Thrown | Key Methods |
|---|---|---|
TransferException |
Base transport failure | — |
RequestException |
Request-level error | getRequest(): RequestInterface |
HttpException |
Non-2xx response received | getResponse(), getStatusCode(), static create() |
NetworkException |
Connection refused / DNS failure | getRequest() (inherited) |
HttpException::create() builds a message in a normalized format:
HttpFoundation Primitives¶
HttpMethod¶
Fight\Common\Application\HttpFoundation\HttpMethod
String constant class for HTTP methods:
All standard methods: HEAD, GET, POST, PUT, PATCH, DELETE, PURGE, OPTIONS,
TRACE, CONNECT.
HttpStatus¶
Fight\Common\Application\HttpFoundation\HttpStatus
Integer constant class for HTTP status codes:
HttpStatus::OK; // 200
HttpStatus::CREATED; // 201
HttpStatus::NOT_FOUND; // 404
HttpStatus::I_AM_A_TEAPOT; // 418
HttpStatus::INTERNAL_SERVER_ERROR; // 500
Covers all standard codes 100–511 plus I_AM_A_TEAPOT (418) and ENHANCE_YOUR_CALM (420).
Installation¶
The HTTP client layer depends on PSR-7 interfaces (psr/http-message) and PSR-17 factory
interfaces (psr/http-factory), which are required by the library. The Guzzle adapter
additionally requires:
These provide the ClientInterface, PromiseInterface, and concrete PSR-7 implementations
that the adapters delegate to.
Symfony Configuration¶
# config/packages/common_http_client.yaml
services:
_defaults:
autowire: true
autoconfigure: true
# --- Guzzle client (PSR-18 compatible) ---
GuzzleHttp\ClientInterface:
class: GuzzleHttp\Client
arguments:
$config:
base_uri: '%env(API_BASE_URI)%'
timeout: 5.0
connect_timeout: 2.0
http_errors: false # let the adapter handle status codes
# --- PSR-7 factories ---
Fight\Common\Adapter\HttpClient\Guzzle\GuzzleMessageFactory: ~
Fight\Common\Adapter\HttpClient\Guzzle\GuzzleStreamFactory: ~
Fight\Common\Adapter\HttpClient\Guzzle\GuzzleUriFactory: ~
# --- Transport ---
Fight\Common\Adapter\HttpClient\Guzzle\GuzzleClient: ~
Fight\Common\Adapter\HttpClient\Logging\LoggingHttpClient:
decorates: Fight\Common\Adapter\HttpClient\Guzzle\GuzzleClient
arguments:
- '@.inner'
- '@logger'
- 'info'
# --- Facade ---
Fight\Common\Application\HttpClient\HttpService:
arguments:
- '@Fight\Common\Adapter\HttpClient\Guzzle\GuzzleClient'
- '@Fight\Common\Adapter\HttpClient\Guzzle\GuzzleMessageFactory'
- '@Fight\Common\Adapter\HttpClient\Guzzle\GuzzleStreamFactory'
- '@Fight\Common\Adapter\HttpClient\Guzzle\GuzzleUriFactory'
# --- Interface aliases ---
Fight\Common\Application\HttpClient\Transport\HttpClient:
alias: Fight\Common\Adapter\HttpClient\Guzzle\GuzzleClient
Fight\Common\Application\HttpClient\Message\MessageFactory:
alias: Fight\Common\Adapter\HttpClient\Guzzle\GuzzleMessageFactory
Fight\Common\Application\HttpClient\Message\StreamFactory:
alias: Fight\Common\Adapter\HttpClient\Guzzle\GuzzleStreamFactory
Fight\Common\Application\HttpClient\Message\UriFactory:
alias: Fight\Common\Adapter\HttpClient\Guzzle\GuzzleUriFactory
Environment-specific overrides:
# config/packages/dev/common_http_client.yaml
services:
GuzzleHttp\ClientInterface:
class: GuzzleHttp\Client
arguments:
$config:
base_uri: '%env(DEV_API_BASE_URI)%'
timeout: 10.0
verify: false
# config/packages/test/common_http_client.yaml
services:
# Swap the logging decorator for a lightweight client in tests
Fight\Common\Adapter\HttpClient\Guzzle\GuzzleClient:
class: GuzzleHttp\Client
arguments:
$config:
base_uri: 'http://localhost'
timeout: 0.5
handler: '@test.http.handler' # MockHandler for stubbing responses
Usage Examples¶
Basic GET Request¶
use Fight\Common\Application\HttpClient\HttpService;
use Fight\Common\Application\HttpFoundation\HttpMethod;
class UserApiService
{
public function __construct(private HttpService $http) {}
public function getUser(int $id): array
{
$request = $this->http->createRequest(HttpMethod::GET, "/users/{$id}");
$response = $this->http->send($request);
return json_decode((string) $response->getBody(), true);
}
}
POST with JSON Body¶
class OrderApiService
{
public function __construct(private HttpService $http) {}
public function createOrder(array $data): array
{
$request = $this->http->createRequest(
'POST',
'/orders',
['Content-Type' => 'application/json'],
json_encode($data)
);
$response = $this->http->send($request);
return json_decode((string) $response->getBody(), true);
}
}
Async Request¶
$promise = $this->http->sendAsync($request);
// Attach callbacks
$promise = $promise->then(
function (ResponseInterface $response) {
// handle response
return $response;
},
function (Throwable $exception) {
// handle error
throw $exception;
}
);
// Wait for resolution
$promise->wait();
if ($promise->getState() === Promise::FULFILLED) {
$response = $promise->getResponse();
}
Exception Handling¶
use Fight\Common\Application\HttpClient\Exception\HttpException;
use Fight\Common\Application\HttpClient\Exception\NetworkException;
try {
$response = $this->http->send($request);
} catch (HttpException $e) {
// Non-2xx response
$status = $e->getStatusCode(); // 404, 500, etc.
$body = $e->getResponse()->getBody();
} catch (NetworkException $e) {
// Connection failure
$uri = $e->getRequest()->getUri();
}
Logging Decorator¶
use Fight\Common\Adapter\HttpClient\Logging\LoggingHttpClient;
use Fight\Common\Adapter\HttpClient\Guzzle\GuzzleClient;
$inner = new GuzzleClient(new GuzzleHttp\Client(['base_uri' => 'https://api.example.com']));
$client = new LoggingHttpClient($inner, $logger, LogLevel::DEBUG);
$request = (new GuzzleMessageFactory())->createRequest('GET', '/health');
$response = $client->send($request);
// Logs: method, URI, headers, body → then status, reason, response headers, body
Testing with a Mock Client¶
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Fight\Common\Adapter\HttpClient\Guzzle\GuzzleClient;
$mock = new MockHandler([
new Response(200, [], json_encode(['id' => 1])),
new Response(404, [], 'Not Found'),
]);
$handler = HandlerStack::create($mock);
$client = new GuzzleClient(new Client(['handler' => $handler]));
$service = new HttpService(
$client,
new GuzzleMessageFactory(),
new GuzzleStreamFactory(),
new GuzzleUriFactory()
);
$response = $service->send($request); // 200 response
$response = $service->send($request); // 404 response