# Web-Push

Web Notifications made easy

![Illustration by Freepik Stories (https://stories.freepik.com/communication)](https://276909137-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MMvwiWqrzksniHHOh6C%2F-MMvwm4Lm8hGD06g7gg-%2F-MMvzcusVoqmgzy7Bnln%2FMessages-rafiki.svg?alt=media\&token=02743d23-586d-4c24-8ac1-8db7f87f0c65)

WebPush can be used to send notifications to endpoints which server delivers Web Push notifications as described in the following specifications

* The [RFC8030: Generic Event Delivery Using HTTP Push](https://tools.ietf.org/html/rfc8030)
* The [RFC8291: “Message Encryption for Web Push](https://tools.ietf.org/html/rfc8291)
* The [RFC8292: Voluntary Application Server Identification (VAPID) for Web Push](https://tools.ietf.org/html/rfc8292)

In addition, some features from the [Push API](https://w3c.github.io/push-api/) are implemented. This specification is a working draft at the time of writing (2020-11).

This project allows sending notifications on compatible browsers. List and versions available at <https://caniuse.com/push-api>


# Requirements

## Mandatory

* PHP 8.0+
* A PSR-17 (HTTP Message Factory) implementation
* A PSR-18 (HTTP Client) implementation
* The `JSON` extension

## Optional

* A PSR-3 (Logger Interface) implementation for debugging

## Extension Specific

### VAPID extension

* Required:
  * `openssl` extension
  * `mbstring` extension
  * A JWT Provider
* Optional:
  * A PSR-3 (Logger Interface) implementation for debugging

{% hint style="success" %}
This library provides JWT Provider implementations for [web-token](https://web-token.spomky-labs.com) and [lcobucci/jwt](https://github.com/lcobucci/jwt)
{% endhint %}

### Payload extension

* Required:
  * `openssl` extension
  * `mbstring` extension
* Optional:
  * A PSR-6 (Caching Interface) implementation
  * A PSR-3 (Logger Interface) implementation for debugging


# Fluent Syntax

In the documentation, you will see that methods are called “fluently”.

```php
<?php

use WebPush\Payload\AES128GCM;
use WebPush\Payload\AESGCM;
use WebPush\Payload\PayloadExtension;

$payloadExtension = PayloadExtension::create()
    ->addContentEncoding(AESGCM::create()->maxPadding())
    ->addContentEncoding(AES128GCM::create()->maxPadding())
;
```

If you don’t adhere to this coding style, you are free to use the “standard” way of coding. The following example has the same behavior ase above.

```php
<?php

use WebPush\Payload\AES128GCM;
use WebPush\Payload\AESGCM;
use WebPush\Payload\PayloadExtension;

$aesgcm = new AESGCM();
$aesgcm->maxPadding();

$aes128gcm = new AES128GCM();
$aes128gcm->maxPadding();

$payloadExtension = new PayloadExtension();
$payloadExtension->addContentEncoding($aesgcm);
$payloadExtension->addContentEncoding($aes128gcm);
```


# Contributing

First of all, **thank you** for contributing.

Bugs or feature requests can be posted online on the GitHub issues section of the project.

Few rules to ease code reviews and merges:

* You MUST follow the [PSR-12](https://www.php-fig.org/psr/psr-12/) coding standard.
* You MUST run the test suite (see below).
* You MUST write (or update) unit tests when bugs are fixed or features are added.
* You SHOULD write documentation.
* You MAY follow the [PSR-5](https://github.com/php-fig/fig-standards/blob/master/proposed/phpdoc.md) and [PSR-19](https://github.com/php-fig/fig-standards/blob/master/proposed/phpdoc-tags.md).

We use the following branching workflow:

* Each minor version has a dedicated branch (e.g. v1.1, v1.2, v2.0, v2.1…)
* The default branch is set to the last minor version (e.g. v2.1).

To contribute use [Pull Requests](https://help.github.com/articles/using-pull-requests), please, write commit messages that make sense, and rebase your branch before submitting your PR.

Your PR **should NOT** be submitted to the master branch but to the last minor version branch or to another minor version in case of bug fix.


# License

The MIT License (MIT)

Copyright (c) 2020-2021 Spomky-Labs

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.�


# Overview

The Web Push protocol allows your application to easily engage users by sending notifications to the browser. The subscription to these notifications are done by the user (opt-in).

The notification types depend on the application. For example, it could be a notification for an internal message or an alert before account closure.

We will see in this documentation that the Web Push API offers several options to customize the notifications by adding buttons, vibration schema, images, urgency indictor and more.

You want to test it? Please go to [this demo page](https://serviceworke.rs/push-payload_demo.html) to see what your browser already supports.


# The Subscription

The subscription is created on client side when the end-user allows your application to send push messages.

On client side (Javascript), you can simply send to your server the object you receive using `JSON.stringify`.

{% hint style="info" %}
Javascript examples to get a Subscription from the web browser are not provided here. Please refer to other resources such as blog posts or library documentation.
{% endhint %}

A subscription object will look like:

```javascript
{
 "endpoint":"https://updates.push.services.mozilla.com/wpush/v2/AAAAAAAA[…]AAAAAAAAA",
 "keys":{
 "auth":"XXXXXXXXXXXXXX",
 "p256dh":"YYYYYYYY[…]YYYYYYYYYYYYY"
 }
}
```

On server side, you can get a `WebPush\Subscription` object from the JSON string using the dedicated method `WebPush\Subscription::createFromString`.

```php
<?php

use WebPush\Subscription;

$subscription = Subscription::createFromString('{"endpoint":"https://updates.push.services.mozilla.com/wpush/v2/AAAAAAAA[…]AAAAAAAAA","keys":{"auth":"XXXXXXXXXXXXXX","p256dh":"YYYYYYYY[…]YYYYYYYYYYYYY"}}');
```

## Supported Content Encodings

By default, the content encoding `aesgcm` will be used. This encoding indicates how the payload of the notification should be formatted. The PushManager object from the Push API may list all acceptable encodings. In this case, it could be interesting to set these encodings to the Subscription object.

```javascript
// Retreive the supported content encodings
const supportedContentEncodings = PushManager.supportedContentEncodings || ['aesgcm'];

// Assign the encodings to the subscription object
const jsonSubscription = Object.assign(
    subscription.toJSON(),
    { supportedContentEncodings }
);

// Send the subscription object to the application server
fetch('/subscription/add', {
    method: 'POST',
    body: JSON.stringify(jsonSubscription),
});
```

This will result in something like as follow:

```javascript
{
 "endpoint":"https://updates.push.services.mozilla.com/wpush/v2/AAAAAAAA[…]AAAAAAAAA",
 "keys":{
 "auth":"XXXXXXXXXXXXXX",
 "p256dh":"YYYYYYYY[…]YYYYYYYYYYYYY",
 "supportedContentEncodings":["aes128gcm","aesgcm"]
 }
}
```

{% hint style="warning" %}
The order of `supportedContentEncodings` is important. First supported item will be used. If possible, `AES128GCM` should be used as prefered content encoding.
{% endhint %}


# The Notification

To reach the client (web browser), you need to send a Notification to the Subscription.

```php
<?php
use WebPush\Notification;

$notification = Notification::create();
```

The Notification should have a payload. In this case, the payload will be encrypted on server side and decrypted by the client.

That payload may be a string or a JSON object. The structure of the latter is described in the next section.

```php
<?php
use WebPush\Notification;

$notification = Notification::create()
    ->withPayload('Hello world')
;
```

## TTL (Time-To-Live)

With this feature, a value in seconds is added to the notification. It suggests how long a push message is retained by the push service. A value of 0 (zero) indicates the notification is delivered immediately.

```php
<?php
use WebPush\Notification;

$notification = Notification::create()
    ->withTTL(3600)
;
```

## Topic

A push message that has been stored by the push service can be replaced with new content. If the user agent is offline during the time the push messages are sent, updating a push message avoids the situation where outdated or redundant messages are sent to the user agent.

Only push messages that have been assigned a topic can be replaced. A push message with a topic replaces any outstanding push message with an identical topic.

```php
<?php
use WebPush\Notification;

$notification = Notification::create()
    ->withTopic('user-account-updated')
;
```

## Urgency

For a device that is battery-powered, it is often critical it remains dormant for extended periods.

Radio communication in particular consumes significant power and limits the length of time the device can operate.

To avoid consuming resources to receive trivial messages, it is helpful if an application server can communicate the urgency of a message and if the user agent can request that the push server only forwards messages of a specific urgency.

| Urgency  | Device State               | Examples                                    |
| -------- | -------------------------- | ------------------------------------------- |
| very-low | On power and Wi-Fi         | Advertisements                              |
| low      | On either power or Wi-Fi   | Topic updates                               |
| normal   | On neither power nor Wi-Fi | Chat or Calendar Message                    |
| high     | Low battery                | Incoming phone call or time-sensitive alert |

{% hint style="warning" %}
Be carful with the `very-low` urgency: it is not recognized by all Web-Push services
{% endhint %}

```php
<?php
use WebPush\Notification;

$notification = Notification::create()
    ->veryLowUrgency()
    ->lowUrgency()
    ->normalUrgency()
    ->highUrgency()
;
```

## Asynchronous Response

Your application may prefer asynchronous responses to request confirmation from the push service when a push message is delivered and then acknowledged by the user agent. The push service MUST support delivery confirmations to use this feature.

```php
<?php
use WebPush\Notification;

$notification = Notification::create()
    ->async() // Prefer async response
    ->sync() // Prefer sync response (default)
;
```

{% hint style="warning" %}
The `async` mode is not recognised by all Web Push services. In case of failure, you should try sending `sync`notifications.
{% endhint %}

## JSON Messages

As mentioned in the overview section, the specification [defines a structure for the payload](https://developer.mozilla.org/en-US/docs/Web/API/Notification/Notification#parameters). This structure contains properties that the client should be understood and render an appropriate way.

The library provides a `WebPush\Message` class with convenient methods to ease the creation of a message.

```php
<?php
use WebPush\Action;
use WebPush\Message;
use WebPush\Notification;

$message = Message::create('This is the title', null, true)
    ->mute() // Silent
    ->unmute() // Not silent (default)

    ->auto() //Direction = auto (default)
    ->ltr() //Direction = left to right
    ->rtl() //Direction = right to left

    ->addAction(Action::create('alert', 'Click me!'))

    ->interactionRequired()
    ->noInteraction()

    ->renotify()
    ->doNotRenotify() // Default
    
    ->withBody('Hello World!')

    ->withIcon('https://…')
    ->withImage('https://…')
    ->withData(['foo' => 'BAR']) // Arbitrary data
    ->withBadge('badge1')
    ->withLang('fr-FR')
    ->withTimestamp(time())
    ->withTag('foo')

    ->vibrate(300, 100, 400)

    ->toString() // Converts the Message object into a string
;

$notification = Notification::create()
    ->withPayload($message)
;
```

{% hint style="info" %}
Please note that the second and the third parameters are needed for `v1.1+` branch but bill be removed in `v2.0`.
{% endhint %}

The resulting notification payload will look like as follow:

```javascript
{
    "title":"This is the title",
    "options":{
        "actions":[
            {
                "action":"alert",
                "title":"Click me!"
            }
        ],
        "badge":"badge1",
        "body":"Hello World!",
        "data":{
            "foo":"BAR"
        },
        "dir":"rtl",
        "icon":"https://…",
        "image":"https://…",
        "l ang":"fr-FR",
        "renotify":false,
        "requireInteraction":false,
        "silent":false,
        "tag":"foo",
        "timestamp":1629145424,
        "vibrate":[
            300,
            100,
            400
        ]
    }
}
```

On client side, you can easily load that payload and display the notification:

```javascript
  const {title, options}  = payload;
  const notification = new Notification(title, options);
```


# The Status Report

After sending a notification, you will receive a StatusReport object.

This status report has the following properties:

* The [notification](/v2.0/common-concepts/the-notification)
* The [subscription](/v2.0/common-concepts/the-subscription)
* The PSR-7 request
* The PSR-7 response

{% hint style="warning" %}
Because of the presence of the Request and Response object, the StatusReport object cannot be serialized.
{% endhint %}

Depending on the status code, you will be able to know if it is a success or not. In case of success, you can directly access the management link (`location` header parameter) or the links entity fields in case of asynchronous call. In case of failure, the response code indicates the main reason for rejection (invalid authorization token, expired endpoint...)

```php
<?php
use WebPush\Subscription;
use WebPush\Notification;
use WebPush\WebPushService;

/** @var Notification $notification */
/** @var Subscription $subscription */
/** @var WebPushService $webPushService */
$statusReport = $webPushService->send($notification, $subscription);

if(!$statusReport->isSuccess()) {
    //Something went wrong
} else {
    $statusReport->getLocation();
    $statusReport->getLinks();
}
```

One of the failure reasons could be the expiration of the subscription (too old or cancelled by the end-user). This can be checked with the method `hasExpired()`. In this case, you should simply delete the subscription as it is not possible to send notifications anymore.

```php
<?php

if($statusReport->hasExpired()) {
    $this->subscriptionRepository->remove($subscription);
}
```


# VAPID

Voluntary Application Server Identification

“**VAPID**” stands for “**V**oluntary **Ap**plication Server **Id**entification”.

This feature allows to application server to send information about itself to a push service.

A consistent identity can be used by a push service to establish behavioral expectations for an application server. Significant deviations from an established norm can then be used to trigger exception-handling procedures.

Voluntarily provided contact information can be used to contact an application server operator in the case of exceptional situations. Additionally, the design of RFC8030 relies on maintaining the secrecy of push message subscription URIs.

Any application server in possession of a push message subscription URI is able to send messages to the user agent.

If use of a subscription could be limited to a single application server, this would reduce the impact of the push message subscription URI being learned by an unauthorized party.

In order to use this feature, you must generate ECDSA key pairs. Hereafter an example using OpenSSL.

```bash
openssl ecparam -genkey -name prime256v1 -out private_key.pem
openssl ec -in private_key.pem -pubout -outform DER|tail -c 65|base64|tr -d '=' |tr '/+' '_-' >> public_key.txt
openssl ec -in private_key.pem -outform DER|tail -c +8|head -c 32|base64|tr -d '=' |tr '/+' '_-' >> private_key.txt
```


# Installation

The library can be installed using the package `spomky-labs/web-push-lib`

In addition to this package, you must install the required dependencies that are namely:

* [A HTTP Client implementation](https://packagist.org/providers/psr/http-client-implementation)
* [A PSR7 Request Factory implementation](https://packagist.org/providers/psr/http-factory-implementation)

In the following example, we will install `nyholm/prs7` and `symfony/http-client`.

```bash
composer require nyholm/psr7 symfony/http-client spomky-labs/web-push-lib
```

## VAPID Header

The [VAPID header](/v2.0/common-concepts/vapid) authenticates your server and prevent malicious application to send notifications to your users. The header contains a signed JSON Web Token (JWS).

The library provides bridges for the following libraries `web-token` and `lcobucci/jwt`.

Please install `web-token/jwt-signature-algorithm-ecdsa` or `lcobucci/jwt` depending on the library you want to use.

{% hint style="info" %}
It is possible to use any other JWS provider. This will be detailed in the future.
{% endhint %}


# The Extension Manager

The Web Push service requires an Extension Manager. This object manages extensions that will manipulate the request before sending it to the Push Service.

In the example below, we add all basic extensions.

```php
use WebPush\ExtensionManager;
use WebPush\PreferAsyncExtension;
use WebPush\TopicExtension;
use WebPush\TTLExtension;
use WebPush\UrgencyExtension;

$extensionManager = ExtensionManager::create()
    ->add(TTLExtension::create())
    ->add(UrgencyExtension::create())
    ->add(TopicExtension::create())
    ->add(PreferAsyncExtension::create())
;
```

{% hint style="info" %}
Please note that the TTL Extension is usually required by Push Services. To avoid any trouble, please use all extensions.
{% endhint %}

## Payload Extension

The payload extension allows Notifications to have a payload. This extension requires Content Encoding objects that will be responsible of the payload encryption.

The library provides the `AESGCM` and `AES128GCM` content encoding. These encodings are normally supported by all Push Services. The library is able to support any future encoding is deemed necessary.

```php
$payloadExtension = PayloadExtension::create()
    ->addContentEncoding(AESGCM::create())
    ->addContentEncoding(AES128GCM::create())
;

$extensionManager = ExtensionManager::create()
    ->add($payloadExtension)
;
```

## VAPID Extension

The [VAPID header](/v2.0/common-concepts/vapid) authenticates your server and prevent malicious application to send notifications to your users. The header contains a signed JSON Web Token (JWS).

The library provides bridges for the following libraries `web-token` and `lcobucci/jwt`.

Please install `web-token/jwt-signature-algorithm-ecdsa` or `lcobucci/jwt` depending on the library you want to use.

```php
use WebPush\VAPID\WebTokenProvider;
use WebPush\VAPID\LcobucciProvider;

// Web-Token
$jwsProvider = WebTokenProvider::create(
    'BB4W1qfBi7MF_Lnrc6i2oL-glAuKF4kevy9T0k2vyKV4qvuBrN3T6o9-7-NR3mKHwzDXzD3fe7XvIqIU1iADpGQ', // Public key
    'C40jLFSa5UWxstkFvdwzT3eHONE2FIJSEsVIncSCAqU' // Private key
);

// lcobucci/jwt
$jwsProvider = LcobucciProvider::create(
    'BB4W1qfBi7MF_Lnrc6i2oL-glAuKF4kevy9T0k2vyKV4qvuBrN3T6o9-7-NR3mKHwzDXzD3fe7XvIqIU1iADpGQ',
    'C40jLFSa5UWxstkFvdwzT3eHONE2FIJSEsVIncSCAqU'
);

$extensionManager = ExtensionManager::create()
    ->add(VAPIDExtension::create('http://my-service.com', $jwsProvider)
);
```

{% hint style="danger" %}
The public key used with your server shall be the same as the one in your Javascript application.
{% endhint %}

{% hint style="warning" %}
If this public/private key changes, subscriptions will become invalid.
{% endhint %}


# The Web Push Service

The WebPush object requires a PSR-17 Request Factory, a PSR-18 Http Client and an [Extension Manager](/v2.0/the-library/advanced-service).

```php
use Nyholm\Psr7\Factory\Psr17Factory;
use Symfony\Component\HttpClient\Psr18Client;
use WebPush\WebPush;

$client = new Psr18Client();
$requestFactory = new Psr17Factory();

$service = new WebPush($client, $requestFactory, $extensionManager);
```

The service is now ready to send Notifications to the Subscriptions. The StatusReport object that is returned [is explained here](/v2.0/common-concepts/the-status-report).

```php
<?php

use WebPush\Subscription;
use WebPush\Notification;

$subscription = Subscription::createFromString('{"endpoint":"https://updates.push.services.mozilla.com/wpush/v2/AAAAAAAA[…]AAAAAAAAA","keys":{"auth":"XXXXXXXXXXXXXX","p256dh":"YYYYYYYY[…]YYYYYYYYYYYYY"}}');
$notification = Notification::create()
    ->withPayload('Hello world')
;

$statusReport = $service->send($notification, $subscription);
```

{% hint style="info" %}
In this example, we load the Subscription object from a string, but usually to retrieve the Subscription objects from a database or a dedicated storage.
{% endhint %}


# Installation

The bundle can be installed using the package `spomky-labs/web-push-bundle`

In addition to this package, you must install the required dependencies that are namely:

* [A HTTP Client implementation](https://packagist.org/providers/psr/http-client-implementation)
* [A PSR7 Request Factory implementation](https://packagist.org/providers/psr/http-factory-implementation)

In the following example, we will install `nyholm/prs7` and `symfony/http-client`.

```bash
composer require nyholm/psr7 symfony/http-client spomky-labs/web-push-bundle
```

If you use Symfony Flex, the bundle is ready to be used. Otherwise, you must enable it. The bundle class is `WebPush\Bundle\WebPushBundle`.

When done, the bundle is ready and can send the notifications. However, there are extra packages we highly recommend to install and set up.

## VAPID Header

The [VAPID header](/v2.0/common-concepts/vapid) authenticates your server and prevent malicious application to send notifications to your users. The header contains a signed JSON Web Token (JWS).

The library provides bridges for the following libraries `web-token` and `lcobucci/jwt`.

Please install `web-token/jwt-signature-algorithm-ecdsa` or `lcobucci/jwt` depending on the library you want to use.

{% hint style="info" %}
It is possible to use any other JWS provider. This will be detailed in the future.
{% endhint %}


# Configuration

## VAPID Support

To enable the VAPID header feature, you must install a JWS Provider (see [installation](/v2.0/the-symfony-bundle/installation)) and configure it with your public and private key (see [this page](/v2.0/common-concepts/vapid) to create these keys)

{% code title="config/packages/webpush.yaml" %}

```yaml
webpush:
  vapid:
    enabled: true # Enable the feature
    subject: 'https://my-service.com:8000' # An URL or an email address
    web_token:
      enabled: true # We use web-token in this example
      public_key: 'BB4W1qfBi7MF_Lnrc6i2oL-glAuKF4kevy9T0k2vyKV4qvuBrN3T6o9-7-NR3mKHwzDXzD3fe7XvIqIU1iADpGQ'
      private_key: 'C40jLFSa5UWxstkFvdwzT3eHONE2FIJSEsVIncSCAqU'
```

{% endcode %}

When using `lcobucci/jwt`, the configuration is very similar.

{% code title="config/packages/webpush.yaml" %}

```yaml
webpush:
  vapid:
    enabled: true
    subject: 'https://my-service.com:8000'
    lcobucci:
      enabled: true # We use web-token in this example
      public_key: 'BB4W1qfBi7MF_Lnrc6i2oL-glAuKF4kevy9T0k2vyKV4qvuBrN3T6o9-7-NR3mKHwzDXzD3fe7XvIqIU1iADpGQ'
      private_key: 'C40jLFSa5UWxstkFvdwzT3eHONE2FIJSEsVIncSCAqU'
```

{% endcode %}

{% hint style="danger" %}
You cannot enable both `web-token` and `lcobucci/jwt` at the same time
{% endhint %}

### Token Lifetime

By default, the library generates VAPID headers that are valid for 1 hour. You can change this value if needed. The parameter requires a relative string as showed [in the PHP documentation](https://www.php.net/manual/en/datetime.formats.relative.php).

{% code title="config/packages/webpush.yaml" %}

```yaml
webpush:
  vapid:
    enabled: true
    token_lifetime: 'now +2 hours'
```

{% endcode %}

{% hint style="warning" %}
The token lifetime should not be greater than 24 hours. Most of the Web Push Services will reject such long-life tokens
{% endhint %}

## Payload Support

### Padding

To obfuscate the real length of the notifications, messages can be padded before encryption. This operation consists in the concatenation of your message and arbitrary data in front of it. When encrypted, the messages will have the same size which reduces attacks.

By default, the padding is set to `recommended` i.e. \~3k bytes.

Acceptable values for this parameter are:

* `none`: no padding
* `recommended`: default value
* `max`: see warning below
* an integer: should be between `0` and `4078` or `3993` for `AESGCM` and `AES128GCM` respectively

{% code title="config/packages/webpush.yaml" %}

```yaml
webpush:
  payload:
    aesgcm:
      padding: 'none' # "none", "recommended", "max" or an integer (0 to 4078)
    aes128gcm:
      padding: 'none' # "none", "recommended", "max" or an integer (0 to 3993)
```

{% endcode %}

{% hint style="danger" %}
Please don't use "`none`" unless your are sending notifications in a development environment.
{% endhint %}

{% hint style="warning" %}
The value "`max`" increases the integrity protection of the messages, but there are known issues on Android and notification are not correctly delivered.
{% endhint %}

### Caching

The notifications [may have a payload](/v2.0/common-concepts/the-notification#json-messages). This payload is encrypted on server side and, during this process, a random key is generated.

The creation of this random key takes approximately 150ms and can impact your server performance when sending thousand of notifications at once.

To reduce the impact on your server, you can enable the caching feature and reuse the encryption key for a defined period of time.

{% hint style="danger" %}
As encryption keys will be stored in the cache, you should make sure the cache is not shared otherwise you may have a security issue.
{% endhint %}

This parameter requires a PSR-6 Cache compatible service. If you set `Psr\Log\CacheItemPoolInterface`, the default Symfony cache will be used.

{% code title="config/packages/webpush.yaml" %}

```yaml
webpush:
  payload:
    aesgcm:
      cache: Psr\Cache\CacheItemPoolInterface
      cache_lifetime: '+1 hour' #Default: now +30min
    aes128gcm:
      cache: Psr\Cache\CacheItemPoolInterface
      cache_lifetime: '+1 hour' #Default: now +30min
```

{% endcode %}

{% hint style="success" %}
You can see the impact of this feature on the CI/CD Pipelines of this library. Go the <https://github.com/Spomky-Labs/web-push/actions?query=workflow%3ABenchmark> and find a summary table displayed at the end of each test.
{% endhint %}

## Debugging

If you have troubles sending notifications, you can log some messages from the libray. To do so, you just have to set the parameter logger in the configuration.

This parameter requires a PSR-3 logger. If you set `Psr\Log\LoggerInterface`, the Symfony logger will be used (PSR-3 copmpatible).

{% code title="config/packages/webpush.yaml" %}

```yaml
webpush:
  logger: Psr\Log\LoggerInterface
```

{% endcode %}


# The Web Push Service

The bundle provides a public Web Push service that you can inject this service into your application components.

In the following example, let's imagine that a notification is dispatched using the Symfony Messanger component and catched by an event handler. This handler will fetch all subscriptions and send the notification.

{% hint style="info" %}
The SubscriptionRepository class is totally fictive
{% endhint %}

{% code title="src/MessageHandler/SendNotification.php" %}

```php
<?php

declare(strict_types=1);

namespace App\MessageHandler;

use App\Message\SubscriptionExpired;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use WebPush\Notification;
use WebPush\WebPush;

final class SendPushNotifications implements MessageHandlerInterface
{
    private MessageBusInterface $messageBus;
    private SubscriptionRepository $repository;
    private WebPush $webPush;

    public function __construct(MessageBusInterface $messageBus, SubscriptionRepository $repository, WebPush $webPush)
    {
        $this->messageBus = $messageBus;
        $this->repository = $repository;
        $this->webPush = $webPush;
    }

    public function __invoke(Notification $notification): void
    {
        // Fetch all subscriptions
        $subscriptions = $this->repository->fetchAllSubscriptions();
        foreach ($subscriptions as $subscription) {
            //Sends the notification to the subscriber
            $report = $this->webPush->send($notification, $subscription);

            //If the subscription expired
            if ($report->subscriptionExpired()) {
                //We dispatch a new message and expect for
                // the subscription to be deleted
                $this->messageBus->dispatch(
                    new SubscriptionExpired($subscription)
                );
            }
        }
    }
}
```

{% endcode %}


# Doctrine

The bundle provides new Doctrine type and Schema to simplify the way you store the `Subscription` objects with Doctrine.

## Using The Doctrine Mapping

### Configuration

To enable this feature, the following configuration option  shall be set:

```yaml
webpush:
    doctrine_mapping: true
```

This will tell the bundle to register the Subscription object as a Doctrine mapped-superclass. The DoctrineBundle shall be enabled. No additional configuration is required.

### The `Subscription` Entity

First of all, we need to create a Subscription Entity that extends the Subscription object. In this example, we also need to associate one or more Subscription entities to a specific user (Many To One relationship).

{% code title="src/Entity/Subscription.php" %}

```php
<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use WebPush\Subscription as WebPushSubscription;

/**
 * @ORM\Table(name="subscriptions")
 * @ORM\Entity
 */
class Subscription extends WebPushSubscription
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private ?int $id = null;
    
    /**
     * @ORM\ManyToOne(targetEntity="User", inversedBy="subscriptions", cascade={"persist"})
     * @ORM\JoinColumn(name="user_id", referencedColumnName="id", nullable="true")
     */
    private ?User $user;

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getUser(): ?User
    {
        return $this->user;
    }

    public function setUser(?User $user): self
    {
        $this->user = $user;

        return $this;
    }

    // We need to override this method as it returns a WebPush\Subscription and we want an entity
    public static function createFromString(string $input): self
    {
        $base = BaseSubscription::createFromString($input);
        $object = new self($base->getEndpoint());
        $object->withContentEncodings($base->getSupportedContentEncodings());
        foreach ($base->getKeys()->all() as $k => $v) {
            $object->getKeys()->set($k, $v);
        }

        return $object;
    }
}
```

{% endcode %}

{% hint style="info" %}
In this exaple, we assume you already have a valid User entity class.
{% endhint %}

### The `User` Entity

Now, to have a bidirectional relationship between this class and the User entity class, we will add this relationship to the User class.

{% code title="src/Entity/User.php" %}

```php
<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Table(name="users")
 * @ORM\Entity
 */
class User //Usual interface here
{
    //Usual user stuff here

    /**
     * @ORM\OneToMany(targetEntity="Subscription", mappedBy="user")
     */
    private Collection $subscriptions;
    
    public function __construct()
    {
        $this->notifications = new ArrayCollection();
    }

    /**
     * @return Notification[]
     */
    public function getSubscriptions(): array
    {
        return $this->notifications->toArray();
    }

    public function addSubscription(Subscription $subscription): self
    {
        $subscription->setUser($this);
        $this->subscriptions->add($subscription);

        return $this;
    }

    public function removeSubscription(Subscription $subscription): self
    {
        $child->setUser(null);
        $this->subscriptions->removeElement($subscription);

        return $this;
    }
}
```

{% endcode %}

## Sending Notifications To A User

Now that your entities are set, you can register Subcriptions and assign them to your users. To send a Notification to a specific user, you just have to get all subscriptions using `$user->getSubscriptions()`.

{% code title="" %}

```php
$subscriptions = $user->getSubscriptions();
foreach ($subscriptions as $subscription) {
    $report = $this->webPush->send($notification, $subscription);
    if ($report->subscriptionExpired()) {
        //...Remove this subscription
    }
}
```

{% endcode %}

## Using Doctrine Type

The bundle auto-register the Doctrine type webpush\_subscription. The Subscription object will automatically be converted.

{% code title="src/Entity/Subscription.php" %}

```php
<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use WebPush\Subscription as WebPushSubscription;

/**
 * @ORM\Table(name="subscriptions")
 * @ORM\Entity
 */
class Subscription
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private ?int $id = null;
    
    /**
     * @ORM\ManyToOne(targetEntity="User", inversedBy="subscriptions", cascade={"persist"})
     * @ORM\JoinColumn(name="user_id", referencedColumnName="id", nullable="true")
     */
    private ?User $user;
    
    /**
     * @ORM\Column(type="webpush_subscription")
     */
    private WebPushSubscription $subscription;

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getUser(): ?User
    {
        return $this->user;
    }

    public function setUser(?User $user): self
    {
        $this->user = $user;

        return $this;
    }

    public function getSubscription(): WebPushSubscription
    {
        return $this->subscription;
    }
}
```

{% endcode %}

## Using Your Own Entity Class

{% hint style="info" %}
New in v1.1!
{% endhint %}

Starting with v1.1, it is possible to use your own Subscription entity class. The only constraint is that it shall implement the interface `WebPush\SubscriptionInterface` or shall have a method that returns an object that implements this interface.

```php
<?php

declare(strict_types=1);

namespace App\Entity;

use function array_key_exists;
use Assert\Assertion;
use DateTimeInterface;
use Safe\DateTimeImmutable;
use function Safe\json_decode;

class Subscription implements SubscriptionInterface
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private ?int $id = null;
    
    /**
     * @ORM\Column(type="integer")
     */
    private string $endpoint;

    /**
     * @ORM\Column(type="array")
     *
     * @var string[]
     */
    private array $keys = [];

    /**
     * @ORM\Column(type="array")
     *
     * @var string[]
     */
    private array $supportedContentEncodings = ['aesgcm'];

    /**
     * @ORM\Column(type="integer", nullable=true)
     */
    private ?int $expirationTime = null;

    public function __construct(string $endpoint)
    {
        $this->endpoint = $endpoint;
    }

    /**
     * @param string[] $contentEncodings
     */
    public function setContentEncodings(array $contentEncodings): self
    {
        $this->supportedContentEncodings = $contentEncodings;

        return $this;
    }

    public function getKeys(): array
    {
        return $this->keys;
    }

    public function hasKey(string $key): bool
    {
        return isset($this->keys[$key]);
    }

    /**
     * @return array<string, string>
     */
    public function getKey(string $key): string
    {
        Assertion::keyExists($this->keys, $key, 'The key does not exist');

        return $this->keys[$key];
    }

    public function setKeys(array $keys): self
    {
        $this->keys = $keys;

        return $this;
    }

    public function getExpirationTime(): ?int
    {
        return $this->expirationTime;
    }

    public function setExpirationTime(?int $expirationTime): self
    {
        $this->expirationTime = $expirationTime;

        return $this;
    }

    public function getEndpoint(): string
    {
        return $this->endpoint;
    }

    /**
     * @return string[]
     */
    public function getSupportedContentEncodings(): array
    {
        return $this->supportedContentEncodings;
    }

    /**
     * @return array<string, string|string[]>
     */
    public function jsonSerialize(): array
    {
        return [
            'endpoint' => $this->endpoint,
            'supportedContentEncodings' => $this->supportedContentEncodings,
            'keys' => $this->keys,
        ];
    }
}
```


# Example

Please have a look at the demo available at <https://github.com/Spomky-Labs/web-push-demo>.


# Web-Push

Web Notifications made easy

![Illustration by Freepik Stories (https://stories.freepik.com/communication)](https://2758315385-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MMvwiWqrzksniHHOh6C%2F-MMvwm4Lm8hGD06g7gg-%2F-MMvzcusVoqmgzy7Bnln%2FMessages-rafiki.svg?alt=media\&token=02743d23-586d-4c24-8ac1-8db7f87f0c65)

WebPush can be used to send notifications to endpoints which server delivers Web Push notifications as described in the following specifications

* The [RFC8030: Generic Event Delivery Using HTTP Push](https://tools.ietf.org/html/rfc8030)
* The [RFC8291: “Message Encryption for Web Push](https://tools.ietf.org/html/rfc8291)
* The [RFC8292: Voluntary Application Server Identification (VAPID) for Web Push](https://tools.ietf.org/html/rfc8292)

In addition, some features from the [Push API](https://w3c.github.io/push-api/) are implemented. This specification is a working draft at the time of writing (2023-11).

This project allows sending notifications on compatible browsers. List and versions available at <https://caniuse.com/push-api>


# Requirements

## Mandatory

* PHP 8.2+
* The `JSON` extension

## Optional

* A PSR-3 (Logger Interface) implementation for debugging

## Extension Specific

### VAPID extension

* Required:
  * `openssl` extension
  * `mbstring` extension
  * A JWT Provider
  * A PSR-20 (Clock) implementation
* Optional:
  * A PSR-3 (Logger Interface) implementation for debugging

{% hint style="success" %}
This library provides JWT Provider implementations for [web-token](https://web-token.spomky-labs.com) and [lcobucci/jwt](https://github.com/lcobucci/jwt)
{% endhint %}

### Payload extension

* Required:
  * `openssl` extension
  * `mbstring` extension
* Optional:
  * A PSR-6 (Caching Interface) implementation
  * A PSR-3 (Logger Interface) implementation for debugging


# Fluent Syntax

In the documentation, you will see that methods are called “fluently”.

```php
<?php

use WebPush\Payload\AES128GCM;
use WebPush\Payload\AESGCM;
use WebPush\Payload\PayloadExtension;

$payloadExtension = PayloadExtension::create()
    ->addContentEncoding(AESGCM::create()->maxPadding())
    ->addContentEncoding(AES128GCM::create()->maxPadding())
;
```

If you don’t adhere to this coding style, you are free to use the “standard” way of coding. The following example has the same behavior ase above.

```php
<?php

use WebPush\Payload\AES128GCM;
use WebPush\Payload\AESGCM;
use WebPush\Payload\PayloadExtension;

$aesgcm = new AESGCM();
$aesgcm->maxPadding();

$aes128gcm = new AES128GCM();
$aes128gcm->maxPadding();

$payloadExtension = new PayloadExtension();
$payloadExtension->addContentEncoding($aesgcm);
$payloadExtension->addContentEncoding($aes128gcm);
```


# Contributing

First of all, **thank you** for contributing.

Bugs or feature requests can be posted online on the GitHub issues section of the project.

Few rules to ease code reviews and merges:

* You MUST follow the [PSR-12](https://www.php-fig.org/psr/psr-12/) coding standard.
* You MUST run the test suite (see below).
* You MUST write (or update) unit tests when bugs are fixed or features are added.
* You SHOULD write documentation.
* You MAY follow the [PSR-5](https://github.com/php-fig/fig-standards/blob/master/proposed/phpdoc.md) and [PSR-19](https://github.com/php-fig/fig-standards/blob/master/proposed/phpdoc-tags.md).

We use the following branching workflow:

* Each minor version has a dedicated branch (e.g. v1.1, v1.2, v2.0, v2.1…)
* The default branch is set to the last minor version (e.g. v2.1).

To contribute use [Pull Requests](https://help.github.com/articles/using-pull-requests), please, write commit messages that make sense, and rebase your branch before submitting your PR.

Your PR **should NOT** be submitted to the master branch but to the last minor version branch or to another minor version in case of bug fix.


# License

The MIT License (MIT)

Copyright (c) 2020-2023 Spomky-Labs

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.�


# Overview

The Web Push protocol allows your application to easily engage users by sending notifications to the browser. The subscription to these notifications are done by the user (opt-in).

The notification types depend on the application. For example, it could be a notification for an internal message or an alert before account closure.

We will see in this documentation that the Web Push API offers several options to customize the notifications by adding buttons, vibration schema, images, urgency indictor and more.

You want to test it? Please go to [this demo page](https://serviceworke.rs/push-payload_demo.html) to see what your browser already supports.


# The Subscription

The subscription is created on client side when the end-user allows your application to send push messages.

On client side (Javascript), you can simply send to your server the object you receive using `JSON.stringify`.

{% hint style="info" %}
Javascript examples to get a Subscription from the web browser are not provided here. Please refer to other resources such as blog posts or library documentation.
{% endhint %}

A subscription object will look like:

```javascript
{
 "endpoint":"https://updates.push.services.mozilla.com/wpush/v2/AAAAAAAA[…]AAAAAAAAA",
 "keys":{
 "auth":"XXXXXXXXXXXXXX",
 "p256dh":"YYYYYYYY[…]YYYYYYYYYYYYY"
 }
}
```

On server side, you can get a `WebPush\Subscription` object from the JSON string using the dedicated method `WebPush\Subscription::createFromString`.

```php
<?php

use WebPush\Subscription;

$subscription = Subscription::createFromString('{"endpoint":"https://updates.push.services.mozilla.com/wpush/v2/AAAAAAAA[…]AAAAAAAAA","keys":{"auth":"XXXXXXXXXXXXXX","p256dh":"YYYYYYYY[…]YYYYYYYYYYYYY"}}');
```

## Supported Content Encodings

By default, the content encoding `aesgcm` will be used. This encoding indicates how the payload of the notification should be formatted. The PushManager object from the Push API may list all acceptable encodings. In this case, it could be interesting to set these encodings to the Subscription object.

```javascript
// Retreive the supported content encodings
const supportedContentEncodings = PushManager.supportedContentEncodings || ['aesgcm'];

// Assign the encodings to the subscription object
const jsonSubscription = Object.assign(
    subscription.toJSON(),
    { supportedContentEncodings }
);

// Send the subscription object to the application server
fetch('/subscription/add', {
    method: 'POST',
    body: JSON.stringify(jsonSubscription),
});
```

This will result in something like as follow:

```javascript
{
 "endpoint":"https://updates.push.services.mozilla.com/wpush/v2/AAAAAAAA[…]AAAAAAAAA",
 "keys":{
 "auth":"XXXXXXXXXXXXXX",
 "p256dh":"YYYYYYYY[…]YYYYYYYYYYYYY",
 "supportedContentEncodings":["aes128gcm","aesgcm"]
 }
}
```

{% hint style="warning" %}
The order of `supportedContentEncodings` is important. First supported item will be used. If possible, `AES128GCM` should be used as prefered content encoding.
{% endhint %}


# The Notification

To reach the client (web browser), you need to send a Notification to the Subscription.

```php
<?php
use WebPush\Notification;

$notification = Notification::create();
```

The Notification should have a payload. In this case, the payload will be encrypted on server side and decrypted by the client.

That payload may be a string or a JSON object. The structure of the latter is described in the next section.

```php
<?php
use WebPush\Notification;

$notification = Notification::create()
    ->withPayload('Hello world')
;
```

## TTL (Time-To-Live)

With this feature, a value in seconds is added to the notification. It suggests how long a push message is retained by the push service. A value of 0 (zero) indicates the notification is delivered immediately.

```php
<?php
use WebPush\Notification;

$notification = Notification::create()
    ->withTTL(3600)
;
```

## Topic

A push message that has been stored by the push service can be replaced with new content. If the user agent is offline during the time the push messages are sent, updating a push message avoids the situation where outdated or redundant messages are sent to the user agent.

Only push messages that have been assigned a topic can be replaced. A push message with a topic replaces any outstanding push message with an identical topic.

```php
<?php
use WebPush\Notification;

$notification = Notification::create()
    ->withTopic('user-account-updated')
;
```

## Urgency

For a device that is battery-powered, it is often critical it remains dormant for extended periods.

Radio communication in particular consumes significant power and limits the length of time the device can operate.

To avoid consuming resources to receive trivial messages, it is helpful if an application server can communicate the urgency of a message and if the user agent can request that the push server only forwards messages of a specific urgency.

| Urgency  | Device State               | Examples                                    |
| -------- | -------------------------- | ------------------------------------------- |
| very-low | On power and Wi-Fi         | Advertisements                              |
| low      | On either power or Wi-Fi   | Topic updates                               |
| normal   | On neither power nor Wi-Fi | Chat or Calendar Message                    |
| high     | Low battery                | Incoming phone call or time-sensitive alert |

{% hint style="warning" %}
Be carful with the `very-low` urgency: it is not recognized by all Web-Push services
{% endhint %}

```php
<?php
use WebPush\Notification;

$notification = Notification::create()
    ->veryLowUrgency()
    ->lowUrgency()
    ->normalUrgency()
    ->highUrgency()
;
```

## Asynchronous Response

Your application may prefer asynchronous responses to request confirmation from the push service when a push message is delivered and then acknowledged by the user agent. The push service MUST support delivery confirmations to use this feature.

```php
<?php
use WebPush\Notification;

$notification = Notification::create()
    ->async() // Prefer async response
    ->sync() // Prefer sync response (default)
;
```

{% hint style="warning" %}
The `async` mode is not recognised by all Web Push services. In case of failure, you should try sending `sync`notifications.
{% endhint %}

## JSON Messages

As mentioned in the overview section, the specification [defines a structure for the payload](https://developer.mozilla.org/en-US/docs/Web/API/Notification/Notification#parameters). This structure contains properties that the client should be understood and render an appropriate way.

The library provides a `WebPush\Message` class with convenient methods to ease the creation of a message.

```php
<?php
use WebPush\Action;
use WebPush\Message;
use WebPush\Notification;

$message = Message::create('This is the title', null, true)
    ->mute() // Silent
    ->unmute() // Not silent (default)

    ->auto() //Direction = auto (default)
    ->ltr() //Direction = left to right
    ->rtl() //Direction = right to left

    ->addAction(Action::create('alert', 'Click me!'))

    ->interactionRequired()
    ->noInteraction()

    ->renotify()
    ->doNotRenotify() // Default
    
    ->withBody('Hello World!')

    ->withIcon('https://…')
    ->withImage('https://…')
    ->withData(['foo' => 'BAR']) // Arbitrary data
    ->withBadge('badge1')
    ->withLang('fr-FR')
    ->withTimestamp(time())
    ->withTag('foo')

    ->vibrate(300, 100, 400)

    ->toString() // Converts the Message object into a string
;

$notification = Notification::create()
    ->withPayload($message)
;
```

The resulting notification payload will look like as follow:

```javascript
{
    "title":"This is the title",
    "options":{
        "actions":[
            {
                "action":"alert",
                "title":"Click me!"
            }
        ],
        "badge":"badge1",
        "body":"Hello World!",
        "data":{
            "foo":"BAR"
        },
        "dir":"rtl",
        "icon":"https://…",
        "image":"https://…",
        "lang":"fr-FR",
        "renotify":false,
        "requireInteraction":false,
        "silent":false,
        "tag":"foo",
        "timestamp":1629145424,
        "vibrate":[
            300,
            100,
            400
        ]
    }
}
```

On client side, you can easily load that payload and display the notification:

```javascript
  const {title, options}  = payload;
  const notification = new Notification(title, options);
```


# The Status Report

After sending a notification, you will receive a StatusReport object.

This status report has the following properties:

* The [notification](/3.0.x/common-concepts/the-notification)
* The [subscription](/3.0.x/common-concepts/the-subscription)
* The status code
* The notification URL (refers to the push service provider)
* The links for push notification management

Depending on the status code, you will be able to know if it is a success or not. In case of success, you can directly access the management link (`location` header parameter) or the links entity fields in case of asynchronous call. In case of failure, the response code indicates the main reason for rejection (invalid authorization token, expired endpoint...)

```php
<?php
use WebPush\Subscription;
use WebPush\Notification;
use WebPush\WebPushService;

/** @var Notification $notification */
/** @var Subscription $subscription */
/** @var WebPushService $webPushService */
$statusReport = $webPushService->send($notification, $subscription);

if(!$statusReport->isSuccess()) {
    //Something went wrong
} else {
    $statusReport->getLocation();
    $statusReport->getLinks();
}
```

One of the failure reasons could be the expiration of the subscription (too old or cancelled by the end-user). This can be checked with the method `isSubscriptionExpired()`. In this case, you should simply delete the subscription as it is not possible to send notifications anymore.

```php
<?php

if($statusReport->isSubscriptionExpired()) {
    $this->subscriptionRepository->remove($subscription);
}
```


# VAPID

Voluntary Application Server Identification

“**VAPID**” stands for “**V**oluntary **Ap**plication Server **Id**entification”.

This feature allows to application server to send information about itself to a push service.

A consistent identity can be used by a push service to establish behavioral expectations for an application server. Significant deviations from an established norm can then be used to trigger exception-handling procedures.

Voluntarily provided contact information can be used to contact an application server operator in the case of exceptional situations. Additionally, the design of [RFC8030](https://datatracker.ietf.org/doc/html/rfc8030) relies on maintaining the secrecy of push message subscription URIs.

Any application server in possession of a push message subscription URI is able to send messages to the user agent.

If use of a subscription could be limited to a single application server, this would reduce the impact of the push message subscription URI being learned by an unauthorized party.

In order to use this feature, you must generate ECDSA key pairs. Hereafter an example using OpenSSL.

```bash
openssl ecparam -genkey -name prime256v1 -out private_key.pem
openssl ec -in private_key.pem -pubout -outform DER|tail -c 65|base64|tr -d '=' |tr '/+' '_-' >> public_key.txt
openssl ec -in private_key.pem -outform DER|tail -c +8|head -c 32|base64|tr -d '=' |tr '/+' '_-' >> private_key.txt
```

Please refer to [this page](/3.0.x/the-library/advanced-service#vapid-extension) for using the VAPID feature.


# Installation

The library can be installed using the package `spomky-labs/web-push-lib`

```bash
composer require spomky-labs/web-push-lib
```

## VAPID Header

The [VAPID header](/3.0.x/common-concepts/vapid) authenticates your server and prevent malicious application to send notifications to your users. The header contains a signed JSON Web Token (JWS).

The library provides bridges for the following libraries `web-token` and `lcobucci/jwt`.

Please install `web-token/jwt-signature-algorithm-ecdsa` or `lcobucci/jwt` depending on the library you want to use.

{% hint style="info" %}
It is possible to use any other JWS provider. This will be detailed in the future.
{% endhint %}


# The Extension Manager

The Web Push service requires an Extension Manager. This object manages extensions that will manipulate the request before sending it to the Push Service.

In the example below, we add all basic extensions.

```php
use WebPush\ExtensionManager;
use WebPush\PreferAsyncExtension;
use WebPush\TopicExtension;
use WebPush\TTLExtension;
use WebPush\UrgencyExtension;

$extensionManager = ExtensionManager::create()
    ->add(TTLExtension::create())
    ->add(UrgencyExtension::create())
    ->add(TopicExtension::create())
    ->add(PreferAsyncExtension::create())
;
```

{% hint style="info" %}
Please note that the TTL Extension is usually required by Push Services. To avoid any trouble, please use all extensions.
{% endhint %}

## Payload Extension

The payload extension allows Notifications to have a payload. This extension requires Content Encoding objects that will be responsible of the payload encryption.

The library provides the `AESGCM` and `AES128GCM` content encoding. These encodings are normally supported by all Push Services. The library is able to support any future encoding is deemed necessary.

```php
$clock = //PSR-20 clock
$payloadExtension = PayloadExtension::create()
    ->addContentEncoding(AESGCM::create($clock))
    ->addContentEncoding(AES128GCM::create($clock))
;

$extensionManager = ExtensionManager::create()
    ->add($payloadExtension)
;
```

## VAPID Extension

The [VAPID header](/3.0.x/common-concepts/vapid) authenticates your server and prevent malicious application to send notifications to your users. The header contains a signed JSON Web Token (JWS).

The library provides bridges for the following libraries `web-token` and `lcobucci/jwt`.

Please install `web-token/jwt-signature-algorithm-ecdsa` or `lcobucci/jwt` depending on the library you want to use.

```php
use WebPush\VAPID\WebTokenProvider;
use WebPush\VAPID\LcobucciProvider;

// Web-Token
$jwsProvider = WebTokenProvider::create(
    'BB4W1qfBi7MF_Lnrc6i2oL-glAuKF4kevy9T0k2vyKV4qvuBrN3T6o9-7-NR3mKHwzDXzD3fe7XvIqIU1iADpGQ', // Public key
    'C40jLFSa5UWxstkFvdwzT3eHONE2FIJSEsVIncSCAqU' // Private key
);

// lcobucci/jwt
$jwsProvider = LcobucciProvider::create(
    'BB4W1qfBi7MF_Lnrc6i2oL-glAuKF4kevy9T0k2vyKV4qvuBrN3T6o9-7-NR3mKHwzDXzD3fe7XvIqIU1iADpGQ',
    'C40jLFSa5UWxstkFvdwzT3eHONE2FIJSEsVIncSCAqU'
);

$extensionManager = ExtensionManager::create()
    ->add(VAPIDExtension::create('http://my-service.com', $jwsProvider)
);
```

{% hint style="danger" %}
The public key used with your server shall be the same as the one in your Javascript application.
{% endhint %}

{% hint style="warning" %}
If this public/private key changes, subscriptions will become invalid.
{% endhint %}


# The Web Push Service

The WebPush object requires a [HTTP Client](https://symfony.com/doc/current/http_client.html) and an [Extension Manager](/3.0.x/the-library/advanced-service).

```php
use Symfony\Component\HttpClient\HttpClient;
use WebPush\WebPush;

$client = HttpClient::create();

$service = new WebPush($client, $extensionManager);
```

The service is now ready to send Notifications to the Subscriptions. The StatusReport object that is returned [is explained here](/3.0.x/common-concepts/the-status-report).

```php
<?php

use WebPush\Subscription;
use WebPush\Notification;

$subscription = Subscription::createFromString('{"endpoint":"https://updates.push.services.mozilla.com/wpush/v2/AAAAAAAA[…]AAAAAAAAA","keys":{"auth":"XXXXXXXXXXXXXX","p256dh":"YYYYYYYY[…]YYYYYYYYYYYYY"}}');
$notification = Notification::create()
    ->withPayload('Hello world')
;

$statusReport = $service->send($notification, $subscription);
```

{% hint style="info" %}
In this example, we load the Subscription object from a string, but usually to retrieve the Subscription objects from a database or a dedicated storage.
{% endhint %}


# Installation

The bundle can be installed using the package `spomky-labs/web-push-bundle`

```bash
composer require spomky-labs/web-push-bundle
```

If you use Symfony Flex, the bundle is ready to be used. Otherwise, you must enable it. The bundle class is `WebPush\Bundle\WebPushBundle`.

When done, the bundle is ready and can send the notifications. However, there are extra packages we highly recommend to install and set up.

## VAPID Header

The [VAPID header](/3.0.x/common-concepts/vapid) authenticates your server and prevent malicious application to send notifications to your users. The header contains a signed JSON Web Token (JWS).

The library provides bridges for the following libraries `web-token` and `lcobucci/jwt`.

Please install `web-token/jwt-signature-algorithm-ecdsa` or `lcobucci/jwt` depending on the library you want to use.

{% hint style="info" %}
It is possible to use any other JWS provider. This will be detailed in the future.
{% endhint %}


# Configuration

## VAPID Support

To enable the VAPID header feature, you must install a JWS Provider (see [installation](/3.0.x/the-symfony-bundle/installation)) and configure it with your public and private key (see [this page](/3.0.x/common-concepts/vapid) to create these keys)

{% code title="config/packages/webpush.yaml" %}

```yaml
webpush:
  vapid:
    enabled: true # Enable the feature
    subject: 'https://my-service.com:8000' # An URL or an email address
    web_token:
      enabled: true # We use web-token in this example
      public_key: 'BB4W1qfBi7MF_Lnrc6i2oL-glAuKF4kevy9T0k2vyKV4qvuBrN3T6o9-7-NR3mKHwzDXzD3fe7XvIqIU1iADpGQ'
      private_key: 'C40jLFSa5UWxstkFvdwzT3eHONE2FIJSEsVIncSCAqU'
```

{% endcode %}

When using `lcobucci/jwt`, the configuration is very similar.

{% code title="config/packages/webpush.yaml" %}

```yaml
webpush:
  vapid:
    enabled: true
    subject: 'https://my-service.com:8000'
    lcobucci:
      enabled: true # We use web-token in this example
      public_key: 'BB4W1qfBi7MF_Lnrc6i2oL-glAuKF4kevy9T0k2vyKV4qvuBrN3T6o9-7-NR3mKHwzDXzD3fe7XvIqIU1iADpGQ'
      private_key: 'C40jLFSa5UWxstkFvdwzT3eHONE2FIJSEsVIncSCAqU'
```

{% endcode %}

{% hint style="danger" %}
You cannot enable both `web-token` and `lcobucci/jwt` at the same time
{% endhint %}

### Token Lifetime

By default, the library generates VAPID headers that are valid for 1 hour. You can change this value if needed. The parameter requires a relative string as showed [in the PHP documentation](https://www.php.net/manual/en/datetime.formats.relative.php).

{% code title="config/packages/webpush.yaml" %}

```yaml
webpush:
  vapid:
    enabled: true
    token_lifetime: 'now +2 hours'
```

{% endcode %}

{% hint style="warning" %}
The token lifetime should not be greater than 24 hours. Most of the Web Push Services will reject such long-life tokens
{% endhint %}

## Payload Support

### Padding

To obfuscate the real length of the notifications, messages can be padded before encryption. This operation consists in the concatenation of your message and arbitrary data in front of it. When encrypted, the messages will have the same size which reduces attacks.

By default, the padding is set to `recommended` i.e. \~3k bytes.

Acceptable values for this parameter are:

* `none`: no padding
* `recommended`: default value
* `max`: see warning below
* an integer: should be between `0` and `4078` or `3993` for `AESGCM` and `AES128GCM` respectively

{% code title="config/packages/webpush.yaml" %}

```yaml
webpush:
  payload:
    aesgcm:
      padding: 'none' # "none", "recommended", "max" or an integer (0 to 4078)
    aes128gcm:
      padding: 'none' # "none", "recommended", "max" or an integer (0 to 3993)
```

{% endcode %}

{% hint style="danger" %}
Please don't use "`none`" unless your are sending notifications in a development environment.
{% endhint %}

{% hint style="warning" %}
The value "`max`" increases the integrity protection of the messages, but there are known issues on Android and notification are not correctly delivered.
{% endhint %}

### Caching

The notifications [may have a payload](/3.0.x/common-concepts/the-notification#json-messages). This payload is encrypted on server side and, during this process, a random key is generated.

The creation of this random key takes approximately 150ms and can impact your server performance when sending thousand of notifications at once.

To reduce the impact on your server, you can enable the caching feature and reuse the encryption key for a defined period of time.

{% hint style="danger" %}
As encryption keys will be stored in the cache, you should make sure the cache is not shared otherwise you may have a security issue.
{% endhint %}

This parameter requires a PSR-6 Cache compatible service. If you set `Psr\Log\CacheItemPoolInterface`, the default Symfony cache will be used.

{% code title="config/packages/webpush.yaml" %}

```yaml
webpush:
  payload:
    aesgcm:
      cache: Psr\Cache\CacheItemPoolInterface
      cache_lifetime: '+1 hour' #Default: now +30min
    aes128gcm:
      cache: Psr\Cache\CacheItemPoolInterface
      cache_lifetime: '+1 hour' #Default: now +30min
```

{% endcode %}

{% hint style="success" %}
You can see the impact of this feature on the CI/CD Pipelines of this library. Go the <https://github.com/Spomky-Labs/web-push/actions?query=workflow%3ABenchmark> and find a summary table displayed at the end of each test.
{% endhint %}

## Debugging

If you have troubles sending notifications, you can log some messages from the libray. To do so, you just have to set the parameter logger in the configuration.

This parameter requires a PSR-3 logger. If you set `Psr\Log\LoggerInterface`, the Symfony logger will be used (PSR-3 copmpatible).

{% code title="config/packages/webpush.yaml" %}

```yaml
webpush:
  logger: Psr\Log\LoggerInterface
```

{% endcode %}


# The Web Push Service

The bundle provides a public Web Push service that you can inject this service into your application components.

In the following example, let's imagine that a notification is dispatched using the Symfony Messanger component and catched by an event handler. This handler will fetch all subscriptions and send the notification.

{% hint style="info" %}
The SubscriptionRepository class is totally fictive
{% endhint %}

{% code title="src/MessageHandler/SendNotification.php" %}

```php
<?php

declare(strict_types=1);

namespace App\MessageHandler;

use App\Message\SubscriptionExpired;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use WebPush\Notification;
use WebPush\WebPush;

final class SendPushNotifications implements MessageHandlerInterface
{
    private MessageBusInterface $messageBus;
    private SubscriptionRepository $repository;
    private WebPush $webPush;

    public function __construct(MessageBusInterface $messageBus, SubscriptionRepository $repository, WebPush $webPush)
    {
        $this->messageBus = $messageBus;
        $this->repository = $repository;
        $this->webPush = $webPush;
    }

    public function __invoke(Notification $notification): void
    {
        // Fetch all subscriptions
        $subscriptions = $this->repository->fetchAllSubscriptions();
        foreach ($subscriptions as $subscription) {
            //Sends the notification to the subscriber
            $report = $this->webPush->send($notification, $subscription);

            //If the subscription expired
            if ($report->subscriptionExpired()) {
                //We dispatch a new message and expect for
                // the subscription to be deleted
                $this->messageBus->dispatch(
                    new SubscriptionExpired($subscription)
                );
            }
        }
    }
}
```

{% endcode %}


# Doctrine

The bundle provides new Doctrine type and Schema to simplify the way you store the `Subscription` objects with Doctrine.

## Using The Doctrine Mapping

### Configuration

To enable this feature, the following configuration option  shall be set:

```yaml
webpush:
    doctrine_mapping: true
```

This will tell the bundle to register the Subscription object as a Doctrine mapped-superclass. The DoctrineBundle shall be enabled. No additional configuration is required.

### The `Subscription` Entity

First of all, we need to create a Subscription Entity that extends the Subscription object. In this example, we also need to associate one or more Subscription entities to a specific user (Many To One relationship).

{% code title="src/Entity/Subscription.php" %}

```php
<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use WebPush\Subscription as WebPushSubscription;

#[ORM\Table(name: 'subscriptions')]
#[ORM\Entity]
class Subscription extends WebPushSubscription
{
    #[ORM\Id]
    #[ORM\Column(type: 'integer')]
    #[ORM\GeneratedValue(strategy: 'AUTO')]
    private ?int $id = null;

    #[ORM\ManyToOne(targetEntity: User::class, cascade: ['persist'], inversedBy: 'subscriptions')]
    #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: true)]
    
    private ?User $user;

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getUser(): ?User
    {
        return $this->user;
    }

    public function setUser(?User $user): self
    {
        $this->user = $user;

        return $this;
    }

    // We need to override this method as it returns a WebPush\Subscription and we want an entity
    public static function createFromString(string $input): self
    {
        $base = BaseSubscription::createFromString($input);
        $object = new self($base->getEndpoint());
        $object->withContentEncodings($base->getSupportedContentEncodings());
        foreach ($base->getKeys()->all() as $k => $v) {
            $object->getKeys()->set($k, $v);
        }

        return $object;
    }
}
```

{% endcode %}

{% hint style="info" %}
In this exaple, we assume you already have a valid User entity class.
{% endhint %}

### The `User` Entity

Now, to have a bidirectional relationship between this class and the User entity class, we will add this relationship to the User class.

{% code title="src/Entity/User.php" %}

```php
<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Table(name="users")
 * @ORM\Entity
 */
#[ORM\Table(name: 'users')]
#[ORM\Entity]
class User //Usual interface here
{
    //Usual user stuff here

    #[ORM\OneToMany(targetEntity: Subscription::class, mappedBy: 'user')]
    private Collection $subscriptions;

    public function __construct()
    {
        $this->notifications = new ArrayCollection();
    }

    /**
     * @return Notification[]
     */
    public function getSubscriptions(): array
    {
        return $this->notifications->toArray();
    }

    public function addSubscription(Subscription $subscription): self
    {
        $subscription->setUser($this);
        $this->subscriptions->add($subscription);

        return $this;
    }

    public function removeSubscription(Subscription $subscription): self
    {
        $child->setUser(null);
        $this->subscriptions->removeElement($subscription);

        return $this;
    }
}
```

{% endcode %}

## Sending Notifications To A User

Now that your entities are set, you can register Subcriptions and assign them to your users. To send a Notification to a specific user, you just have to get all subscriptions using `$user->getSubscriptions()`.

{% code title="" %}

```php
$subscriptions = $user->getSubscriptions();
foreach ($subscriptions as $subscription) {
    $report = $this->webPush->send($notification, $subscription);
    if ($report->isSubscriptionExpired()) {
        //...Remove this subscription
    }
}
```

{% endcode %}

## Using Your Own Entity Class

It is possible to use your own Subscription entity class. The only constraint is that it shall implement the interface `WebPush\SubscriptionInterface` or shall have a method that returns an object that implements this interface.


# Example

Please have a look at the demo available at <https://github.com/Spomky-Labs/web-push-demo>.


# Web-Push

Web Notifications made easy

![Illustration by Freepik Stories (https://stories.freepik.com/communication)](https://content.gitbook.com/content/SByscvsg9HRfXBL2Fhq9/blobs/NPw42ldynkXIKWrAjX4R/Messages-rafiki.svg)

WebPush can be used to send notifications to endpoints which server delivers Web Push notifications as described in the following specifications

* The [RFC8030: Generic Event Delivery Using HTTP Push](https://tools.ietf.org/html/rfc8030)
* The [RFC8291: “Message Encryption for Web Push](https://tools.ietf.org/html/rfc8291)
* The [RFC8292: Voluntary Application Server Identification (VAPID) for Web Push](https://tools.ietf.org/html/rfc8292)

In addition, some features from the [Push API](https://w3c.github.io/push-api/) are implemented. This specification is a working draft at the time of writing (2023-11).

This project allows sending notifications on compatible browsers. List and versions available at <https://caniuse.com/push-api>


# Requirements

## Mandatory

* PHP 8.2+
* The `JSON` extension

## Optional

* A PSR-3 (Logger Interface) implementation for debugging

## Extension Specific

### VAPID extension

* Required:
  * `openssl` extension
  * `mbstring` extension
  * A JWT Provider
  * A PSR-20 (Clock) implementation
* Optional:
  * A PSR-3 (Logger Interface) implementation for debugging

{% hint style="success" %}
This library provides JWT Provider implementations for [web-token](https://web-token.spomky-labs.com) and [lcobucci/jwt](https://github.com/lcobucci/jwt)
{% endhint %}

### Payload extension

* Required:
  * `openssl` extension
  * `mbstring` extension
* Optional:
  * A PSR-6 (Caching Interface) implementation
  * A PSR-3 (Logger Interface) implementation for debugging


# Fluent Syntax

In the documentation, you will see that methods are called “fluently”.

```php
<?php

use WebPush\Payload\AES128GCM;
use WebPush\Payload\AESGCM;
use WebPush\Payload\PayloadExtension;

$payloadExtension = PayloadExtension::create()
    ->addContentEncoding(AESGCM::create()->maxPadding())
    ->addContentEncoding(AES128GCM::create()->maxPadding())
;
```

If you don’t adhere to this coding style, you are free to use the “standard” way of coding. The following example has the same behavior ase above.

```php
<?php

use WebPush\Payload\AES128GCM;
use WebPush\Payload\AESGCM;
use WebPush\Payload\PayloadExtension;

$aesgcm = new AESGCM();
$aesgcm->maxPadding();

$aes128gcm = new AES128GCM();
$aes128gcm->maxPadding();

$payloadExtension = new PayloadExtension();
$payloadExtension->addContentEncoding($aesgcm);
$payloadExtension->addContentEncoding($aes128gcm);
```


# Contributing

First of all, **thank you** for contributing.

Bugs or feature requests can be posted online on the GitHub issues section of the project.

Few rules to ease code reviews and merges:

* You MUST follow the [PSR-12](https://www.php-fig.org/psr/psr-12/) coding standard.
* You MUST run the test suite (see below).
* You MUST write (or update) unit tests when bugs are fixed or features are added.
* You SHOULD write documentation.
* You MAY follow the [PSR-5](https://github.com/php-fig/fig-standards/blob/master/proposed/phpdoc.md) and [PSR-19](https://github.com/php-fig/fig-standards/blob/master/proposed/phpdoc-tags.md).

We use the following branching workflow:

* Each minor version has a dedicated branch (e.g. v1.1, v1.2, v2.0, v2.1…)
* The default branch is set to the last minor version (e.g. v2.1).

To contribute use [Pull Requests](https://help.github.com/articles/using-pull-requests), please, write commit messages that make sense, and rebase your branch before submitting your PR.

Your PR **should NOT** be submitted to the master branch but to the last minor version branch or to another minor version in case of bug fix.


# License

The MIT License (MIT)

Copyright (c) 2020-2023 Spomky-Labs

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.�


# Overview

The Web Push protocol allows your application to easily engage users by sending notifications to the browser. The subscription to these notifications are done by the user (opt-in).

The notification types depend on the application. For example, it could be a notification for an internal message or an alert before account closure.

We will see in this documentation that the Web Push API offers several options to customize the notifications by adding buttons, vibration schema, images, urgency indictor and more.

You want to test it? Please go to [this demo page](https://serviceworke.rs/push-payload_demo.html) to see what your browser already supports.


# The Subscription

The subscription is created on client side when the end-user allows your application to send push messages.

On client side (Javascript), you can simply send to your server the object you receive using `JSON.stringify`.

{% hint style="info" %}
Javascript examples to get a Subscription from the web browser are not provided here. Please refer to other resources such as blog posts or library documentation.
{% endhint %}

A subscription object will look like:

```javascript
{
 "endpoint":"https://updates.push.services.mozilla.com/wpush/v2/AAAAAAAA[…]AAAAAAAAA",
 "keys":{
 "auth":"XXXXXXXXXXXXXX",
 "p256dh":"YYYYYYYY[…]YYYYYYYYYYYYY"
 }
}
```

On server side, you can get a `WebPush\Subscription` object from the JSON string using the dedicated method `WebPush\Subscription::createFromString`.

```php
<?php

use WebPush\Subscription;

$subscription = Subscription::createFromString('{"endpoint":"https://updates.push.services.mozilla.com/wpush/v2/AAAAAAAA[…]AAAAAAAAA","keys":{"auth":"XXXXXXXXXXXXXX","p256dh":"YYYYYYYY[…]YYYYYYYYYYYYY"}}');
```

## Supported Content Encodings

By default, the content encoding `aesgcm` will be used. This encoding indicates how the payload of the notification should be formatted. The PushManager object from the Push API may list all acceptable encodings. In this case, it could be interesting to set these encodings to the Subscription object.

```javascript
// Retreive the supported content encodings
const supportedContentEncodings = PushManager.supportedContentEncodings || ['aesgcm'];

// Assign the encodings to the subscription object
const jsonSubscription = Object.assign(
    subscription.toJSON(),
    { supportedContentEncodings }
);

// Send the subscription object to the application server
fetch('/subscription/add', {
    method: 'POST',
    body: JSON.stringify(jsonSubscription),
});
```

This will result in something like as follow:

```javascript
{
 "endpoint":"https://updates.push.services.mozilla.com/wpush/v2/AAAAAAAA[…]AAAAAAAAA",
 "keys":{
 "auth":"XXXXXXXXXXXXXX",
 "p256dh":"YYYYYYYY[…]YYYYYYYYYYYYY",
 "supportedContentEncodings":["aes128gcm","aesgcm"]
 }
}
```

{% hint style="warning" %}
The order of `supportedContentEncodings` is important. First supported item will be used. If possible, `AES128GCM` should be used as prefered content encoding.
{% endhint %}


# The Notification

To reach the client (web browser), you need to send a Notification to the Subscription.

```php
<?php
use WebPush\Notification;

$notification = Notification::create();
```

The Notification should have a payload. In this case, the payload will be encrypted on server side and decrypted by the client.

That payload may be a string or a JSON object. The structure of the latter is described in the next section.

```php
<?php
use WebPush\Notification;

$notification = Notification::create()
    ->withPayload('Hello world')
;
```

## TTL (Time-To-Live)

With this feature, a value in seconds is added to the notification. It suggests how long a push message is retained by the push service. A value of 0 (zero) indicates the notification is delivered immediately.

```php
<?php
use WebPush\Notification;

$notification = Notification::create()
    ->withTTL(3600)
;
```

## Topic

A push message that has been stored by the push service can be replaced with new content. If the user agent is offline during the time the push messages are sent, updating a push message avoids the situation where outdated or redundant messages are sent to the user agent.

Only push messages that have been assigned a topic can be replaced. A push message with a topic replaces any outstanding push message with an identical topic.

```php
<?php
use WebPush\Notification;

$notification = Notification::create()
    ->withTopic('user-account-updated')
;
```

## Urgency

For a device that is battery-powered, it is often critical it remains dormant for extended periods.

Radio communication in particular consumes significant power and limits the length of time the device can operate.

To avoid consuming resources to receive trivial messages, it is helpful if an application server can communicate the urgency of a message and if the user agent can request that the push server only forwards messages of a specific urgency.

| Urgency  | Device State               | Examples                                    |
| -------- | -------------------------- | ------------------------------------------- |
| very-low | On power and Wi-Fi         | Advertisements                              |
| low      | On either power or Wi-Fi   | Topic updates                               |
| normal   | On neither power nor Wi-Fi | Chat or Calendar Message                    |
| high     | Low battery                | Incoming phone call or time-sensitive alert |

{% hint style="warning" %}
Be carful with the `very-low` urgency: it is not recognized by all Web-Push services
{% endhint %}

```php
<?php
use WebPush\Notification;

$notification = Notification::create()
    ->veryLowUrgency()
    ->lowUrgency()
    ->normalUrgency()
    ->highUrgency()
;
```

## Asynchronous Response

Your application may prefer asynchronous responses to request confirmation from the push service when a push message is delivered and then acknowledged by the user agent. The push service MUST support delivery confirmations to use this feature.

```php
<?php
use WebPush\Notification;

$notification = Notification::create()
    ->async() // Prefer async response
    ->sync() // Prefer sync response (default)
;
```

{% hint style="warning" %}
The `async` mode is not recognised by all Web Push services. In case of failure, you should try sending `sync`notifications.
{% endhint %}

## JSON Messages

As mentioned in the overview section, the specification [defines a structure for the payload](https://developer.mozilla.org/en-US/docs/Web/API/Notification/Notification#parameters). This structure contains properties that the client should be understood and render an appropriate way.

The library provides a `WebPush\Message` class with convenient methods to ease the creation of a message.

```php
<?php
use WebPush\Action;
use WebPush\Message;
use WebPush\Notification;

$message = Message::create('This is the title', null, true)
    ->mute() // Silent
    ->unmute() // Not silent (default)

    ->auto() //Direction = auto (default)
    ->ltr() //Direction = left to right
    ->rtl() //Direction = right to left

    ->addAction(Action::create('alert', 'Click me!'))

    ->interactionRequired()
    ->noInteraction()

    ->renotify()
    ->doNotRenotify() // Default
    
    ->withBody('Hello World!')

    ->withIcon('https://…')
    ->withImage('https://…')
    ->withData(['foo' => 'BAR']) // Arbitrary data
    ->withBadge('badge1')
    ->withLang('fr-FR')
    ->withTimestamp(time())
    ->withTag('foo')

    ->vibrate(300, 100, 400)

    ->toString() // Converts the Message object into a string
;

$notification = Notification::create()
    ->withPayload($message)
;
```

The resulting notification payload will look like as follow:

```javascript
{
    "title":"This is the title",
    "options":{
        "actions":[
            {
                "action":"alert",
                "title":"Click me!"
            }
        ],
        "badge":"badge1",
        "body":"Hello World!",
        "data":{
            "foo":"BAR"
        },
        "dir":"rtl",
        "icon":"https://…",
        "image":"https://…",
        "lang":"fr-FR",
        "renotify":false,
        "requireInteraction":false,
        "silent":false,
        "tag":"foo",
        "timestamp":1629145424,
        "vibrate":[
            300,
            100,
            400
        ]
    }
}
```

On client side, you can easily load that payload and display the notification:

```javascript
  const {title, options}  = payload;
  const notification = new Notification(title, options);
```


# The Status Report

After sending a notification, you will receive a StatusReport object.

This status report has the following properties:

* The [notification](/3.1.x/common-concepts/the-notification)
* The [subscription](/3.1.x/common-concepts/the-subscription)
* The status code
* The notification URL (refers to the push service provider)
* The links for push notification management

Depending on the status code, you will be able to know if it is a success or not. In case of success, you can directly access the management link (`location` header parameter) or the links entity fields in case of asynchronous call. In case of failure, the response code indicates the main reason for rejection (invalid authorization token, expired endpoint...)

```php
<?php
use WebPush\Subscription;
use WebPush\Notification;
use WebPush\WebPushService;

/** @var Notification $notification */
/** @var Subscription $subscription */
/** @var WebPushService $webPushService */
$statusReport = $webPushService->send($notification, $subscription);

if(!$statusReport->isSuccess()) {
    //Something went wrong
} else {
    $statusReport->getLocation();
    $statusReport->getLinks();
}
```

One of the failure reasons could be the expiration of the subscription (too old or cancelled by the end-user). This can be checked with the method `isSubscriptionExpired()`. In this case, you should simply delete the subscription as it is not possible to send notifications anymore.

```php
<?php

if($statusReport->isSubscriptionExpired()) {
    $this->subscriptionRepository->remove($subscription);
}
```


# VAPID

Voluntary Application Server Identification

“**VAPID**” stands for “**V**oluntary **Ap**plication Server **Id**entification”.

This feature allows to application server to send information about itself to a push service.

A consistent identity can be used by a push service to establish behavioral expectations for an application server. Significant deviations from an established norm can then be used to trigger exception-handling procedures.

Voluntarily provided contact information can be used to contact an application server operator in the case of exceptional situations. Additionally, the design of [RFC8030](https://datatracker.ietf.org/doc/html/rfc8030) relies on maintaining the secrecy of push message subscription URIs.

Any application server in possession of a push message subscription URI is able to send messages to the user agent.

If use of a subscription could be limited to a single application server, this would reduce the impact of the push message subscription URI being learned by an unauthorized party.

In order to use this feature, you must generate ECDSA key pairs. Hereafter an example using OpenSSL.

```bash
openssl ecparam -genkey -name prime256v1 -out private_key.pem
openssl ec -in private_key.pem -pubout -outform DER|tail -c 65|base64|tr -d '=' |tr '/+' '_-' >> public_key.txt
openssl ec -in private_key.pem -outform DER|tail -c +8|head -c 32|base64|tr -d '=' |tr '/+' '_-' >> private_key.txt
```

Please refer to [this page](/3.1.x/the-library/advanced-service#vapid-extension) for using the VAPID feature.


# Installation

The library can be installed using the package `spomky-labs/web-push-lib`

```bash
composer require spomky-labs/web-push-lib
```

## VAPID Header

The [VAPID header](/3.1.x/common-concepts/vapid) authenticates your server and prevent malicious application to send notifications to your users. The header contains a signed JSON Web Token (JWS).

The library provides bridges for the following libraries `web-token` and `lcobucci/jwt`.

Please install `web-token/jwt-signature-algorithm-ecdsa` or `lcobucci/jwt` depending on the library you want to use.

{% hint style="info" %}
It is possible to use any other JWS provider. This will be detailed in the future.
{% endhint %}


# The Extension Manager

The Web Push service requires an Extension Manager. This object manages extensions that will manipulate the request before sending it to the Push Service.

In the example below, we add all basic extensions.

```php
use WebPush\ExtensionManager;
use WebPush\PreferAsyncExtension;
use WebPush\TopicExtension;
use WebPush\TTLExtension;
use WebPush\UrgencyExtension;

$extensionManager = ExtensionManager::create()
    ->add(TTLExtension::create())
    ->add(UrgencyExtension::create())
    ->add(TopicExtension::create())
    ->add(PreferAsyncExtension::create())
;
```

{% hint style="info" %}
Please note that the TTL Extension is usually required by Push Services. To avoid any trouble, please use all extensions.
{% endhint %}

## Payload Extension

The payload extension allows Notifications to have a payload. This extension requires Content Encoding objects that will be responsible of the payload encryption.

The library provides the `AESGCM` and `AES128GCM` content encoding. These encodings are normally supported by all Push Services. The library is able to support any future encoding is deemed necessary.

```php
$clock = //PSR-20 clock
$payloadExtension = PayloadExtension::create()
    ->addContentEncoding(AESGCM::create($clock))
    ->addContentEncoding(AES128GCM::create($clock))
;

$extensionManager = ExtensionManager::create()
    ->add($payloadExtension)
;
```

## VAPID Extension

The [VAPID header](/3.1.x/common-concepts/vapid) authenticates your server and prevent malicious application to send notifications to your users. The header contains a signed JSON Web Token (JWS).

The library provides bridges for the following libraries `web-token` and `lcobucci/jwt`.

Please install `web-token/jwt-signature-algorithm-ecdsa` or `lcobucci/jwt` depending on the library you want to use.

```php
use WebPush\VAPID\WebTokenProvider;
use WebPush\VAPID\LcobucciProvider;

// Web-Token
$jwsProvider = WebTokenProvider::create(
    'BB4W1qfBi7MF_Lnrc6i2oL-glAuKF4kevy9T0k2vyKV4qvuBrN3T6o9-7-NR3mKHwzDXzD3fe7XvIqIU1iADpGQ', // Public key
    'C40jLFSa5UWxstkFvdwzT3eHONE2FIJSEsVIncSCAqU' // Private key
);

// lcobucci/jwt
$jwsProvider = LcobucciProvider::create(
    'BB4W1qfBi7MF_Lnrc6i2oL-glAuKF4kevy9T0k2vyKV4qvuBrN3T6o9-7-NR3mKHwzDXzD3fe7XvIqIU1iADpGQ',
    'C40jLFSa5UWxstkFvdwzT3eHONE2FIJSEsVIncSCAqU'
);

$extensionManager = ExtensionManager::create()
    ->add(VAPIDExtension::create('http://my-service.com', $jwsProvider)
);
```

{% hint style="danger" %}
The public key used with your server shall be the same as the one in your Javascript application.
{% endhint %}

{% hint style="warning" %}
If this public/private key changes, subscriptions will become invalid.
{% endhint %}


# The Web Push Service

The WebPush object requires a [HTTP Client](https://symfony.com/doc/current/http_client.html) and an [Extension Manager](/3.1.x/the-library/advanced-service).

```php
use Symfony\Component\HttpClient\HttpClient;
use WebPush\WebPush;

$client = HttpClient::create();

$service = new WebPush($client, $extensionManager);
```

The service is now ready to send Notifications to the Subscriptions. The StatusReport object that is returned [is explained here](/3.1.x/common-concepts/the-status-report).

```php
<?php

use WebPush\Subscription;
use WebPush\Notification;

$subscription = Subscription::createFromString('{"endpoint":"https://updates.push.services.mozilla.com/wpush/v2/AAAAAAAA[…]AAAAAAAAA","keys":{"auth":"XXXXXXXXXXXXXX","p256dh":"YYYYYYYY[…]YYYYYYYYYYYYY"}}');
$notification = Notification::create()
    ->withPayload('Hello world')
;

$statusReport = $service->send($notification, $subscription);
```

{% hint style="info" %}
In this example, we load the Subscription object from a string, but usually to retrieve the Subscription objects from a database or a dedicated storage.
{% endhint %}


# Installation

The bundle can be installed using the package `spomky-labs/web-push-bundle`

```bash
composer require spomky-labs/web-push-bundle
```

If you use Symfony Flex, the bundle is ready to be used. Otherwise, you must enable it. The bundle class is `WebPush\Bundle\WebPushBundle`.

When done, the bundle is ready and can send the notifications. However, there are extra packages we highly recommend to install and set up.

## VAPID Header

The [VAPID header](/3.1.x/common-concepts/vapid) authenticates your server and prevent malicious application to send notifications to your users. The header contains a signed JSON Web Token (JWS).

The library provides bridges for the following libraries `web-token` and `lcobucci/jwt`.

Please install `web-token/jwt-signature-algorithm-ecdsa` or `lcobucci/jwt` depending on the library you want to use.

{% hint style="info" %}
It is possible to use any other JWS provider. This will be detailed in the future.
{% endhint %}


# Configuration

## VAPID Support

To enable the VAPID header feature, you must install a JWS Provider (see [installation](/3.1.x/the-symfony-bundle/installation)) and configure it with your public and private key (see [this page](/3.1.x/common-concepts/vapid) to create these keys)

{% code title="config/packages/webpush.yaml" %}

```yaml
webpush:
  vapid:
    enabled: true # Enable the feature
    subject: 'https://my-service.com:8000' # An URL or an email address
    web_token:
      enabled: true # We use web-token in this example
      public_key: 'BB4W1qfBi7MF_Lnrc6i2oL-glAuKF4kevy9T0k2vyKV4qvuBrN3T6o9-7-NR3mKHwzDXzD3fe7XvIqIU1iADpGQ'
      private_key: 'C40jLFSa5UWxstkFvdwzT3eHONE2FIJSEsVIncSCAqU'
```

{% endcode %}

When using `lcobucci/jwt`, the configuration is very similar.

{% code title="config/packages/webpush.yaml" %}

```yaml
webpush:
  vapid:
    enabled: true
    subject: 'https://my-service.com:8000'
    lcobucci:
      enabled: true # We use web-token in this example
      public_key: 'BB4W1qfBi7MF_Lnrc6i2oL-glAuKF4kevy9T0k2vyKV4qvuBrN3T6o9-7-NR3mKHwzDXzD3fe7XvIqIU1iADpGQ'
      private_key: 'C40jLFSa5UWxstkFvdwzT3eHONE2FIJSEsVIncSCAqU'
```

{% endcode %}

{% hint style="danger" %}
You cannot enable both `web-token` and `lcobucci/jwt` at the same time
{% endhint %}

### Token Lifetime

By default, the library generates VAPID headers that are valid for 1 hour. You can change this value if needed. The parameter requires a relative string as showed [in the PHP documentation](https://www.php.net/manual/en/datetime.formats.relative.php).

{% code title="config/packages/webpush.yaml" %}

```yaml
webpush:
  vapid:
    enabled: true
    token_lifetime: 'now +2 hours'
```

{% endcode %}

{% hint style="warning" %}
The token lifetime should not be greater than 24 hours. Most of the Web Push Services will reject such long-life tokens
{% endhint %}

## Payload Support

### Padding

To obfuscate the real length of the notifications, messages can be padded before encryption. This operation consists in the concatenation of your message and arbitrary data in front of it. When encrypted, the messages will have the same size which reduces attacks.

By default, the padding is set to `recommended` i.e. \~3k bytes.

Acceptable values for this parameter are:

* `none`: no padding
* `recommended`: default value
* `max`: see warning below
* an integer: should be between `0` and `4078` or `3993` for `AESGCM` and `AES128GCM` respectively

{% code title="config/packages/webpush.yaml" %}

```yaml
webpush:
  payload:
    aesgcm:
      padding: 'none' # "none", "recommended", "max" or an integer (0 to 4078)
    aes128gcm:
      padding: 'none' # "none", "recommended", "max" or an integer (0 to 3993)
```

{% endcode %}

{% hint style="danger" %}
Please don't use "`none`" unless your are sending notifications in a development environment.
{% endhint %}

{% hint style="warning" %}
The value "`max`" increases the integrity protection of the messages, but there are known issues on Android and notification are not correctly delivered.
{% endhint %}

### Caching

The notifications [may have a payload](/3.1.x/common-concepts/the-notification#json-messages). This payload is encrypted on server side and, during this process, a random key is generated.

The creation of this random key takes approximately 150ms and can impact your server performance when sending thousand of notifications at once.

To reduce the impact on your server, you can enable the caching feature and reuse the encryption key for a defined period of time.

{% hint style="danger" %}
As encryption keys will be stored in the cache, you should make sure the cache is not shared otherwise you may have a security issue.
{% endhint %}

This parameter requires a PSR-6 Cache compatible service. If you set `Psr\Log\CacheItemPoolInterface`, the default Symfony cache will be used.

{% code title="config/packages/webpush.yaml" %}

```yaml
webpush:
  payload:
    aesgcm:
      cache: Psr\Cache\CacheItemPoolInterface
      cache_lifetime: '+1 hour' #Default: now +30min
    aes128gcm:
      cache: Psr\Cache\CacheItemPoolInterface
      cache_lifetime: '+1 hour' #Default: now +30min
```

{% endcode %}

{% hint style="success" %}
You can see the impact of this feature on the CI/CD Pipelines of this library. Go the <https://github.com/Spomky-Labs/web-push/actions?query=workflow%3ABenchmark> and find a summary table displayed at the end of each test.
{% endhint %}

## Debugging

If you have troubles sending notifications, you can log some messages from the libray. To do so, you just have to set the parameter logger in the configuration.

This parameter requires a PSR-3 logger. If you set `Psr\Log\LoggerInterface`, the Symfony logger will be used (PSR-3 copmpatible).

{% code title="config/packages/webpush.yaml" %}

```yaml
webpush:
  logger: Psr\Log\LoggerInterface
```

{% endcode %}


# The Web Push Service

The bundle provides a public Web Push service that you can inject this service into your application components.

In the following example, let's imagine that a notification is dispatched using the Symfony Messanger component and catched by an event handler. This handler will fetch all subscriptions and send the notification.

{% hint style="info" %}
The SubscriptionRepository class is totally fictive
{% endhint %}

{% code title="src/MessageHandler/SendNotification.php" %}

```php
<?php

declare(strict_types=1);

namespace App\MessageHandler;

use App\Message\SubscriptionExpired;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use WebPush\Notification;
use WebPush\WebPush;

final class SendPushNotifications implements MessageHandlerInterface
{
    private MessageBusInterface $messageBus;
    private SubscriptionRepository $repository;
    private WebPush $webPush;

    public function __construct(MessageBusInterface $messageBus, SubscriptionRepository $repository, WebPush $webPush)
    {
        $this->messageBus = $messageBus;
        $this->repository = $repository;
        $this->webPush = $webPush;
    }

    public function __invoke(Notification $notification): void
    {
        // Fetch all subscriptions
        $subscriptions = $this->repository->fetchAllSubscriptions();
        foreach ($subscriptions as $subscription) {
            //Sends the notification to the subscriber
            $report = $this->webPush->send($notification, $subscription);

            //If the subscription expired
            if ($report->subscriptionExpired()) {
                //We dispatch a new message and expect for
                // the subscription to be deleted
                $this->messageBus->dispatch(
                    new SubscriptionExpired($subscription)
                );
            }
        }
    }
}
```

{% endcode %}


# Doctrine

The bundle provides new Doctrine type and Schema to simplify the way you store the `Subscription` objects with Doctrine.

## Using The Doctrine Mapping

### Configuration

To enable this feature, the following configuration option  shall be set:

```yaml
webpush:
    doctrine_mapping: true
```

This will tell the bundle to register the Subscription object as a Doctrine mapped-superclass. The DoctrineBundle shall be enabled. No additional configuration is required.

### The `Subscription` Entity

First of all, we need to create a Subscription Entity that extends the Subscription object. In this example, we also need to associate one or more Subscription entities to a specific user (Many To One relationship).

{% code title="src/Entity/Subscription.php" %}

```php
<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use WebPush\Subscription as WebPushSubscription;

#[ORM\Table(name: 'subscriptions')]
#[ORM\Entity]
class Subscription extends WebPushSubscription
{
    #[ORM\Id]
    #[ORM\Column(type: 'integer')]
    #[ORM\GeneratedValue(strategy: 'AUTO')]
    private ?int $id = null;

    #[ORM\ManyToOne(targetEntity: User::class, cascade: ['persist'], inversedBy: 'subscriptions')]
    #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: true)]
    
    private ?User $user;

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getUser(): ?User
    {
        return $this->user;
    }

    public function setUser(?User $user): self
    {
        $this->user = $user;

        return $this;
    }

    // We need to override this method as it returns a WebPush\Subscription and we want an entity
    public static function createFromString(string $input): self
    {
        $base = BaseSubscription::createFromString($input);
        $object = new self($base->getEndpoint());
        $object->withContentEncodings($base->getSupportedContentEncodings());
        foreach ($base->getKeys()->all() as $k => $v) {
            $object->getKeys()->set($k, $v);
        }

        return $object;
    }
}
```

{% endcode %}

{% hint style="info" %}
In this exaple, we assume you already have a valid User entity class.
{% endhint %}

### The `User` Entity

Now, to have a bidirectional relationship between this class and the User entity class, we will add this relationship to the User class.

{% code title="src/Entity/User.php" %}

```php
<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Table(name="users")
 * @ORM\Entity
 */
#[ORM\Table(name: 'users')]
#[ORM\Entity]
class User //Usual interface here
{
    //Usual user stuff here

    #[ORM\OneToMany(targetEntity: Subscription::class, mappedBy: 'user')]
    private Collection $subscriptions;

    public function __construct()
    {
        $this->notifications = new ArrayCollection();
    }

    /**
     * @return Notification[]
     */
    public function getSubscriptions(): array
    {
        return $this->notifications->toArray();
    }

    public function addSubscription(Subscription $subscription): self
    {
        $subscription->setUser($this);
        $this->subscriptions->add($subscription);

        return $this;
    }

    public function removeSubscription(Subscription $subscription): self
    {
        $child->setUser(null);
        $this->subscriptions->removeElement($subscription);

        return $this;
    }
}
```

{% endcode %}

## Sending Notifications To A User

Now that your entities are set, you can register Subcriptions and assign them to your users. To send a Notification to a specific user, you just have to get all subscriptions using `$user->getSubscriptions()`.

{% code title="" %}

```php
$subscriptions = $user->getSubscriptions();
foreach ($subscriptions as $subscription) {
    $report = $this->webPush->send($notification, $subscription);
    if ($report->isSubscriptionExpired()) {
        //...Remove this subscription
    }
}
```

{% endcode %}

## Using Your Own Entity Class

It is possible to use your own Subscription entity class. The only constraint is that it shall implement the interface `WebPush\SubscriptionInterface` or shall have a method that returns an object that implements this interface.


# Example

Please have a look at the demo available at <https://github.com/Spomky-Labs/web-push-demo>.


# Web-Push

Web Notifications made easy

![Illustration by Freepik Stories (https://stories.freepik.com/communication)](https://content.gitbook.com/content/UcSVSFjXinPaAnQOeF2P/blobs/ZL6AAwlKSyffXGZQXLOt/Messages-rafiki.svg)

WebPush can be used to send notifications to endpoints that deliver Web Push notifications as described in the following specifications

* The [RFC8030: Generic Event Delivery Using HTTP Push](https://tools.ietf.org/html/rfc8030)
* The [RFC8291: Message Encryption for Web Push](https://tools.ietf.org/html/rfc8291)
* The [RFC8292: Voluntary Application Server Identification (VAPID) for Web Push](https://tools.ietf.org/html/rfc8292)

In addition, some features from the [Push API](https://w3c.github.io/push-api/) are implemented. This specification is a working draft at the time of writing (2023-11).

This project allows sending notifications on compatible browsers. List and versions available at <https://caniuse.com/push-api>


# Requirements

## Mandatory

* PHP 8.2+
* The `JSON` extension

## Optional

* A PSR-3 (Logger Interface) implementation for debugging

## Extension Specific

### VAPID extension

* Required:
  * `openssl` extension
  * `mbstring` extension
  * A JWT Provider
  * A PSR-20 (Clock) implementation
* Optional:
  * A PSR-3 (Logger Interface) implementation for debugging

{% hint style="success" %}
This library provides JWT Provider implementations for [web-token](https://web-token.spomky-labs.com) and [lcobucci/jwt](https://github.com/lcobucci/jwt)
{% endhint %}

### Payload extension

* Required:
  * `openssl` extension
  * `mbstring` extension
* Optional:
  * A PSR-6 (Caching Interface) implementation
  * A PSR-3 (Logger Interface) implementation for debugging


# Fluent Syntax

In the documentation, you will see that methods are called "fluently".

```php
<?php

use Psr\Clock\ClockInterface;
use Symfony\Component\Clock\NativeClock;
use WebPush\Payload\AES128GCM;
use WebPush\Payload\AESGCM;
use WebPush\Payload\PayloadExtension;

$clock = new NativeClock(); // PSR-20 Clock implementation

$payloadExtension = PayloadExtension::create()
    ->addContentEncoding(AESGCM::create($clock)->maxPadding())
    ->addContentEncoding(AES128GCM::create($clock)->maxPadding())
;
```

If you don't adhere to this coding style, you are free to use the "standard" way of coding. The following example has the same behavior as above.

```php
<?php

use Psr\Clock\ClockInterface;
use Symfony\Component\Clock\NativeClock;
use WebPush\Payload\AES128GCM;
use WebPush\Payload\AESGCM;
use WebPush\Payload\PayloadExtension;

$clock = new NativeClock(); // PSR-20 Clock implementation

$aesgcm = new AESGCM($clock);
$aesgcm->maxPadding();

$aes128gcm = new AES128GCM($clock);
$aes128gcm->maxPadding();

$payloadExtension = new PayloadExtension();
$payloadExtension->addContentEncoding($aesgcm);
$payloadExtension->addContentEncoding($aes128gcm);
```


# Contributing

First of all, **thank you** for contributing.

Bugs or feature requests can be posted online on the GitHub issues section of the project.

Few rules to ease code reviews and merges:

* You MUST follow the [PSR-12](https://www.php-fig.org/psr/psr-12/) coding standard.
* You MUST run the test suite (see below).
* You MUST write (or update) unit tests when bugs are fixed or features are added.
* You SHOULD write documentation.
* You MAY follow the [PSR-5](https://github.com/php-fig/fig-standards/blob/master/proposed/phpdoc.md) and [PSR-19](https://github.com/php-fig/fig-standards/blob/master/proposed/phpdoc-tags.md).

We use the following branching workflow:

* Each minor version has a dedicated branch (e.g. v1.1, v1.2, v2.0, v2.1…)
* The default branch is set to the last minor version (e.g. v2.1).

To contribute use [Pull Requests](https://help.github.com/articles/using-pull-requests), please, write commit messages that make sense, and rebase your branch before submitting your PR.

Your PR **should NOT** be submitted to the master branch but to the last minor version branch or to another minor version in case of bug fix.


# License

The MIT License (MIT)

Copyright (c) 2020-2026 Spomky-Labs

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.�


# Overview

The Web Push protocol allows your application to easily engage users by sending notifications to the browser. The subscription to these notifications is done by the user (opt-in).

The notification types depend on the application. For example, it could be a notification for an internal message or an alert before account closure.

We will see in this documentation that the Web Push API offers several options to customize the notifications by adding buttons, vibration schema, images, urgency indicator and more.

## How Web Push Works

Web Push involves three main actors:

1. **The User Agent (Browser)**: The user's web browser that receives and displays notifications
2. **The Push Service**: A service provided by the browser vendor (e.g., Google FCM, Mozilla Push Service) that routes notifications
3. **Your Application Server**: Your backend that sends notifications

### The Web Push Flow

```
1. User visits your website
   ↓
2. User grants notification permission
   ↓
3. Browser creates a subscription with the Push Service
   ↓
4. Subscription data is sent to your Application Server
   ↓
5. Your Application Server stores the subscription
   ↓
6. When needed, your Application Server sends a notification
   ↓
7. Push Service delivers to the Browser
   ↓
8. Browser displays the notification to the user
```

### Key Concepts

**Subscription**: A unique endpoint and set of keys that identify a user's device and browser. Each user can have multiple subscriptions (desktop, mobile, etc.).

**VAPID**: Voluntary Application Server Identification authenticates your server to the push service, preventing unauthorized parties from sending notifications.

**Payload Encryption**: All notification content is encrypted end-to-end. Only the user's browser can decrypt the message.

**Service Worker**: A background script that runs independently of your web page and handles push events even when the site isn't open.

## Use Cases

Web Push notifications are ideal for:

* **Real-time Updates**: Chat messages, social media interactions, breaking news
* **Transactional Notifications**: Order confirmations, shipping updates, payment receipts
* **Engagement**: Abandoned cart reminders, content recommendations, event reminders
* **Alerts**: System status, security alerts, time-sensitive information
* **Collaboration**: Team notifications, document changes, mentions

## Benefits of Web Push

### For Users

* **Timely Information**: Receive updates instantly without checking the app
* **Control**: Users must explicitly opt-in and can unsubscribe at any time
* **Cross-Device**: Works on desktop and mobile browsers
* **No App Installation**: Works directly in the browser, no app download required

### For Developers

* **Standard Protocol**: Based on open web standards (RFC 8030, 8291, 8292)
* **Cross-Platform**: One implementation works across Chrome, Firefox, Safari, Edge
* **Scalable**: Push services handle the infrastructure
* **Reliable Delivery**: Guaranteed delivery even when the browser is closed

## Browser Support

Web Push is supported by all modern browsers:

* ✅ Chrome (Desktop & Android) - Full support
* ✅ Firefox (Desktop & Android) - Full support
* ✅ Safari (Desktop & iOS 16.4+) - Full support
* ✅ Edge (Desktop & Android) - Full support
* ✅ Opera (Desktop & Android) - Full support

Check the latest browser compatibility at <https://caniuse.com/push-api>

## Requirements

To implement Web Push notifications, you need:

1. **HTTPS**: Web Push only works on secure origins (https\:// or localhost)
2. **Service Worker**: A registered service worker to handle push events
3. **User Permission**: Explicit user consent to receive notifications
4. **VAPID Keys**: Public/private key pair to authenticate your server
5. **Backend Implementation**: Server-side code to send notifications (this library!)

## Security & Privacy

Web Push is designed with security and privacy in mind:

* **End-to-End Encryption**: All notification payloads are encrypted
* **User Consent**: Users must explicitly grant permission
* **Revocable**: Users can revoke permission at any time
* **VAPID Authentication**: Prevents unauthorized sending
* **No Tracking**: Push services don't have access to notification content

## Testing

You want to test it? Please go to [this demo page](https://serviceworke.rs/push-payload_demo.html) to see what your browser already supports.

## Next Steps

Now that you understand the basics, explore the detailed documentation:

* [The Subscription](/common-concepts/the-subscription) - Learn about subscription objects and content encodings
* [The Notification](/common-concepts/the-notification) - Discover how to create and customize notifications
* [The Status Report](/common-concepts/the-status-report) - Understand delivery status and error handling
* [VAPID](/common-concepts/vapid) - Set up server authentication

Then, choose your implementation:

* [The Library](/the-library/installation) - Standalone PHP library
* [The Symfony Bundle](/the-symfony-bundle/installation) - Symfony integration


# The Subscription

The subscription is created on client side when the end-user allows your application to send push messages.

A subscription is a unique identifier that represents the user's device and browser. It contains:

* An **endpoint URL** provided by the push service
* **Encryption keys** for securing the message payload
* **Supported content encodings** for payload encryption

## Creating a Subscription

On client side (Javascript), you can simply send to your server the object you receive using `JSON.stringify`.

{% hint style="info" %}
Javascript examples to get a Subscription from the web browser are not provided here. Please refer to other resources such as blog posts or library documentation.
{% endhint %}

A subscription object will look like:

```javascript
{
 "endpoint":"https://updates.push.services.mozilla.com/wpush/v2/AAAAAAAA[…]AAAAAAAAA",
 "keys":{
 "auth":"XXXXXXXXXXXXXX",
 "p256dh":"YYYYYYYY[…]YYYYYYYYYYYYY"
 }
}
```

### Understanding the Subscription Components

* **endpoint**: The unique URL where your server sends notifications. Each subscription has a different endpoint.
* **keys.auth**: Authentication secret for message encryption
* **keys.p256dh**: Public key for Elliptic Curve Diffie-Hellman key agreement

## Server-Side Processing

On server side, you can get a `WebPush\Subscription` object from the JSON string using the dedicated method `WebPush\Subscription::createFromString`.

```php
<?php

use WebPush\Subscription;

$subscription = Subscription::createFromString('{"endpoint":"https://updates.push.services.mozilla.com/wpush/v2/AAAAAAAA[…]AAAAAAAAA","keys":{"auth":"XXXXXXXXXXXXXX","p256dh":"YYYYYYYY[…]YYYYYYYYYYYYY"}}');
```

## Supported Content Encodings

By default, the content encoding `aesgcm` will be used. This encoding indicates how the payload of the notification should be formatted. The PushManager object from the Push API may list all acceptable encodings. In this case, it could be interesting to set these encodings to the Subscription object.

The two standard encodings are:

* **aes128gcm** (recommended): Newer, more efficient encoding defined in RFC 8291
* **aesgcm** (legacy): Older encoding for backwards compatibility

```javascript
// Retrieve the supported content encodings
const supportedContentEncodings = PushManager.supportedContentEncodings || ['aesgcm'];

// Assign the encodings to the subscription object
const jsonSubscription = Object.assign(
    subscription.toJSON(),
    { supportedContentEncodings }
);

// Send the subscription object to the application server
fetch('/subscription/add', {
    method: 'POST',
    body: JSON.stringify(jsonSubscription),
});
```

This will result in something like the following:

```javascript
{
 "endpoint":"https://updates.push.services.mozilla.com/wpush/v2/AAAAAAAA[…]AAAAAAAAA",
 "keys":{
 "auth":"XXXXXXXXXXXXXX",
 "p256dh":"YYYYYYYY[…]YYYYYYYYYYYYY"
 },
 "supportedContentEncodings":["aes128gcm","aesgcm"]
}
```

{% hint style="warning" %}
The order of `supportedContentEncodings` is important. First supported item will be used. If possible, `AES128GCM` should be used as preferred content encoding.
{% endhint %}

## Subscription Lifecycle

### 1. Creation

A subscription is created when the user grants notification permission and your service worker subscribes to push notifications.

### 2. Storage

Your application server must store the subscription to send notifications later. Store:

* The complete subscription object
* Associated user information
* Creation timestamp (useful for cleanup)

### 3. Usage

Use the stored subscription to send notifications at any time, even when the user is not on your website.

### 4. Expiration

Subscriptions can expire for several reasons:

* User revokes notification permission
* User clears browser data
* Push service expires old subscriptions
* Subscription endpoint becomes invalid

### 5. Cleanup

Always handle expired subscriptions:

* Remove them from your database when you receive a 404 or 410 error
* Implement a strategy to detect and clean abandoned subscriptions

## Best Practices

### Store Essential Information

```php
// Minimum data to store
$subscriptionData = [
    'user_id' => $userId,
    'endpoint' => $subscription->getEndpoint(),
    'keys' => $subscription->getKeys(),
    'encodings' => $subscription->getSupportedContentEncodings(),
    'created_at' => new DateTime(),
];
```

### Handle Multiple Subscriptions per User

A single user may have multiple subscriptions (different devices/browsers). Store all of them:

```php
// Send to all user's subscriptions
foreach ($userSubscriptions as $subscription) {
    $report = $webPush->send($notification, $subscription);

    if ($report->isSubscriptionExpired()) {
        // Remove this specific subscription
        $repository->remove($subscription);
    }
}
```

### Implement Subscription Refresh

If a subscription expires, prompt the user to resubscribe:

```javascript
// Check if subscription is still valid
const subscription = await registration.pushManager.getSubscription();
if (!subscription) {
    // Subscription was revoked, show UI to resubscribe
    showResubscribePrompt();
}
```

### Security Considerations

1. **Validate Endpoint URLs**: Ensure endpoints are from known push services
2. **Store Securely**: Treat subscriptions as sensitive data
3. **Rate Limiting**: Implement rate limits to prevent abuse
4. **User Association**: Always associate subscriptions with authenticated users

```php
// Validate endpoint before storing
$endpoint = $subscription->getEndpoint();
$allowedDomains = [
    'fcm.googleapis.com',
    'updates.push.services.mozilla.com',
    'web.push.apple.com',
];

$isValid = false;
foreach ($allowedDomains as $domain) {
    if (str_contains($endpoint, $domain)) {
        $isValid = true;
        break;
    }
}

if (!$isValid) {
    throw new \InvalidArgumentException('Invalid push service endpoint');
}
```

## Subscription Uniqueness

Each subscription is unique to:

* A specific browser
* A specific device
* A specific user profile (if the browser has multiple profiles)
* A specific origin (your website domain)

This means:

* The same user on Chrome desktop and Firefox desktop will have 2 different subscriptions
* The same user on desktop and mobile will have 2 different subscriptions
* If a user clears their browser data, they will get a new subscription

## Testing Subscriptions

Always test your subscription handling:

```php
// Test with a real subscription
$testSubscription = Subscription::createFromString($jsonFromBrowser);

// Verify it has all required components
assert($testSubscription->getEndpoint() !== '');
assert(count($testSubscription->getKeys()) >= 2);
assert(in_array('auth', array_keys($testSubscription->getKeys())));
assert(in_array('p256dh', array_keys($testSubscription->getKeys())));
```

## Performance Optimization

### Caching Subscriptions

When sending notifications to multiple users or sending frequently, implement caching strategies to improve performance:

#### Application-Level Caching

Use your application's cache layer (Redis, Memcached, etc.) to cache subscription lookups:

```php
use Psr\Cache\CacheItemPoolInterface;
use WebPush\Subscription;

class CachedSubscriptionRepository
{
    public function __construct(
        private SubscriptionRepository $repository,
        private CacheItemPoolInterface $cache
    ) {}

    public function findByUserId(string $userId): ?Subscription
    {
        $cacheKey = "subscription.user.{$userId}";
        $item = $this->cache->getItem($cacheKey);

        if ($item->isHit()) {
            $json = $item->get();
            return Subscription::createFromString($json);
        }

        $subscription = $this->repository->findByUserId($userId);
        if ($subscription !== null) {
            $item->set($subscription->toString());
            $item->expiresAfter(3600); // Cache for 1 hour
            $this->cache->save($item);
        }

        return $subscription;
    }

    public function invalidate(string $userId): void
    {
        $cacheKey = "subscription.user.{$userId}";
        $this->cache->deleteItem($cacheKey);
    }
}
```

#### Doctrine Query Result Cache

If using Doctrine, leverage query result caching:

```php
use Doctrine\ORM\EntityRepository;

class UserRepository extends EntityRepository
{
    public function findWithSubscription(int $userId): ?User
    {
        return $this->createQueryBuilder('u')
            ->leftJoin('u.subscription', 's')
            ->addSelect('s')
            ->where('u.id = :userId')
            ->setParameter('userId', $userId)
            ->getQuery()
            ->useResultCache(true, 3600, "user_subscription_{$userId}")
            ->getOneOrNullResult();
    }
}
```

#### Batch Loading

When sending to multiple users, load subscriptions in batches to reduce database queries:

```php
use WebPush\Notification;
use WebPush\WebPushService;

class BulkNotificationSender
{
    public function __construct(
        private WebPushService $webPush,
        private EntityManagerInterface $em
    ) {}

    public function sendToUsers(Notification $notification, array $userIds): array
    {
        // Load all subscriptions in one query
        $subscriptions = $this->em->createQueryBuilder()
            ->select('s')
            ->from(UserSubscription::class, 's')
            ->where('s.userId IN (:userIds)')
            ->setParameter('userIds', $userIds)
            ->getQuery()
            ->getResult();

        // Send to all subscriptions
        return $this->webPush->sendToMultiple($notification, $subscriptions);
    }
}
```

#### Cache Invalidation Strategy

Always invalidate the cache when subscriptions change:

```php
class SubscriptionService
{
    public function __construct(
        private SubscriptionRepository $repository,
        private CacheItemPoolInterface $cache
    ) {}

    public function updateSubscription(string $userId, string $subscriptionJson): void
    {
        $subscription = Subscription::createFromString($subscriptionJson);
        $this->repository->save($userId, $subscription);

        // Invalidate cache
        $this->cache->deleteItem("subscription.user.{$userId}");
    }

    public function removeExpiredSubscription(string $userId): void
    {
        $this->repository->remove($userId);

        // Invalidate cache
        $this->cache->deleteItem("subscription.user.{$userId}");
    }
}
```

### Best Practices for High-Volume Sending

When sending to thousands of users:

1. **Use Background Jobs**: Queue notifications for asynchronous processing
2. **Batch Subscriptions**: Load subscriptions in batches of 100-1000
3. **Cache Aggressively**: Cache subscription lookups for 1-4 hours
4. **Monitor Cache Hit Rate**: Aim for >80% hit rate
5. **Clean Up Expired**: Remove expired subscriptions immediately to reduce database size

```php
use Symfony\Component\Messenger\MessageBusInterface;

class NotificationDispatcher
{
    public function __construct(
        private MessageBusInterface $bus,
        private CacheItemPoolInterface $cache
    ) {}

    public function sendToAllUsers(Notification $notification): void
    {
        // Get cached user count
        $userCount = $this->getCachedUserCount();
        $batchSize = 1000;

        // Dispatch batch jobs
        for ($offset = 0; $offset < $userCount; $offset += $batchSize) {
            $this->bus->dispatch(new SendNotificationBatch(
                $notification,
                $offset,
                $batchSize
            ));
        }
    }

    private function getCachedUserCount(): int
    {
        $item = $this->cache->getItem('user.count');
        if ($item->isHit()) {
            return $item->get();
        }

        $count = $this->countUsers();
        $item->set($count);
        $item->expiresAfter(300); // Cache for 5 minutes
        $this->cache->save($item);

        return $count;
    }
}
```

{% hint style="info" %}
**Note**: The web-push library itself does not provide caching functionality, as this is the responsibility of your application layer. The examples above show recommended patterns for implementing caching in your application.
{% endhint %}

## Subscription Lifecycle and Expiration Management

### Understanding Subscription Expiration

Web Push subscriptions **do not have explicit expiration dates**. The subscription JSON from the browser contains no temporal information:

```json
{
  "endpoint": "https://fcm.googleapis.com/fcm/send/...",
  "keys": {
    "auth": "...",
    "p256dh": "..."
  }
}
```

The only way to know a subscription has expired is to attempt sending and receive a 404 or 410 error.

### Why Subscriptions Expire

1. **User Revocation** - User denies notification permission
2. **Browser Data Clearing** - User clears browser data
3. **App/Browser Uninstall** - Complete removal
4. **Push Service Policy** - Services may expire inactive subscriptions (timeframes vary by provider)
5. **VAPID Key Changes** - Changing your VAPID keys invalidates old subscriptions

### Tracking Subscription Health

Since subscriptions don't have built-in expiration dates, track metadata at the application level:

```php
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class UserSubscription
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    private int $id;

    #[ORM\Column]
    private int $userId;

    #[ORM\Column(type: 'text')]
    private string $endpoint;

    #[ORM\Column(type: 'json')]
    private array $keys;

    #[ORM\Column(type: 'json')]
    private array $supportedContentEncodings;

    #[ORM\Column]
    private \DateTimeImmutable $createdAt;

    #[ORM\Column(nullable: true)]
    private ?\DateTimeImmutable $lastSuccessfulSendAt = null;

    #[ORM\Column]
    private int $consecutiveFailures = 0;

    #[ORM\Column(nullable: true)]
    private ?\DateTimeImmutable $lastFailureAt = null;

    public function __construct(int $userId, string $subscriptionJson)
    {
        $subscription = \WebPush\Subscription::createFromString($subscriptionJson);

        $this->userId = $userId;
        $this->endpoint = $subscription->getEndpoint();
        $this->keys = $subscription->getKeys();
        $this->supportedContentEncodings = $subscription->getSupportedContentEncodings();
        $this->createdAt = new \DateTimeImmutable();
    }

    public function markSuccessfulSend(): void
    {
        $this->lastSuccessfulSendAt = new \DateTimeImmutable();
        $this->consecutiveFailures = 0;
        $this->lastFailureAt = null;
    }

    public function markFailedSend(): void
    {
        $this->consecutiveFailures++;
        $this->lastFailureAt = new \DateTimeImmutable();
    }

    public function toSubscription(): \WebPush\Subscription
    {
        $subscription = new \WebPush\Subscription($this->endpoint);
        foreach ($this->keys as $key => $value) {
            $subscription->setKey($key, $value);
        }
        $subscription->withContentEncodings($this->supportedContentEncodings);

        return $subscription;
    }
}
```

### Identifying Likely Expired Subscriptions

Use heuristics to identify subscriptions that are probably expired:

```php
class SubscriptionHealthService
{
    /**
     * Check if a subscription is likely expired based on usage patterns.
     */
    public function isLikelyExpired(UserSubscription $subscription): bool
    {
        // Multiple consecutive failures
        if ($subscription->getConsecutiveFailures() >= 3) {
            return true;
        }

        // Never successfully used and older than 7 days
        if ($subscription->getLastSuccessfulSendAt() === null) {
            $age = $this->getDaysOld($subscription->getCreatedAt());
            if ($age > 7) {
                return true;
            }
        }

        // Not used successfully in 90 days
        if ($subscription->getLastSuccessfulSendAt() !== null) {
            $daysSinceLastUse = $this->getDaysOld($subscription->getLastSuccessfulSendAt());
            if ($daysSinceLastUse > 90) {
                return true;
            }
        }

        return false;
    }

    /**
     * Get subscription health score (0-100, higher is healthier).
     */
    public function getHealthScore(UserSubscription $subscription): int
    {
        $score = 100;

        // Penalty for consecutive failures
        $score -= $subscription->getConsecutiveFailures() * 25;

        // Penalty for age without use
        if ($subscription->getLastSuccessfulSendAt() === null) {
            $age = $this->getDaysOld($subscription->getCreatedAt());
            $score -= min($age * 2, 50);
        } else {
            $daysSinceLastUse = $this->getDaysOld($subscription->getLastSuccessfulSendAt());
            $score -= min($daysSinceLastUse, 50);
        }

        return max(0, $score);
    }

    private function getDaysOld(\DateTimeImmutable $date): int
    {
        $interval = $date->diff(new \DateTimeImmutable());
        return (int) $interval->days;
    }
}
```

### Automated Cleanup Strategies

#### Strategy 1: Clean After Failed Sends

Update subscription status after each send attempt:

```php
use WebPush\StatusReport;

class NotificationSender
{
    public function __construct(
        private WebPushService $webPush,
        private EntityManagerInterface $em,
        private SubscriptionRepository $repository
    ) {}

    public function sendToUser(int $userId, Notification $notification): bool
    {
        $subscription = $this->repository->findByUserId($userId);
        if ($subscription === null) {
            return false;
        }

        try {
            $report = $this->webPush->send($notification, $subscription->toSubscription());

            if ($report->isSuccess()) {
                $subscription->markSuccessfulSend();
                $this->em->flush();
                return true;
            }

            if ($report->isSubscriptionExpired()) {
                // Remove immediately
                $this->em->remove($subscription);
                $this->em->flush();
                return false;
            }

            // Mark as failed
            $subscription->markFailedSend();
            $this->em->flush();

            // Remove after 3 consecutive failures
            if ($subscription->getConsecutiveFailures() >= 3) {
                $this->em->remove($subscription);
                $this->em->flush();
            }

            return false;

        } catch (\Throwable $e) {
            $subscription->markFailedSend();
            $this->em->flush();
            return false;
        }
    }
}
```

#### Strategy 2: Scheduled Cleanup Job

Run periodic cleanup to remove stale subscriptions:

```php
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class CleanupExpiredSubscriptionsCommand extends Command
{
    protected static $defaultName = 'app:cleanup-subscriptions';

    public function __construct(
        private EntityManagerInterface $em,
        private SubscriptionHealthService $healthService
    ) {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $subscriptions = $this->em->getRepository(UserSubscription::class)->findAll();
        $removed = 0;

        foreach ($subscriptions as $subscription) {
            if ($this->healthService->isLikelyExpired($subscription)) {
                $this->em->remove($subscription);
                $removed++;
            }
        }

        $this->em->flush();

        $output->writeln("Removed {$removed} likely expired subscriptions");

        return Command::SUCCESS;
    }
}
```

#### Strategy 3: Proactive Health Check

Test subscriptions before important campaigns:

```php
class SubscriptionHealthChecker
{
    public function __construct(
        private WebPushService $webPush,
        private EntityManagerInterface $em
    ) {}

    /**
     * Test if a subscription is still alive by sending a silent test notification.
     */
    public function testSubscription(UserSubscription $subscription): bool
    {
        try {
            $testNotification = Notification::create()
                ->withPayload(json_encode(['type' => 'health_check', 'silent' => true]))
                ->withTTL(Notification::TTL_IMMEDIATE);

            $report = $this->webPush->send($testNotification, $subscription->toSubscription());

            if ($report->isSuccess()) {
                $subscription->markSuccessfulSend();
                $this->em->flush();
                return true;
            }

            if ($report->isSubscriptionExpired()) {
                $this->em->remove($subscription);
                $this->em->flush();
                return false;
            }

            $subscription->markFailedSend();
            $this->em->flush();
            return false;

        } catch (\Throwable $e) {
            $subscription->markFailedSend();
            $this->em->flush();
            return false;
        }
    }

    /**
     * Test all subscriptions for a user and return healthy ones.
     */
    public function getHealthySubscriptions(int $userId): array
    {
        $subscriptions = $this->em->getRepository(UserSubscription::class)
            ->findBy(['userId' => $userId]);

        $healthy = [];
        foreach ($subscriptions as $subscription) {
            if ($this->testSubscription($subscription)) {
                $healthy[] = $subscription;
            }
        }

        return $healthy;
    }
}
```

### Push Service Expiration Policies

Different push services have different expiration policies:

| Push Service | Typical Expiration Policy                                              |
| ------------ | ---------------------------------------------------------------------- |
| FCM (Google) | 60-90 days of inactivity                                               |
| Mozilla Push | No automatic expiration, but may clean very old inactive subscriptions |
| Apple Push   | \~30 days of inactivity                                                |
| Windows Push | Variable, typically 30-90 days                                         |

{% hint style="warning" %}
These are **estimated** timeframes based on observed behavior. Push services do not publicly document their exact expiration policies, and they may change at any time.
{% endhint %}

### Best Practices

1. **Track Metadata**: Always store `createdAt`, `lastSuccessfulSendAt`, and `consecutiveFailures`
2. **Update After Every Send**: Mark success or failure after each notification attempt
3. **Remove Immediately on 404/410**: Don't retry expired subscriptions
4. **Use Heuristics**: Remove subscriptions with 3+ consecutive failures
5. **Periodic Cleanup**: Run a scheduled job weekly to remove stale subscriptions
6. **Monitor Health**: Track subscription health scores for analytics
7. **Re-subscription Flow**: Make it easy for users to re-subscribe if needed

### Example: Complete Subscription Management

```php
class SubscriptionManager
{
    public function __construct(
        private EntityManagerInterface $em,
        private WebPushService $webPush,
        private LoggerInterface $logger
    ) {}

    public function saveSubscription(int $userId, string $subscriptionJson): void
    {
        // Check if subscription already exists
        $existing = $this->em->getRepository(UserSubscription::class)
            ->findOneBy(['userId' => $userId]);

        if ($existing !== null) {
            // Update existing
            $this->em->remove($existing);
        }

        // Create new subscription
        $subscription = new UserSubscription($userId, $subscriptionJson);
        $this->em->persist($subscription);
        $this->em->flush();

        $this->logger->info('Subscription saved', [
            'user_id' => $userId,
            'endpoint' => $subscription->getEndpoint()
        ]);
    }

    public function sendNotification(int $userId, Notification $notification): array
    {
        $subscriptions = $this->em->getRepository(UserSubscription::class)
            ->findBy(['userId' => $userId]);

        $results = ['sent' => 0, 'failed' => 0, 'removed' => 0];

        foreach ($subscriptions as $subscription) {
            try {
                $report = $this->webPush->send($notification, $subscription->toSubscription());

                if ($report->isSuccess()) {
                    $subscription->markSuccessfulSend();
                    $results['sent']++;
                } elseif ($report->isSubscriptionExpired()) {
                    $this->em->remove($subscription);
                    $results['removed']++;
                } else {
                    $subscription->markFailedSend();
                    $results['failed']++;

                    // Remove after 3 failures
                    if ($subscription->getConsecutiveFailures() >= 3) {
                        $this->em->remove($subscription);
                        $results['removed']++;
                    }
                }
            } catch (\Throwable $e) {
                $subscription->markFailedSend();
                $results['failed']++;
                $this->logger->error('Send failed', ['error' => $e->getMessage()]);
            }
        }

        $this->em->flush();

        return $results;
    }

    public function cleanupExpiredSubscriptions(): int
    {
        $qb = $this->em->createQueryBuilder();

        // Find subscriptions with 3+ failures OR not used in 90 days
        $subscriptions = $qb->select('s')
            ->from(UserSubscription::class, 's')
            ->where('s.consecutiveFailures >= 3')
            ->orWhere('s.lastSuccessfulSendAt < :threshold')
            ->orWhere('s.lastSuccessfulSendAt IS NULL AND s.createdAt < :createdThreshold')
            ->setParameter('threshold', new \DateTimeImmutable('-90 days'))
            ->setParameter('createdThreshold', new \DateTimeImmutable('-7 days'))
            ->getQuery()
            ->getResult();

        $count = count($subscriptions);
        foreach ($subscriptions as $subscription) {
            $this->em->remove($subscription);
        }

        $this->em->flush();

        $this->logger->info('Cleaned up expired subscriptions', ['count' => $count]);

        return $count;
    }
}
```

{% hint style="success" %}
**Key Takeaway**: Web Push subscriptions don't have built-in expiration tracking. Your application must implement tracking and cleanup strategies to maintain a healthy subscription database.
{% endhint %}

## Common Issues

### Subscription Not Received

* Check that the user granted permission
* Verify HTTPS is enabled
* Ensure service worker is properly registered

### Subscription Immediately Expires

* Check VAPID keys match between client and server
* Verify the push service endpoint is accessible
* Ensure proper payload encryption

### Multiple Subscriptions for Same User

This is normal and expected. Users can have multiple devices and browsers.

## Next Steps

* Learn how to create [Notifications](/common-concepts/the-notification) to send to your subscriptions
* Understand [Status Reports](/common-concepts/the-status-report) to handle delivery results
* Set up [VAPID](/common-concepts/vapid) authentication for secure sending


# The Notification

To reach the client (web browser), you need to send a Notification to the Subscription.

```php
<?php
use WebPush\Notification;

$notification = Notification::create();
```

The Notification should have a payload. In this case, the payload will be encrypted on server side and decrypted by the client.

That payload may be a string or a JSON object. The structure of the latter is described in the next section.

```php
<?php
use WebPush\Notification;

$notification = Notification::create()
    ->withPayload('Hello world')
;
```

## TTL (Time-To-Live)

With this feature, a value in seconds is added to the notification. It suggests how long a push message is retained by the push service. A value of 0 (zero) indicates the notification is delivered immediately.

### Using TTL Constants

The library provides predefined constants for common TTL values:

```php
<?php
use WebPush\Notification;

// Using constants (recommended)
$notification = Notification::create()
    ->withTTL(Notification::TTL_ONE_HOUR);

// Available constants:
// TTL_IMMEDIATE     = 0          (deliver immediately or not at all)
// TTL_ONE_MINUTE    = 60
// TTL_FIVE_MINUTES  = 300
// TTL_TEN_MINUTES   = 600
// TTL_ONE_HOUR      = 3600
// TTL_ONE_DAY       = 86400
// TTL_ONE_WEEK      = 604800
// TTL_FOUR_WEEKS    = 2419200
```

### Using Custom TTL Values

You can also specify custom TTL values in seconds:

```php
<?php
use WebPush\Notification;

$notification = Notification::create()
    ->withTTL(3600); // 1 hour in seconds
```

## Topic

A push message that has been stored by the push service can be replaced with new content. If the user agent is offline during the time the push messages are sent, updating a push message avoids the situation where outdated or redundant messages are sent to the user agent.

Only push messages that have been assigned a topic can be replaced. A push message with a topic replaces any outstanding push message with an identical topic.

```php
<?php
use WebPush\Notification;

$notification = Notification::create()
    ->withTopic('user-account-updated')
;
```

## Urgency

For a device that is battery-powered, it is often critical it remains dormant for extended periods.

Radio communication in particular consumes significant power and limits the length of time the device can operate.

To avoid consuming resources to receive trivial messages, it is helpful if an application server can communicate the urgency of a message and if the user agent can request that the push server only forwards messages of a specific urgency.

| Urgency  | Device State               | Examples                                    |
| -------- | -------------------------- | ------------------------------------------- |
| very-low | On power and Wi-Fi         | Advertisements                              |
| low      | On either power or Wi-Fi   | Topic updates                               |
| normal   | On neither power nor Wi-Fi | Chat or Calendar Message                    |
| high     | Low battery                | Incoming phone call or time-sensitive alert |

{% hint style="warning" %}
Be careful with the `very-low` urgency: it is not recognized by all Web-Push services
{% endhint %}

```php
<?php
use WebPush\Notification;

$notification = Notification::create()
    ->veryLowUrgency()
    ->lowUrgency()
    ->normalUrgency()
    ->highUrgency()
;
```

## Asynchronous Response

Your application may prefer asynchronous responses to request confirmation from the push service when a push message is delivered and then acknowledged by the user agent. The push service MUST support delivery confirmations to use this feature.

```php
<?php
use WebPush\Notification;

$notification = Notification::create()
    ->async() // Prefer async response
    ->sync() // Prefer sync response (default)
;
```

{% hint style="warning" %}
The `async` mode is not recognized by all Web Push services. In case of failure, you should try sending `sync` notifications.
{% endhint %}

## JSON Messages

As mentioned in the overview section, the specification [defines a structure for the payload](https://developer.mozilla.org/en-US/docs/Web/API/Notification/Notification#parameters). This structure contains properties that the client should understand and render in an appropriate way.

The library provides a `WebPush\Message` class with convenient methods to ease the creation of a message.

```php
<?php
use WebPush\Action;
use WebPush\Message;
use WebPush\Notification;

$message = Message::create('This is the title')
    ->mute() // Silent
    ->unmute() // Not silent (default)

    ->auto() //Direction = auto (default)
    ->ltr() //Direction = left to right
    ->rtl() //Direction = right to left

    ->addAction(Action::create('alert', 'Click me!'))

    ->interactionRequired()
    ->noInteraction()

    ->renotify()
    ->doNotRenotify() // Default

    ->withBody('Hello World!')

    ->withIcon('https://…')
    ->withImage('https://…')
    ->withData(['foo' => 'BAR']) // Arbitrary data
    ->withBadge('badge1')
    ->withLang('fr-FR')
    ->withTimestamp(time())
    ->withTag('foo')

    ->vibrate(300, 100, 400)

    ->toString() // Converts the Message object into a string
;

$notification = Notification::create()
    ->withPayload($message)
;
```

The resulting notification payload will look as follows:

```javascript
{
    "title":"This is the title",
    "options":{
        "actions":[
            {
                "action":"alert",
                "title":"Click me!"
            }
        ],
        "badge":"badge1",
        "body":"Hello World!",
        "data":{
            "foo":"BAR"
        },
        "dir":"rtl",
        "icon":"https://…",
        "image":"https://…",
        "lang":"fr-FR",
        "renotify":false,
        "requireInteraction":false,
        "silent":false,
        "tag":"foo",
        "timestamp":1629145424,
        "vibrate":[
            300,
            100,
            400
        ]
    }
}
```

On client side, you can easily load that payload and display the notification:

```javascript
self.addEventListener('push', function(event) {
    const data = event.data.json();
    const {title, ...options} = data;

    event.waitUntil(
        self.registration.showNotification(title, options)
    );
});
```

## Best Practices

### Choose Appropriate TTL Values

The Time-To-Live (TTL) determines how long notifications are retained if the user is offline:

```php
// Time-sensitive: expires quickly
$urgentNotification = Notification::create()
    ->withPayload('Flash sale ends in 10 minutes!')
    ->withTTL(Notification::TTL_TEN_MINUTES);

// Important but not urgent
$normalNotification = Notification::create()
    ->withPayload('New message from John')
    ->withTTL(Notification::TTL_ONE_DAY);

// Persistent notification
$persistentNotification = Notification::create()
    ->withPayload('New feature available')
    ->withTTL(Notification::TTL_ONE_WEEK);
```

### Use Topics Wisely

Topics prevent notification spam by replacing old notifications with new ones:

```php
// Weather updates - only show the latest
$weatherUpdate = Notification::create()
    ->withTopic('weather-alert')
    ->withPayload($latestWeatherData);

// Stock price updates - replace with latest price
$stockUpdate = Notification::create()
    ->withTopic('stock-AAPL')
    ->withPayload($currentPrice);

// User mentions - don't replace, show all
$mention = Notification::create()
    // No topic - each mention is shown separately
    ->withPayload('Sarah mentioned you in a comment');
```

### Set Appropriate Urgency

Match urgency to content to optimize battery life:

```php
// High urgency - user needs to act immediately
Notification::create()
    ->highUrgency()
    ->withPayload('Your bank account requires immediate attention')
    ->withTTL(Notification::TTL_ONE_HOUR);

// Normal urgency - standard messages
Notification::create()
    ->normalUrgency()
    ->withPayload('New comment on your post')
    ->withTTL(Notification::TTL_ONE_DAY);

// Low urgency - can wait
Notification::create()
    ->lowUrgency()
    ->withPayload('Weekly summary available')
    ->withTTL(Notification::TTL_ONE_WEEK);

// Very low urgency - promotional content
Notification::create()
    ->veryLowUrgency()
    ->withPayload('Check out our new products')
    ->withTTL(Notification::TTL_FOUR_WEEKS);
```

### Craft Effective Messages

Good notification messages are:

* **Clear**: User understands the content immediately
* **Actionable**: User knows what to do next
* **Concise**: Get to the point quickly
* **Relevant**: Personalized to the user

```php
use WebPush\Message;
use WebPush\Action;

// Example: E-commerce order notification
$message = Message::create('Order Shipped!')
    ->withBody('Your order #12345 is on its way')
    ->withIcon('/icons/shipping.png')
    ->withBadge('/icons/badge.png')
    ->withData([
        'orderId' => '12345',
        'trackingUrl' => 'https://example.com/track/12345'
    ])
    ->addAction(Action::create('track', 'Track Package'))
    ->addAction(Action::create('view', 'View Order'))
    ->interactionRequired(); // User must interact

$notification = Notification::create()
    ->withPayload($message->toString())
    ->normalUrgency()
    ->withTTL(Notification::TTL_ONE_DAY);
```

### Optimize for Mobile

Mobile devices have limited battery and screen space:

```php
// Mobile-optimized notification
$mobileNotification = Message::create('New Message')
    ->withBody('John: Can we meet tomorrow?')
    ->withIcon('/icons/chat-64.png') // Small icon
    ->withBadge('/icons/unread-badge.png')
    ->withData(['chatId' => '123', 'userId' => '456'])
    ->addAction(Action::create('reply', 'Reply'))
    ->addAction(Action::create('view', 'View'))
    ->withTag('chat-123') // Group related notifications
    ->renotify() // Alert user even if previous notification exists
    ->vibrate(200, 100, 200); // Short vibration pattern
```

## Common Notification Patterns

### 1. Chat Message

```php
$chatMessage = Message::create($senderName)
    ->withBody($messagePreview)
    ->withIcon($senderAvatar)
    ->withBadge('/icons/message-badge.png')
    ->withData([
        'chatId' => $chatId,
        'senderId' => $senderId,
        'messageId' => $messageId
    ])
    ->addAction(Action::create('reply', 'Reply'))
    ->addAction(Action::create('view', 'View'))
    ->withTag("chat-{$chatId}") // Group by conversation
    ->renotify()
    ->withTimestamp(time());

$notification = Notification::create()
    ->withPayload($chatMessage->toString())
    ->highUrgency()
    ->withTTL(Notification::TTL_ONE_HOUR);
```

### 2. System Alert

```php
$alert = Message::create('System Maintenance')
    ->withBody('Scheduled maintenance in 30 minutes')
    ->withIcon('/icons/warning.png')
    ->withBadge('/icons/alert-badge.png')
    ->interactionRequired()
    ->withTag('system-maintenance')
    ->vibrate(300, 200, 300);

$notification = Notification::create()
    ->withPayload($alert->toString())
    ->highUrgency()
    ->withTTL(1800); // 30 minutes
```

### 3. News Update

```php
$news = Message::create('Breaking News')
    ->withBody($headline)
    ->withIcon('/icons/news.png')
    ->withImage($articleImage)
    ->withData(['articleId' => $articleId])
    ->addAction(Action::create('read', 'Read Article'))
    ->withTag('news')
    ->withTimestamp(time());

$notification = Notification::create()
    ->withPayload($news->toString())
    ->normalUrgency()
    ->withTopic('breaking-news')
    ->withTTL(Notification::TTL_ONE_DAY);
```

### 4. Silent Background Sync

```php
// Silent notification for background sync
$syncNotification = Notification::create()
    ->withPayload(json_encode(['type' => 'sync', 'data' => $syncData]))
    ->async()
    ->lowUrgency()
    ->withTTL(Notification::TTL_IMMEDIATE); // Deliver immediately or not at all
```

## Handling Delivery Failures

Always handle failures gracefully:

```php
use WebPush\Notification;
use WebPush\WebPushService;

/** @var WebPushService $webPush */
/** @var Subscription $subscription */

try {
    $notification = Notification::create()
        ->withPayload($message->toString())
        ->withTTL(Notification::TTL_ONE_DAY);

    $report = $webPush->send($notification, $subscription);

    if (!$report->isSuccess()) {
        // Log the failure
        $logger->error('Notification delivery failed', [
            'subscription' => $subscription->getEndpoint(),
            'error' => $report->getLocation()
        ]);

        // Handle expired subscriptions
        if ($report->isSubscriptionExpired()) {
            $subscriptionRepository->remove($subscription);
        }
    }
} catch (\Throwable $e) {
    $logger->error('Exception sending notification', [
        'message' => $e->getMessage(),
        'subscription' => $subscription->getEndpoint()
    ]);
}
```

## Testing Notifications

Always test your notifications before sending to production:

```php
// Create a test notification
$testNotification = Message::create('Test Notification')
    ->withBody('This is a test')
    ->withIcon('/test-icon.png')
    ->toString();

// Send to your own subscription
$report = $webPush->send(
    Notification::create()->withPayload($testNotification),
    $yourTestSubscription
);

// Verify delivery
assert($report->isSuccess(), 'Test notification failed to send');
```

## Performance Considerations

### Batch Sending

When sending to many subscriptions, batch efficiently:

```php
$subscriptions = $repository->getAllActive();
$notification = Notification::create()->withPayload($message);

foreach ($subscriptions as $subscription) {
    // Send asynchronously when possible
    $report = $webPush->send($notification, $subscription);

    // Cleanup expired subscriptions immediately
    if ($report->isSubscriptionExpired()) {
        $repository->remove($subscription);
    }
}
```

### Respect Rate Limits

Push services have rate limits. Implement throttling:

```php
$rateLimit = 100; // notifications per second
$delay = 1000000 / $rateLimit; // microseconds

foreach ($subscriptions as $subscription) {
    $webPush->send($notification, $subscription);
    usleep($delay);
}
```

## Validation Exceptions

The library throws specific exceptions when notification properties are invalid. Each exception provides direct access to the problematic value for better debugging.

### Available Exceptions

```php
use WebPush\Exception\InvalidTopicException;
use WebPush\Exception\InvalidTTLException;
use WebPush\Exception\InvalidUrgencyException;
```

### InvalidTopicException

Thrown when topic validation fails. Access the invalid topic via `$e->topic`:

```php
try {
    $notification = Notification::create()
        ->withTopic('invalid@topic'); // @ not allowed
} catch (InvalidTopicException $e) {
    echo "Invalid topic: {$e->topic}";
    // Output: Invalid topic: invalid@topic
}
```

**Common causes:**

* Topic exceeds 32 characters
* Topic contains invalid characters (only `a-z`, `A-Z`, `0-9`, `-`, `.`, `_`, `~` allowed)
* Empty topic

### InvalidTTLException

Thrown when TTL is negative. Access the invalid TTL via `$e->ttl`:

```php
try {
    $notification = Notification::create()
        ->withTTL(-1); // Must be >= 0
} catch (InvalidTTLException $e) {
    echo "Invalid TTL: {$e->ttl}";
    // Output: Invalid TTL: -1
}
```

### InvalidUrgencyException

Thrown when urgency is not a valid value. Access the invalid urgency via `$e->urgency`:

```php
try {
    $notification = Notification::create()
        ->withUrgency('critical'); // Not a valid urgency
} catch (InvalidUrgencyException $e) {
    echo "Invalid urgency: {$e->urgency}";
    // Output: Invalid urgency: critical
}
```

**Valid urgency values:** `very-low`, `low`, `normal`, `high`

### Handling Multiple Validations

Catch specific exceptions for targeted error handling:

```php
use WebPush\Exception\InvalidTopicException;
use WebPush\Exception\InvalidTTLException;
use WebPush\Exception\ValidationException;

try {
    $notification = Notification::create()
        ->withTopic($userTopic)
        ->withTTL($userTTL)
        ->withPayload($message);

} catch (InvalidTopicException $e) {
    return "Topic '{$e->topic}' is invalid: use only letters, numbers, and - . _ ~";
} catch (InvalidTTLException $e) {
    return "TTL must be positive (got {$e->ttl})";
} catch (ValidationException $e) {
    return "Validation error: " . $e->getMessage();
}
```

{% hint style="info" %}
See the [Exceptions](/common-concepts/exceptions) page for complete documentation on error handling strategies and best practices.
{% endhint %}

## Next Steps

* Understand [Status Reports](/common-concepts/the-status-report) to handle delivery results
* Learn about [Subscriptions](/common-concepts/the-subscription) management
* Set up [VAPID](/common-concepts/vapid) for secure authentication
* Read about [Exception Handling](/common-concepts/exceptions) for validation errors


# The Status Report

After sending a notification, you will receive a StatusReport object.

This status report has the following properties:

* The [notification](/common-concepts/the-notification)
* The [subscription](/common-concepts/the-subscription)
* The status code
* The notification URL (refers to the push service provider)
* The links for push notification management

Depending on the status code, you will be able to know if it is a success or not. In case of success, you can directly access the management link (`location` header parameter) or the links entity fields in case of asynchronous call. In case of failure, the response code indicates the main reason for rejection (invalid authorization token, expired endpoint...)

## Basic Usage

```php
<?php
use WebPush\Subscription;
use WebPush\Notification;
use WebPush\WebPushService;

/** @var Notification $notification */
/** @var Subscription $subscription */
/** @var WebPushService $webPushService */
$statusReport = $webPushService->send($notification, $subscription);

if(!$statusReport->isSuccess()) {
    //Something went wrong
} else {
    $statusReport->getLocation();
    $statusReport->getLinks();
}
```

## Helper Methods

The StatusReport provides several helper methods to simplify error handling:

```php
// Get the HTTP status code
$code = $statusReport->getStatusCode(); // 200, 404, 500, etc.

// Check error types
$statusReport->isSuccess();           // true for 2xx codes
$statusReport->isClientError();       // true for 4xx codes
$statusReport->isServerError();       // true for 5xx codes
$statusReport->isRateLimited();       // true for 429
$statusReport->isSubscriptionExpired(); // true for 404 or 410
$statusReport->isTransportError();    // true if no HTTP response (network error)

// Check if the error is retryable
$statusReport->isRetryable();         // true for 5xx or 429

// Get a human-readable error message
$message = $statusReport->getErrorMessage();
// Returns: "Rate limit exceeded", "Subscription expired", etc.
```

### Simplified Error Handling

```php
$report = $webPush->send($notification, $subscription);

if ($report->isSuccess()) {
    // Notification delivered successfully
    return;
}

// Handle subscription expiration
if ($report->isSubscriptionExpired()) {
    $repository->remove($subscription);
    return;
}

// Log error with readable message
$logger->error('Failed to send notification', [
    'endpoint' => $subscription->getEndpoint(),
    'error' => $report->getErrorMessage(),
    'status_code' => $report->getStatusCode()
]);

// Retry if the error is retryable
if ($report->isRetryable()) {
    // Queue for retry with exponential backoff
    $queue->retry($notification, $subscription);
}
```

## Understanding Status Codes

The status code follows HTTP standards and indicates the result of the push notification delivery.

### Success Codes (2xx)

#### 201 Created

The notification was successfully sent and queued for delivery.

```php
if ($statusReport->isSuccess()) {
    // Notification accepted by push service
    $location = $statusReport->getLocation();
    // Store location for tracking if needed
}
```

### Client Error Codes (4xx)

#### 400 Bad Request

The request was malformed or invalid.

**Common causes:**

* Invalid payload encryption
* Malformed subscription
* Missing required headers

```php
if ($report->getStatusCode() === 400) {
    $logger->error('Bad request', [
        'subscription' => $subscription->getEndpoint()
    ]);
    // Check your payload encryption and subscription format
}
```

#### 401 Unauthorized

The VAPID authentication failed.

**Common causes:**

* Invalid VAPID signature
* Expired VAPID token
* Mismatched VAPID public key

```php
if ($report->getStatusCode() === 401) {
    $logger->critical('VAPID authentication failed', [
        'endpoint' => $subscription->getEndpoint()
    ]);
    // Check your VAPID keys and token generation
}
```

#### 404 Not Found

The subscription endpoint no longer exists.

**Action required:** Remove the subscription from your database.

```php
if ($report->getStatusCode() === 404) {
    $logger->info('Subscription not found', [
        'endpoint' => $subscription->getEndpoint()
    ]);
    $subscriptionRepository->remove($subscription);
}
```

#### 410 Gone

The subscription has expired and will never be valid again.

**Action required:** Remove the subscription from your database.

```php
if ($report->getStatusCode() === 410) {
    $logger->info('Subscription expired', [
        'endpoint' => $subscription->getEndpoint()
    ]);
    $subscriptionRepository->remove($subscription);
}
```

{% hint style="info" %}
One of the failure reasons could be the expiration of the subscription (too old or cancelled by the end-user). This can be checked with the method `isSubscriptionExpired()`. In this case, you should simply delete the subscription as it is not possible to send notifications anymore.
{% endhint %}

```php
<?php

if($statusReport->isSubscriptionExpired()) {
    $this->subscriptionRepository->remove($subscription);
}
```

#### 413 Payload Too Large

The notification payload exceeds the size limit.

**Maximum sizes:**

* Chrome/Edge: 4096 bytes
* Firefox: 4096 bytes
* Safari: 4096 bytes

```php
if ($report->getStatusCode() === 413) {
    $logger->warning('Payload too large', [
        'size' => strlen($notification->getPayload())
    ]);
    // Reduce your notification payload size
}
```

#### 429 Too Many Requests

You've exceeded the rate limit for the push service.

**Action required:** Implement rate limiting and exponential backoff.

```php
if ($report->getStatusCode() === 429) {
    $logger->warning('Rate limit exceeded');
    // Wait and retry with exponential backoff
    sleep(60); // Wait 1 minute before retrying
}
```

### Server Error Codes (5xx)

#### 500 Internal Server Error

The push service encountered an error.

**Action:** Retry with exponential backoff.

#### 502 Bad Gateway

The push service is temporarily unavailable.

**Action:** Retry with exponential backoff.

#### 503 Service Unavailable

The push service is temporarily overloaded.

**Action:** Retry later.

## Handling Errors

### Comprehensive Error Handling

```php
use WebPush\Notification;
use WebPush\WebPushService;
use Psr\Log\LoggerInterface;

function sendWithErrorHandling(
    WebPushService $webPush,
    Notification $notification,
    Subscription $subscription,
    LoggerInterface $logger,
    SubscriptionRepository $repository
): bool {
    try {
        $report = $webPush->send($notification, $subscription);

        // Success
        if ($report->isSuccess()) {
            $logger->info('Notification sent successfully', [
                'endpoint' => $subscription->getEndpoint()
            ]);
            return true;
        }

        // Subscription expired
        if ($report->isSubscriptionExpired()) {
            $logger->info('Subscription expired, removing', [
                'endpoint' => $subscription->getEndpoint()
            ]);
            $repository->remove($subscription);
            return false;
        }

        // Handle specific error codes
        $statusCode = $report->getStatusCode();
        switch ($statusCode) {
            case 400:
                $logger->error('Bad request - check payload format');
                break;

            case 401:
                $logger->critical('VAPID authentication failed');
                break;

            case 413:
                $logger->warning('Payload too large');
                break;

            case 429:
                $logger->warning('Rate limit exceeded, will retry later');
                // Implement retry logic
                break;

            case 500:
            case 502:
            case 503:
                $logger->error('Push service error, will retry', [
                    'code' => $statusCode
                ]);
                // Implement retry with exponential backoff
                break;

            default:
                $logger->error('Unknown error', [
                    'code' => $statusCode
                ]);
        }

        return false;

    } catch (\Throwable $e) {
        $logger->error('Exception sending notification', [
            'message' => $e->getMessage(),
            'endpoint' => $subscription->getEndpoint()
        ]);
        return false;
    }
}
```

### Retry Strategy with Exponential Backoff

```php
function sendWithRetry(
    WebPushService $webPush,
    Notification $notification,
    Subscription $subscription,
    int $maxRetries = 3
): bool {
    $attempt = 0;
    $delay = 1; // Initial delay in seconds

    while ($attempt < $maxRetries) {
        $report = $webPush->send($notification, $subscription);

        if ($report->isSuccess()) {
            return true;
        }

        // Don't retry on client errors (except rate limit)
        $code = $report->getStatusCode();
        if ($code >= 400 && $code < 500 && $code !== 429) {
            return false;
        }

        // Exponential backoff
        $attempt++;
        if ($attempt < $maxRetries) {
            sleep($delay);
            $delay *= 2; // Double the delay for next retry
        }
    }

    return false;
}
```

## Batch Processing with Error Handling

### Using sendToMultiple()

The recommended way to send notifications to multiple subscriptions is using the `sendToMultiple()` method:

```php
use WebPush\Notification;
use WebPush\WebPush;
use WebPush\StatusReport;

$notification = Notification::create()
    ->withPayload('{"title":"Update","body":"New content available"}')
    ->withTTL(Notification::TTL_ONE_HOUR);

// Send to multiple subscriptions
$reports = $webPush->sendToMultiple($notification, $subscriptions);

// Process results
foreach ($reports as $report) {
    if ($report->isSubscriptionExpired()) {
        $repository->remove($report->getSubscription());
    } elseif (!$report->isSuccess()) {
        $logger->error('Failed to send', [
            'endpoint' => $report->getSubscription()->getEndpoint(),
            'error' => $report->getErrorMessage()
        ]);
    }
}
```

{% hint style="info" %}
The `sendToMultiple()` method does not throw exceptions for individual failures. Instead, it attempts to send to all subscriptions and returns a StatusReport for each one, allowing you to inspect successes and failures.
{% endhint %}

### Filtering Reports

Use the static helper methods to filter and analyze batch results:

```php
// Filter successful deliveries
$successful = StatusReport::filterSuccessful($reports);

// Filter failed deliveries
$failed = StatusReport::filterFailed($reports);

// Filter expired subscriptions
$expired = StatusReport::filterExpired($reports);

// Filter retryable errors
$retryable = StatusReport::filterRetryable($reports);

// Get statistics
$stats = StatusReport::getStatistics($reports);
// Returns: ['total' => 100, 'successful' => 85, 'failed' => 15, 'expired' => 5, 'retryable' => 3]
```

### Complete Batch Processing Example

```php
use WebPush\Notification;
use WebPush\WebPushService;
use WebPush\StatusReport;

function sendToMultipleSubscriptions(
    WebPushService $webPush,
    Notification $notification,
    array $subscriptions,
    SubscriptionRepository $repository,
    LoggerInterface $logger
): array {
    // Send to all subscriptions
    $reports = $webPush->sendToMultiple($notification, $subscriptions);

    // Get statistics
    $stats = StatusReport::getStatistics($reports);
    $logger->info('Batch send completed', $stats);

    // Remove expired subscriptions
    $expired = StatusReport::filterExpired($reports);
    foreach ($expired as $report) {
        $repository->remove($report->getSubscription());
    }

    // Queue retryable errors
    $retryable = StatusReport::filterRetryable($reports);
    foreach ($retryable as $report) {
        $queue->retry($report->getNotification(), $report->getSubscription());
    }

    // Log permanent failures
    $failed = StatusReport::filterFailed($reports);
    foreach ($failed as $report) {
        if (!$report->isRetryable() && !$report->isSubscriptionExpired()) {
            $logger->error('Permanent failure', [
                'endpoint' => $report->getSubscription()->getEndpoint(),
                'error' => $report->getErrorMessage(),
                'code' => $report->getStatusCode()
            ]);
        }
    }

    return $stats;
}
```

## Monitoring and Metrics

Track your notification delivery metrics:

```php
class NotificationMetrics
{
    public function __construct(
        private MetricsCollector $metrics
    ) {}

    public function recordDelivery(StatusReport $report, Subscription $subscription): void
    {
        // Record success/failure
        if ($report->isSuccess()) {
            $this->metrics->increment('notifications.sent.success');
        } else {
            $this->metrics->increment('notifications.sent.failed');
            $this->metrics->increment("notifications.sent.failed.{$report->getStatusCode()}");
        }

        // Record expired subscriptions
        if ($report->isSubscriptionExpired()) {
            $this->metrics->increment('subscriptions.expired');
        }

        // Record by push service
        $endpoint = $subscription->getEndpoint();
        if (str_contains($endpoint, 'fcm.googleapis.com')) {
            $this->metrics->increment('notifications.sent.fcm');
        } elseif (str_contains($endpoint, 'mozilla.com')) {
            $this->metrics->increment('notifications.sent.mozilla');
        } elseif (str_contains($endpoint, 'apple.com')) {
            $this->metrics->increment('notifications.sent.apple');
        }
    }
}
```

## Debugging Failed Deliveries

When debugging failures, collect all relevant information:

```php
function debugFailedDelivery(StatusReport $report, Notification $notification, Subscription $subscription): void
{
    $debugInfo = [
        'status_code' => $report->getStatusCode(),
        'location' => $report->getLocation(),
        'links' => $report->getLinks(),
        'endpoint' => $subscription->getEndpoint(),
        'supported_encodings' => $subscription->getSupportedContentEncodings(),
        'payload_size' => strlen($notification->getPayload() ?? ''),
        'ttl' => $notification->getTTL(),
        'urgency' => $notification->getUrgency(),
        'topic' => $notification->getTopic(),
    ];

    error_log('Failed notification delivery: ' . json_encode($debugInfo, JSON_PRETTY_PRINT));
}
```

## Best Practices

1. **Always Check Success**: Don't assume notifications are delivered
2. **Handle Expired Subscriptions**: Clean up your database immediately
3. **Implement Retry Logic**: Use exponential backoff for server errors
4. **Log Everything**: Track success rates and error patterns
5. **Monitor Metrics**: Keep track of delivery rates by push service
6. **Respect Rate Limits**: Implement throttling to avoid 429 errors
7. **Validate Early**: Check subscription format before sending
8. **Test Thoroughly**: Test with real subscriptions from different browsers

## Common Issues and Solutions

### High Failure Rate

* Check VAPID keys are correctly configured
* Verify payload encryption is working
* Ensure subscriptions are fresh and valid

### Subscriptions Expiring Quickly

* User might be clearing browser data frequently
* Check if you're using the correct public key
* Verify the subscription format is correct

### Random Failures

* Implement retry logic with exponential backoff
* Monitor push service status pages
* Check for network connectivity issues

## Next Steps

* Learn about [Notifications](/common-concepts/the-notification) structure and options
* Understand [Subscriptions](/common-concepts/the-subscription) lifecycle
* Set up [VAPID](/common-concepts/vapid) authentication properly


# VAPID

Voluntary Application Server Identification

"**VAPID**" stands for "**V**oluntary **Ap**plication Server **Id**entification".

This feature allows the application server to send information about itself to a push service.

A consistent identity can be used by a push service to establish behavioral expectations for an application server. Significant deviations from an established norm can then be used to trigger exception-handling procedures.

Voluntarily provided contact information can be used to contact an application server operator in the case of exceptional situations. Additionally, the design of [RFC8030](https://datatracker.ietf.org/doc/html/rfc8030) relies on maintaining the secrecy of push message subscription URIs.

Any application server in possession of a push message subscription URI is able to send messages to the user agent.

If use of a subscription could be limited to a single application server, this would reduce the impact of the push message subscription URI being learned by an unauthorized party.

In order to use this feature, you must generate ECDSA key pairs. Hereafter an example using OpenSSL.

```bash
openssl ecparam -genkey -name prime256v1 -out private_key.pem
openssl ec -in private_key.pem -pubout -outform DER|tail -c 65|base64|tr -d '=' |tr '/+' '_-' >> public_key.txt
openssl ec -in private_key.pem -outform DER|tail -c +8|head -c 32|base64|tr -d '=' |tr '/+' '_-' >> private_key.txt
```

Please refer to [this page](/the-library/advanced-service#vapid-extension) for using the VAPID feature.


# Exceptions

The Web Push library uses a hierarchy of specific exceptions to provide clear error messages and enable precise error handling.

## Exception Hierarchy

```
WebPushException (interface)
└── AbstractWebPushException
    ├── ValidationException
    │   ├── InvalidTopicException
    │   ├── InvalidTTLException
    │   ├── InvalidUrgencyException
    │   └── InvalidPayloadException
    └── OperationException (deprecated, use specific exceptions)
```

## Validation Exceptions

All validation exceptions extend `ValidationException` and expose the problematic value as a `public readonly` property, making debugging easier.

### InvalidTopicException

Thrown when a notification topic is invalid.

**Property:** `public readonly string $topic`

**Causes:**

* Empty topic
* Topic exceeds 32 characters
* Topic contains invalid characters (only `a-z`, `A-Z`, `0-9`, `-`, `.`, `_`, `~` allowed)

```php
use WebPush\Exception\InvalidTopicException;
use WebPush\Notification;

try {
    $notification = Notification::create()
        ->withTopic('invalid@topic'); // @ is not allowed
} catch (InvalidTopicException $e) {
    echo "Invalid topic: {$e->topic}\n";
    echo "Error: {$e->getMessage()}\n";
    // Output:
    // Invalid topic: invalid@topic
    // Error: Topic must contain only URL-safe characters (a-z, A-Z, 0-9, -, ., _, ~)
}
```

### InvalidTTLException

Thrown when a notification TTL (Time-To-Live) is invalid.

**Property:** `public readonly int $ttl`

**Causes:**

* Negative TTL value

```php
use WebPush\Exception\InvalidTTLException;
use WebPush\Notification;

try {
    $notification = Notification::create()
        ->withTTL(-1); // TTL cannot be negative
} catch (InvalidTTLException $e) {
    echo "Invalid TTL: {$e->ttl}\n";
    echo "Error: {$e->getMessage()}\n";
    // Output:
    // Invalid TTL: -1
    // Error: Invalid TTL
}
```

### InvalidUrgencyException

Thrown when a notification urgency is invalid.

**Property:** `public readonly string $urgency`

**Causes:**

* Urgency value not in: `very-low`, `low`, `normal`, `high`

```php
use WebPush\Exception\InvalidUrgencyException;
use WebPush\Notification;

try {
    $notification = Notification::create()
        ->withUrgency('urgent'); // Invalid urgency
} catch (InvalidUrgencyException $e) {
    echo "Invalid urgency: {$e->urgency}\n";
    echo "Error: {$e->getMessage()}\n";
    // Output:
    // Invalid urgency: urgent
    // Error: Invalid urgency parameter
}
```

### InvalidPayloadException

Thrown when a notification payload is invalid.

**Property:** `public readonly int $size`

**Causes:**

* Payload exceeds maximum size

```php
use WebPush\Exception\InvalidPayloadException;
use WebPush\Notification;

try {
    $notification = Notification::create()
        ->withPayload($hugePayload); // Payload too large
} catch (InvalidPayloadException $e) {
    echo "Payload size: {$e->size} bytes\n";
    echo "Error: {$e->getMessage()}\n";
}
```

## Error Handling Strategies

### Strategy 1: Catch Specific Exceptions

Handle each type of validation error differently:

```php
use WebPush\Exception\InvalidTopicException;
use WebPush\Exception\InvalidTTLException;
use WebPush\Exception\InvalidUrgencyException;
use WebPush\Notification;

function createNotification(array $data): Notification
{
    try {
        $notification = Notification::create();

        if (isset($data['topic'])) {
            $notification->withTopic($data['topic']);
        }

        if (isset($data['ttl'])) {
            $notification->withTTL($data['ttl']);
        }

        if (isset($data['urgency'])) {
            $notification->withUrgency($data['urgency']);
        }

        return $notification;

    } catch (InvalidTopicException $e) {
        throw new \InvalidArgumentException(
            "Invalid topic '{$e->topic}': must be max 32 chars with URL-safe characters only"
        );
    } catch (InvalidTTLException $e) {
        throw new \InvalidArgumentException(
            "Invalid TTL '{$e->ttl}': must be a positive integer"
        );
    } catch (InvalidUrgencyException $e) {
        throw new \InvalidArgumentException(
            "Invalid urgency '{$e->urgency}': must be 'very-low', 'low', 'normal', or 'high'"
        );
    }
}
```

### Strategy 2: Catch All Validation Errors

Use the base `ValidationException` to catch any validation error:

```php
use WebPush\Exception\ValidationException;
use WebPush\Notification;
use Psr\Log\LoggerInterface;

class NotificationFactory
{
    public function __construct(
        private LoggerInterface $logger
    ) {}

    public function createFromUserInput(array $input): ?Notification
    {
        try {
            return Notification::create()
                ->withTopic($input['topic'] ?? 'default')
                ->withTTL($input['ttl'] ?? Notification::TTL_ONE_HOUR)
                ->withUrgency($input['urgency'] ?? Notification::URGENCY_NORMAL)
                ->withPayload($input['message'] ?? '');

        } catch (ValidationException $e) {
            $this->logger->error('Notification validation failed', [
                'error' => $e->getMessage(),
                'exception' => get_class($e)
            ]);
            return null;
        }
    }
}
```

### Strategy 3: Provide User Feedback

Use exception properties to give specific feedback to users:

```php
use WebPush\Exception\InvalidTopicException;
use WebPush\Exception\ValidationException;
use WebPush\Notification;

class NotificationController
{
    public function create(array $request): array
    {
        try {
            $notification = Notification::create()
                ->withTopic($request['topic'])
                ->withPayload($request['message']);

            return ['success' => true];

        } catch (InvalidTopicException $e) {
            return [
                'success' => false,
                'field' => 'topic',
                'value' => $e->topic,
                'message' => match (true) {
                    strlen($e->topic) > 32 => "Topic is too long (max 32 characters)",
                    $e->topic === '' => "Topic cannot be empty",
                    default => "Topic contains invalid characters (only a-z, A-Z, 0-9, -, ., _, ~ allowed)"
                }
            ];
        } catch (ValidationException $e) {
            return [
                'success' => false,
                'message' => $e->getMessage()
            ];
        }
    }
}
```

### Strategy 4: Logging with Context

Log validation errors with full context for debugging:

```php
use WebPush\Exception\InvalidTopicException;
use WebPush\Exception\InvalidTTLException;
use WebPush\Exception\ValidationException;
use Psr\Log\LoggerInterface;

class NotificationLogger
{
    public function __construct(
        private LoggerInterface $logger
    ) {}

    public function logValidationError(ValidationException $e): void
    {
        $context = [
            'exception' => get_class($e),
            'message' => $e->getMessage()
        ];

        // Add specific context based on exception type
        if ($e instanceof InvalidTopicException) {
            $context['topic'] = $e->topic;
            $context['topic_length'] = strlen($e->topic);
        } elseif ($e instanceof InvalidTTLException) {
            $context['ttl'] = $e->ttl;
        }

        $this->logger->warning('Notification validation failed', $context);
    }
}
```

## Best Practices

### 1. Catch Specific Exceptions

Prefer catching specific exceptions over the generic `ValidationException` when you need different handling:

```php
// ✅ Good - specific handling
try {
    $notification->withTopic($userInput);
} catch (InvalidTopicException $e) {
    return "Please use only letters, numbers, and dashes in the topic";
}

// ❌ Less ideal - generic handling
try {
    $notification->withTopic($userInput);
} catch (ValidationException $e) {
    return $e->getMessage(); // Generic error message
}
```

### 2. Use Exception Properties

Leverage the `public readonly` properties for debugging and logging:

```php
// ✅ Good - use the property
catch (InvalidTopicException $e) {
    $logger->error('Invalid topic', [
        'topic' => $e->topic,
        'length' => strlen($e->topic)
    ]);
}

// ❌ Less ideal - parse the message
catch (InvalidTopicException $e) {
    // Don't parse error messages!
    if (str_contains($e->getMessage(), 'too long')) {
        // ...
    }
}
```

### 3. Validate Early

Validate user input as early as possible:

```php
// ✅ Good - validate before processing
public function scheduleNotification(array $data): void
{
    try {
        $notification = Notification::create()
            ->withTopic($data['topic'])
            ->withTTL($data['ttl']);
    } catch (ValidationException $e) {
        throw new InvalidRequestException($e->getMessage());
    }

    // Continue with valid notification
    $this->queue->push($notification);
}
```

### 4. Provide Helpful Error Messages

Transform technical exceptions into user-friendly messages:

```php
public function createNotificationFromForm(array $form): Notification
{
    try {
        return Notification::create()
            ->withTopic($form['topic'])
            ->withTTL((int) $form['ttl']);

    } catch (InvalidTopicException $e) {
        throw new \DomainException(
            "The topic '{$e->topic}' is invalid. " .
            "Topics can only contain letters, numbers, and these characters: - . _ ~"
        );
    } catch (InvalidTTLException $e) {
        throw new \DomainException(
            "The TTL must be a positive number (you provided: {$e->ttl})"
        );
    }
}
```

## Common Scenarios

### Form Validation

```php
class NotificationFormValidator
{
    public function validate(array $form): array
    {
        $errors = [];

        // Validate topic
        if (!empty($form['topic'])) {
            try {
                Notification::create()->withTopic($form['topic']);
            } catch (InvalidTopicException $e) {
                $errors['topic'] = match (true) {
                    strlen($e->topic) > 32 => 'Topic must be 32 characters or less',
                    !preg_match('/^[a-zA-Z0-9\-._~]+$/', $e->topic) =>
                        'Topic can only contain letters, numbers, and: - . _ ~',
                    default => 'Invalid topic'
                };
            }
        }

        // Validate TTL
        if (isset($form['ttl'])) {
            try {
                Notification::create()->withTTL((int) $form['ttl']);
            } catch (InvalidTTLException $e) {
                $errors['ttl'] = 'TTL must be a positive number';
            }
        }

        return $errors;
    }
}
```

### API Error Responses

```php
use Symfony\Component\HttpFoundation\JsonResponse;
use WebPush\Exception\ValidationException;

class NotificationApiController
{
    public function create(Request $request): JsonResponse
    {
        try {
            $notification = Notification::create()
                ->withTopic($request->get('topic'))
                ->withTTL($request->get('ttl'))
                ->withPayload($request->get('message'));

            // Send notification...

            return new JsonResponse(['success' => true]);

        } catch (ValidationException $e) {
            return new JsonResponse([
                'success' => false,
                'error' => [
                    'type' => basename(str_replace('\\', '/', get_class($e))),
                    'message' => $e->getMessage()
                ]
            ], 400);
        }
    }
}
```

### Testing

```php
use PHPUnit\Framework\TestCase;
use WebPush\Exception\InvalidTopicException;
use WebPush\Notification;

class NotificationTest extends TestCase
{
    public function testTopicValidation(): void
    {
        $this->expectException(InvalidTopicException::class);

        $notification = Notification::create()
            ->withTopic('invalid@topic');
    }

    public function testTopicExceptionContainsValue(): void
    {
        try {
            Notification::create()->withTopic('bad@topic');
            $this->fail('Expected InvalidTopicException');
        } catch (InvalidTopicException $e) {
            $this->assertSame('bad@topic', $e->topic);
        }
    }
}
```

## Migration from OperationException

If your code currently catches `OperationException`, you can migrate gradually:

### Before (still works)

```php
use WebPush\Exception\OperationException;

try {
    $notification->withTopic($topic);
} catch (OperationException $e) {
    // Handle error
}
```

### After (recommended)

```php
use WebPush\Exception\InvalidTopicException;
use WebPush\Exception\ValidationException;

try {
    $notification->withTopic($topic);
} catch (InvalidTopicException $e) {
    // Handle topic-specific error with access to $e->topic
} catch (ValidationException $e) {
    // Handle any validation error
}
```

## Summary

✅ **Specific Exceptions** - Each validation error has its own exception type ✅ **Contextual Properties** - Access problematic values via `public readonly` properties ✅ **Type-Safe** - IDEs can autocomplete exception types and properties ✅ **Better Debugging** - Log exact values that caused errors ✅ **Granular Handling** - Catch and handle specific error types differently ✅ **User-Friendly** - Transform technical errors into helpful messages

## Next Steps

* Learn about [Notifications](/common-concepts/the-notification) and their properties
* Understand [Status Reports](/common-concepts/the-status-report) for delivery errors
* See [VAPID](/common-concepts/vapid) authentication setup


# Installation

The library can be installed using the package `spomky-labs/web-push-lib`

```bash
composer require spomky-labs/web-push-lib
```

## VAPID Header

The [VAPID header](/common-concepts/vapid) authenticates your server and prevents malicious applications from sending notifications to your users. The header contains a signed JSON Web Token (JWS).

The library provides bridges for the following libraries `web-token` and `lcobucci/jwt`.

Please install `web-token/jwt-library` or `lcobucci/jwt` depending on the library you want to use.

{% hint style="info" %}
It is possible to use any other JWS provider. This will be detailed in the future.
{% endhint %}


# The Extension Manager

The Web Push service requires an Extension Manager. This object manages extensions that will manipulate the request before sending it to the Push Service.

In the example below, we add all basic extensions.

```php
use WebPush\ExtensionManager;
use WebPush\PreferAsyncExtension;
use WebPush\TopicExtension;
use WebPush\TTLExtension;
use WebPush\UrgencyExtension;

$extensionManager = ExtensionManager::create()
    ->add(TTLExtension::create())
    ->add(UrgencyExtension::create())
    ->add(TopicExtension::create())
    ->add(PreferAsyncExtension::create())
;
```

{% hint style="info" %}
Please note that the TTL Extension is usually required by Push Services. To avoid any trouble, please use all extensions.
{% endhint %}

## Payload Extension

The payload extension allows Notifications to have a payload. This extension requires Content Encoding objects that will be responsible for the payload encryption.

The library provides the `AESGCM` and `AES128GCM` content encoding. These encodings are normally supported by all Push Services. The library is able to support any future encoding if deemed necessary.

Both content encodings require a PSR-20 Clock implementation. You can use `symfony/clock` for example.

```php
use Psr\Clock\ClockInterface;
use Symfony\Component\Clock\NativeClock;
use WebPush\Payload\AES128GCM;
use WebPush\Payload\AESGCM;
use WebPush\Payload\PayloadExtension;

$clock = new NativeClock(); // PSR-20 clock implementation

$payloadExtension = PayloadExtension::create()
    ->addContentEncoding(AESGCM::create($clock))
    ->addContentEncoding(AES128GCM::create($clock))
;

$extensionManager = ExtensionManager::create()
    ->add($payloadExtension)
;
```

## VAPID Extension

The [VAPID header](/common-concepts/vapid) authenticates your server and prevents malicious applications from sending notifications to your users. The header contains a signed JSON Web Token (JWS).

The library provides bridges for the following libraries `web-token` and `lcobucci/jwt`.

Please install `web-token/jwt-library` or `lcobucci/jwt` depending on the library you want to use.

The VAPID extension requires a PSR-20 Clock implementation. You can use `symfony/clock` for example.

```php
use Psr\Clock\ClockInterface;
use Symfony\Component\Clock\NativeClock;
use WebPush\VAPID\LcobucciProvider;
use WebPush\VAPID\VAPIDExtension;
use WebPush\VAPID\WebTokenProvider;

$clock = new NativeClock(); // PSR-20 clock implementation

// Web-Token
$jwsProvider = WebTokenProvider::create(
    'BB4W1qfBi7MF_Lnrc6i2oL-glAuKF4kevy9T0k2vyKV4qvuBrN3T6o9-7-NR3mKHwzDXzD3fe7XvIqIU1iADpGQ', // Public key
    'C40jLFSa5UWxstkFvdwzT3eHONE2FIJSEsVIncSCAqU' // Private key
);

// OR lcobucci/jwt
$jwsProvider = LcobucciProvider::create(
    'BB4W1qfBi7MF_Lnrc6i2oL-glAuKF4kevy9T0k2vyKV4qvuBrN3T6o9-7-NR3mKHwzDXzD3fe7XvIqIU1iADpGQ',
    'C40jLFSa5UWxstkFvdwzT3eHONE2FIJSEsVIncSCAqU'
);

$extensionManager = ExtensionManager::create()
    ->add(VAPIDExtension::create('http://my-service.com', $jwsProvider, $clock))
;
```

{% hint style="danger" %}
The public key used with your server shall be the same as the one in your Javascript application.
{% endhint %}

{% hint style="warning" %}
If this public/private key changes, subscriptions will become invalid.
{% endhint %}


# The Web Push Service

The WebPush object requires a [HTTP Client](https://symfony.com/doc/current/http_client.html) and an [Extension Manager](/the-library/advanced-service).

```php
use Symfony\Component\HttpClient\HttpClient;
use WebPush\WebPush;

$client = HttpClient::create();

$service = new WebPush($client, $extensionManager);
```

The service is now ready to send Notifications to the Subscriptions. The StatusReport object that is returned [is explained here](/common-concepts/the-status-report).

```php
<?php

use WebPush\Subscription;
use WebPush\Notification;

$subscription = Subscription::createFromString('{"endpoint":"https://updates.push.services.mozilla.com/wpush/v2/AAAAAAAA[…]AAAAAAAAA","keys":{"auth":"XXXXXXXXXXXXXX","p256dh":"YYYYYYYY[…]YYYYYYYYYYYYY"}}');
$notification = Notification::create()
    ->withPayload('Hello world')
;

$statusReport = $service->send($notification, $subscription);
```

{% hint style="info" %}
In this example, we load the Subscription object from a string, but you usually retrieve the Subscription objects from a database or a dedicated storage.
{% endhint %}

## Sending to Multiple Subscriptions

The `sendToMultiple()` method allows you to send a notification to multiple subscriptions efficiently:

```php
<?php

use WebPush\Notification;
use WebPush\StatusReport;

$notification = Notification::create()
    ->withPayload('{"title":"Breaking News","body":"Check this out"}')
    ->withTTL(Notification::TTL_ONE_HOUR);

// Send to multiple subscriptions
$reports = $service->sendToMultiple($notification, $subscriptions);

// Process results using helper methods
$successful = StatusReport::filterSuccessful($reports);
$failed = StatusReport::filterFailed($reports);
$expired = StatusReport::filterExpired($reports);

// Get statistics
$stats = StatusReport::getStatistics($reports);
// Returns: ['total' => 100, 'successful' => 85, 'failed' => 15, 'expired' => 5, 'retryable' => 3]

// Handle expired subscriptions
foreach ($expired as $report) {
    // Remove from database
    $repository->remove($report->getSubscription());
}

// Handle retryable errors
$retryable = StatusReport::filterRetryable($reports);
foreach ($retryable as $report) {
    // Queue for retry
    $queue->retry($report->getNotification(), $report->getSubscription());
}
```

{% hint style="info" %}
Unlike `send()`, the `sendToMultiple()` method does not throw exceptions for individual failures. It attempts to send to all subscriptions and returns a StatusReport for each one, allowing you to inspect both successes and failures.
{% endhint %}

## Error Handling

The service may throw exceptions for transport or HTTP errors:

```php
<?php

use WebPush\Exception\OperationException;

try {
    $report = $service->send($notification, $subscription);

    if ($report->isSuccess()) {
        // Success!
    } elseif ($report->isSubscriptionExpired()) {
        // Remove expired subscription
        $repository->remove($subscription);
    } elseif ($report->isRetryable()) {
        // Queue for retry (5xx or 429 errors)
        $queue->retry($notification, $subscription);
    } else {
        // Log permanent failure
        $logger->error('Failed to send notification', [
            'error' => $report->getErrorMessage(),
            'status_code' => $report->getStatusCode()
        ]);
    }
} catch (OperationException $e) {
    // Network or transport error
    $logger->error('Transport error', ['message' => $e->getMessage()]);
}
```

## Validation Exceptions

When creating notifications with invalid properties, the library throws specific exceptions. Each exception exposes the problematic value as a `public readonly` property for easy debugging:

```php
<?php

use WebPush\Exception\InvalidTopicException;
use WebPush\Exception\InvalidTTLException;
use WebPush\Exception\InvalidUrgencyException;
use WebPush\Exception\ValidationException;
use WebPush\Notification;

// Example: Validating user input
function createNotificationFromInput(array $input): Notification
{
    try {
        return Notification::create()
            ->withTopic($input['topic'])
            ->withTTL((int) $input['ttl'])
            ->withUrgency($input['urgency'])
            ->withPayload($input['message']);

    } catch (InvalidTopicException $e) {
        throw new \InvalidArgumentException(
            "Invalid topic '{$e->topic}': must be max 32 chars with URL-safe characters only"
        );
    } catch (InvalidTTLException $e) {
        throw new \InvalidArgumentException(
            "Invalid TTL '{$e->ttl}': must be a positive integer"
        );
    } catch (InvalidUrgencyException $e) {
        throw new \InvalidArgumentException(
            "Invalid urgency '{$e->urgency}': must be 'very-low', 'low', 'normal', or 'high'"
        );
    }
}
```

See the [Exceptions](/common-concepts/exceptions) documentation for complete details on error handling strategies.


# Example

This page provides a complete example of implementing Web Push notifications using the standalone library (without Symfony).

## Complete Working Example

### Step 1: Installation

```bash
composer require spomky-labs/web-push-lib
composer require symfony/http-client
composer require symfony/clock
```

### Step 2: Generate VAPID Keys

```bash
# Generate private key
openssl ecparam -genkey -name prime256v1 -out private_key.pem

# Extract public key
openssl ec -in private_key.pem -pubout -outform DER|tail -c 65|base64|tr -d '=' |tr '/+' '_-' > public_key.txt

# Extract private key
openssl ec -in private_key.pem -outform DER|tail -c +8|head -c 32|base64|tr -d '=' |tr '/+' '_-' > private_key.txt
```

### Step 3: Create the Web Push Service

{% code title="src/WebPushServiceFactory.php" %}

```php
<?php

declare(strict_types=1);

namespace App;

use Psr\Clock\ClockInterface;
use Symfony\Component\Clock\NativeClock;
use Symfony\Component\HttpClient\HttpClient;
use WebPush\ExtensionManager;
use WebPush\Payload\AES128GCM;
use WebPush\Payload\AESGCM;
use WebPush\Payload\PayloadExtension;
use WebPush\PreferAsyncExtension;
use WebPush\TopicExtension;
use WebPush\TTLExtension;
use WebPush\UrgencyExtension;
use WebPush\VAPID\LcobucciProvider;
use WebPush\VAPID\VAPIDExtension;
use WebPush\VAPID\WebTokenProvider;
use WebPush\WebPush;

class WebPushServiceFactory
{
    public static function create(
        string $vapidPublicKey,
        string $vapidPrivateKey,
        string $vapidSubject
    ): WebPush {
        $clock = new NativeClock();

        // Create HTTP client
        $httpClient = HttpClient::create();

        // Create Extension Manager
        $extensionManager = self::createExtensionManager($clock, $vapidPublicKey, $vapidPrivateKey, $vapidSubject);

        // Create and return WebPush service
        return WebPush::create($httpClient, $extensionManager);
    }

    private static function createExtensionManager(
        ClockInterface $clock,
        string $vapidPublicKey,
        string $vapidPrivateKey,
        string $vapidSubject
    ): ExtensionManager {
        // Create VAPID extension (choose one provider)
        // Option 1: Using web-token/jwt
        $jwsProvider = WebTokenProvider::create($vapidPublicKey, $vapidPrivateKey);

        // Option 2: Using lcobucci/jwt (uncomment to use)
        // $jwsProvider = LcobucciProvider::create($vapidPublicKey, $vapidPrivateKey);

        $vapidExtension = VAPIDExtension::create($vapidSubject, $jwsProvider, $clock);

        // Create payload extension
        $payloadExtension = PayloadExtension::create()
            ->addContentEncoding(AESGCM::create($clock))
            ->addContentEncoding(AES128GCM::create($clock));

        // Create extension manager with all extensions
        return ExtensionManager::create()
            ->add(TTLExtension::create())
            ->add(UrgencyExtension::create())
            ->add(TopicExtension::create())
            ->add(PreferAsyncExtension::create())
            ->add($payloadExtension)
            ->add($vapidExtension);
    }
}
```

{% endcode %}

### Step 4: Create a Subscription Manager

{% code title="src/SubscriptionManager.php" %}

```php
<?php

declare(strict_types=1);

namespace App;

use WebPush\Subscription;

class SubscriptionManager
{
    private array $subscriptions = [];

    public function __construct(
        private readonly string $storagePath
    ) {
        $this->load();
    }

    public function add(Subscription $subscription, string $userId): void
    {
        if (!isset($this->subscriptions[$userId])) {
            $this->subscriptions[$userId] = [];
        }

        $endpoint = $subscription->getEndpoint();
        $this->subscriptions[$userId][$endpoint] = $subscription;
        $this->save();
    }

    public function remove(string $endpoint, string $userId): void
    {
        if (isset($this->subscriptions[$userId][$endpoint])) {
            unset($this->subscriptions[$userId][$endpoint]);
            $this->save();
        }
    }

    /**
     * @return Subscription[]
     */
    public function getByUser(string $userId): array
    {
        return $this->subscriptions[$userId] ?? [];
    }

    public function getAll(): array
    {
        $all = [];
        foreach ($this->subscriptions as $userSubscriptions) {
            $all = array_merge($all, array_values($userSubscriptions));
        }
        return $all;
    }

    private function load(): void
    {
        if (!file_exists($this->storagePath)) {
            return;
        }

        $data = json_decode(file_get_contents($this->storagePath), true);

        foreach ($data as $userId => $subscriptions) {
            foreach ($subscriptions as $subscriptionData) {
                $subscription = Subscription::createFromString(json_encode($subscriptionData));
                $this->subscriptions[$userId][$subscription->getEndpoint()] = $subscription;
            }
        }
    }

    private function save(): void
    {
        $data = [];
        foreach ($this->subscriptions as $userId => $subscriptions) {
            $data[$userId] = array_map(
                fn(Subscription $s) => $s->jsonSerialize(),
                array_values($subscriptions)
            );
        }

        file_put_contents($this->storagePath, json_encode($data, JSON_PRETTY_PRINT));
    }
}
```

{% endcode %}

### Step 5: Handle Subscription from Browser

{% code title="public/subscribe.php" %}

```php
<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

use App\SubscriptionManager;
use WebPush\Subscription;

// Get the posted data
$data = json_decode(file_get_contents('php://input'), true);

if (!isset($data['endpoint'])) {
    http_response_code(400);
    echo json_encode(['error' => 'Invalid subscription data']);
    exit;
}

// Create subscription from browser data
$subscription = Subscription::createFromString(json_encode($data));

// Get user ID (in real app, get from session)
$userId = $_SESSION['user_id'] ?? 'anonymous';

// Save subscription
$manager = new SubscriptionManager(__DIR__ . '/../data/subscriptions.json');
$manager->add($subscription, $userId);

http_response_code(201);
echo json_encode(['message' => 'Subscription saved']);
```

{% endcode %}

### Step 6: Send Notifications

{% code title="src/NotificationSender.php" %}

```php
<?php

declare(strict_types=1);

namespace App;

use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use WebPush\Message;
use WebPush\Notification;
use WebPush\Subscription;
use WebPush\WebPush;

class NotificationSender
{
    private LoggerInterface $logger;

    public function __construct(
        private readonly WebPush $webPush,
        private readonly SubscriptionManager $subscriptionManager,
        ?LoggerInterface $logger = null
    ) {
        $this->logger = $logger ?? new NullLogger();
    }

    public function sendToUser(string $userId, string $title, string $body, array $data = []): void
    {
        $subscriptions = $this->subscriptionManager->getByUser($userId);

        if (empty($subscriptions)) {
            $this->logger->info("No subscriptions found for user {$userId}");
            return;
        }

        $message = Message::create($title)
            ->withBody($body)
            ->withData($data)
            ->withIcon('/icon-192.png')
            ->withBadge('/badge-72.png');

        $notification = Notification::create()
            ->withPayload($message->toString())
            ->withTTL(3600);

        foreach ($subscriptions as $subscription) {
            $this->sendNotification($notification, $subscription, $userId);
        }
    }

    public function sendToAll(string $title, string $body, array $data = []): void
    {
        $subscriptions = $this->subscriptionManager->getAll();

        if (empty($subscriptions)) {
            $this->logger->info("No subscriptions found");
            return;
        }

        $message = Message::create($title)
            ->withBody($body)
            ->withData($data);

        $notification = Notification::create()
            ->withPayload($message->toString())
            ->withTTL(3600);

        foreach ($subscriptions as $subscription) {
            $this->sendNotification($notification, $subscription);
        }
    }

    private function sendNotification(
        Notification $notification,
        Subscription $subscription,
        ?string $userId = null
    ): void {
        try {
            $report = $this->webPush->send($notification, $subscription);

            if ($report->isSubscriptionExpired()) {
                $this->logger->info('Subscription expired, removing', [
                    'endpoint' => $subscription->getEndpoint()
                ]);

                if ($userId) {
                    $this->subscriptionManager->remove($subscription->getEndpoint(), $userId);
                }
            } elseif (!$report->isSuccess()) {
                $this->logger->error('Failed to send notification', [
                    'endpoint' => $subscription->getEndpoint()
                ]);
            } else {
                $this->logger->info('Notification sent successfully', [
                    'endpoint' => $subscription->getEndpoint()
                ]);
            }
        } catch (\Throwable $e) {
            $this->logger->error('Exception while sending notification', [
                'endpoint' => $subscription->getEndpoint(),
                'error' => $e->getMessage()
            ]);
        }
    }
}
```

{% endcode %}

### Step 7: Usage Example

{% code title="examples/send-notification.php" %}

```php
<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

use App\NotificationSender;
use App\SubscriptionManager;
use App\WebPushServiceFactory;

// Configuration
$vapidPublicKey = file_get_contents(__DIR__ . '/../keys/public_key.txt');
$vapidPrivateKey = file_get_contents(__DIR__ . '/../keys/private_key.txt');
$vapidSubject = 'mailto:admin@example.com';

// Create services
$webPush = WebPushServiceFactory::create(
    trim($vapidPublicKey),
    trim($vapidPrivateKey),
    $vapidSubject
);

$subscriptionManager = new SubscriptionManager(__DIR__ . '/../data/subscriptions.json');
$notificationSender = new NotificationSender($webPush, $subscriptionManager);

// Send notification to specific user
$notificationSender->sendToUser(
    'user123',
    'Hello!',
    'This is a test notification',
    ['url' => 'https://example.com/notifications']
);

// Or send to all users
$notificationSender->sendToAll(
    'Important Update',
    'We have an important announcement for all users!',
    ['url' => 'https://example.com/announcement']
);

echo "Notifications sent!\n";
```

{% endcode %}

### Step 8: Client-side JavaScript

{% code title="public/js/push.js" %}

```javascript
// Configuration
const API_BASE_URL = '/api';

// Request notification permission
async function requestNotificationPermission() {
    const permission = await Notification.requestPermission();
    if (permission !== 'granted') {
        console.error('Notification permission denied');
        return false;
    }
    return true;
}

// Subscribe to push notifications
async function subscribeToPush() {
    if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
        console.error('Push notifications are not supported');
        return;
    }

    try {
        // Register service worker
        const registration = await navigator.serviceWorker.register('/service-worker.js');
        await navigator.serviceWorker.ready;

        // Get VAPID public key from your server
        const response = await fetch(`${API_BASE_URL}/vapid-public-key`);
        const { publicKey } = await response.json();

        // Subscribe
        const subscription = await registration.pushManager.subscribe({
            userVisibleOnly: true,
            applicationServerKey: urlBase64ToUint8Array(publicKey)
        });

        // Get supported content encodings
        const supportedContentEncodings = PushManager.supportedContentEncodings || ['aesgcm'];

        // Prepare subscription data
        const subscriptionData = {
            endpoint: subscription.endpoint,
            keys: {
                p256dh: arrayBufferToBase64(subscription.getKey('p256dh')),
                auth: arrayBufferToBase64(subscription.getKey('auth'))
            },
            supportedContentEncodings: supportedContentEncodings
        };

        // Send to server
        await fetch(`${API_BASE_URL}/subscribe`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(subscriptionData)
        });

        console.log('Successfully subscribed to push notifications');
    } catch (error) {
        console.error('Failed to subscribe:', error);
    }
}

// Unsubscribe from push notifications
async function unsubscribeFromPush() {
    try {
        const registration = await navigator.serviceWorker.ready;
        const subscription = await registration.pushManager.getSubscription();

        if (!subscription) {
            return;
        }

        await fetch(`${API_BASE_URL}/unsubscribe`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ endpoint: subscription.endpoint })
        });

        await subscription.unsubscribe();
        console.log('Successfully unsubscribed from push notifications');
    } catch (error) {
        console.error('Failed to unsubscribe:', error);
    }
}

// Helper functions
function urlBase64ToUint8Array(base64String) {
    const padding = '='.repeat((4 - base64String.length % 4) % 4);
    const base64 = (base64String + padding)
        .replace(/\-/g, '+')
        .replace(/_/g, '/');

    const rawData = window.atob(base64);
    const outputArray = new Uint8Array(rawData.length);

    for (let i = 0; i < rawData.length; ++i) {
        outputArray[i] = rawData.charCodeAt(i);
    }
    return outputArray;
}

function arrayBufferToBase64(buffer) {
    const bytes = new Uint8Array(buffer);
    let binary = '';
    for (let i = 0; i < bytes.byteLength; i++) {
        binary += String.fromCharCode(bytes[i]);
    }
    return window.btoa(binary)
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=+$/, '');
}

// Initialize on page load
document.addEventListener('DOMContentLoaded', async () => {
    const subscribeBtn = document.getElementById('subscribe-btn');
    const unsubscribeBtn = document.getElementById('unsubscribe-btn');

    if (subscribeBtn) {
        subscribeBtn.addEventListener('click', async () => {
            if (await requestNotificationPermission()) {
                await subscribeToPush();
            }
        });
    }

    if (unsubscribeBtn) {
        unsubscribeBtn.addEventListener('click', unsubscribeFromPush);
    }
});
```

{% endcode %}

{% code title="public/service-worker.js" %}

```javascript
self.addEventListener('push', function(event) {
    if (!event.data) {
        return;
    }

    const data = event.data.json();
    const { title, options } = data;

    event.waitUntil(
        self.registration.showNotification(title, options)
    );
});

self.addEventListener('notificationclick', function(event) {
    event.notification.close();

    const urlToOpen = event.notification.data?.url || '/';

    event.waitUntil(
        clients.openWindow(urlToOpen)
    );
});
```

{% endcode %}

### Step 9: Simple HTML Page

{% code title="public/index.html" %}

```html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Web Push Notifications Example</title>
</head>
<body>
    <h1>Web Push Notifications</h1>

    <div>
        <button id="subscribe-btn">Subscribe to Notifications</button>
        <button id="unsubscribe-btn">Unsubscribe</button>
    </div>

    <script src="/js/push.js"></script>
</body>
</html>
```

{% endcode %}

## Running the Example

1. **Generate VAPID keys** (Step 2)
2. **Create the directory structure**:

   ```bash
   mkdir -p data keys public/js
   ```
3. **Save your keys** in the `keys/` directory
4. **Start a PHP development server**:

   ```bash
   php -S localhost:8000 -t public
   ```
5. **Visit** `http://localhost:8000` and click "Subscribe to Notifications"
6. **Send a test notification**:

   ```bash
   php examples/send-notification.php
   ```

## Notes

* This example uses file-based storage for simplicity. In production, use a database.
* The `WebPush\Subscription` class handles all the complexity of Web Push subscriptions.
* Always handle errors when sending notifications, as subscriptions can expire.
* The service worker must be served from the root of your domain or use the `Service-Worker-Allowed` header.


# Installation

The bundle can be installed using the package `spomky-labs/web-push-bundle`

```bash
composer require spomky-labs/web-push-bundle
```

If you use Symfony Flex, the bundle is ready to be used. Otherwise, you must enable it. The bundle class is `WebPush\Bundle\WebPushBundle`.

When done, the bundle is ready and can send the notifications. However, there are extra packages we highly recommend to install and set up.

## VAPID Header

The [VAPID header](/common-concepts/vapid) authenticates your server and prevents malicious applications from sending notifications to your users. The header contains a signed JSON Web Token (JWS).

The library provides bridges for the following libraries `web-token` and `lcobucci/jwt`.

Please install `web-token/jwt-library` or `lcobucci/jwt` depending on the library you want to use.

{% hint style="info" %}
It is possible to use any other JWS provider. This will be detailed in the future.
{% endhint %}


# Configuration

## VAPID Support

To enable the VAPID header feature, you must install a JWS Provider (see [installation](/the-symfony-bundle/installation)) and configure it with your public and private key (see [this page](/common-concepts/vapid) to create these keys)

{% code title="config/packages/webpush.yaml" %}

```yaml
webpush:
  vapid:
    enabled: true # Enable the feature
    subject: 'https://my-service.com:8000' # An URL or an email address
    web_token:
      enabled: true # We use web-token in this example
      public_key: 'BB4W1qfBi7MF_Lnrc6i2oL-glAuKF4kevy9T0k2vyKV4qvuBrN3T6o9-7-NR3mKHwzDXzD3fe7XvIqIU1iADpGQ'
      private_key: 'C40jLFSa5UWxstkFvdwzT3eHONE2FIJSEsVIncSCAqU'
```

{% endcode %}

When using `lcobucci/jwt`, the configuration is very similar.

{% code title="config/packages/webpush.yaml" %}

```yaml
webpush:
  vapid:
    enabled: true
    subject: 'https://my-service.com:8000'
    lcobucci:
      enabled: true # We use lcobucci/jwt in this example
      public_key: 'BB4W1qfBi7MF_Lnrc6i2oL-glAuKF4kevy9T0k2vyKV4qvuBrN3T6o9-7-NR3mKHwzDXzD3fe7XvIqIU1iADpGQ'
      private_key: 'C40jLFSa5UWxstkFvdwzT3eHONE2FIJSEsVIncSCAqU'
```

{% endcode %}

{% hint style="danger" %}
You cannot enable both `web-token` and `lcobucci/jwt` at the same time
{% endhint %}

### Custom JWS Provider

If you want to use a custom JWS Provider (not `web-token` or `lcobucci/jwt`), you can configure it as follows:

{% code title="config/packages/webpush.yaml" %}

```yaml
webpush:
  vapid:
    enabled: true
    subject: 'https://my-service.com:8000'
    custom:
      enabled: true
      id: 'app.my_custom_jws_provider' # The service ID of your custom provider
```

{% endcode %}

Your custom provider must implement the `WebPush\VAPID\JWSProvider` interface.

### Token Lifetime

By default, the library generates VAPID headers that are valid for 1 hour. You can change this value if needed. The parameter requires a relative string as showed [in the PHP documentation](https://www.php.net/manual/en/datetime.formats.relative.php).

{% code title="config/packages/webpush.yaml" %}

```yaml
webpush:
  vapid:
    enabled: true
    token_lifetime: 'now +2 hours'
```

{% endcode %}

{% hint style="warning" %}
The token lifetime should not be greater than 24 hours. Most of the Web Push Services will reject such long-life tokens
{% endhint %}

## Payload Support

### Padding

To obfuscate the real length of the notifications, messages can be padded before encryption. This operation consists in the concatenation of your message and arbitrary data in front of it. When encrypted, the messages will have the same size which reduces attacks.

By default, the padding is set to `recommended` i.e. \~3k bytes.

Acceptable values for this parameter are:

* `none`: no padding
* `recommended`: default value
* `max`: see warning below
* an integer: should be between `0` and `4078` or `3993` for `AESGCM` and `AES128GCM` respectively

{% code title="config/packages/webpush.yaml" %}

```yaml
webpush:
  payload:
    aesgcm:
      padding: 'none' # "none", "recommended", "max" or an integer (0 to 4078)
    aes128gcm:
      padding: 'none' # "none", "recommended", "max" or an integer (0 to 3993)
```

{% endcode %}

{% hint style="danger" %}
Please don't use "`none`" unless you are sending notifications in a development environment.
{% endhint %}

{% hint style="warning" %}
The value "`max`" increases the integrity protection of the messages, but there are known issues on Android and notifications are not correctly delivered.
{% endhint %}

### Caching

The notifications [may have a payload](/common-concepts/the-notification#json-messages). This payload is encrypted on server side and, during this process, a random key is generated.

The creation of this random key takes approximately 150ms and can impact your server performance when sending thousand of notifications at once.

To reduce the impact on your server, you can enable the caching feature and reuse the encryption key for a defined period of time.

{% hint style="danger" %}
As encryption keys will be stored in the cache, you should make sure the cache is not shared otherwise you may have a security issue.
{% endhint %}

This parameter requires a PSR-6 Cache compatible service. If you set `Psr\Log\CacheItemPoolInterface`, the default Symfony cache will be used.

{% code title="config/packages/webpush.yaml" %}

```yaml
webpush:
  payload:
    aesgcm:
      cache: Psr\Cache\CacheItemPoolInterface
      cache_lifetime: '+1 hour' #Default: now +30min
    aes128gcm:
      cache: Psr\Cache\CacheItemPoolInterface
      cache_lifetime: '+1 hour' #Default: now +30min
```

{% endcode %}

{% hint style="success" %}
You can see the impact of this feature on the CI/CD Pipelines of this library. Go the <https://github.com/Spomky-Labs/web-push/actions?query=workflow%3ABenchmark> and find a summary table displayed at the end of each test.
{% endhint %}

## Debugging

If you have troubles sending notifications, you can log some messages from the library. To do so, you just have to set the parameter logger in the configuration.

This parameter requires a PSR-3 logger. If you set `Psr\Log\LoggerInterface`, the Symfony logger will be used (PSR-3 compatible).

{% code title="config/packages/webpush.yaml" %}

```yaml
webpush:
  logger: Psr\Log\LoggerInterface
```

{% endcode %}


# The Web Push Service

The bundle provides a public Web Push service that you can inject into your application components.

In the following example, let's imagine that a notification is dispatched using the Symfony Messenger component and caught by an event handler. This handler will fetch all subscriptions and send the notification.

{% hint style="info" %}
The SubscriptionRepository class is totally fictive
{% endhint %}

{% code title="src/MessageHandler/SendNotification.php" %}

```php
<?php

declare(strict_types=1);

namespace App\MessageHandler;

use App\Message\SubscriptionExpired;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
use WebPush\Notification;
use WebPush\WebPushService;

#[AsMessageHandler]
final readonly class SendPushNotifications
{
    public function __construct(
        private MessageBusInterface $messageBus,
        private SubscriptionRepository $repository,
        private WebPushService $webPush
    ) {
    }

    public function __invoke(Notification $notification): void
    {
        // Fetch all subscriptions
        $subscriptions = $this->repository->fetchAllSubscriptions();

        // Send to all subscriptions at once
        $reports = $this->webPush->sendToMultiple($notification, $subscriptions);

        // Handle expired subscriptions
        $expired = \WebPush\StatusReport::filterExpired($reports);
        foreach ($expired as $report) {
            // Dispatch a message to delete expired subscription
            $this->messageBus->dispatch(
                new SubscriptionExpired($report->getSubscription())
            );
        }

        // Optionally: handle retryable errors
        $retryable = \WebPush\StatusReport::filterRetryable($reports);
        foreach ($retryable as $report) {
            // Queue for retry (5xx or 429 errors)
            $this->messageBus->dispatch(
                new RetryNotification($report->getNotification(), $report->getSubscription())
            );
        }
    }
}
```

{% endcode %}

## Validation Exceptions

When creating notifications from user input or configuration, validation exceptions provide clear error messages with contextual properties:

{% code title="src/Service/NotificationFactory.php" %}

```php
<?php

declare(strict_types=1);

namespace App\Service;

use Psr\Log\LoggerInterface;
use WebPush\Exception\InvalidTopicException;
use WebPush\Exception\InvalidTTLException;
use WebPush\Exception\ValidationException;
use WebPush\Notification;

final readonly class NotificationFactory
{
    public function __construct(
        private LoggerInterface $logger
    ) {}

    public function createFromRequest(array $data): ?Notification
    {
        try {
            return Notification::create()
                ->withTopic($data['topic'] ?? 'default')
                ->withTTL($data['ttl'] ?? Notification::TTL_ONE_HOUR)
                ->withPayload($data['message'] ?? '');

        } catch (InvalidTopicException $e) {
            $this->logger->error('Invalid topic in request', [
                'topic' => $e->topic,
                'error' => $e->getMessage()
            ]);
            return null;

        } catch (InvalidTTLException $e) {
            $this->logger->error('Invalid TTL in request', [
                'ttl' => $e->ttl,
                'error' => $e->getMessage()
            ]);
            return null;

        } catch (ValidationException $e) {
            $this->logger->error('Validation error', [
                'error' => $e->getMessage()
            ]);
            return null;
        }
    }
}
```

{% endcode %}

See the [Exceptions](/common-concepts/exceptions) documentation for complete error handling strategies.


# Doctrine

This section explains how to store `Subscription` objects using Doctrine ORM in your Symfony application.

## Creating a Subscription Entity

To persist subscriptions in your database, you need to create a Doctrine entity. There are two approaches:

1. **Extend the base `WebPush\Subscription` class** (recommended for simplicity)
2. **Implement the `WebPush\SubscriptionInterface` interface** (more flexibility)

### Approach 1: Extending the Base Class

In this example, we create a Subscription entity that extends the base `WebPush\Subscription` class. We also associate one or more Subscription entities to a specific user (Many-To-One relationship).

{% code title="src/Entity/Subscription.php" %}

```php
<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use WebPush\Subscription as WebPushSubscription;

#[ORM\Table(name: 'subscriptions')]
#[ORM\Entity]
class Subscription extends WebPushSubscription
{
    #[ORM\Id]
    #[ORM\Column(type: 'integer')]
    #[ORM\GeneratedValue(strategy: 'AUTO')]
    private ?int $id = null;

    #[ORM\ManyToOne(targetEntity: User::class, cascade: ['persist'], inversedBy: 'subscriptions')]
    #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: true)]
    
    private ?User $user;

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getUser(): ?User
    {
        return $this->user;
    }

    public function setUser(?User $user): self
    {
        $this->user = $user;

        return $this;
    }

    // We need to override this method as it returns a WebPush\Subscription and we want an entity
    public static function createFromString(string $input): self
    {
        $base = parent::createFromString($input);
        $object = new self($base->getEndpoint());
        $object->withContentEncodings($base->getSupportedContentEncodings());
        foreach ($base->getKeys() as $k => $v) {
            $object->setKey($k, $v);
        }

        return $object;
    }
}
```

{% endcode %}

{% hint style="info" %}
In this example, we assume you already have a valid User entity class.
{% endhint %}

### The `User` Entity

Now, to have a bidirectional relationship between this class and the User entity class, we will add this relationship to the User class.

{% code title="src/Entity/User.php" %}

```php
<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Table(name="users")
 * @ORM\Entity
 */
#[ORM\Table(name: 'users')]
#[ORM\Entity]
class User //Usual interface here
{
    //Usual user stuff here

    #[ORM\OneToMany(targetEntity: Subscription::class, mappedBy: 'user')]
    private Collection $subscriptions;

    public function __construct()
    {
        $this->subscriptions = new ArrayCollection();
    }

    /**
     * @return Subscription[]
     */
    public function getSubscriptions(): array
    {
        return $this->subscriptions->toArray();
    }

    public function addSubscription(Subscription $subscription): self
    {
        $subscription->setUser($this);
        $this->subscriptions->add($subscription);

        return $this;
    }

    public function removeSubscription(Subscription $subscription): self
    {
        $subscription->setUser(null);
        $this->subscriptions->removeElement($subscription);

        return $this;
    }
}
```

{% endcode %}

## Sending Notifications To A User

Now that your entities are set, you can register Subcriptions and assign them to your users. To send a Notification to a specific user, you just have to get all subscriptions using `$user->getSubscriptions()`.

{% code title="" %}

```php
$subscriptions = $user->getSubscriptions();
foreach ($subscriptions as $subscription) {
    $report = $this->webPush->send($notification, $subscription);
    if ($report->isSubscriptionExpired()) {
        //...Remove this subscription
    }
}
```

{% endcode %}

### Approach 2: Implementing the Interface Directly

Instead of extending the `WebPush\Subscription` class, you can create your own entity class that implements the `WebPush\SubscriptionInterface` interface. This approach gives you more flexibility in how you structure your entity.

```php
<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use WebPush\SubscriptionInterface;

#[ORM\Table(name: 'subscriptions')]
#[ORM\Entity]
class Subscription implements SubscriptionInterface
{
    #[ORM\Id]
    #[ORM\Column(type: 'integer')]
    #[ORM\GeneratedValue(strategy: 'AUTO')]
    private ?int $id = null;

    #[ORM\Column(type: 'string')]
    private string $endpoint;

    #[ORM\Column(type: 'json')]
    private array $keys = [];

    #[ORM\Column(type: 'json')]
    private array $supportedContentEncodings = ['aesgcm'];

    #[ORM\Column(type: 'integer', nullable: true)]
    private ?int $expirationTime = null;

    #[ORM\ManyToOne(targetEntity: User::class, cascade: ['persist'], inversedBy: 'subscriptions')]
    #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: true)]
    private ?User $user;

    public function __construct(string $endpoint)
    {
        $this->endpoint = $endpoint;
    }

    // Implement all methods from SubscriptionInterface
    public function getEndpoint(): string
    {
        return $this->endpoint;
    }

    public function getKeys(): array
    {
        return $this->keys;
    }

    public function hasKey(string $key): bool
    {
        return isset($this->keys[$key]);
    }

    public function getKey(string $key): string
    {
        return $this->keys[$key] ?? throw new \RuntimeException('Key not found');
    }

    public function getSupportedContentEncodings(): array
    {
        return $this->supportedContentEncodings;
    }

    public function getExpirationTime(): ?int
    {
        return $this->expirationTime;
    }

    public function jsonSerialize(): array
    {
        return [
            'endpoint' => $this->endpoint,
            'keys' => $this->keys,
            'supportedContentEncodings' => $this->supportedContentEncodings,
        ];
    }

    // Additional methods for Doctrine
    public function getId(): ?int
    {
        return $this->id;
    }

    public function getUser(): ?User
    {
        return $this->user;
    }

    public function setUser(?User $user): self
    {
        $this->user = $user;
        return $this;
    }
}
```

Both approaches (extending the class or implementing the interface) are valid and can be used depending on your needs.


# Example

This page provides a complete example of implementing Web Push notifications in a Symfony application.

## Complete Working Example

### Step 1: Configuration

First, configure the bundle with VAPID authentication:

{% code title="config/packages/webpush.yaml" %}

```yaml
webpush:
  vapid:
    enabled: true
    subject: 'mailto:admin@example.com'
    web_token:
      enabled: true
      public_key: '%env(WEBPUSH_PUBLIC_KEY)%'
      private_key: '%env(WEBPUSH_PRIVATE_KEY)%'
  payload:
    aes128gcm:
      padding: 'recommended'
    aesgcm:
      padding: 'recommended'
  logger: 'monolog.logger'
```

{% endcode %}

{% code title=".env" %}

```bash
# Generate keys with: openssl ecparam -genkey -name prime256v1 -out private_key.pem
# Extract public key: openssl ec -in private_key.pem -pubout -outform DER|tail -c 65|base64|tr -d '=' |tr '/+' '_-'
# Extract private key: openssl ec -in private_key.pem -outform DER|tail -c +8|head -c 32|base64|tr -d '=' |tr '/+' '_-'
WEBPUSH_PUBLIC_KEY=your-public-key-here
WEBPUSH_PRIVATE_KEY=your-private-key-here
```

{% endcode %}

### Step 2: Create the Subscription Entity

{% code title="src/Entity/PushSubscription.php" %}

```php
<?php

declare(strict_types=1);

namespace App\Entity;

use App\Repository\PushSubscriptionRepository;
use Doctrine\ORM\Mapping as ORM;
use WebPush\Subscription as WebPushSubscription;

#[ORM\Entity(repositoryClass: PushSubscriptionRepository::class)]
#[ORM\Table(name: 'push_subscriptions')]
class PushSubscription extends WebPushSubscription
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\ManyToOne(targetEntity: User::class)]
    #[ORM\JoinColumn(nullable: false)]
    private User $user;

    #[ORM\Column]
    private \DateTimeImmutable $createdAt;

    public function __construct(string $endpoint, User $user)
    {
        parent::__construct($endpoint);
        $this->user = $user;
        $this->createdAt = new \DateTimeImmutable();
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getUser(): User
    {
        return $this->user;
    }

    public function getCreatedAt(): \DateTimeImmutable
    {
        return $this->createdAt;
    }

    public static function createFromString(string $input): self
    {
        throw new \RuntimeException('Use createFromRequest instead');
    }
}
```

{% endcode %}

### Step 3: Create the Repository

{% code title="src/Repository/PushSubscriptionRepository.php" %}

```php
<?php

declare(strict_types=1);

namespace App\Repository;

use App\Entity\PushSubscription;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

class PushSubscriptionRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, PushSubscription::class);
    }

    public function save(PushSubscription $subscription): void
    {
        $this->getEntityManager()->persist($subscription);
        $this->getEntityManager()->flush();
    }

    public function remove(PushSubscription $subscription): void
    {
        $this->getEntityManager()->remove($subscription);
        $this->getEntityManager()->flush();
    }

    /**
     * @return PushSubscription[]
     */
    public function findByUser(User $user): array
    {
        return $this->findBy(['user' => $user]);
    }

    public function findByEndpoint(string $endpoint): ?PushSubscription
    {
        return $this->findOneBy(['endpoint' => $endpoint]);
    }
}
```

{% endcode %}

### Step 4: Create the Subscription Controller

{% code title="src/Controller/PushSubscriptionController.php" %}

```php
<?php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\PushSubscription;
use App\Repository\PushSubscriptionRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use WebPush\Subscription;

#[Route('/api/push')]
class PushSubscriptionController extends AbstractController
{
    public function __construct(
        private readonly PushSubscriptionRepository $repository
    ) {
    }

    #[Route('/subscribe', name: 'push_subscribe', methods: ['POST'])]
    public function subscribe(Request $request): JsonResponse
    {
        $user = $this->getUser();
        if (!$user) {
            return new JsonResponse(['error' => 'Unauthorized'], Response::HTTP_UNAUTHORIZED);
        }

        $data = json_decode($request->getContent(), true);
        if (!isset($data['endpoint'])) {
            return new JsonResponse(['error' => 'Invalid subscription'], Response::HTTP_BAD_REQUEST);
        }

        // Check if subscription already exists
        $existing = $this->repository->findByEndpoint($data['endpoint']);
        if ($existing) {
            return new JsonResponse(['message' => 'Already subscribed'], Response::HTTP_OK);
        }

        // Create subscription from browser data
        $baseSubscription = Subscription::createFromString(json_encode($data));

        $subscription = new PushSubscription($baseSubscription->getEndpoint(), $user);
        $subscription->withContentEncodings($baseSubscription->getSupportedContentEncodings());

        foreach ($baseSubscription->getKeys() as $key => $value) {
            $subscription->setKey($key, $value);
        }

        $this->repository->save($subscription);

        return new JsonResponse(['message' => 'Subscription saved'], Response::HTTP_CREATED);
    }

    #[Route('/unsubscribe', name: 'push_unsubscribe', methods: ['POST'])]
    public function unsubscribe(Request $request): JsonResponse
    {
        $user = $this->getUser();
        if (!$user) {
            return new JsonResponse(['error' => 'Unauthorized'], Response::HTTP_UNAUTHORIZED);
        }

        $data = json_decode($request->getContent(), true);
        $subscription = $this->repository->findByEndpoint($data['endpoint'] ?? '');

        if (!$subscription || $subscription->getUser() !== $user) {
            return new JsonResponse(['error' => 'Subscription not found'], Response::HTTP_NOT_FOUND);
        }

        $this->repository->remove($subscription);

        return new JsonResponse(['message' => 'Subscription removed'], Response::HTTP_OK);
    }

    #[Route('/public-key', name: 'push_public_key', methods: ['GET'])]
    public function getPublicKey(): JsonResponse
    {
        return new JsonResponse([
            'publicKey' => $this->getParameter('env(WEBPUSH_PUBLIC_KEY)')
        ]);
    }
}
```

{% endcode %}

### Step 5: Create the Notification Service

{% code title="src/Service/PushNotificationService.php" %}

```php
<?php

declare(strict_types=1);

namespace App\Service;

use App\Entity\PushSubscription;
use App\Entity\User;
use App\Repository\PushSubscriptionRepository;
use Psr\Log\LoggerInterface;
use WebPush\Message;
use WebPush\Notification;
use WebPush\WebPushService;

final readonly class PushNotificationService
{
    public function __construct(
        private WebPushService $webPush,
        private PushSubscriptionRepository $repository,
        private LoggerInterface $logger
    ) {
    }

    public function sendToUser(User $user, string $title, string $body, array $data = []): void
    {
        $subscriptions = $this->repository->findByUser($user);

        if (empty($subscriptions)) {
            $this->logger->info('No subscriptions found for user', ['user_id' => $user->getId()]);
            return;
        }

        $message = Message::create($title)
            ->withBody($body)
            ->withData($data)
            ->withIcon('/icon-192.png')
            ->withBadge('/badge-72.png');

        $notification = Notification::create()
            ->withPayload($message->toString())
            ->withTTL(3600);

        foreach ($subscriptions as $subscription) {
            $this->sendNotification($notification, $subscription);
        }
    }

    private function sendNotification(Notification $notification, PushSubscription $subscription): void
    {
        try {
            $report = $this->webPush->send($notification, $subscription);

            if ($report->isSubscriptionExpired()) {
                $this->logger->info('Subscription expired, removing', [
                    'endpoint' => $subscription->getEndpoint()
                ]);
                $this->repository->remove($subscription);
            } elseif (!$report->isSuccess()) {
                $this->logger->error('Failed to send notification', [
                    'endpoint' => $subscription->getEndpoint()
                ]);
            }
        } catch (\Throwable $e) {
            $this->logger->error('Exception while sending notification', [
                'endpoint' => $subscription->getEndpoint(),
                'error' => $e->getMessage()
            ]);
        }
    }
}
```

{% endcode %}

### Step 6: Client-side JavaScript

{% code title="public/js/push-notifications.js" %}

```javascript
// Request notification permission
async function requestNotificationPermission() {
    const permission = await Notification.requestPermission();
    if (permission !== 'granted') {
        console.log('Notification permission denied');
        return false;
    }
    return true;
}

// Subscribe to push notifications
async function subscribeToPush() {
    if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
        console.error('Push notifications are not supported');
        return;
    }

    try {
        // Register service worker
        const registration = await navigator.serviceWorker.register('/service-worker.js');
        await navigator.serviceWorker.ready;

        // Get public key from server
        const response = await fetch('/api/push/public-key');
        const { publicKey } = await response.json();

        // Subscribe
        const subscription = await registration.pushManager.subscribe({
            userVisibleOnly: true,
            applicationServerKey: urlBase64ToUint8Array(publicKey)
        });

        // Get supported content encodings
        const supportedContentEncodings = PushManager.supportedContentEncodings || ['aesgcm'];

        // Send subscription to server
        const subscriptionData = {
            endpoint: subscription.endpoint,
            keys: {
                p256dh: arrayBufferToBase64(subscription.getKey('p256dh')),
                auth: arrayBufferToBase64(subscription.getKey('auth'))
            },
            supportedContentEncodings: supportedContentEncodings
        };

        await fetch('/api/push/subscribe', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(subscriptionData)
        });

        console.log('Successfully subscribed to push notifications');
    } catch (error) {
        console.error('Failed to subscribe:', error);
    }
}

// Unsubscribe from push notifications
async function unsubscribeFromPush() {
    try {
        const registration = await navigator.serviceWorker.ready;
        const subscription = await registration.pushManager.getSubscription();

        if (!subscription) {
            return;
        }

        await fetch('/api/push/unsubscribe', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ endpoint: subscription.endpoint })
        });

        await subscription.unsubscribe();
        console.log('Successfully unsubscribed from push notifications');
    } catch (error) {
        console.error('Failed to unsubscribe:', error);
    }
}

// Helper functions
function urlBase64ToUint8Array(base64String) {
    const padding = '='.repeat((4 - base64String.length % 4) % 4);
    const base64 = (base64String + padding)
        .replace(/\-/g, '+')
        .replace(/_/g, '/');

    const rawData = window.atob(base64);
    const outputArray = new Uint8Array(rawData.length);

    for (let i = 0; i < rawData.length; ++i) {
        outputArray[i] = rawData.charCodeAt(i);
    }
    return outputArray;
}

function arrayBufferToBase64(buffer) {
    const bytes = new Uint8Array(buffer);
    let binary = '';
    for (let i = 0; i < bytes.byteLength; i++) {
        binary += String.fromCharCode(bytes[i]);
    }
    return window.btoa(binary)
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=+$/, '');
}
```

{% endcode %}

{% code title="public/service-worker.js" %}

```javascript
self.addEventListener('push', function(event) {
    if (!event.data) {
        return;
    }

    const data = event.data.json();
    const { title, options } = data;

    event.waitUntil(
        self.registration.showNotification(title, options)
    );
});

self.addEventListener('notificationclick', function(event) {
    event.notification.close();

    event.waitUntil(
        clients.openWindow('/')
    );
});
```

{% endcode %}

### Step 7: Usage Example

{% code title="src/Controller/NotificationTestController.php" %}

```php
<?php

declare(strict_types=1);

namespace App\Controller;

use App\Service\PushNotificationService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class NotificationTestController extends AbstractController
{
    #[Route('/test/notification', name: 'test_notification')]
    public function sendTestNotification(PushNotificationService $pushService): Response
    {
        $user = $this->getUser();

        $pushService->sendToUser(
            $user,
            'Test Notification',
            'This is a test notification from your Symfony app!',
            ['url' => '/dashboard']
        );

        return new Response('Notification sent!');
    }
}
```

{% endcode %}

## Demo Application

For a complete working demo application, please visit: <https://github.com/Spomky-Labs/web-push-demo>


