Push-уведомления во Flutter-приложении с бэкендом на Laravel: App Store и RuStore

В этой статье разберём, как с нуля сделать push-уведомления в мобильном приложении на Flutter, если бэкенд написан на Laravel, а приложение публикуется в App Store и RuStore. Пройдём весь путь: какие сервисы доставки нужны и почему их два, что настроить в Apple Developer, Firebase и консоли RuStore, как приложение получает токен и передаёт его серверу, как Laravel хранит токены и отправляет уведомления через очередь, как открыть нужный экран по нажатию и как всё это проверить. В конце — типичные ошибки и чек-лист запуска.

Сквозной пример — приложение фитнес-клуба: пользователь записывается на тренировку, а за два часа до начала получает push «Тренировка через 2 часа» и по нажатию попадает на экран этой тренировки. Названия классов и маршрутов условные, их легко заменить на свои. Версии на октябрь 2026 года: Laravel 12–13, Flutter 3.35+, firebase_messaging 17.x, flutter_rustore_push 7.x.

Как устроены push-уведомления

Ваш сервер не может сам «достучаться» до телефона: у телефона нет постоянного адреса, он то в Wi-Fi, то в мобильной сети, то спит. Поэтому доставкой занимается сервис платформы, с которым телефон держит одно постоянное соединение на все приложения сразу:

  • на iPhone — APNs (Apple Push Notification service);
  • на Android с сервисами Google — FCM (Firebase Cloud Messaging);
  • на Android из RuStore — RuStore Push (работает через приложение RuStore, сервисы Google не нужны).

Схема всегда одна и та же:

1. Приложение ──регистрация──> Сервис доставки (APNs / FCM / RuStore)
                                  │
2. Приложение <─────токен─────────┘
   │
3. └──POST /api/v1/device-tokens──> Laravel (сохраняет токен за пользователем)

4. Событие на сервере ("тренировка через 2 часа")
   Laravel ──токен + текст──> Сервис доставки ──> Телефон ──> Шторка уведомлений

Токен — это адрес конкретной установки приложения на конкретном устройстве. Не пользователя: у одного человека может быть iPhone и Android-планшет, то есть два токена. Токен может смениться (переустановка приложения, восстановление из бэкапа, обновление SDK), и тогда старый перестаёт работать. Из этого следуют три правила, на которых держится вся архитектура:

  • токенов у пользователя много, храним их отдельной таблицей;
  • приложение отправляет токен при каждом запуске и при его обновлении, а сервер делает upsert;
  • сервер удаляет токен, когда сервис доставки отвечает, что токен больше недействителен.

Какой сервис выбрать для каждой платформы

Для App Store вариантов по сути нет: доставка на iPhone всегда идёт через APNs. Вопрос только в том, ходит ли ваш сервер в APNs напрямую или через Firebase, который сам переадресует сообщение в APNs. Для Android из RuStore разумный выбор — RuStore Push: он работает и на устройствах без сервисов Google (например, на новых Huawei), а приложение RuStore у пользователя гарантированно есть — он из него ваше приложение и установил.

Канал публикации Сервис доставки Flutter-пакет Что нужно серверу
App Store (iOS) APNs через FCM firebase_messaging Сервисный аккаунт Google (JSON) и ID проекта Firebase
RuStore (Android) RuStore Push flutter_rustore_push ID проекта и сервисный токен из консоли RuStore
Google Play (если появится) FCM firebase_messaging То же, что для iOS

Почему для iOS удобно взять Firebase, а не APNs напрямую: пакет firebase_messaging — самый распространённый и хорошо документированный способ получить токен, обработать нажатие и показ уведомлений во Flutter. Сервер при этом общается с одним HTTP API от Google, а не реализует HTTP/2 и JWT-подпись для Apple. Если зависимость от Google для вас неприемлема, сервер может слать в APNs напрямую с ключом .p8 — логика на стороне Laravel меняется только в одном классе отправки (дальше увидите, что он изолирован).

Если позже приложение появится в Google Play, к Android-сборке для Google Play добавится FCM, а таблица токенов и код отправки уже будут к этому готовы: у каждого токена хранится провайдер.

Шаг 1. Подготовка сервисов

