When writing a framework-agnostic PHP package that calls a remote API, how do PSR-17 factories and PSR-18's ClientInterface keep it independent of any HTTP implementation, and what does sendRequest() throw?
answer
- depend on interfaces, inject implementations
- createRequest(), createStream(), createUri()
- sendRequest(RequestInterface): ResponseInterface
- 4xx and 5xx are not exceptions
- NetworkExceptionInterface vs RequestExceptionInterface
basics
~20 sThe package type-hints PSR-17 factories to build requests and PSR-18's ClientInterface to send them; the application injects concrete implementations. sendRequest() returns 4xx and 5xx responses normally and throws ClientExceptionInterface only when it cannot send or parse.
solid answer
~40 sThe package's constructor takes `Psr\Http\Client\ClientInterface`, a PSR-17 `RequestFactoryInterface` and a `StreamFactoryInterface`, and its `composer.json` requires the PSR interface packages, not a client. It builds requests with `createRequest('POST', $uri)`, attaches a body from `createStream($json)` via `withBody()`, and sends it with `sendRequest(RequestInterface $request): ResponseInterface`. The application wires in whichever client and PSR-7 implementation it already uses. PSR-18's error rule is the part candidates miss: a well-formed response is never an error, so a 404 or 503 comes back as a normal response and the package must check `getStatusCode()` itself. A client throws `ClientExceptionInterface` only when it cannot send the request or cannot parse the response: `RequestExceptionInterface` for a malformed request, `NetworkExceptionInterface` for network failures including timeouts. Both expose `getRequest()`.
code
php · 33 lines<?php
declare(strict_types=1);
use Psr\Http\Client\ClientInterface;
use Psr\Http\Client\NetworkExceptionInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
final class QuotaApi
{
public function __construct(
private ClientInterface $http,
private RequestFactoryInterface $requests,
private StreamFactoryInterface $streams,
) {}
public function report(string $clientId, int $used): void
{
$request = $this->requests->createRequest('POST', 'https://quota.example.com/v1/usage')
->withHeader('Content-Type', 'application/json')
->withBody($this->streams->createStream(json_encode(['client' => $clientId, 'used' => $used], JSON_THROW_ON_ERROR)));
try {
$response = $this->http->sendRequest($request);
} catch (NetworkExceptionInterface $e) {
throw new QuotaUnavailable('Quota service unreachable', previous: $e);
}
if ($response->getStatusCode() >= 400) { // 4xx/5xx are returned, not thrown
throw new QuotaRejected('Quota service answered ' . $response->getStatusCode());
}
}
}go deeper
Recall that PSR-17 creates messages, PSR-18 sends requests, and that a 404 from sendRequest() comes back as an ordinary response.
Explain how factories replace new on implementation classes and how the RequestExceptionInterface and NetworkExceptionInterface split works.
Design the package's constructor around the three interface sets, translate status codes into domain exceptions yourself, and test it with an in-memory client.
Decide when an SDK should stay implementation-agnostic on PSR-18 and when a hard dependency on one client is worth it for async calls, streaming or built-in retries.
## The problem the two standards solve A reusable package that talks to a remote API needs to **create** request objects and **send** them. If it calls `new SomeVendor\Request(...)` and a specific client's methods, every application that installs it inherits those libraries, possibly in conflicting versions. PHP-FIG split the job into interfaces: - **PSR-7** defines the message objects; - **PSR-17** defines factories that create them; - **PSR-18** defines a client that sends a request and returns a response. A package that depends only on these interfaces lets the application choose the implementations. ## PSR-17: the factories PSR-17 has one interface per object type; an implementation may provide several in one class. | Interface | Method | |---|---| | `RequestFactoryInterface` | `createRequest(string $method, $uri): RequestInterface` | | `ResponseFactoryInterface` | `createResponse(int $code = 200, string $reasonPhrase = ''): ResponseInterface` | | `ServerRequestFactoryInterface` | `createServerRequest(string $method, $uri, array $serverParams = [])` | | `StreamFactoryInterface` | `createStream(string $content = '')`, `createStreamFromFile(string $filename, string $mode = 'r')`, `createStreamFromResource($resource)` | | `UploadedFileFactoryInterface` | `createUploadedFile(...)` | | `UriFactoryInterface` | `createUri(string $uri = '')` | `createRequest()` accepts the URI as a string or a `UriInterface`. PSR-17 deliberately has no factory that builds a server request from superglobals: its meta document notes that some runtimes do not populate them, so that step is left to implementations. ## PSR-18: the client `Psr\Http\Client\ClientInterface` has a single method: ```php public function sendRequest(RequestInterface $request): ResponseInterface; ``` A client may alter the request it actually sends, or the response it returns, for example by compressing or decompressing a body, but must keep the message consistent (drop `Content-Encoding` and fix `Content-Length` after decompressing). Because messages are immutable, the object sent may not be the one passed in, so identity comparisons with `===` are not reliable. A client must also reassemble 1xx interim responses and return a final response with status 200 or higher. ## PSR-18 error semantics This is where interviews probe: 1. A **well-formed HTTP response is not an error**. Status codes in the 400 and 500 ranges MUST NOT cause an exception; they are returned normally. 2. A client throws `ClientExceptionInterface` if and only if it cannot send the request at all, or cannot parse the response into a PSR-7 object. 3. A request that is malformed or missing critical information, such as a host or method, raises `RequestExceptionInterface`. 4. A network failure of any kind, **including a timeout**, raises `NetworkExceptionInterface`. Both sub-interfaces extend `ClientExceptionInterface`, which extends `\Throwable`, and both provide `getRequest()`. Clients may throw more specific exception classes as long as they implement the right interface. So a package must check the status itself: map `getStatusCode() >= 400` to its own domain exceptions, and catch `NetworkExceptionInterface` separately if it wants to retry on timeouts. ## Wiring it together - The package's `composer.json` requires the interface packages (`psr/http-client`, `psr/http-factory`, `psr/http-message`), not a client library. - Its constructor accepts `ClientInterface`, `RequestFactoryInterface` and `StreamFactoryInterface`. - The application's dependency injection container, or a hand-written bootstrap, passes in concrete objects. - Tests pass an in-memory `ClientInterface` that records requests and returns canned responses, with no network at all. ## Trade-offs - PSR-18 has **no async API** and no options argument: timeouts, retries and proxies are configured on the concrete client before it is injected. - The package loses convenience helpers a specific client library offers, in exchange for not dictating the application's dependencies. - The status-code rule moves responsibility into the package: it must decide what a 404 or 429 from the API means. ## Explicit injection versus automatic lookup Some packages locate an installed PSR-17 and PSR-18 implementation automatically when none is passed in. That lowers setup effort but hides a dependency and can pick a different implementation than the application expects. Constructor injection keeps the choice visible and testable; a common compromise is to accept the interfaces as constructor parameters and only fall back to lookup when they are omitted, documenting which implementations the fallback may choose.
- How do you unit-test a package that depends on PSR-18's ClientInterface?Inject a test implementation of `ClientInterface` that records each request it receives and returns a prepared `ResponseInterface`. The test then asserts on the recorded method, URI, headers and body, and can simulate failures by throwing an object that implements `NetworkExceptionInterface`. No network or real client library is involved.
- Where does a PSR-18 package configure a request timeout?Not through PSR-18: `sendRequest()` takes only the request, with no options. Timeouts, retries and proxies are set on the concrete client when the application constructs it. The package can only react, by catching `NetworkExceptionInterface`, which PSR-18 says covers timeouts.
saying these in an interview costs you the question
- Expects sendRequest() to throw on a 404 or 500 response.
- Requires a concrete HTTP client library in a reusable package's composer.json.
- Believes PSR-17 provides a factory that builds a server request from superglobals.
- Compares the request from an exception to the original with === to identify it.
- Treats a timeout as a RequestExceptionInterface rather than a network failure.