Como implementar notificações Web Push com PHP 8.0+: guia prático completo

O que vamos desenvolver

Neste tutorial, vamos construir um sistema de notificações Web Push utilizando PHP 8.0 ou superior, MySQL, JavaScript e Service Worker.

O objetivo é permitir que uma aplicação envie notificações para os navegadores dos usuários, mesmo quando eles não estiverem com o site aberto.

Ao finalizar, teremos um sistema capaz de:

  1. Solicitar autorização para enviar notificações.
  2. Registrar os navegadores dos usuários.
  3. Armazenar inscrições no MySQL.
  4. Enviar notificações utilizando VAPID.
  5. Exibir notificações no navegador.
  6. Abrir páginas quando o usuário clicar em uma notificação.
  7. Cancelar inscrições.
  8. Identificar inscrições expiradas.
  9. Organizar os envios em uma fila para processamento assíncrono.

Vamos construir primeiro uma versão funcional e depois entender como prepará-la para ambientes de produção.

Como funciona o Web Push?

O Web Push permite que um servidor envie mensagens para um navegador por meio de um serviço intermediário chamado Push Service.

O PHP não precisa manter uma conexão aberta com cada navegador.

O processo funciona assim:

  1. O usuário acessa o site.
  2. O navegador solicita autorização para enviar notificações.
  3. O navegador cria uma inscrição.
  4. O PHP recebe os dados dessa inscrição.
  5. O servidor armazena os dados no banco.
  6. Quando precisamos enviar uma notificação, o PHP utiliza os dados armazenados.
  7. O serviço Push encaminha a mensagem.
  8. O Service Worker recebe a mensagem e apresenta a notificação.

Existem três componentes principais:

ComponenteResponsabilidade
PHPRegistrar inscrições e enviar notificações
Push ServiceEncaminhar as mensagens
Service WorkerReceber e exibir notificações

Uma observação importante: o serviço Push aceitar uma mensagem não significa que o usuário necessariamente a recebeu ou visualizou.

Requisitos

Para acompanhar o tutorial, precisamos de:

ComponenteRequisito
PHP8.0 ou superior
MySQL8.0 ou superior
ComposerInstalado
JavaScriptNavegador com suporte à Push API
HTTPSObrigatório em produção
Extensões PHPcURL, OpenSSL, JSON e mbstring

A extensão GMP também é recomendada para melhorar o desempenho de operações criptográficas.

Atenção à compatibilidade: a biblioteca Minishlink possui versões diferentes para cada faixa de PHP:

PHPVersão da biblioteca
PHP 8.0Minishlink 8.x
PHP 8.1Minishlink 9.x
PHP 8.2 ou superiorMinishlink 10.x ou 11.x

A versão 8.x permite executar o exemplo em PHP 8.0, mas é uma versão antiga, sem manutenção atual. Para projetos novos em produção, prefira PHP 8.2 ou superior e uma versão mantida da biblioteca.

Referência: Minishlink Web Push.

Preparando o projeto

Vamos criar um diretório para organizar nossa implementação.

mkdir webpush-php
cd webpush-php

Inicialize o Composer:

composer init --no-interaction

Instale a biblioteca compatível com a versão do PHP utilizada.

Para PHP 8.0:

composer require minishlink/web-push:^8.0

Para PHP 8.1:

composer require minishlink/web-push:^9.0

Para PHP 8.2 ou superior:

composer require minishlink/web-push:^11.0

Confira a instalação:

composer show minishlink/web-push

Não misture as três versões no mesmo projeto. O Composer deve resolver e registrar uma versão compatível com o ambiente escolhido.

Guarde o arquivo composer.lock no controle de versão. Ele registra as dependências efetivamente instaladas.

Organizando os arquivos

Vamos utilizar uma estrutura simples:

webpush-php/
├── config/
│   └── config.php
├── public/
│   ├── index.php
│   ├── subscribe.php
│   ├── unsubscribe.php
│   └── service-worker.js
├── src/
│   ├── Database.php
│   └── PushSender.php
├── bin/
│   └── send.php
├── vendor/
├── .env
├── composer.json
└── composer.lock

O diretório public será o único acessível pela internet.

Os arquivos de configuração, credenciais e dependências devem permanecer fora da área pública.

Gerando as chaves VAPID

Antes de enviar notificações, precisamos gerar as chaves VAPID.

VAPID é um mecanismo que identifica a aplicação responsável pelo envio das notificações.

Execute:

php -r "require 'vendor/autoload.php'; print_r(Minishlink\WebPush\VAPID::createVapidKeys());"

O comando retornará duas chaves:

  1. publicKey: chave pública, utilizada pelo navegador.
  2. privateKey: chave privada, utilizada pelo PHP.

Crie um arquivo de configuração com as variáveis de ambiente:

VAPID_PUBLIC_KEY="SUA_CHAVE_PUBLICA"
VAPID_PRIVATE_KEY="SUA_CHAVE_PRIVADA"
VAPID_SUBJECT="mailto:admin@seudominio.com"

DB_HOST="127.0.0.1"
DB_NAME="webpush"
DB_USER="webpush_user"
DB_PASS="SENHA_FORTE"

Essas variáveis devem ser configuradas no ambiente do servidor. O PHP não carrega arquivos .env automaticamente.

Nunca coloque a chave privada no JavaScript ou em um diretório público.

Também não gere novas chaves a cada execução. A aplicação deve reutilizar o mesmo par de chaves VAPID.

Criando o banco de dados

Precisamos armazenar as inscrições dos navegadores.

Execute no MySQL:

CREATE DATABASE webpush
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;

USE webpush;

CREATE TABLE push_subscriptions (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT UNSIGNED NOT NULL,
    endpoint_hash CHAR(64) NOT NULL UNIQUE,
    endpoint TEXT NOT NULL,
    p256dh VARCHAR(255) NOT NULL,
    auth VARCHAR(255) NOT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
        ON UPDATE CURRENT_TIMESTAMP,
    expired_at DATETIME NULL,
    INDEX idx_user (user_id),
    INDEX idx_expired (expired_at)
) ENGINE=InnoDB;

Entenda as colunas:

ColunaFinalidade
idIdentificador da inscrição
user_idUsuário proprietário
endpointEndereço do serviço Push
endpoint_hashIdentificação única do endpoint
p256dhChave pública da inscrição
authSegredo de autenticação
expired_atData de cancelamento ou expiração

O endpoint é fornecido pelo navegador e não deve ser tratado como uma URL confiável.

Configurando a conexão com o MySQL

Arquivo src/Database.php:

<?php

declare(strict_types=1);

final class Database
{
    public static function connection(): PDO
    {
        $dsn = sprintf(
            'mysql:host=%s;dbname=%s;charset=utf8mb4',
            getenv('DB_HOST'),
            getenv('DB_NAME')
        );

        return new PDO(
            $dsn,
            getenv('DB_USER'),
            getenv('DB_PASS'),
            [
                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                PDO::ATTR_EMULATE_PREPARES => false,
            ]
        );
    }
}

Essa classe utiliza PDO e consultas preparadas para evitar problemas comuns de segurança nas operações com o banco.

O carregamento das variáveis de ambiente deve acontecer antes da conexão.

Criando a página de inscrição

Agora vamos implementar a parte que o usuário verá.

Arquivo public/index.php:

<?php
session_start();

if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
?>