iOS: ключ APNs и проект Firebase

  1. В Apple Developer → Certificates, Identifiers & Profiles → Keys создайте ключ с галочкой Apple Push Notifications service (APNs). Скачайте файл .p8 (скачать можно только один раз), запишите Key ID и Team ID. Один ключ работает для всех приложений команды и для обоих окружений — sandbox и production.
  2. В Identifiers у App ID приложения включите capability Push Notifications.
  3. Создайте проект в консоли Firebase, добавьте в него iOS-приложение с вашим Bundle ID.
  4. Project settings → Cloud Messaging → Apple app configuration — загрузите ключ .p8, укажите Key ID и Team ID.
  5. Project settings → Service accounts → Generate new private key — скачается JSON сервисного аккаунта. Это секрет для сервера: он кладётся на сервер вне репозитория и никогда не попадает в приложение.

Ключ APNs в формате .p8 предпочтительнее старых сертификатов .p12: он не истекает каждый год и не привязан к одному приложению.

Android: проект в консоли RuStore

  1. В консоли RuStore откройте Push-уведомления → Проекты и создайте проект.
  2. Укажите имя пакета (applicationId) и SHA-256-отпечаток ключа подписи той сборки, которая уходит в RuStore.
  3. Скопируйте ID проекта (нужен и приложению, и серверу) и сервисный токен (только для сервера).

Отпечаток должен совпадать с ключом, которым на самом деле подписан APK/AAB для RuStore. Частая ловушка: отпечаток взяли от debug-ключа или от ключа другой сборки, и токен в приложении не выдаётся. Посмотреть отпечаток подписанного файла можно так:

keytool -printcert -jarfile app-release.apk

Шаг 2. Хранение токенов в Laravel

Таблица и модель

Провайдер выносим в enum: и в валидации, и при выборе способа отправки это одно и то же значение.

// app/Enums/PushProvider.php
namespace App\Enums;

enum PushProvider: string
{
    case Fcm = 'fcm';
    case RuStore = 'rustore';
}
// database/migrations/2026_10_16_000000_create_device_tokens_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('device_tokens', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('provider', 16);   // fcm | rustore
            $table->string('platform', 16);   // ios | android
            $table->string('token', 512)->unique();
            $table->string('app_version', 32)->nullable();
            $table->timestamp('last_seen_at')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('device_tokens');
    }
};

Уникальность — по самому токену, а не по паре «пользователь + токен». Если на одном телефоне сначала вошёл Иван, а потом Мария, токен должен перейти к Марии, иначе Иван продолжит получать её уведомления на чужой телефон.

// app/Models/DeviceToken.php
namespace App\Models;

use App\Enums\PushProvider;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class DeviceToken extends Model
{
    protected $fillable = ['user_id', 'provider', 'platform', 'token', 'app_version', 'last_seen_at'];

    protected function casts(): array
    {
        return [
            'provider' => PushProvider::class,
            'last_seen_at' => 'datetime',
        ];
    }

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

В модели User — связь:

public function deviceTokens(): HasMany
{
    return $this->hasMany(DeviceToken::class);
}

API для приложения

Валидация — в отдельном FormRequest:

// app/Http/Requests/StoreDeviceTokenRequest.php
namespace App\Http\Requests;

use App\Enums\PushProvider;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class StoreDeviceTokenRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'token' => ['required', 'string', 'max:512'],
            'provider' => ['required', Rule::enum(PushProvider::class)],
            'platform' => ['required', Rule::in(['ios', 'android'])],
            'app_version' => ['nullable', 'string', 'max:32'],
        ];
    }
}
// app/Http/Controllers/Api/V1/DeviceTokenController.php
namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Http\Requests\StoreDeviceTokenRequest;
use App\Models\DeviceToken;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

class DeviceTokenController extends Controller
{
    public function store(StoreDeviceTokenRequest $request): Response
    {
        DeviceToken::updateOrCreate(
            ['token' => $request->validated('token')],
            [
                ...$request->validated(),
                'user_id' => $request->user()->id,
                'last_seen_at' => now(),
            ],
        );

        return response()->noContent();
    }

    public function destroy(Request $request): Response
    {
        $request->validate(['token' => ['required', 'string']]);

        $request->user()->deviceTokens()
            ->where('token', $request->input('token'))
            ->delete();

        return response()->noContent();
    }
}
// routes/api.php
use App\Http\Controllers\Api\V1\DeviceTokenController;

