From f2f9d526ae444e422088998af0c0e3f7e54a7d07 Mon Sep 17 00:00:00 2001 From: Louis Lagrange Date: Thu, 23 Jul 2026 16:22:27 +0100 Subject: [PATCH 1/2] feat: use PSR-18 http interface (or http async) --- README.md | 40 ++++++++++----- composer.json | 17 ++++++- src/WebPush.php | 127 ++++++++++++++++++++++++++++-------------------- 3 files changed, 119 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 31312f5..e61d4b6 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,7 @@ This string will make the vendor show to the user only the last notification of #### batchSize -If you send tens of thousands notifications at a time, you may get memory overflows due to how endpoints are called in Guzzle. +If you send tens of thousands notifications at a time, you may get memory overflows depending on your HTTP client. In order to fix this, WebPush sends notifications in batches. The default size is 1000. Depending on your server configuration (memory), you may want to decrease this number. Do this while instantiating WebPush or calling `setDefaultOptions`. Or, if you want to customize this for a specific flush, give it as a parameter : `$webPush->flush($batchSize)`. @@ -340,23 +340,40 @@ $webPush->setAutomaticPadding(true); // enable automatic padding to default maxi ### Customizing the HTTP client -WebPush uses [Guzzle](https://github.com/guzzle/guzzle). It will use the most appropriate client it finds, -and most of the time it will be `MultiCurl`, which allows to send multiple notifications in parallel. +WebPush is HTTP-client-agnostic: it depends only on [PSR-18](https://www.php-fig.org/psr/psr-18/) (`psr/http-client`) +and [PSR-17](https://www.php-fig.org/psr/psr-17/) (`psr/http-factory`) interfaces, not on any specific implementation. -You can customize the default request options and timeout when instantiating WebPush: +If you don't inject one explicitly, WebPush uses [php-http/discovery](https://github.com/php-http/discovery) to +auto-detect a suitable client/factories among your installed dependencies (Guzzle, Symfony HttpClient, Nyholm PSR-7, +etc). + +To customize the client (timeout, redirects, proxy, etc.), configure your PSR-18 client instance directly and inject +it into WebPush: ```php 20, \GuzzleHttp\RequestOptions::ALLOW_REDIRECTS => false, -]; // see \GuzzleHttp\RequestOptions -$webPush = new WebPush([], [], $timeout, $clientOptions); +]); // see \GuzzleHttp\RequestOptions, or use any other PSR-18 client +$webPush = new WebPush([], [], $client); ``` +#### Concurrent sending (`flushPooled()`) + +`flushPooled()` requires an [HTTPlug](https://github.com/php-http/httplug) async client (`Http\Client\HttpAsyncClient`) +to send notifications concurrently as PSR-18 itself is strictly synchronous. Install an HTTPlug adapter, e.g. for Guzzle: + +```bash +composer require php-http/guzzle7-adapter +``` + +It will be auto-discovered, same as the PSR-18 client. If none is available, `flushPooled()` throws a `\LogicException`. +Use `flush()` if you don't need concurrency. + ## Common questions (FAQ) ### Is there any plugin/bundle/extension for my favorite PHP framework? @@ -383,9 +400,10 @@ Internally, WebPush uses the [WebToken](https://github.com/web-token) framework Here are some ideas: 1. Make sure MultiCurl is available on your server -2. Find the right balance for your needs between security and performance (see above) -3. Find the right batch size (set it in `defaultOptions` or as parameter to `flush()`) -4. Use `flushPooled()` instead of `flush()`. The former uses concurrent requests, accelerating the process and often doubling the speed of the requests. +2. Install an HTTPlug async adapter, e.g. `php-http/guzzle7-adapter` (see "Customizing the HTTP client" above) +3. Find the right balance for your needs between security and performance (see above) +4. Find the right batch size (set it in `defaultOptions` or as parameter to `flush()`) +5. Use `flushPooled()` instead of `flush()`. The former uses concurrent requests, accelerating the process and often doubling the speed of the requests. ### How to solve "SSL certificate problem: unable to get local issuer certificate"? diff --git a/composer.json b/composer.json index 4cbdfa8..5fb82b6 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,11 @@ "ext-json": "*", "ext-mbstring": "*", "ext-openssl": "*", - "guzzlehttp/guzzle": "^7.9.2", + "php-http/discovery": "^1.19", + "php-http/httplug": "^2.4", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1|^2.0", "psr/log": "^2.0|^3.0", "spomky-labs/base64url": "^2.0.4", "symfony/polyfill-php83": "^1.33", @@ -41,10 +45,14 @@ }, "suggest": { "ext-bcmath": "Optional for performance.", - "ext-gmp": "Optional for performance." + "ext-gmp": "Optional for performance.", + "php-http/guzzle7-adapter": "Enables concurrent sending via WebPush::flushPooled() if you use Guzzle." }, "require-dev": { "friendsofphp/php-cs-fixer": "^v3.92.2", + "guzzlehttp/guzzle": "^7.9.2", + "guzzlehttp/psr7": "^2.7", + "php-http/guzzle7-adapter": "^1.1", "phpstan/phpstan": "^2.1.33", "phpstan/phpstan-deprecation-rules": "^2.0", "phpstan/phpstan-phpunit": "^2.0", @@ -56,5 +64,10 @@ "psr-4": { "Minishlink\\WebPush\\": "src" } + }, + "config": { + "allow-plugins": { + "php-http/discovery": true + } } } diff --git a/src/WebPush.php b/src/WebPush.php index 62ae942..9babe61 100644 --- a/src/WebPush.php +++ b/src/WebPush.php @@ -11,18 +11,25 @@ namespace Minishlink\WebPush; use Base64Url\Base64Url; -use GuzzleHttp\Client; -use GuzzleHttp\Pool; -use GuzzleHttp\Exception\ConnectException; -use GuzzleHttp\Exception\RequestException; -use GuzzleHttp\Psr7\Request; +use Http\Client\Exception\HttpException; +use Http\Client\HttpAsyncClient; +use Http\Discovery\HttpAsyncClientDiscovery; +use Http\Discovery\Psr17FactoryDiscovery; +use Http\Discovery\Psr18ClientDiscovery; +use Psr\Http\Client\ClientExceptionInterface; +use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamFactoryInterface; use Psr\Log\LoggerInterface; class WebPush { - protected Client $client; + protected ClientInterface $client; + protected RequestFactoryInterface $requestFactory; + protected StreamFactoryInterface $streamFactory; + protected ?HttpAsyncClient $asyncClient; protected array $auth; protected ?LoggerInterface $logger; @@ -56,7 +63,10 @@ class WebPush * * @param array $auth Some servers need authentication * @param array $defaultOptions TTL, urgency, topic, batchSize, requestConcurrency - * @param int|null $timeout Timeout of POST request + * @param ClientInterface|null $client PSR-18 HTTP client. Defaults to an auto-discovered client (e.g. Guzzle, if installed). Configure timeouts/proxies/redirects directly on this client instance. + * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory. Defaults to an auto-discovered factory. + * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory. Defaults to an auto-discovered factory. + * @param HttpAsyncClient|null $asyncClient Optional HTTPlug async client, required for concurrent sending via flushPooled(). Defaults to an auto-discovered async client, if any is installed. * @param LoggerInterface|null $logger Optional PSR-3 logger; if provided, replaces trigger_error() calls * * @throws \ErrorException @@ -64,8 +74,10 @@ class WebPush public function __construct( array $auth = [], array $defaultOptions = [], - ?int $timeout = 30, - array $clientOptions = [], + ?ClientInterface $client = null, + ?RequestFactoryInterface $requestFactory = null, + ?StreamFactoryInterface $streamFactory = null, + ?HttpAsyncClient $asyncClient = null, ?LoggerInterface $logger = null ) { $this->logger = $logger; @@ -80,10 +92,19 @@ public function __construct( $this->setDefaultOptions($defaultOptions); - if (!array_key_exists('timeout', $clientOptions) && isset($timeout)) { - $clientOptions['timeout'] = $timeout; + $this->client = $client ?? Psr18ClientDiscovery::find(); + $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + + if ($asyncClient) { + $this->asyncClient = $asyncClient; + } else { + try { + $this->asyncClient = HttpAsyncClientDiscovery::find(); + } catch (\Throwable) { + $this->asyncClient = null; + } } - $this->client = new Client($clientOptions); } /** @@ -157,19 +178,13 @@ public function flush(?int $batchSize = null): \Generator // for each endpoint server type $requests = $this->prepare($batch); - $promises = []; - foreach ($requests as $request) { - $promises[] = $this->client->sendAsync($request) - ->then(function ($response) use ($request) { - /** @var ResponseInterface $response **/ - return new MessageSentReport($request, $response); - }) - ->otherwise(fn($reason) => $this->createRejectedReport($reason)); - } - - foreach ($promises as $promise) { - yield $promise->wait(); + try { + $response = $this->client->sendRequest($request); + yield new MessageSentReport($request, $response); + } catch (ClientExceptionInterface $reason) { + yield $this->createRejectedReport($request, $reason); + } } } @@ -181,9 +196,14 @@ public function flush(?int $batchSize = null): \Generator /** * Flush notifications. Triggers concurrent requests. * + * Requires an HTTPlug async client (e.g. via `php-http/guzzle7-adapter`), injected in the + * constructor or auto-discovered. See the "Customizing the HTTP client" section of the README. + * * @param callable(MessageSentReport): void $callback Callback for each notification * @param null|int $batchSize Defaults the value defined in defaultOptions during instantiation (which defaults to 1000). - * @param null|int $requestConcurrency Defaults the value defined in defaultOptions during instantiation (which defaults to 100). + * @param null|int $requestConcurrency Unused. Concurrency is now controlled by the underlying async client's own configuration. + * + * @throws \LogicException If no HTTPlug async client is available */ public function flushPooled(callable $callback, ?int $batchSize = null, ?int $requestConcurrency = null): void { @@ -191,33 +211,36 @@ public function flushPooled(callable $callback, ?int $batchSize = null, ?int $re return; } - if (null === $batchSize) { - $batchSize = $this->defaultOptions['batchSize']; + if (!$this->asyncClient) { + throw new \LogicException('flushPooled() requires an HTTPlug async client for concurrent sending. Install one, e.g. "composer require php-http/guzzle7-adapter", or use flush() for sequential sending.'); } - if (null === $requestConcurrency) { - $requestConcurrency = $this->defaultOptions['requestConcurrency']; + if (null === $batchSize) { + $batchSize = $this->defaultOptions['batchSize']; } $batches = array_chunk($this->notifications, $batchSize); $this->notifications = []; foreach ($batches as $batch) { - $batch = $this->prepare($batch); - $pool = new Pool($this->client, $batch, [ - 'concurrency' => $requestConcurrency, - 'fulfilled' => function (ResponseInterface $response, int $index) use ($callback, $batch): void { - /** @var RequestInterface $request **/ - $request = $batch[$index]; - $callback(new MessageSentReport($request, $response)); - }, - 'rejected' => function ($reason) use ($callback): void { - $callback($this->createRejectedReport($reason)); - }, - ]); - - $promise = $pool->promise(); - $promise->wait(); + $requests = $this->prepare($batch); + + $promises = []; + foreach ($requests as $request) { + $promises[] = $this->asyncClient->sendAsyncRequest($request) + ->then( + function (ResponseInterface $response) use ($callback, $request): void { + $callback(new MessageSentReport($request, $response)); + }, + function (\Throwable $reason) use ($callback, $request): void { + $callback($this->createRejectedReport($request, $reason)); + } + ); + } + + foreach ($promises as $promise) { + $promise->wait(); + } } if ($this->reuseVAPIDHeaders) { @@ -225,15 +248,11 @@ public function flushPooled(callable $callback, ?int $batchSize = null, ?int $re } } - protected function createRejectedReport(RequestException|ConnectException $reason): MessageSentReport + protected function createRejectedReport(RequestInterface $request, \Throwable $reason): MessageSentReport { - if ($reason instanceof RequestException) { - $response = $reason->getResponse(); - } else { - $response = null; - } + $response = $reason instanceof HttpException ? $reason->getResponse() : null; - return new MessageSentReport($reason->getRequest(), $response, false, $reason->getMessage()); + return new MessageSentReport($request, $response, false, $reason->getMessage()); } /** @@ -315,7 +334,11 @@ protected function prepare(array $notifications): array } } - $requests[] = new Request('POST', $endpoint, $headers, $content); + $request = $this->requestFactory->createRequest('POST', $endpoint); + foreach ($headers as $name => $value) { + $request = $request->withHeader($name, $value); + } + $requests[] = $request->withBody($this->streamFactory->createStream($content)); } return $requests; From 5a56ac4dde095a8d96b56e5991c3454c5857da43 Mon Sep 17 00:00:00 2001 From: Louis Lagrange Date: Thu, 23 Jul 2026 16:27:25 +0100 Subject: [PATCH 2/2] fix: classify HTTP error responses as failures in flush()/flushPooled() PSR-18 clients (per spec) never throw for 4xx/5xx status codes, only for transport-level failures. Guzzle's sendRequest() implementation explicitly disables http_errors to comply, so a 410 "Gone" response was silently reported as success. Classify success from the response status code instead of relying solely on thrown exceptions. --- src/WebPush.php | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/WebPush.php b/src/WebPush.php index 9babe61..a819062 100644 --- a/src/WebPush.php +++ b/src/WebPush.php @@ -181,7 +181,7 @@ public function flush(?int $batchSize = null): \Generator foreach ($requests as $request) { try { $response = $this->client->sendRequest($request); - yield new MessageSentReport($request, $response); + yield $this->createReport($request, $response); } catch (ClientExceptionInterface $reason) { yield $this->createRejectedReport($request, $reason); } @@ -230,7 +230,7 @@ public function flushPooled(callable $callback, ?int $batchSize = null, ?int $re $promises[] = $this->asyncClient->sendAsyncRequest($request) ->then( function (ResponseInterface $response) use ($callback, $request): void { - $callback(new MessageSentReport($request, $response)); + $callback($this->createReport($request, $response)); }, function (\Throwable $reason) use ($callback, $request): void { $callback($this->createRejectedReport($request, $reason)); @@ -248,6 +248,24 @@ function (\Throwable $reason) use ($callback, $request): void { } } + /** + * PSR-18 clients only throw for transport-level failures (DNS, connection refused, ...); + * HTTP error status codes (4xx, 5xx) are returned as a normal response and must be + * classified here. + */ + protected function createReport(RequestInterface $request, ResponseInterface $response): MessageSentReport + { + $statusCode = $response->getStatusCode(); + if ($statusCode >= 400) { + $reasonPhrase = $response->getReasonPhrase(); + $reason = '' !== $reasonPhrase ? $reasonPhrase : 'Push service responded with status code '.$statusCode; + + return new MessageSentReport($request, $response, false, $reason); + } + + return new MessageSentReport($request, $response); + } + protected function createRejectedReport(RequestInterface $request, \Throwable $reason): MessageSentReport { $response = $reason instanceof HttpException ? $reason->getResponse() : null;