<!DOCTYPE html>
<html lang="pt-BR">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Notificações Web Push</title>
</head>
<body>

    <h1>Notificações Web Push</h1>

    <p>Receba novidades diretamente no navegador.</p>

    <button id="enable-push">
        Ativar notificações
    </button>

    <button id="disable-push">
        Desativar notificações
    </button>

    <script>
        const publicKey = 'SUA_CHAVE_PUBLICA_VAPID';

        const csrfToken = <?= json_encode(
            $_SESSION['csrf_token']
        ) ?>;

        function decodeKey(value) {
            const padding = '='.repeat(
                (4 - value.length % 4) % 4
            );

            const decoded = atob(
                (value + padding)
                    .replace(/-/g, '+')
                    .replace(/_/g, '/')
            );

            return Uint8Array.from(
                decoded,
                character => character.charCodeAt(0)
            );
        }

        document
            .getElementById('enable-push')
            .addEventListener('click', async () => {

                try {
                    if (
                        !('serviceWorker' in navigator) ||
                        !('PushManager' in window)
                    ) {
                        throw new Error(
                            'Seu navegador não suporta notificações.'
                        );
                    }

                    const permission =
                        await Notification.requestPermission();

                    if (permission !== 'granted') {
                        throw new Error(
                            'Permissão não concedida.'
                        );
                    }

                    const registration =
                        await navigator.serviceWorker.register(
                            '/service-worker.js'
                        );

                    const subscription =
                        await registration.pushManager.subscribe({
                            userVisibleOnly: true,
                            applicationServerKey: decodeKey(publicKey)
                        });

                    const response = await fetch(
                        '/subscribe.php',
                        {
                            method: 'POST',
                            credentials: 'same-origin',
                            headers: {
                                'Content-Type': 'application/json',
                                'X-CSRF-Token': csrfToken
                            },
                            body: JSON.stringify(subscription)
                        }
                    );

                    if (!response.ok) {
                        throw new Error(
                            'Não foi possível salvar a inscrição.'
                        );
                    }

                    alert('Notificações ativadas!');

                } catch (error) {
                    console.error(error);
                    alert(error.message);
                }
            });

        document
            .getElementById('disable-push')
            .addEventListener('click', async () => {

                try {
                    const registration =
                        await navigator.serviceWorker.ready;

                    const subscription =
                        await registration.pushManager.getSubscription();

                    if (!subscription) {
                        return;
                    }

                    const response = await fetch(
                        '/unsubscribe.php',
                        {
                            method: 'POST',
                            credentials: 'same-origin',
                            headers: {
                                'Content-Type': 'application/json',
                                'X-CSRF-Token': csrfToken
                            },
                            body: JSON.stringify({
                                endpoint: subscription.endpoint
                            })
                        }
                    );

                    if (!response.ok) {
                        throw new Error(
                            'Falha ao cancelar no servidor.'
                        );
                    }

                    await subscription.unsubscribe();

                    alert('Notificações desativadas.');

                } catch (error) {
                    console.error(error);
                    alert(error.message);
                }
            });
    </script>

</body>
</html>

O que acontece aqui?

  1. O usuário clica em ativar.
  2. O navegador solicita autorização.
  3. O Service Worker é registrado.
  4. O navegador cria uma inscrição.
  5. O PHP recebe os dados para armazená-los.

A chave pública VAPID precisa ser substituída pela chave gerada anteriormente.

O exemplo pressupõe uma aplicação com sessão autenticada e usuário identificado no servidor. Em uma aplicação real, o ID do usuário deve vir da sessão, nunca de um valor enviado pelo navegador.

Criando o endpoint para salvar a inscrição

O navegador envia um JSON parecido com este:

{
    "endpoint": "https://push.example.com/...",
    "expirationTime": null,
    "keys": {
        "p256dh": "CHAVE_PUBLICA",
        "auth": "SEGREDO"
    }
}

Observe que p256dh e auth estão dentro do objeto keys.

Arquivo public/subscribe.php:

<?php

declare(strict_types=1);

session_start();

require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/src/Database.php';

header('Content-Type: application/json; charset=utf-8');

function respond(int $status, array $data): void
{
    http_response_code($status);

    echo json_encode($data);

    exit;
}

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    respond(405, ['error' => 'Método não permitido.']);
}