Route::prefix('v1')->middleware('auth:sanctum')->group(function () {
    Route::post('device-tokens', [DeviceTokenController::class, 'store']);
    Route::delete('device-tokens', [DeviceTokenController::class, 'destroy']);
});

last_seen_at обновляется при каждом запуске приложения. По нему видно «живые» устройства, а токены, которые не обновлялись, например, полгода, можно чистить по расписанию.

Шаг 3. Отправка уведомлений из Laravel

Отправку строим из четырёх частей:

  • PushMessage — что отправляем (заголовок, текст, данные);
  • PushSender — интерфейс «отправь сообщение на этот токен» с двумя реализациями: FCM и RuStore;
  • PushChannel — свой канал для стандартных уведомлений Laravel: выбирает отправщика по провайдеру токена и удаляет мёртвые токены;
  • само уведомление, например WorkoutReminder, которое ставится в очередь.

Так бизнес-код пишет привычное $user->notify(...) и ничего не знает про Firebase и RuStore, а смена провайдера затрагивает один класс.

Настройки

// config/services.php
'fcm' => [
    'project_id' => env('FCM_PROJECT_ID'),
    'credentials' => env('FCM_CREDENTIALS'), // абсолютный путь к JSON сервисного аккаунта
],

'rustore_push' => [
    'project_id' => env('RUSTORE_PUSH_PROJECT_ID'),
    'service_token' => env('RUSTORE_PUSH_SERVICE_TOKEN'),
],
# .env на сервере (в репозиторий не коммитим)
FCM_PROJECT_ID=fitness-club-app
FCM_CREDENTIALS=/var/www/secrets/firebase-service-account.json
RUSTORE_PUSH_PROJECT_ID=...
RUSTORE_PUSH_SERVICE_TOKEN=...

Сообщение и интерфейс отправщика

// app/Services/Push/PushMessage.php
namespace App\Services\Push;

final readonly class PushMessage
{
    /**
     * @param  array<string, string>  $data  только строки: так требуют и FCM, и RuStore
     */
    public function __construct(
        public string $title,
        public string $body,
        public array $data = [],
    ) {}
}
// app/Services/Push/PushSender.php
namespace App\Services\Push;

interface PushSender
{
    /**
     * @throws InvalidPushToken если сервис доставки сообщил, что токен больше не действует
     */
    public function send(string $token, PushMessage $message): void;
}
// app/Services/Push/InvalidPushToken.php
namespace App\Services\Push;

use RuntimeException;

class InvalidPushToken extends RuntimeException {}

Отправка через FCM (iOS)

FCM принимает запросы по HTTP v1 API. Авторизация — OAuth 2.0 токен доступа, который получается из JSON сервисного аккаунта. Для этого есть официальная библиотека Google:

composer require google/auth

Токен доступа живёт час, поэтому кешируем его на 50 минут, а не запрашиваем на каждое уведомление.

// app/Services/Push/FcmSender.php
namespace App\Services\Push;

use Google\Auth\Credentials\ServiceAccountCredentials;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

final class FcmSender implements PushSender
{
    public function send(string $token, PushMessage $message): void
    {
        $payload = [
            'token' => $token,
            'notification' => ['title' => $message->title, 'body' => $message->body],
            'apns' => ['payload' => ['aps' => ['sound' => 'default']]],
        ];

        if ($message->data !== []) {
            $payload['data'] = $message->data;
        }

        $response = Http::withToken($this->accessToken())
            ->post(sprintf(
                'https://fcm.googleapis.com/v1/projects/%s/messages:send',
                config('services.fcm.project_id'),
            ), ['message' => $payload]);

        // 404 UNREGISTERED — приложение удалено или токен устарел.
        if ($response->notFound()) {
            throw new InvalidPushToken($token);
        }

        $response->throw();
    }

    private function accessToken(): string
    {
        return Cache::remember('push.fcm.access_token', now()->addMinutes(50), function (): string {
            $credentials = new ServiceAccountCredentials(
                'https://www.googleapis.com/auth/firebase.messaging',
                json_decode(file_get_contents(config('services.fcm.credentials')), true, flags: JSON_THROW_ON_ERROR),
            );

            return $credentials->fetchAuthToken()['access_token'];
        });
    }
}

