skip to content

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?

level: seniorimportance: should knowfreq 28%

answer

  1. depend on interfaces, inject implementations
  2. createRequest(), createStream(), createUri()
  3. sendRequest(RequestInterface): ResponseInterface
  4. 4xx and 5xx are not exceptions
  5. NetworkExceptionInterface vs RequestExceptionInterface

basics

~20 s

The 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 s

The 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
<?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

for a junior

Recall that PSR-17 creates messages, PSR-18 sends requests, and that a 404 from sendRequest() comes back as an ordinary response.

for a middle

Explain how factories replace new on implementation classes and how the RequestExceptionInterface and NetworkExceptionInterface split works.

for a senior

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.

for a principal

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.