if (empty($_SESSION['user_id'])) {
    respond(401, ['error' => 'Autenticação necessária.']);
}

$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';

if (
    empty($_SESSION['csrf_token']) ||
    !hash_equals($_SESSION['csrf_token'], $csrf)
) {
    respond(403, ['error' => 'Token CSRF inválido.']);
}

if (
    (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 10000
) {
    respond(413, ['error' => 'Requisição muito grande.']);
}

$data = json_decode(
    file_get_contents('php://input'),
    true
);

if (!is_array($data)) {
    respond(400, ['error' => 'JSON inválido.']);
}

$endpoint = $data['endpoint'] ?? '';
$keys = $data['keys'] ?? [];

$p256dh = $keys['p256dh'] ?? '';
$auth = $keys['auth'] ?? '';

if (
    !is_string($endpoint) ||
    !is_string($p256dh) ||
    !is_string($auth) ||
    strlen($endpoint) > 4096 ||
    strlen($p256dh) > 255 ||
    strlen($auth) > 255
) {
    respond(400, ['error' => 'Dados inválidos.']);
}

$url = parse_url($endpoint);

if (
    !is_array($url) ||
    strtolower($url['scheme'] ?? '') !== 'https' ||
    empty($url['host']) ||
    isset($url['user']) ||
    isset($url['pass']) ||
    isset($url['port']) && $url['port'] !== 443 ||
    !preg_match('/^[A-Za-z0-9_-]+$/', $p256dh) ||
    !preg_match('/^[A-Za-z0-9_-]+$/', $auth)
) {
    respond(400, ['error' => 'Inscrição inválida.']);
}

try {
    $pdo = Database::connection();

    $hash = hash('sha256', $endpoint);

    $sql = "
        INSERT INTO push_subscriptions
            (user_id, endpoint_hash, endpoint, p256dh, auth)
        VALUES
            (:user_id, :hash, :endpoint, :p256dh, :auth)
        ON DUPLICATE KEY UPDATE
            user_id = VALUES(user_id),
            endpoint = VALUES(endpoint),
            p256dh = VALUES(p256dh),
            auth = VALUES(auth),
            expired_at = NULL,
            updated_at = UTC_TIMESTAMP()
    ";

    $stmt = $pdo->prepare($sql);

    $stmt->execute([
        'user_id' => $_SESSION['user_id'],
        'hash' => $hash,
        'endpoint' => $endpoint,
        'p256dh' => $p256dh,
        'auth' => $auth
    ]);

    respond(200, ['success' => true]);

} catch (Throwable $e) {
    error_log($e->getMessage());

    respond(500, ['error' => 'Erro ao salvar inscrição.']);
}

Esse exemplo precisa ser complementado com uma política de propriedade da inscrição: não permita que um endpoint já associado a outra conta seja transferido automaticamente apenas porque alguém enviou o mesmo endereço.

Também é necessário aplicar uma allowlist de provedores Push e controles de saída de rede. A validação de HTTPS, sozinha, não protege contra SSRF.

Criando o Service Worker

Agora precisamos implementar o arquivo que receberá as notificações.

Arquivo public/service-worker.js:

self.addEventListener('push', event => {

    let payload = {};

    try {
        payload = event.data
            ? event.data.json()
            : {};
    } catch {
        payload = {};
    }

    const title = payload.title || 'Saber Dev';

    const options = {
        body: payload.body || 'Você recebeu uma notificação.',
        icon: '/icons/icon-192.png',
        badge: '/icons/badge-72.png',
        data: {
            url: payload.url || '/'
        }
    };

    event.waitUntil(
        self.registration.showNotification(
            title,
            options
        )
    );
});

self.addEventListener('notificationclick', event => {

    event.notification.close();

    const url = new URL(
        event.notification.data.url || '/',
        self.location.origin
    );

    if (url.origin !== self.location.origin) {
        return;
    }

    event.waitUntil(
        clients.openWindow(url.href)
    );
});

O evento push é executado quando uma mensagem chega.

O evento notificationclick é executado quando o usuário clica na notificação.

A validação da origem impede que o clique abra uma página externa inesperada.

Enviando a primeira notificação

Agora chegamos à parte principal: enviar uma mensagem utilizando PHP.

Arquivo src/PushSender.php:

<?php

declare(strict_types=1);

use Minishlink\WebPush\Subscription;
use Minishlink\WebPush\WebPush;

final class PushSender
{
    public static function send(
        array $subscription,
        array $payload
    ): bool {

        $auth = [
            'VAPID' => [
                'subject' => getenv('VAPID_SUBJECT'),
                'publicKey' => getenv('VAPID_PUBLIC_KEY'),
                'privateKey' => getenv('VAPID_PRIVATE_KEY')
            ]
        ];

        $webPush = new WebPush($auth);

        $target = Subscription::create([
            'endpoint' => $subscription['endpoint'],
            'publicKey' => $subscription['p256dh'],
            'authToken' => $subscription['auth']
        ]);

        $report = $webPush->sendOneNotification(
            $target,
            json_encode(
                $payload,
                JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE
            ),
            ['TTL' => 60]
        );

        return $report->isSuccess();
    }
}

A biblioteca cuida da criptografia e da comunicação com o serviço Push.

Observe que não precisamos implementar manualmente o protocolo de criptografia.

Criando um comando para enviar notificações

Não devemos disponibilizar um endpoint público que permita qualquer pessoa enviar notificações.

Para nosso primeiro teste, vamos utilizar um comando executado pelo terminal.

Arquivo bin/send.php:

<?php

declare(strict_types=1);

require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/src/Database.php';
require dirname(__DIR__) . '/src/PushSender.php';

if (PHP_SAPI !== 'cli') {
    exit("Execute este arquivo pelo terminal.\n");
}

$subscriptionId = (int) ($argv[1] ?? 0);

if ($subscriptionId <= 0) {
    exit("Informe o ID da inscrição.\n");
}

$pdo = Database::connection();

$stmt = $pdo->prepare(
    "SELECT *
     FROM push_subscriptions
     WHERE id = :id
       AND expired_at IS NULL"
);

$stmt->execute(['id' => $subscriptionId]);

$subscription = $stmt->fetch();

if (!$subscription) {
    exit("Inscrição não encontrada.\n");
}

$payload = [
    'title' => 'Nova notificação!',
    'body' => 'Seu sistema Web Push está funcionando.',
    'url' => '/'
];

try {
    $success = PushSender::send(
        $subscription,
        $payload
    );

    if ($success) {
        echo "O serviço Push aceitou a notificação.\n";
    } else {
        echo "O serviço Push recusou a notificação.\n";
    }

} catch (Throwable $e) {
    error_log($e->getMessage());

    echo "Falha durante o envio.\n";
}

Execute:

php bin/send.php 1

O número 1 representa o ID da inscrição armazenada no banco.

Se tudo estiver configurado corretamente, o navegador poderá receber a notificação.

Esse exemplo é uma implementação inicial. Em produção, precisamos analisar os relatórios HTTP completos, tratar inscrições expiradas e classificar corretamente as falhas.

Cancelando uma inscrição no servidor

O navegador também precisa conseguir cancelar uma inscrição armazenada.

Arquivo public/unsubscribe.php:

<?php

declare(strict_types=1);

session_start();

require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/src/Database.php';

header('Content-Type: application/json; charset=utf-8');

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit;
}

