wimski / http-requests
Opinionated starter kit for making HTTP requests (API implementations)
Requires
- php: ^8.3
- psr/http-client: ^1.0
- psr/http-factory: ^1.1
- psr/http-message: ^2.0
Requires (Dev)
- laravel/pint: ^1.24
- mockery/mockery: ^1.6.10
- phpstan/phpstan: ^2.1
- phpstan/phpstan-mockery: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^12.3
- symfony/var-dumper: ^7.0 || ^8.0
This package is auto-updated.
Last update: 2026-04-02 08:33:58 UTC
README
HTTP Requests
An opinionated, extensible PHP library for building type-safe HTTP API clients using PSR standards.
Overview
This library provides a structured framework for implementing HTTP API clients with a focus on:
- Type Safety: Full generic type support with PHPDoc annotations
- Extensibility: Interface-driven architecture allowing custom implementations
- PSR Compliance: Built on PSR-7, PSR-17, and PSR-18 standards
- Separation of Concerns: Clear separation between requests, responses, and data transformation
Requirements
- PHP 8.3+
- PSR-7 HTTP Message implementation
- PSR-17 HTTP Factory implementation
- PSR-18 HTTP Client implementation
Discovery is recommended for agnostic PSR HTTP class usage.
Installation
composer require wimski/http-requests
Core Concepts
Architecture
The library follows a layered architecture:
- Client Layer:
Clientorchestrates the request/response lifecycle - Request Layer:
RequestInterfaceimplementations define API endpoints - Factory Layer: Factories transform between PSR and domain objects
- Response Layer:
ResponseInterfaceimplementations wrap API responses - Data Layer: DTOs/entities representing API resources
Key Components
ClientInterface: Main entry point for making HTTP requestsRequestInterface: Defines HTTP request specificationsResponseInterface: Wraps response data with type safetyDataFactoryInterface: Transforms arrays into typed objectsResponseBodyFactoryInterface: Parses HTTP response bodiesStreamFactoryInterface: Serializes request bodies
Basic Usage
1. Define Your Data Object
readonly class User { public function __construct( public int $id, public string $name, public string $email, ) {} }
2. Create a Request
use Wimski\HttpRequests\Requests\AbstractRequest; use Wimski\HttpRequests\Responses\SingleResponse; /** * @extends AbstractRequest<User, SingleResponse<User>> */ class GetUserRequest extends AbstractRequest { public function __construct( protected readonly int $userId, ) {} public function getUri(): string { return "/users/{$this->userId}"; } public function getHeaders(): array { return [ 'accept' => 'application/json', ]; } public function getResponseDataClass(): string { return User::class; } public function getResponseClass(): string { return SingleResponse::class; } }
3. Set Up the Client
use Http\Discovery\HttpClientDiscovery; use Http\Discovery\Psr17FactoryDiscovery; use Wimski\HttpRequests\Client; use Wimski\HttpRequests\Factories\DataFactory; use Wimski\HttpRequests\Factories\FormStreamFactory; use Wimski\HttpRequests\Factories\JsonResponseBodyFactory; use Wimski\HttpRequests\Factories\JsonStreamFactory; use Wimski\HttpRequests\Factories\RequestFactory; use Wimski\HttpRequests\Factories\ResponseBodyFactory;use Wimski\HttpRequests\Factories\ResponseFactory; use Wimski\HttpRequests\Factories\SingleResponseFactory; use Wimski\HttpRequests\Factories\UriFactory; $streamFactory = Psr17FactoryDiscovery::findStreamFactory(); $client = new Client( httpClient: HttpClientDiscovery::find(), requestFactory: new RequestFactory( uriFactory: new UriFactory( httpUriFactory: Psr17FactoryDiscovery::findUriFactory(), baseUri: 'https://api.example.com', ), httpRequestFactory: Psr17FactoryDiscovery::findRequestFactory(), new StreamFactory( new FormStreamFactory($streamFactory), new JsonStreamFactory($streamFactory), // Add your custom stream factories here ), ), responseFactory: new ResponseFactory( new SingleResponseFactory( responseBodyFactory: new ResponseBodyFactory( new JsonResponseBodyFactory(), // Add your custom response body factories here ), dataFactory: new DataFactory( // Add your custom data factories here ), ), // Add your custom response factories here ), );
4. Make Requests
$request = new GetUserRequest(userId: 123); /** @var SingleResponse<User> $response */ $response = $client->request($request); $user = $response->getData(); echo $user->name; // Type-safe access
Extending the Library
Custom Data Factory
Implement DataFactoryInterface to control how arrays are transformed into objects:
use Wimski\HttpRequests\Contracts\Factories\DataFactoryInterface; readonly class SymfonySerializerDataFactory implements DataFactoryInterface { public function __construct( protected SerializerInterface $serializer, ) {} public function supports(string $class): bool { // Support all classes in your namespace return str_starts_with($class, 'App\\Api\\Data\\'); } public function make(string $class, array $data): object { return $this->serializer->denormalize($data, $class); } }
Register it with the DataFactory:
$dataFactory = new DataFactory( new SymfonySerializerDataFactory($serializer), // Fallback factories... );
Custom Response Body Factory
Support different content types by implementing ResponseBodyFactoryInterface:
use Psr\Http\Message\ResponseInterface as HttpResponseInterface; use Wimski\HttpRequests\Contracts\Factories\ResponseBodyFactoryInterface; use Wimski\HttpRequests\Contracts\Requests\RequestInterface; readonly class XmlResponseBodyFactory implements ResponseBodyFactoryInterface { public function supports(string $accept): bool { return $accept === 'application/xml'; } public function make(HttpResponseInterface $httpResponse, RequestInterface $request): array { // Parse the $httpResponse->getBody()->getContents() string as XML // and transform it into an array } }
Custom Stream Factory
Handle different request body formats by implementing StreamFactoryInterface,
and probably extending AbstractStreamFactory:
use Wimski\HttpRequests\Contracts\Requests\RequestInterface; use Wimski\HttpRequests\Factories\AbstractStreamFactory; readonly class XmlStreamFactory extends AbstractStreamFactory { public function supports(string $contentType): bool { return $contentType === 'application/xml'; } public function make(RequestInterface $request): string { // Transform $request->getBody() array into an XML string } }
Multi-Item Responses
For endpoints returning collections, use MultiResponse:
use App\Api\Data\User; use Wimski\HttpRequests\Requests\AbstractRequest; use Wimski\HttpRequests\Responses\MultiResponse; /** * @extends AbstractRequest<User, MultiResponse<User>> */ class ListUsersRequest extends AbstractRequest { public function getUri(): string { return '/users'; } public function getHeaders(): array { return ['accept' => 'application/json']; } public function getResponseDataClass(): string { return User::class; } public function getResponseClass(): string { return MultiResponse::class; } }
Create a custom MultiResponseFactory:
use Wimski\HttpRequests\Factories\MultiResponseFactory; readonly class ApiMultiResponseFactory extends MultiResponseFactory { protected function makeData(array $body, string $class): array { // Assuming API returns: {"data": [...]} $items = $body['data'] ?? []; return array_map( fn(array $item) => $this->dataFactory->make($class, $item), $items, ); } protected function makePagination(array $body): ?ResponsePaginationInterface { // Extract pagination metadata if present if (!isset($body['meta'])) { return null; } // ApiResponsePagination implements ResponsePaginationInterface return new ApiResponsePagination( currentPage: $body['meta']['current_page'], totalPages: $body['meta']['total_pages'], perPage: $body['meta']['per_page'], total: $body['meta']['total'], ); } }
Pagination Support
Implement pagination interfaces for paginated requests:
use Wimski\HttpRequests\Contracts\Requests\RequestPaginationInterface; readonly class ApiRequestPagination implements RequestPaginationInterface { public function __construct( protected int $page = 1, protected int $perPage = 20, ) {} public function getPagination(): array { return [ 'page' => $this->page, 'per_page' => $this->perPage, ]; } }
Use it in your request:
$request = new ListUsersRequest(); $request->setPagination(new ApiRequestPagination(page: 2, perPage: 50)); $response = $client->request($request);
Custom Request Methods
Override methods in AbstractRequest for specific behaviors:
use Wimski\HttpRequests\Enums\HttpRequestMethodEnum; use Wimski\HttpRequests\Requests\AbstractRequest; class CreateUserRequest extends AbstractRequest { public function __construct( protected readonly string $name, protected readonly string $email, ) {} public function getMethod(): HttpRequestMethodEnum { return HttpRequestMethodEnum::POST; } public function getUri(): string { return '/users'; } public function getHeaders(): array { return [ 'accept' => 'application/json', 'content-type' => 'application/json', ]; } public function getBody(): array { return [ 'name' => $this->name, 'email' => $this->email, ]; } public function getQuery(): array { return ['type' => 'json']; } public function getResponseDataClass(): string { return User::class; } public function getResponseClass(): string { return SingleResponse::class; } }
Query Parameters
Add query parameters via getQuery():
// /users/search?q={$query}&role={$role} class SearchUsersRequest extends AbstractRequest { public function __construct( protected readonly string $query, protected readonly ?string $role = null, ) {} public function getUri(): string { return '/users/search'; } public function getQuery(): array { $query = ['q' => $this->query]; if ($this->role !== null) { $query['role'] = $this->role; } return $query; } // ... other methods }
Custom Client Behavior
Extend Client to add custom behavior:
use Psr\Http\Message\RequestInterface as HttpRequestInterface; use Psr\Http\Message\ResponseInterface as HttpResponseInterface; use Wimski\HttpRequests\Client; use Wimski\HttpRequests\Contracts\Requests\RequestInterface; readonly class AuthenticatedClient extends Client { public function __construct( protected string $apiToken, // ... parent dependencies ) { parent::__construct(...); } protected function makeRequest(RequestInterface $request): HttpRequestInterface { return parent::makeRequest($request) ->withHeader('Authorization', "Bearer {$this->apiToken}"); } protected function validateStatusCode(HttpResponseInterface $httpResponse, HttpRequestInterface $httpRequest): void { // Custom status code handling $statusCode = $httpResponse->getStatusCode(); if ($statusCode === 401) { throw new AuthenticationException('Invalid API token'); } parent::validateStatusCode($httpResponse, $httpRequest); } }
Custom Response Types
Create custom response classes for specialized use cases:
use Wimski\HttpRequests\Contracts\Responses\ResponseInterface; /** * @template TData of object * @implements ResponseInterface<TData> */ readonly class PagedResponse implements ResponseInterface { /** * @param list<TData> $data */ public function __construct( protected array $data, protected int $currentPage, protected int $totalPages, protected int $total, ) {} public function getData(): array { return $this->data; } public function getPagination(): ?ResponsePaginationInterface { return new PagedResponsePagination( $this->currentPage, $this->totalPages, $this->total, ); } public function hasNextPage(): bool { return $this->currentPage < $this->totalPages; } public function hasPreviousPage(): bool { return $this->currentPage > 1; } }
Advanced Examples
Handling File Uploads
use Wimski\HttpRequests\Contracts\Requests\RequestInterface; use Wimski\HttpRequests\Factories\AbstractStreamFactory; readonly class MultipartStreamFactory extends AbstractStreamFactory { public function supports(string $contentType): bool { return str_starts_with($contentType, 'multipart/form-data'); } public function make(RequestInterface $request): string { $boundary = uniqid('', true); $body = ''; foreach ($request->getBody() as $name => $value) { $body .= "--{$boundary}\r\n"; $body .= "Content-Disposition: form-data; name=\"{$name}\"\r\n\r\n"; $body .= "{$value}\r\n"; } $body .= "--{$boundary}--\r\n"; return $body; } }
Rate Limiting
use Psr\Http\Message\RequestInterface as HttpRequestInterface; use Psr\Http\Message\ResponseInterface as HttpResponseInterface; use Wimski\HttpRequests\Client; use Wimski\HttpRequests\Contracts\Requests\RequestInterface; readonly class RateLimitedClient extends Client { protected int $lastRequestTime = 0; protected int $minDelayMs = 100; protected function sendRequest( HttpRequestInterface $httpRequest, RequestInterface $request ): HttpResponseInterface { $now = (int) (microtime(true) * 1000); $elapsed = $now - $this->lastRequestTime; if ($elapsed < $this->minDelayMs) { usleep(($this->minDelayMs - $elapsed) * 1000); } $response = parent::sendRequest($httpRequest, $request); $this->lastRequestTime = (int) (microtime(true) * 1000); return $response; } }
Retry Logic
use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Message\RequestInterface as HttpRequestInterface; use Psr\Http\Message\ResponseInterface as HttpResponseInterface; use Wimski\HttpRequests\Client; use Wimski\HttpRequests\Contracts\Requests\RequestInterface; readonly class RetryableClient extends Client { public function __construct( protected int $maxRetries = 3, protected int $retryDelayMs = 1000, // ... parent dependencies ) { parent::__construct(...); } protected function sendRequest( HttpRequestInterface $httpRequest, RequestInterface $request ): HttpResponseInterface { $attempt = 0; $lastException = null; while ($attempt < $this->maxRetries) { try { return parent::sendRequest($httpRequest, $request); } catch (ClientExceptionInterface $exception) { $lastException = $exception; $attempt++; if ($attempt < $this->maxRetries) { usleep($this->retryDelayMs * 1000 * $attempt); } } } throw $lastException; } }
Exception Handling
The library provides specific exceptions for different failure scenarios:
use Wimski\HttpRequests\Exceptions\HttpStatusCodeException; use Wimski\HttpRequests\Exceptions\RequestException; use Wimski\HttpRequests\Exceptions\ResponseException; try { $response = $client->request($request); } catch (HttpStatusCodeException $exception) { // HTTP error status code (4xx, 5xx) $statusCode = $exception->getResponse()->getStatusCode(); $body = $exception->getMessage(); } catch (RequestException $exception) { // Failed to create or send the request $previous = $exception->getPrevious(); } catch (ResponseException $e) { // Failed to parse or transform the response $previous = $exception->getPrevious(); }
Testing
The interface-driven design makes testing straightforward:
use Mockery; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Wimski\HttpRequests\Contracts\ClientInterface; class UserServiceTest extends TestCase { #[Test] public function test_can_fetch_user(): void { $client = Mockery::mock(ClientInterface::class); $client ->shouldReceive('request') ->once() ->andReturn(new SingleResponse(new User(1, 'John', 'john@example.com'))); $service = new UserService($client); $user = $service->getUser(1); $this->assertEquals('John', $user->name); } }