Если не хочется писать отправку руками, есть готовые пакеты: kreait/laravel-firebase и канал laravel-notification-channels/fcm. Свой класс на 40 строк проще понять и отладить, а для RuStore готового канала всё равно нет — подход получается единым для обоих провайдеров.

Отправка через RuStore Push (Android)

API RuStore Push повторяет формат FCM: тот же messages:send, тот же объект message с token, notification и data. Авторизация проще — постоянный сервисный токен из консоли в заголовке Authorization: Bearer.

// app/Services/Push/RuStoreSender.php
namespace App\Services\Push;

use Illuminate\Support\Facades\Http;

final class RuStoreSender implements PushSender
{
    public function send(string $token, PushMessage $message): void
    {
        $payload = [
            'token' => $token,
            'notification' => ['title' => $message->title, 'body' => $message->body],
        ];

        if ($message->data !== []) {
            $payload['data'] = $message->data;
        }

        $response = Http::withToken(config('services.rustore_push.service_token'))
            ->post(sprintf(
                'https://vkpns.rustore.ru/v1/projects/%s/messages:send',
                config('services.rustore_push.project_id'),
            ), ['message' => $payload]);

        // 404 NOT_FOUND — токен неверный или истёк.
        if ($response->notFound()) {
            throw new InvalidPushToken($token);
        }

        $response->throw();
    }
}

Ограничения RuStore Push, которые стоит знать: размер сообщения — до 4096 байт, без title в notification SDK уведомление не покажет, а при превышении лимита запросов API отвечает статусом TOO_MANY_REQUESTS.

Канал уведомлений

Свой канал — это обычный класс с методом send(). Laravel вызывает его, если уведомление вернуло этот класс из via().

// app/Notifications/Channels/PushChannel.php
namespace App\Notifications\Channels;

use App\Enums\PushProvider;
use App\Models\User;
use App\Notifications\Contracts\HasPushMessage;
use App\Services\Push\FcmSender;
use App\Services\Push\InvalidPushToken;
use App\Services\Push\RuStoreSender;

final class PushChannel
{
    public function __construct(
        private readonly FcmSender $fcm,
        private readonly RuStoreSender $ruStore,
    ) {}

    public function send(User $notifiable, HasPushMessage $notification): void
    {
        $message = $notification->toPush($notifiable);

        foreach ($notifiable->deviceTokens as $device) {
            $sender = match ($device->provider) {
                PushProvider::Fcm => $this->fcm,
                PushProvider::RuStore => $this->ruStore,
            };

            try {
                $sender->send($device->token, $message);
            } catch (InvalidPushToken) {
                $device->delete();
            }
        }
    }
}
// app/Notifications/Contracts/HasPushMessage.php
namespace App\Notifications\Contracts;

use App\Models\User;
use App\Services\Push\PushMessage;

interface HasPushMessage
{
    public function toPush(User $notifiable): PushMessage;
}

Удаление токена по ответу 404 — обязательная часть, а не оптимизация. Без неё таблица со временем наполняется мёртвыми токенами, каждая рассылка тратит запросы впустую, а сервисы доставки могут ограничивать отправителей с большой долей ошибок.

Уведомление и очередь

// app/Notifications/WorkoutReminder.php
namespace App\Notifications;

use App\Models\User;
use App\Models\Workout;
use App\Notifications\Channels\PushChannel;
use App\Notifications\Contracts\HasPushMessage;
use App\Services\Push\PushMessage;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;

class WorkoutReminder extends Notification implements HasPushMessage, ShouldQueue
{
    use Queueable;

    public int $tries = 3;

    public function __construct(private readonly Workout $workout) {}

    /** @return list<int> пауза перед повторами, секунды */
    public function backoff(): array
    {
        return [10, 60];
    }

    public function via(object $notifiable): array
    {
        return [PushChannel::class];
    }

    public function toPush(User $notifiable): PushMessage
    {
        return new PushMessage(
            title: 'Тренировка через 2 часа',
            body: sprintf('%s в %s', $this->workout->title, $this->workout->starts_at->format('H:i')),
            data: ['route' => '/workouts/'.$this->workout->id],
        );
    }
}

Отправка из любого места кода:

$user->notify(new WorkoutReminder($workout));