if (empty($_SESSION['user_id'])) {
    http_response_code(401);
    exit;
}

$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';

if (
    empty($_SESSION['csrf_token']) ||
    !hash_equals($_SESSION['csrf_token'], $csrf)
) {
    http_response_code(403);
    exit;
}

$data = json_decode(
    file_get_contents('php://input'),
    true
);

$endpoint = $data['endpoint'] ?? '';

if (!is_string($endpoint) || $endpoint === '') {
    http_response_code(400);
    exit;
}

$hash = hash('sha256', $endpoint);

$pdo = Database::connection();

$stmt = $pdo->prepare(
    "UPDATE push_subscriptions
     SET expired_at = UTC_TIMESTAMP()
     WHERE endpoint_hash = :hash
       AND user_id = :user_id
       AND expired_at IS NULL"
);

$stmt->execute([
    'hash' => $hash,
    'user_id' => $_SESSION['user_id']
]);

echo json_encode(['success' => true]);

O cancelamento local e o cancelamento no servidor precisam ser coordenados.

Se o navegador cancelar e o servidor falhar, a aplicação deve permitir sincronizar novamente o estado.

Evoluindo para uma fila de notificações

A implementação anterior envia uma notificação diretamente pelo terminal.

Isso funciona para testes e pequenos cenários, mas não é a melhor abordagem para uma aplicação que precisa enviar milhares de mensagens.

