Application · Adapter
HTTP Client
Send PSR-7 requests through an application-owned port, then bind Guzzle or a PSR-18 client at the edge.
HTTP Client keeps outbound request intent in the Application layer and transport details in an Adapter.
Depend on HttpClient for sending requests, or on HttpService only when the same service also needs
PSR-7 message, stream, and URI factories.
The package requires the PSR contracts. Guzzle packages are optional and needed only for the shipped
Guzzle transport and factories. Psr18Client exposes an already configured Fight HttpClient through
the PSR-18 client contract; transport discovery, timeouts, TLS trust, proxy, retry, and authentication
policy remain consumer-owned.
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 |
Psr18Client points in the other direction: it exposes an already configured Fight HttpClient
through the standard Psr\Http\Client\ClientInterface. It translates Fight network, request, and
transfer failures into the matching PSR-18 exception interfaces. It does not discover a client or
choose timeouts, TLS trust, proxies, retries, or credentials.
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).
Use it only where its data policy is acceptable. Authorization headers, cookies, query values, and bodies can carry secrets or personal data; redact them before they reach a production logger.
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:
# Keep the Fight adapter and replace only its underlying client in tests
GuzzleHttp\ClientInterface:
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