ShouldQueue здесь принципиален. Запрос к FCM или RuStore — это сетевой вызов на сотни миллисекунд, который может упасть по таймауту. В очереди он не тормозит ответ API и автоматически повторяется при временной ошибке (tries и backoff). На сервере нужен постоянно работающий воркер — php artisan queue:work под Supervisor или systemd. Сами напоминания «за два часа» запускаются командой по расписанию, например Schedule::command('workouts:send-reminders')->everyFiveMinutes() в routes/console.php.

Ограничение этой простой схемы: при повторе задача заново отправит уведомление на все устройства пользователя, включая те, куда оно уже дошло. Для напоминаний это приемлемо. Для массовых рассылок на тысячи устройств лучше ставить в очередь отдельную задачу на каждый токен (или пачку токенов) и учитывать лимиты запросов сервисов.

Шаг 4. Flutter-приложение

Зависимости и общий интерфейс

flutter pub add firebase_core firebase_messaging flutter_rustore_push permission_handler

Код экранов не должен знать, какой сервис доставки работает на этом устройстве. Поэтому делаем интерфейс и две реализации:

// lib/push/push_service.dart
abstract interface class PushService {
  /// Значение для поля `provider` в API: `fcm` или `rustore`.
  String get provider;

  /// Спрашивает разрешение. `false` — пользователь отказал или push недоступен.
  Future<bool> requestPermission();

  Future<String?> getToken();

  Stream<String> get onTokenRefresh;

  /// Данные (`data`) уведомления, по нажатию на которое открыли приложение.
  Stream<Map<String, String>> get onOpened;

  /// То же для холодного старта: приложение было закрыто и запущено нажатием.
  Future<Map<String, String>?> initialMessage();
}

Map<String, String> stringMap(Map<Object?, Object?> raw) => {
  for (final entry in raw.entries)
    if (entry.key != null && entry.value != null) '${entry.key}': '${entry.value}',
};

iOS: Firebase Messaging

Подготовка проекта:

  1. flutterfire configure --platforms=ios — создаст lib/firebase_options.dart и GoogleService-Info.plist (нужен FlutterFire CLI).
  2. В Xcode → Runner → Signing & Capabilities добавьте Push Notifications и Background Modes с галочкой Remote notifications.
// lib/push/firebase_push_service.dart
import 'package:firebase_messaging/firebase_messaging.dart';

import 'push_service.dart';

class FirebasePushService implements PushService {
  final _messaging = FirebaseMessaging.instance;

  @override
  String get provider => 'fcm';

  @override
  Future<bool> requestPermission() async {
    final settings = await _messaging.requestPermission(alert: true, badge: true, sound: true);
    // Без этого iOS не показывает баннер, пока приложение открыто.
    await _messaging.setForegroundNotificationPresentationOptions(alert: true, badge: true, sound: true);

    return settings.authorizationStatus == AuthorizationStatus.authorized ||
        settings.authorizationStatus == AuthorizationStatus.provisional;
  }

  @override
  Future<String?> getToken() async {
    // На iOS FCM-токен выдаётся только после APNs-токена, а он приходит с задержкой.
    for (var attempt = 0; attempt < 5; attempt++) {
      if (await _messaging.getAPNSToken() != null) {
        return _messaging.getToken();
      }
      await Future<void>.delayed(const Duration(seconds: 1));
    }
    return null;
  }

  @override
  Stream<String> get onTokenRefresh => _messaging.onTokenRefresh;

  @override
  Stream<Map<String, String>> get onOpened =>
      FirebaseMessaging.onMessageOpenedApp.map((message) => stringMap(message.data));

  @override
  Future<Map<String, String>?> initialMessage() async {
    final message = await _messaging.getInitialMessage();
    return message == null ? null : stringMap(message.data);
  }
}

Android: RuStore Push

В android/app/src/main/AndroidManifest.xml внутри <application> укажите ID проекта из консоли RuStore, а вне <application> — разрешение на уведомления для Android 13+:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

    <application ...>
        <meta-data
            android:name="ru.rustore.sdk.pushclient.project_id"
            android:value="ВАШ_ID_ПРОЕКТА" />
    </application>
</manifest>

Если в release-сборке включена минификация (R8), добавьте правило ProGuard из документации SDK — иначе сборка может работать в debug и молча ломаться в release.