Imagine que um artigo seja publicado e precise gerar notificações para 5.000 usuários.

Enviar todas essas mensagens durante a requisição de publicação pode deixar o site lento.

A solução é utilizar uma fila.

O funcionamento passa a ser:

  1. A aplicação cria uma notificação.
  2. A notificação é registrada no banco.
  3. Um worker busca as tarefas pendentes.
  4. O worker realiza os envios.
  5. O resultado é armazenado.

Uma tabela para controlar as entregas pode ser criada assim:

CREATE TABLE push_deliveries (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    subscription_id BIGINT UNSIGNED NOT NULL,
    payload JSON NOT NULL,
    status ENUM(
        'pending',
        'processing',
        'accepted',
        'failed',
        'dead'
    ) NOT NULL DEFAULT 'pending',
    attempts INT UNSIGNED NOT NULL DEFAULT 0,
    max_attempts INT UNSIGNED NOT NULL DEFAULT 8,
    available_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    locked_until DATETIME NULL,
    lease_token CHAR(36) NULL,
    last_http_status SMALLINT UNSIGNED NULL,
    last_error VARCHAR(1000) NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
        ON UPDATE CURRENT_TIMESTAMP,
    accepted_at DATETIME NULL,
    FOREIGN KEY (subscription_id)
        REFERENCES push_subscriptions(id),
    INDEX idx_queue (status, available_at, id),
    INDEX idx_lease (status, locked_until)
) ENGINE=InnoDB;

O worker poderá processar essas tarefas sem bloquear a aplicação principal.

Como tratar falhas?

Nem todo envio será bem-sucedido.

Precisamos classificar os resultados:

ResultadoAção
HTTP 2xxMarcar como accepted
HTTP 404 ou 410Expirar inscrição
HTTP 429Reagendar
HTTP 5xxReagendar
Falha de conexãoReagendar
HTTP 400Investigar os dados
HTTP 401 ou 403Verificar VAPID
Tentativas esgotadasMarcar como dead

Para as falhas temporárias, utilize backoff exponencial.

Exemplo:

<?php

declare(strict_types=1);

final class RetryPolicy
{
    public static function shouldRetry(
        ?int $status
    ): bool {

        return $status === null
            || $status === 429
            || ($status >= 500 && $status <= 599);
    }

    public static function delay(int $attempt): int
    {
        $attempt = max(1, min($attempt, 10));

        $base = min(
            3600,
            15 * (2 ** ($attempt - 1))
        );

        return $base + random_int(0, 10);
    }
}

O intervalo aumenta progressivamente, evitando que o servidor repita requisições rapidamente quando um serviço externo estiver indisponível.

Protegendo o sistema contra SSRF

Existe um risco de segurança que merece atenção.

