Web push-уведомления в 2026 году стали обязательным инструментом для удержания пользователей, и я покажу, как их правильно настроить. За 10 лет работы с веб-проектами я внедрил push-уведомления для сотен сайтов — от простых блогов до крупных интернет-магазинов.
Что такое web push-уведомления и зачем они нужны
Web push-уведомления — это сообщения, которые приходят пользователю прямо в браузер или на рабочий стол, даже когда сайт закрыт. Честно говоря, это один из самых эффективных способов вернуть пользователя на сайт.
На практике я видел, как правильно настроенные push-уведомления увеличивают возвратность пользователей на 30-50%. У одного моего клиента — интернет-магазина электроники — CTR по push достигал 8%, что в разы выше, чем у email-рассылок.
Основные преимущества web push:
- Мгновенная доставка — уведомление приходит сразу
- Высокая видимость — появляется поверх всех окон
- Работает даже при закрытом браузере
- Не требует email или номера телефона
- Бесплатная доставка через браузеры
А вот минусы, с которыми приходится мириться:
- Требует согласие пользователя
- Не работает в Safari на iOS (хотя Apple обещает добавить в 2026)
- Можно легко отписаться одним кликом
- Ограничения по длине текста
Как работают web push-уведомления технически
Чтобы правильно настроить push, нужно понимать механизм их работы. Я всегда объясняю клиентам эту схему простыми словами:
1. Регистрация Service Worker — специальный скрипт, который работает в фоне
2. Запрос разрешения — браузер спрашивает пользователя о согласии
3. Подписка — браузер создаёт уникальный endpoint для уведомлений
4. Отправка — сервер отправляет уведомление через push-сервис браузера
5. Доставка — браузер показывает уведомление пользователю
Грубо говоря, каждый браузер имеет свой push-сервис:
- Chrome/Edge — Firebase Cloud Messaging (FCM)
- Firefox — Mozilla Push Service
- Safari — Apple Push Notification Service
- Opera — тот же FCM
Весь процесс построен на стандартах Web Push Protocol и VAPID (Voluntary Application Server Identification). Эти технологии обеспечивают безопасность и единообразие работы во всех браузерах.
Настройка Service Worker для push-уведомлений
Service Worker — это сердце всей системы push-уведомлений. Я всегда создаю файл `sw.js` в корне сайта и регистрирую его на всех страницах.
Вот базовый Service Worker для push-уведомлений:
// sw.js
self.addEventListener('push', function(event) {
const options = {
body: 'У нас есть новости для вас!',
icon: '/images/icon-192x192.png',
badge: '/images/badge-72x72.png',
tag: 'notification-tag',
renotify: true,
requireInteraction: false,
actions: [
{
action: 'view',
title: 'Посмотреть',
icon: '/images/view-icon.png'
},
{
action: 'close',
title: 'Закрыть',
icon: '/images/close-icon.png'
}
],
data: {
url: 'https://example.com/news'
}
};
if (event.data) {
const payload = event.data.json();
options.body = payload.body;
options.icon = payload.icon;
options.data.url = payload.url;
}
event.waitUntil(
self.registration.showNotification('WebFull Blog', options)
);
});
self.addEventListener('notificationclick', function(event) {
event.notification.close();
if (event.action === 'view' || !event.action) {
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
}
});
self.addEventListener('notificationclose', function(event) {
console.log('Уведомление закрыто пользователем');
// Здесь можно отправить аналитику
});
Теперь нужно зарегистрировать Service Worker на сайте. Я добавляю этот код в `
` всех страниц:if ('serviceWorker' in navigator && 'PushManager' in window) {
navigator.serviceWorker.register('/sw.js')
.then(function(registration) {
console.log('Service Worker зарегистрирован:', registration);
// Проверяем, есть ли уже подписка
return registration.pushManager.getSubscription();
})
.then(function(subscription) {
if (!subscription) {
// Подписки нет, можно показать кнопку подписки
showSubscribeButton();
} else {
// Подписка есть, отправляем данные на сервер
sendSubscriptionToServer(subscription);
}
})
.catch(function(error) {
console.error('Ошибка регистрации Service Worker:', error);
});
}
function showSubscribeButton() {
const subscribeBtn = document.getElementById('subscribe-btn');
if (subscribeBtn) {
subscribeBtn.style.display = 'block';
subscribeBtn.onclick = subscribeUser;
}
}
На практике у меня был случай, когда Service Worker не регистрировался из-за HTTPS. Помните: push-уведомления работают только по HTTPS (кроме localhost). Если у вас ещё нет SSL-сертификата, обязательно настройте его — читайте мою статью про SSL-сертификаты.
Генерация VAPID-ключей
VAPID (Voluntary Application Server Identification) — это стандарт аутентификации для push-сервисов. Без VAPID-ключей современные браузеры просто не будут доставлять уведомления.
Я генерирую VAPID-ключи через npm-пакет web-push:
npm install web-push -g
web-push generate-vapid-keys
Команда выдаст два ключа:
- Public Key — используется в браузере для подписки
- Private Key — используется на сервере для отправки уведомлений
Честно говоря, я всегда сохраняю эти ключи в переменных окружения. Вот пример для PHP:
# .env
VAPID_PUBLIC_KEY="BMxYzvGfvBkHDkzJqpNgr1PfPkyR_LgFEIp2nS9-XRq9iWNxVG1cZLAJNxTwHnxC_HgCb3QWeLV4K9__Cj2rLhE"
VAPID_PRIVATE_KEY="lGAyujOXaWvKRTfGW4WK3bwQ9L6v2YRj8K5hRyUpHhY"
VAPID_SUBJECT="mailto:admin@webfull.ru"
На клиенте используем только публичный ключ для подписки:
const publicVapidKey = 'BMxYzvGfvBkHDkzJqpNgr1PfPkyR_LgFEIp2nS9-XRq9iWNxVG1cZLAJNxTwHnxC_HgCb3QWeLV4K9__Cj2rLhE';
function subscribeUser() {
navigator.serviceWorker.ready
.then(function(registration) {
return registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicVapidKey)
});
})
.then(function(subscription) {
console.log('Пользователь подписан:', subscription);
sendSubscriptionToServer(subscription);
})
.catch(function(error) {
console.error('Ошибка подписки:', error);
});
}
// Функция для конвертации base64 в Uint8Array
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;
}
Создание системы подписки пользователей
Правильная система подписки — это не просто кнопка "Разрешить уведомления". Нужно продумать UX, момент показа запроса и сохранение подписок на сервере.
Я всегда использую двухэтапную подписку:
- Сначала показываю свой красивый промпт с объяснением пользы
- Только после согласия запрашиваю разрешение браузера
Вот HTML для кастомного промпта:
<div id="push-prompt" class="push-prompt" style="display: none;">
<div class="push-prompt__content">
<h3>Получайте уведомления о новых статьях</h3>
<p>Мы будем присылать только важные обновления, без спама. Отписаться можно в любой момент.</p>
<div class="push-prompt__buttons">
<button id="push-allow" class="btn btn--primary">Разрешить</button>
<button id="push-deny" class="btn btn--secondary">Не сейчас</button>
</div>
</div>
</div>
JavaScript для управления подпиской:
class PushNotifications {
constructor() {
this.publicKey = 'ВАШ_ПУБЛИЧНЫЙ_VAPID_КЛЮЧ';
this.apiEndpoint = '/api/push-subscription';
this.init();
}
init() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
console.log('Push уведомления не поддерживаются');
return;
}
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('SW зарегистрирован');
this.swRegistration = registration;
this.checkSubscription();
})
.catch(error => {
console.error('Ошибка SW:', error);
});
this.bindEvents();
}
bindEvents() {
document.getElementById('push-allow')?.addEventListener('click', () => {
this.subscribe();
});
document.getElementById('push-deny')?.addEventListener('click', () => {
this.hidePrompt();
// Запомним отказ в localStorage
localStorage.setItem('push-denied', Date.now());
});
}
checkSubscription() {
this.swRegistration.pushManager.getSubscription()
.then(subscription => {
if (subscription) {
this.sendSubscriptionToServer(subscription);
} else {
this.maybeShowPrompt();
}
});
}
maybeShowPrompt() {
// Не показываем, если пользователь уже отказался недавно
const denied = localStorage.getItem('push-denied');
if (denied && (Date.now() - denied) < 7 * 24 * 60 * 60 * 1000) {
return;
}
// Показываем через 30 секунд после загрузки страницы
setTimeout(() => {
this.showPrompt();
}, 30000);
}
showPrompt() {
const prompt = document.getElementById('push-prompt');
if (prompt) {
prompt.style.display = 'block';
}
}
hidePrompt() {
const prompt = document.getElementById('push-prompt');
if (prompt) {
prompt.style.display = 'none';
}
}
subscribe() {
this.hidePrompt();
this.swRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlBase64ToUint8Array(this.publicKey)
})
.then(subscription => {
console.log('Подписка создана:', subscription);
this.sendSubscriptionToServer(subscription);
})
.catch(error => {
console.error('Ошибка подписки:', error);
if (error.name === 'NotAllowedError') {
// Пользователь отклонил запрос
localStorage.setItem('push-denied', Date.now());
}
});
}
sendSubscriptionToServer(subscription) {
fetch(this.apiEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
subscription: subscription,
user_agent: navigator.userAgent,
url: window.location.href
})
})
.then(response => response.json())
.then(data => {
console.log('Подписка сохранена на сервере:', data);
})
.catch(error => {
console.error('Ошибка сохранения подписки:', error);
});
}
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;
}
}
// Инициализация
new PushNotifications();
На практике я заметил, что конверсия в подписку сильно зависит от момента показа промпта. Не стоит показывать его сразу — лучше дать пользователю освоиться на сайте.
Серверская часть на PHP
Для отправки push-уведомлений я использую библиотеку web-push-php. Она отлично работает с любыми PHP-проектами — от чистого PHP до Laravel и Bitrix.
Установка через Composer:
composer require minishlink/web-push
Создаю класс для работы с push-уведомлениями:
<?php
class WebPushManager
{
private $webPush;
private $db;
public function __construct($vapidPublicKey, $vapidPrivateKey, $vapidSubject, $db)
{
$auth = [
'VAPID' => [
'subject' => $vapidSubject,
'publicKey' => $vapidPublicKey,
'privateKey' => $vapidPrivateKey,
]
];
$this->webPush = new \Minishlink\WebPush\WebPush($auth);
$this->db = $db;
}
public function saveSubscription($subscriptionData, $userAgent = '', $url = '')
{
$endpoint = $subscriptionData['endpoint'];
$p256dh = $subscriptionData['keys']['p256dh'] ?? '';
$auth = $subscriptionData['keys']['auth'] ?? '';
// Проверяем, есть ли уже такая подписка
$stmt = $this->db->prepare("
SELECT id FROM push_subscriptions
WHERE endpoint = ?
LIMIT 1
");
$stmt->execute([$endpoint]);
if ($stmt->fetch()) {
// Обновляем существующую
$stmt = $this->db->prepare("
UPDATE push_subscriptions
SET p256dh = ?, auth = ?, user_agent = ?, last_seen = NOW()
WHERE endpoint = ?
");
return $stmt->execute([$p256dh, $auth, $userAgent, $endpoint]);
} else {
// Создаём новую
$stmt = $this->db->prepare("
INSERT INTO push_subscriptions
(endpoint, p256dh, auth, user_agent, subscribe_url, created_at, last_seen)
VALUES (?, ?, ?, ?, ?, NOW(), NOW())
");
return $stmt->execute([$endpoint, $p256dh, $auth, $userAgent, $url]);
}
}
public function sendNotification($title, $body, $url = '', $icon = '', $badge = '')
{
// Получаем все активные подписки
$stmt = $this->db->prepare("
SELECT endpoint, p256dh, auth
FROM push_subscriptions
WHERE is_active = 1
");
$stmt->execute();
$subscriptions = $stmt->fetchAll(PDO::FETCH_ASSOC);
$payload = json_encode([
'title' => $title,
'body' => $body,
'url' => $url,
'icon' => $icon,
'badge' => $badge,
'timestamp' => time()
]);
$successCount = 0;
$failedEndpoints = [];
foreach ($subscriptions as $subscription) {
$webPushSubscription = \Minishlink\WebPush\Subscription::create([
'endpoint' => $subscription['endpoint'],
'keys' => [
'p256dh' => $subscription['p256dh'],
'auth' => $subscription['auth']
]
]);
$report = $this->webPush->sendOneNotification(
$webPushSubscription,
$payload
);
if ($report->isSuccess()) {
$successCount++;
} else {
$failedEndpoints[] = $subscription['endpoint'];
// Если подписка больше не валидна, помечаем как неактивную
if ($report->getResponse() && $report->getResponse()->getStatusCode() === 410) {
$this->deactivateSubscription($subscription['endpoint']);
}
}
}
return [
'success_count' => $successCount,
'failed_endpoints' => $failedEndpoints,
'total_sent' => count($subscriptions)
];
}
public function deactivateSubscription($endpoint)
{
$stmt = $this->db->prepare("
UPDATE push_subscriptions
SET is_active = 0
WHERE endpoint = ?
");
return $stmt->execute([$endpoint]);
}
public function getSubscriptionStats()
{
$stmt = $this->db->query("
SELECT
COUNT(*) as total,
COUNT(CASE WHEN is_active = 1 THEN 1 END) as active,
COUNT(CASE WHEN created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) THEN 1 END) as week_new
FROM push_subscriptions
");
return $stmt->fetch(PDO::FETCH_ASSOC);
}
}
SQL для создания таблицы подписок:
CREATE TABLE `push_subscriptions` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`endpoint` varchar(500) NOT NULL,
`p256dh` varchar(255) DEFAULT NULL,
`auth` varchar(255) DEFAULT NULL,
`user_agent` text,
`subscribe_url` varchar(255) DEFAULT NULL,
`is_active` tinyint(1) DEFAULT 1,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_seen` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `endpoint` (`endpoint`),
KEY `is_active` (`is_active`),
KEY `created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Интеграция с популярными CMS
За годы практики я интегрировал push-уведомления с разными CMS. Расскажу про самые популярные варианты.
WordPress
Для WordPress я создаю плагин или добавляю функциональность в functions.php темы:
// functions.php
function webfull_push_init() {
// Регистрируем эндпоинты REST API
add_action('rest_api_init', function () {
register_rest_route('webfull/v1', '/push-subscription', [
'methods' => 'POST',
'callback' => 'webfull_save_push_subscription',
'permission_callback' => '__return_true'
]);
});
// Добавляем скрипты на фронтенд
add_action('wp_enqueue_scripts', 'webfull_push_scripts');
// Хук для отправки уведомлений при публикации поста
add_action('publish_post', 'webfull_send_new_post_notification');
}
add_action('init', 'webfull_push_init');
function webfull_push_scripts() {
wp_enqueue_script('webfull-push', get_template_directory_uri() . '/js/push.js', [], '1.0', true);
wp_localize_script('webfull-push', 'webfullPush', [
'apiUrl' => home_url('/wp-json/webfull/v1/push-subscription'),
'publicKey' => get_option('webfull_vapid_public_key'),
'nonce' => wp_create_nonce('wp_rest')
]);
}
function webfull_save_push_subscription(WP_REST_Request $request) {
global $wpdb;
$subscription = $request->get_param('subscription');
$userAgent = $request->get_param('user_agent');
$url = $request->get_param('url');
if (!$subscription || !isset($subscription['endpoint'])) {
return new WP_Error('invalid_subscription', 'Неверные данные подписки', ['status' => 400]);
}
$endpoint = $subscription['endpoint'];
$p256dh = $subscription['keys']['p256dh'] ?? '';
$auth = $subscription['keys']['auth'] ?? '';
$result = $wpdb->replace(
$wpdb->prefix . 'push_subscriptions',
[
'endpoint' => $endpoint,
'p256dh' => $p256dh,
'auth' => $auth,
'user_agent' => $userAgent,
'subscribe_url' => $url,
'last_seen' => current_time('mysql')
],
['%s', '%s', '%s', '%s', '%s', '%s']
);
if ($result === false) {
return new WP_Error('db_error', 'Ошибка сохранения в БД', ['status' => 500]);
}
return ['success' => true, 'message' => 'Подписка сохранена'];
}
function webfull_send_new_post_notification($post_id) {
$post = get_post($post_id);
// Отправляем только для опубликованных постов
if ($post->post_status !== 'publish' || $post->post_type !== 'post') {
return;
}
// Не отправляем для старых постов (избегаем спама при массовом импорте)
if (strtotime($post->post_date) < strtotime('-1 hour')) {
return;
}
$title = 'Новая статья: ' . get_the_title($post_id);
$excerpt = wp_trim_words(get_the_excerpt($post_id), 15);
$url = get_permalink($post_id);
// Здесь используем наш WebPushManager
$pushManager = new WebPushManager(
get_option('webfull_vapid_public_key'),
get_option('webfull_vapid_private_key'),
get_option('webfull_vapid_subject'),
$wpdb
);
$result = $pushManager->sendNotification($title, $excerpt, $url);
// Логируем результат
error_log("Push уведомления отправлены: {$result['success_count']} из {$result['total_sent']}");
}
Битрикс
В Битрикс я обычно создаю компонент или добавляю функциональность через init.php:
// /bitrix/php_interface/init.php
use Bitrix\Main\EventManager;
// Регистрируем обработчики событий
$eventManager = EventManager::getInstance();
// Отправляем push при добавлении новости
$eventManager->addEventHandler('iblock', 'OnAfterIBlockElementAdd', 'WebfullPushOnNewsAdd');
function WebfullPushOnNewsAdd(&$arFields) {
if ($arFields['IBLOCK_ID'] == 1 && $arFields['ACTIVE'] == 'Y') { // ID инфоблока новостей
$title = 'Новая новость: ' . $arFields['NAME'];
$preview = strip_tags($arFields['PREVIEW_TEXT']);
$url = 'https://' . $_SERVER['HTTP_HOST'] . '/news/' . $arFields['CODE'] . '/';
// Отправляем push
$pushManager = new WebPushManager(
\Bitrix\Main\Config\Option::get('main', 'vapid_public_key'),
\Bitrix\Main\Config\Option::get('main', 'vapid_private_key'),
\Bitrix\Main\Config\Option::get('main', 'vapid_subject'),
\Bitrix\Main\Application::getConnection()
);
$pushManager->sendNotification($title, $preview, $url);
}
}
// Ajax-обработчик для подписки
if ($_POST['action'] == 'save_push_subscription' && check_bitrix_sessid()) {
$subscription = json_decode($_POST['subscription'], true);
if ($subscription && isset($subscription['endpoint'])) {
// Сохраняем подписку в БД
$connection = \Bitrix\Main\Application::getConnection();
$connection->query("
INSERT INTO b_push_subscriptions
(endpoint, p256dh, auth, user_agent, subscribe_url, date_create)
VALUES ('{$subscription['endpoint']}', '{$subscription['keys']['p256dh']}',
'{$subscription['keys']['auth']}', '{$_SERVER['HTTP_USER_AGENT']}',
'{$_SERVER['HTTP_REFERER']}', NOW())
ON DUPLICATE KEY UPDATE
p256dh = VALUES(p256dh), auth = VALUES(auth), date_update = NOW()
");
echo json_encode(['success' => true]);
} else {
echo json_encode(['error' => 'Неверные данные подписки']);
}
die();
}
Честно говоря, с Битрикс иногда возникают нюансы из-за особенностей его архитектуры. Но в целом push-уведомления работают стабильно. Если планируете серьёзный проект на Битрикс, рекомендую мои услуги по поддержке Битрикс.
Оптимизация доставляемости и аналитика
Просто отправлять уведомления недостаточно — нужно отслеживать их эффективность и оптимизировать. За годы работы я выработал несколько правил.
Во-первых, всегда отслеживаю статистику доставки:
public function sendNotificationWithTracking($title, $body, $url = '', $campaignId = null) {
$startTime = microtime(true);
// Логируем начало отправки
$this->logCampaign($campaignId, 'started', [
'title' => $title,
'recipients_count' => $this->getActiveSubscriptionsCount()
]);
$result = $this->sendNotification($title, $body, $url);
$duration = microtime(true) - $startTime;
// Логируем результаты
$this->logCampaign($campaignId, 'completed', [
'success_count' => $result['success_count'],
'failed_count' => count($result['failed_endpoints']),
'total_sent' => $result['total_sent'],
'duration' => round($duration, 2),
'success_rate' => round(($result['success_count'] / $result['total_sent']) * 100, 2)
]);
// Деактивируем неработающие подписки
foreach ($result['failed_endpoints'] as $endpoint) {
$this->deactivateSubscription($endpoint);
}
return $result;
}
private function logCampaign($campaignId, $status, $data) {
if (!$campaignId) return;
$stmt = $this->db->prepare("
INSERT INTO push_campaigns_log
(campaign_id, status, data, created_at)
VALUES (?, ?, ?, NOW())
");
$stmt->execute([$campaignId, $status, json_encode($data)]);
}
Во-вторых, тестирую время отправки. На практике лучше всего работают уведомления:
- Утром с 9 до 11 (рабочие дни)
- Вечером с 19 до 21 (все дни)
- Никогда ночью с 23 до 7
В-третьих, сегментирую аудиторию по поведению:
-- Активные пользователи (заходили за последнюю неделю)
SELECT endpoint FROM push_subscriptions
WHERE last_seen >= DATE_SUB(NOW(), INTERVAL 7 DAY)
AND is_active = 1;
-- Новые подписчики (подписались за последние 3 дня)
SELECT endpoint FROM push_subscriptions
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)
AND is_active = 1;
-- Пользователи с определённых страниц
SELECT endpoint FROM push_subscriptions
WHERE subscribe_url LIKE '%/blog/%'
AND is_active = 1;
Для аналитики я создаю простую админку:
class PushAnalytics {
public function getDashboardData() {
$stats = [];
// Общая статистика подписок
$stmt = $this->db->query("
SELECT
COUNT(*) as total_subscriptions,
COUNT(CASE WHEN is_active = 1 THEN 1 END) as active_subscriptions,
COUNT(CASE WHEN created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) THEN 1 END) as new_week,
COUNT(CASE WHEN created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) THEN 1 END) as new_month
FROM push_subscriptions
");
$stats['subscriptions'] = $stmt->fetch(PDO::FETCH_ASSOC);
// Статистика по кампаниям за месяц
$stmt = $this->db->query("
SELECT
DATE(created_at) as date,
COUNT(*) as campaigns_count,
AVG(JSON_EXTRACT(data, '$.success_rate')) as avg_success_rate,
SUM(JSON_EXTRACT(data, '$.success_count')) as total_delivered
FROM push_campaigns_log
WHERE status = 'completed'
AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY DATE(created_at)
ORDER BY date DESC
");
$stats['campaigns'] = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Топ источники подписок
$stmt = $this->db->query("
SELECT
subscribe_url,
COUNT(*) as subscriptions_count
FROM push_subscriptions
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY subscribe_url
ORDER BY subscriptions_count DESC
LIMIT 10
");
$stats['top_sources'] = $stmt->fetchAll(PDO::FETCH_ASSOC);
return $stats;
}
}
Безопасность и соответствие GDPR
Push-уведомления попадают под GDPR, поэтому нужно правильно обрабатывать персональные данные. Я всегда добавляю несколько обязательных элементов.
Во-первых, чёткое согласие пользователя с объяснением, что именно он разрешает:
<div class="gdpr-notice">
<p>Нажимая "Разрешить", вы соглашаетесь получать push-уведомления о новых статьях и важных обновлениях.
Мы храним данные вашей подписки до её отмены. Вы можете отписаться в любой момент.</p>
<p><a href="/privacy-policy">Политика конфиденциальности</a></p>
</div>
Во-вторых, возможность управления подпиской и удаления данных:
public function unsubscribeByToken($token) {
// Находим подписку по токену
$stmt = $this->db->prepare("
SELECT endpoint FROM push_subscriptions
WHERE MD5(CONCAT(endpoint, 'secret_salt')) = ?
AND is_active = 1
");
$stmt->execute([$token]);
$subscription = $stmt->fetch();
if ($subscription) {
// Удаляем подписку (или помечаем как неактивную)
$stmt = $this->db->prepare("
DELETE FROM push_subscriptions
WHERE endpoint = ?
");
return $stmt->execute([$subscription['endpoint']]);
}
return false;
}
public function generateUnsubscribeLink($endpoint) {
$token = md5($endpoint . 'secret_salt');
return 'https://example.com/unsubscribe?token=' . $token;
}
В-третьих, шифрование чувствительных данных в базе:
// Шифруем ключи подписки
private function encryptSubscriptionKey($key) {
$encryptionKey = base64_decode($_ENV['ENCRYPTION_KEY']);
$iv = random_bytes(16);
$encrypted = openssl_encrypt($key, 'AES-256-CBC', $encryptionKey, 0, $iv);
return base64_encode($iv . $encrypted);
}
private function decryptSubscriptionKey($encryptedKey) {
$encryptionKey = base64_decode($_ENV['ENCRYPTION_KEY']);
$data = base64_decode($encryptedKey);
$iv = substr($data, 0, 16);
$encrypted = substr($data, 16);
return openssl_decrypt($encrypted, 'AES-256-CBC', $encryptionKey, 0, $iv);
}
Честно говоря, с GDPR лучше перебдеть, чем недобдеть. Штрафы могут быть серьёзными — до 4% от оборота компании.
Тестирование и отладка push-уведомлений
Отладка push-уведомлений — отдельная наука. За годы практики я накопил несколько приёмов для быстрого поиска проблем.
Первым делом всегда проверяю консоль браузера. Большинство ошибок видно сразу:
// Добавляю подробное логирование в Service Worker
self.addEventListener('push', function(event) {
console.log('[SW] Push событие получено:', event);
let notificationData = {
title: 'Заголовок по умолчанию',
body: 'Текст по умолчанию',
icon: '/default-icon.png'
};
if (event.data) {
try {
notificationData = event.data.json();
console.log('[SW] Данные уведомления:', notificationData);
} catch (error) {
console.error('[SW] Ошибка парсинга данных:', error);
}
}
const options = {
body: notificationData.body,
icon: notificationData.icon,
badge: notificationData.badge,
data: {
url: notificationData.url,
timestamp: Date.now()
}
};
event.waitUntil(
self.registration.showNotification(notificationData.title, options)
.then(() => {
console.log('[SW] Уведомление показано успешно');
})
.catch(error => {
console.error('[SW] Ошибка показа уведомления:', error);
})
);
});
Для тестирования отправки я создаю отдельную страницу админки:
// test-push.php (доступ только админам!)
if (!current_user_can('manage_options')) {
wp_die('Доступ запрещён');
}
if ($_POST['send_test_push']) {
$pushManager = new WebPushManager(/* параметры */);
$result = $pushManager->sendNotification(
$_POST['test_title'] ?: 'Тестовое уведомление',
$_POST['test_body'] ?: 'Это тестовое push-уведомление',
$_POST['test_url'] ?: home_url(),
$_POST['test_icon'] ?: get_site_icon_url()
);
echo "<div class='notice notice-success'>";
echo "Отправлено: {$result['success_count']} из {$result['total_sent']}";
if (!empty($result['failed_endpoints'])) {
echo "<br>Ошибки: " . count($result['failed_endpoints']);
}
echo "</div>";
}
?>
<form method="post">
<table class="form-table">
<tr>
<th>Заголовок</th>
<td><input type="text" name="test_title" class="regular-text" /></td>
</tr>
<tr>
<th>Текст</th>
<td><textarea name="test_body" rows="3" class="large-text"></textarea></td>
</tr>
<tr>
<th>URL</th>
<td><input type="url" name="test_url" class="regular-text" /></td>
</tr>
<tr>
<th>Иконка</th>
<td><input type="url" name="test_icon" class="regular-text" /></td>
</tr>
</table>
<p class="submit">
<input type="submit" name="send_test_push" class="button-primary" value="Отправить тест" />
</p>
</form>
Для отладки VAPID-ключей использую онлайн-инструменты или создаю простой скрипт:
// debug-vapid.php
function testVapidKeys($publicKey, $privateKey) {
try {
// Проверяем формат ключей
if (strlen(base64_decode($publicKey)) !== 65) {
throw new Exception('Неверный формат публичного ключа');
}
if (strlen(base64_decode($privateKey)) !== 32) {
throw new Exception('Неверный формат приватного ключа');
}
// Проверяем, что ключи связаны
$testAuth = [
'VAPID' => [
'subject' => 'mailto:test@example.com',
'publicKey' => $publicKey,
'privateKey' => $privateKey,
]
];
$webPush = new \Minishlink\WebPush\WebPush($testAuth);
echo "✅ VAPID ключи корректны\n";
return true;
} catch (Exception $e) {
echo "❌ Ошибка VAPID ключей: " . $e->getMessage() . "\n";
return false;
}
}
// Использование
testVapidKeys($publicKey, $privateKey);
А вот самые частые проблемы, с которыми я сталкиваюсь:
- Service Worker не регистрируется — обычно проблема с HTTPS или путём к файлу
- Подписка не создаётся — чаще всего неверный публичный VAPID-ключ
- Уведомления не приходят — проблемы с приватным ключом или endpoint'ами
- Показывается не то содержимое — ошибка в обработке payload в Service Worker
Производительность и масштабирование
Когда подписчиков становится много (у меня были проекты с 100К+ подписок), нужно думать о производительности. Отправка 100 тысяч уведомлений может занять часы, если делать это неправильно.
Я использую очереди для массовой отправки:
class PushQueue {
private $redis;
private $webPush;
public function __construct($redis, $webPush) {
$this->redis = $redis;
$this->webPush = $webPush;
}
public function addCampaign($title, $body, $url, $segments = []) {
$campaignId = uniqid('campaign_');
// Получаем подписчиков по сегментам
$subscriptions = $this->getSubscriptionsBySegments($segments);
// Разбиваем на батчи по 100 штук
$batches = array_chunk($subscriptions, 100);
foreach ($batches as $batchIndex => $batch) {
$job = [
'campaign_id' => $campaignId,
'batch_index' => $batchIndex,
'title' => $title,
'body' => $body,
'url' => $url,
'subscriptions' => $batch
];
// Добавляем задачу в очередь Redis
$this->redis->lpush('push_queue', json_encode($job));
}
return [
'campaign_id' => $campaignId,
'batches_count' => count($batches),
'total_recipients' => count($subscriptions)
];
}
public function processQueue($maxJobs = 10) {
$processedJobs = 0;
while ($processedJobs < $maxJobs) {
$jobData = $this->redis->brpop(['push_queue'], 1);
if (!$jobData) {
break; // Очередь пуста
}
$job = json_decode($jobData[1], true);
try {
$this->processBatch($job);
$processedJobs++;
// Логируем успех
error_log("Обработан batch {$job['batch_index']} кампании {$job['campaign_id']}");
} catch (Exception $e) {
// При ошибке возвращаем задачу в очередь с задержкой
$job['retry_count'] = ($job['retry_count'] ?? 0) + 1;
if ($job['retry_count'] < 3) {
sleep(5); // Задержка перед повтором
$this->redis->lpush('push_queue', json_encode($job));
}
error_log("Ошибка обработки batch: " . $e->getMessage());
}
}
return $processedJobs;
}
private function processBatch($job) {
$payload = json_encode([
'title' => $job['title'],
'body' => $job['body'],
'url' => $job['url'],
'timestamp' => time()
]);
foreach ($job['subscriptions'] as $subscription) {
$webPushSubscription = \Minishlink\WebPush\Subscription::create([
'endpoint' => $subscription['endpoint'],
'keys' => [
'p256dh' => $subscription['p256dh'],
'auth' => $subscription['auth']
]
]);
$this->webPush->queueNotification($webPushSubscription, $payload);
}
// Отправляем весь batch за раз
$reports = $this->webPush->flush();
// Обрабатываем результаты
foreach ($reports as $report) {
if (!$report->isSuccess() && $report->getResponse()) {
$statusCode = $report->getResponse()->getStatusCode();
// 410 Gone означает, что подписка больше не валидна
if ($statusCode === 410) {
$this->deactivateSubscription($report->getEndpoint());
}
}
}
}
}
Для обработки очереди настраиваю cron-задачу:
# Crontab - запускаем каждую минуту
* * * * * /usr/bin/php /path/to/process-push-queue.php >> /var/log/push-queue.log 2>&1
// process-push-queue.php
connect('127.0.0.1', 6379);
$webPush = new \Minishlink\WebPush\WebPush([
'VAPID' => [
'subject' => $_ENV['VAPID_SUBJECT'],
'publicKey' => $_ENV['VAPID_PUBLIC_KEY'],
'privateKey' => $_ENV['VAPID_PRIVATE_KEY'],
]
]);
$queue = new PushQueue($redis, $webPush);
// Обрабатываем до 50 задач за раз
$processed = $queue->processQueue(50);
echo "Обработано задач: $processed\n";
При очень больших объёмах (миллионы подписчиков) стоит рассмотреть специализированные сервисы вроде OneSignal или Pusher. Но для большинства проектов собственное решение работает отлично и даёт полный контроль над данными.
На практике я видел, как правильно настроенные push-уведомления кардинально меняют метрики сайта. Один мой клиент — новостной портал — увеличил среднее время на сайте на 40% просто за счёт своевременных push-уведомлений о важных новостях.
Главное — не злоупотреблять частотой отправки и всегда давать пользователям реальную ценность. Push-уведомления — это мощный инструмент, но с ним нужно обращаться аккуратно.
Нужна помощь с настройкой push-уведомлений?
Наши эксперты помогут интегрировать и настроить эффективную систему веб push-уведомлений на вашем сайте.