// lib/push/rustore_push_service.dart
import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:flutter_rustore_push/flutter_rustore_push.dart';
import 'package:permission_handler/permission_handler.dart';

import 'push_service.dart';

class RuStorePushService implements PushService {
  RuStorePushService() {
    RustorePushClient.attachCallbacks(
      onNewToken: (token) => _tokens.add('$token'),
      onMessageReceived: (message) {},
      onDeletedMessages: () {},
      onError: (error) => debugPrint('RuStore Push: $error'),
      onMessageOpenedApp: (message) => _opened.add(stringMap(message.data)),
    );
  }

  final _tokens = StreamController<String>.broadcast();
  final _opened = StreamController<Map<String, String>>.broadcast();

  @override
  String get provider => 'rustore';

  @override
  Future<bool> requestPermission() async {
    try {
      // false, если RuStore не установлен или устарел.
      if (!await RustorePushClient.available()) return false;
    } catch (error) {
      debugPrint('RuStore Push недоступен: $error');
      return false;
    }
    // На Android 12 и ниже разрешение выдано по умолчанию — вернётся granted.
    return (await Permission.notification.request()).isGranted;
  }

  @override
  Future<String?> getToken() => RustorePushClient.getToken();

  @override
  Stream<String> get onTokenRefresh => _tokens.stream;

  @override
  Stream<Map<String, String>> get onOpened => _opened.stream;

  @override
  Future<Map<String, String>?> initialMessage() async {
    final message = await RustorePushClient.getInitialMessage();
    return message == null ? null : stringMap(message.data);
  }
}

Если в сообщении заполнен notification, RuStore SDK сам показывает уведомление в шторке. Если нужно, чтобы приложение решило само (например, не показывать уведомление о сообщении в чате, который сейчас открыт), отправляйте только data, а показ делайте в onMessageReceived — например, через flutter_local_notifications.

Регистрация токена на сервере

Отдельный класс связывает push-сервис с API. Ошибки здесь не должны ронять приложение: push — дополнительная функция, без него приложение обязано работать.

// lib/push/push_registrar.dart
import 'dart:async';
import 'dart:io' show Platform;

import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';

import 'push_service.dart';

class PushRegistrar {
  PushRegistrar(this._push, this._dio, this._appVersion);

  final PushService _push;
  final Dio _dio;
  final String _appVersion;
  StreamSubscription<String>? _refresh;
  String? _token;

  /// Вызывать после входа и при каждом запуске, если пользователь уже вошёл.
  Future<void> register() async {
    try {
      if (!await _push.requestPermission()) return;
      final token = await _push.getToken();
      if (token != null) await _send(token);
      _refresh ??= _push.onTokenRefresh.listen(_send);
    } catch (error) {
      debugPrint('Push: не удалось зарегистрировать токен: $error');
    }
  }

  /// Вызывать при выходе, ДО удаления токена авторизации.
  Future<void> unregister() async {
    await _refresh?.cancel();
    _refresh = null;
    final token = _token;
    if (token == null) return;
    try {
      await _dio.delete('/v1/device-tokens', data: {'token': token});
    } catch (error) {
      debugPrint('Push: не удалось удалить токен: $error');
    }
    _token = null;
  }

  Future<void> _send(String token) async {
    _token = token;
    await _dio.post('/v1/device-tokens', data: {
      'token': token,
      'provider': _push.provider,
      'platform': Platform.isIOS ? 'ios' : 'android',
      'app_version': _appVersion,
    });
  }
}

Порядок в unregister() важен: удалить токен на сервере можно только пока пользователь ещё авторизован. Если сначала стереть токен Sanctum, запрос DELETE получит 401, и бывший пользователь продолжит получать уведомления на этот телефон.

Сборка всего вместе и переход по нажатию

// lib/main.dart (фрагмент)
import 'dart:io' show Platform;

import 'package:firebase_core/firebase_core.dart';

import 'firebase_options.dart';
import 'push/firebase_push_service.dart';
import 'push/push_service.dart';
import 'push/rustore_push_service.dart';

Future<PushService> createPushService() async {
  if (Platform.isIOS) {
    await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
    return FirebasePushService();
  }
  return RuStorePushService();
}