O endpoint Push é recebido do navegador e posteriormente utilizado pelo servidor para realizar uma conexão HTTP.

Se alguém conseguir cadastrar um endereço malicioso, poderá tentar utilizar o servidor para acessar serviços internos.

Essa vulnerabilidade é chamada de SSRF.

Para evitar esse problema:

  1. Aceite somente HTTPS.
  2. Utilize uma allowlist de provedores Push.
  3. Rejeite endereços IP privados e locais.
  4. Valide registros DNS A e AAAA.
  5. Bloqueie conexões para redes internas.
  6. Desative redirecionamentos automáticos.
  7. Controle as conexões de saída do servidor.

Uma simples consulta DNS não é suficiente para impedir DNS rebinding. O endereço utilizado na conexão precisa ser o mesmo endereço validado, preservando a validação TLS.

Para produção, implemente essa proteção na camada HTTP e na infraestrutura. A biblioteca deve utilizar um cliente HTTP que respeite essa política.

Referência: OWASP SSRF Prevention Cheat Sheet.

Testando a implementação

Antes de disponibilizar o sistema, verifique cada etapa.

No navegador:

  1. O botão solicita autorização?
  2. O Service Worker é registrado?
  3. A inscrição é criada?
  4. O navegador recebe a notificação?
  5. O clique abre a página correta?
  6. O cancelamento funciona?

No PHP:

  1. As inscrições são armazenadas?
  2. As chaves VAPID são carregadas?
  3. O envio retorna o resultado esperado?
  4. As falhas são registradas?
  5. Inscrições expiradas são identificadas?

No MySQL:

  1. Não existem inscrições duplicadas?
  2. As inscrições pertencem aos usuários corretos?
  3. As entregas são armazenadas?
  4. Os estados são atualizados corretamente?

Para testar em ambiente local, o servidor pode ser iniciado com:

php -S localhost:8000 -t public

O navegador precisa estar em um contexto seguro. localhost é considerado contexto seguro para desenvolvimento. Em produção, utilize HTTPS.

Preparando para produção

Antes de disponibilizar o sistema, implemente:

  1. Processamento assíncrono com workers.
  2. Reserva temporária de tarefas.
  3. Recuperação de processos interrompidos.
  4. Retentativas automáticas.
  5. Monitoramento da fila.
  6. Logs estruturados.
  7. Limites de conexões simultâneas.
  8. Proteção contra SSRF.
  9. Controle de autenticação e CSRF.
  10. Política de retenção das inscrições.

Para concorrência, o MySQL 8 oferece FOR UPDATE SKIP LOCKED, permitindo que vários workers reservem tarefas diferentes.

O worker deve utilizar tokens de reserva e validar sua propriedade antes de atualizar uma entrega.

Mesmo com esses controles, não existe garantia de entrega exatamente uma vez. Uma falha após o serviço Push aceitar a mensagem, mas antes de o banco registrar o resultado, pode provocar duplicidade em uma nova tentativa.

Conclusão

O Web Push permite criar um canal de comunicação entre uma aplicação e seus usuários sem depender de uma conexão permanente com cada navegador.

Com PHP, MySQL, JavaScript e Service Worker, conseguimos registrar inscrições, enviar mensagens e apresentar notificações diretamente no navegador.

A melhor maneira de desenvolver esse recurso é começar pela inscrição e pelo primeiro envio, validar o funcionamento e depois adicionar recursos como filas, workers, retentativas e monitoramento.

Essa evolução evita complexidade desnecessária no início e permite construir uma arquitetura preparada para crescer.

Referências

  1. Minishlink Web Push PHP
  2. Minishlink Web Push no Packagist
  3. Exemplo oficial de implementação PHP
  4. MDN: Push API
  5. MDN: PushSubscription
  6. MDN: Service Worker API
  7. MySQL: InnoDB Locking Reads
  8. OWASP: SSRF Prevention