Выбор по Platform.isIOS работает, пока Android-сборка одна — для RuStore. Когда появится сборка для Google Play, выбор переносят на признак сборки: flavor или --dart-define=STORE=google_play.

Переход по нажатию: сервер кладёт в data маршрут, приложение открывает его через роутер. Принимать стоит только известные маршруты, а не любую строку из сообщения:

// lib/push/push_navigation.dart
import 'package:go_router/go_router.dart';

import 'push_service.dart';

final _allowedRoute = RegExp(r'^/workouts/\d+$');

Future<void> listenPushTaps(PushService push, GoRouter router) async {
  void open(Map<String, String>? data) {
    final route = data?['route'];
    if (route != null && _allowedRoute.hasMatch(route)) router.push(route);
  }

  open(await push.initialMessage()); // холодный старт
  push.onOpened.listen(open);        // приложение было в фоне
}

Когда спрашивать разрешение

Спрашивать разрешение на первом же экране — худший вариант: человек ещё не понял, зачем ему уведомления, и нажимает «Не разрешать». На iOS системный диалог показывается один раз; после отказа включить уведомления можно только в настройках телефона. Лучше показать свой экран-объяснение в момент, когда польза очевидна, — например, сразу после записи на первую тренировку: «Напомнить за 2 часа до начала?» — и только после «Да» вызывать requestPermission().

Шаг 5. Проверка

Ручная отправка

Сначала убедитесь, что доставка работает без вашего бэкенда. Для RuStore — прямой запрос с токеном, который приложение вывело в лог:

curl -X POST "https://vkpns.rustore.ru/v1/projects/$PROJECT_ID/messages:send" \
  -H "Authorization: Bearer $SERVICE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"message":{"token":"ТОКЕН_УСТРОЙСТВА","notification":{"title":"Проверка","body":"Push работает"}}}'

Успешный ответ — 200 и пустой объект {}. Для iOS тестовое сообщение удобно отправить из консоли Firebase: Messaging → создать кампанию → Send test message и вставить FCM-токен.

Затем — через Laravel, например из php artisan tinker:

User::find(1)->notify(new App\Notifications\WorkoutReminder(App\Models\Workout::first()));

Проверяйте три состояния приложения: открыто, свёрнуто, полностью закрыто. Это три разных пути обработки, и ошибки обычно прячутся в одном из них. Надёжнее всего проверять на реальных устройствах, причём на iOS — и на сборке из TestFlight: она использует production-окружение APNs, а сборка из Xcode — sandbox.

Автотесты на Pest

Внешние API в тестах подменяются через Http::fake(). Самое ценное, что стоит покрыть, — удаление мёртвых токенов и выбор провайдера:

use App\Enums\PushProvider;
use App\Models\User;
use App\Models\Workout;
use App\Notifications\WorkoutReminder;
use Illuminate\Support\Facades\Http;

beforeEach(function () {
    config([
        'services.rustore_push.project_id' => 'test-project',
        'services.rustore_push.service_token' => 'test-token',
    ]);
});

it('sends reminder through RuStore Push', function () {
    Http::fake(['vkpns.rustore.ru/*' => Http::response([], 200)]);
    $user = User::factory()->create();
    $user->deviceTokens()->create(['provider' => PushProvider::RuStore, 'platform' => 'android', 'token' => 'alive']);

    $user->notify(new WorkoutReminder(Workout::factory()->create()));

    Http::assertSent(fn ($request) => $request['message']['token'] === 'alive'
        && $request->hasHeader('Authorization', 'Bearer test-token'));
});

it('deletes token rejected by RuStore', function () {
    Http::fake(['vkpns.rustore.ru/*' => Http::response(['error' => ['code' => 404, 'status' => 'NOT_FOUND']], 404)]);
    $user = User::factory()->create();
    $user->deviceTokens()->create(['provider' => PushProvider::RuStore, 'platform' => 'android', 'token' => 'stale']);

    $user->notify(new WorkoutReminder(Workout::factory()->create()));

    expect($user->deviceTokens()->count())->toBe(0);
});

В тестах очередь обычно работает синхронно (QUEUE_CONNECTION=sync в phpunit.xml), поэтому уведомление отправляется сразу. Для кода, который только решает, кому отправить, удобнее Notification::fake() и Notification::assertSentTo().

Безопасность и правила магазинов

  • Секреты — только на сервере. JSON сервисного аккаунта Firebase и сервисный токен RuStore дают право слать уведомления всем вашим пользователям. В приложение попадают только ID проекта и публичный GoogleService-Info.plist — это нормально.
  • Не кладите в push чувствительные данные. Текст виден на заблокированном экране и проходит через серверы Apple, Google или VK. «У вас новое сообщение» вместо текста сообщения, «Заказ оплачен» вместо суммы и адреса.
  • Маркетинговые push — только с явного согласия. Правила App Store (пункт 4.5.4 App Review Guidelines) запрещают делать уведомления обязательными для работы приложения и требуют отдельного согласия на рекламные рассылки с возможностью отказаться. Удобно хранить настройки по типам: «Напоминания о тренировках» включены, «Акции клуба» — по желанию.
  • Проверяйте data в приложении. Маршрут из уведомления — внешние данные; открывайте только известные экраны (пример с RegExp выше).

Типичные ошибки

Симптом Частая причина Что сделать
На iOS getToken() возвращает null или падает Нет capability Push Notifications, APNs-токен ещё не пришёл Добавить capability в Xcode и App ID, подождать getAPNSToken()
В TestFlight push не приходят, из Xcode — приходят В Firebase загружен сертификат только для sandbox Использовать ключ .p8: он работает для обоих окружений
На iOS нет баннера, пока приложение открыто Так по умолчанию setForegroundNotificationPresentationOptions(alert: true, ...)
RuStore не выдаёт токен Отпечаток SHA-256 в консоли не совпадает с ключом подписи Сверить keytool -printcert -jarfile с проектом в консоли
RuStore-уведомления приходят с большой задержкой У приложения RuStore нет разрешения на работу в фоне Подсказать пользователю выдать RuStore разрешение на фоновую работу
На Android 13+ ничего не показывается Не запрошено POST_NOTIFICATIONS Запросить разрешение во время работы приложения
Работает в debug, не работает в release Минификация вырезала классы SDK Добавить правило ProGuard из документации
Уведомления приходят бывшему пользователю Токен не удалён при выходе Вызывать DELETE /device-tokens до сброса авторизации
Таблица токенов растёт, ошибок отправки всё больше Не удаляются токены после ответа 404 Ловить InvalidPushToken и удалять запись
Ответ API приходит долго, иногда 500 Отправка push внутри HTTP-запроса Уведомление с ShouldQueue и работающий воркер очереди

Итог: чек-лист запуска

  1. Apple: ключ APNs .p8, capability Push Notifications у App ID и в Xcode, Background Modes → Remote notifications.
  2. Firebase: проект с iOS-приложением, загружен ключ .p8, на сервере — JSON сервисного аккаунта вне репозитория.
  3. RuStore: проект в разделе «Push-уведомления» с правильным applicationId и SHA-256 ключа release-подписи; ID проекта — в манифесте, сервисный токен — в .env сервера.
  4. Laravel: таблица device_tokens с уникальным токеном и провайдером, API POST/DELETE /api/v1/device-tokens, отправщики FCM и RuStore за общим интерфейсом, свой канал уведомлений, удаление токенов по 404.
  5. Очередь: уведомления с ShouldQueue, повторы с паузой, воркер под Supervisor или systemd.
  6. Flutter: один интерфейс PushService и две реализации; токен отправляется при каждом запуске и при обновлении, удаляется при выходе до сброса авторизации.
  7. UX: разрешение спрашиваем в момент, когда польза понятна; маркетинговые уведомления — отдельным согласием.
  8. Переход по нажатию: маршрут в data, проверка по списку разрешённых экранов, обработка холодного старта и фона.
  9. Проверка: ручная отправка через curl и консоль Firebase, три состояния приложения на реальных устройствах, сборка из TestFlight, автотесты с Http::fake().

Главная мысль: push-уведомления — это не одна интеграция, а две независимые половины. Приложение отвечает за разрешение и актуальный токен, сервер — за хранение токенов, очередь и уборку мёртвых адресов. Когда эти обязанности чётко разделены, добавить новый канал (Google Play, прямой APNs или Huawei Push Kit) — это ещё один класс-отправщик и одна реализация PushService, а не переписывание всей системы.