Notifiche Push su iOS ad App Chiusa (Killata) e in Background: Guida Completa da A a Z con Docker e Laravel

Uno dei problemi più frequenti e frustranti nello sviluppo di applicazioni iOS native con backend personalizzato è il seguente: le notifiche arrivano perfettamente quando l'app è aperta o in background, ma smettono completamente di arrivare quando l'app viene chiusa dall'utente (killata tramite swipe-up nell'App Switcher).

In questa guida analizziamo nel dettaglio l'architettura tecnica del sistema di notifiche Apple APNs, la configurazione completa di un ambiente backend containerizzato con Docker, Laravel, PostgreSQL e Redis, e il codice Swift necessario per garantire la ricezione istantanea dei banner e l'aggiornamento del badge numerico su qualsiasi dispositivo fisico.

1. Il Ciclo di Vita iOS: Perché le Notifiche Locali Falliscono ad App Chiusa

Per comprendere la radice del problema, bisogna distinguere i tre stati in cui può trovarsi un'app iOS:

  • Foreground (In-App): Il processo dell'app è attivo e in esecuzione. Qualsiasi timer o richiesta HTTP polling riceve i dati ed emette un banner locale o aggiorna la UI in tempo reale.
  • Background Sospeso: L'utente è uscito dall'app premendo la barra Home ma non l'ha terminata. Il processo rimane allocato nella memoria RAM per qualche minuto. Le notifiche locali e le routine di background fetch possono ancora funzionare temporaneamente.
  • Terminated / Killed (App Chiusa con Swipe-Up): Il sistema operativo iOS termina completamente il processo dell'app. Nessun timer, thread asincrono o listener WebSockets può funzionare. In questo stato, l'unica entità al mondo capace di accendere lo schermo dell'iPhone, mostrare il banner sulla Lock Screen e incrementare il pallino rosso (Badge) è il server APNs di Apple a Cupertino.
Regola Fondamentale

Ad app chiusa, la notifica DEVE partire dal tuo backend ed essere consegnata ad Apple tramite connessione diretta HTTP/2 con chiave di autenticazione .p8. Se il backend non possiede il device_token reale del dispositivo, Apple non potrà recapitare alcun messaggio.

2. Architettura Backend Containerizzata con Docker

La soluzione ideale per orchestrare il backend è un'infrastruttura containerizzata riproducibile composta da PHP 8.3 FPM con cURL HTTP/2, un web server Nginx, un database PostgreSQL e una coda Redis.

Esempio di docker-compose.yml:

version: '3.8'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: laravel_backend
    restart: unless-stopped
    working_dir: /var/www
    volumes:
      - ./:/var/www
      - ./storage/keys:/var/www/storage/keys:ro
    environment:
      - DB_CONNECTION=pgsql
      - DB_HOST=db
      - DB_PORT=5432
      - DB_DATABASE=app_production_db
      - DB_USERNAME=app_user
      - DB_PASSWORD=secret_db_password
      - APNS_KEY_ID=YOUR_KEY_ID
      - APNS_TEAM_ID=YOUR_TEAM_ID
      - APNS_BUNDLE_ID=com.example.app
      - APNS_PRIVATE_KEY_PATH=/var/www/storage/keys/AuthKey_APNS.p8
      - APNS_PRODUCTION=false # true per TestFlight e App Store

  nginx:
    image: nginx:alpine
    container_name: laravel_nginx
    restart: unless-stopped
    ports:
      - "8001:80"
    volumes:
      - ./:/var/www
      - ./docker/nginx/conf.d:/etc/nginx/conf.d

  db:
    image: postgres:16-alpine
    container_name: laravel_postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: app_production_db
      POSTGRES_USER: app_user
      POSTGRES_PASSWORD: secret_db_password
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

3. La Trappola di BadDeviceToken: Sandbox vs. Production APNs

Il motivo principale per cui molte integrazioni falliscono durante i test su dispositivi fisici è il disallineamento tra il gateway di sviluppo (Sandbox) e il gateway di produzione:

  • Build Xcode Debug (cavo o Wi-Fi locale): iOS genera un Token Sandbox. Se il server invia la notifica a https://api.push.apple.com (Production), Apple restituisce immediatamente:
    HTTP 400: {"reason":"BadDeviceToken"}
    e rifiuta la notifica.
  • Build TestFlight & App Store: iOS genera un Token di Produzione, che viene accettato esclusivamente da https://api.push.apple.com.

Implementazione del Doppio Tentativo Automatico (Auto-Fallback) in Laravel:

<?php

namespace App\Services;

use App\Models\User;
use Illuminate\Support\Facades\Log;

class ApnsService
{
    public static function sendToUser(User $user, string $title, string $body, array $customData = []): bool
    {
        if (empty($user->apns_token)) {
            Log::info("APNs: Utente #{$user->id} non ha un device token registrato.");
            return false;
        }

        $badge = $user->unreadNotificationsCount();

        $payload = [
            'aps' => [
                'alert' => [
                    'title' => $title,
                    'body' => $body,
                ],
                'badge' => $badge,
                'sound' => 'default',
                'content-available' => 1,
                'mutable-content' => 1,
                'category' => 'INTERACTION_CATEGORY'
            ]
        ];

        foreach ($customData as $key => $value) {
            $payload[$key] = $value;
        }

        return self::dispatchApnsPayload($user->apns_token, $payload);
    }

    private static function dispatchApnsPayload(string $deviceToken, array $payload): bool
    {
        $keyId = env('APNS_KEY_ID');
        $teamId = env('APNS_TEAM_ID');
        $bundleId = env('APNS_BUNDLE_ID');
        $keyPath = env('APNS_PRIVATE_KEY_PATH');
        $isProd = env('APNS_PRODUCTION', false);

        $privateKey = file_get_contents($keyPath);
        $jwt = self::generateJwtToken($keyId, $teamId, $privateKey);

        // Ordine degli endpoint con fallback automatico
        $endpoints = $isProd
            ? ["https://api.push.apple.com/3/device/{$deviceToken}", "https://api.sandbox.push.apple.com/3/device/{$deviceToken}"]
            : ["https://api.sandbox.push.apple.com/3/device/{$deviceToken}", "https://api.push.apple.com/3/device/{$deviceToken}"];

        foreach ($endpoints as $url) {
            $ch = curl_init();
            curl_setopt_array($ch, [
                CURLOPT_URL => $url,
                CURLOPT_PORT => 443,
                CURLOPT_HTTPHEADER => [
                    "apns-topic: {$bundleId}",
                    "apns-push-type: alert",
                    "apns-priority: 10",
                    "apns-expiration: 0",
                    "authorization: bearer {$jwt}",
                    "content-type: application/json"
                ],
                CURLOPT_POST => true,
                CURLOPT_POSTFIELDS => json_encode($payload),
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_TIMEOUT => 8,
                CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2_0
            ]);

            $response = curl_exec($ch);
            $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
            curl_close($ch);

            if ($httpCode === 200) {
                Log::info("✅ Notifica APNs consegnata con successo via {$url}");
                return true;
            }
        }

        return false;
    }

    private static function generateJwtToken(string $keyId, string $teamId, string $privateKey): string
    {
        $header = json_encode(['alg' => 'ES256', 'kid' => $keyId]);
        $claims = json_encode(['iss' => $teamId, 'iat' => time()]);

        $encode = fn($data) => rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
        $signaturePayload = $encode($header) . '.' . $encode($claims);

        openssl_sign($signaturePayload, $signature, $privateKey, 'sha256');

        return $signaturePayload . '.' . $encode($signature);
    }
}

4. Implementazione Client iOS con SwiftUI & Entitlements

Sul versante client, l'applicazione deve richiedere l'autorizzazione di sistema, registrare il device token nel ciclo di vita dell'AppDelegate e trasmetterlo tempestivamente alle API del backend.

1. Configurazione Info.plist & Entitlements:

<!-- Info.plist -->
<key>UIBackgroundModes</key>
<array>
    <string>remote-notification</string>
    <string>fetch</string>
    <string>audio</string>
    <string>voip</string>
</array>

<!-- App.entitlements -->
<key>aps-environment</key>
<string>development</string> <!-- automatico con Xcode -->

2. Registrazione e Azzeramento Badge in Swift:

import SwiftUI
import UserNotifications

@MainActor
public final class PushManager: ObservableObject {
    public static let shared = PushManager()
    
    public func registerDeviceToken(_ deviceToken: Data) {
        let tokenHex = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
        UserDefaults.standard.set(tokenHex, forKey: "saved_apns_token")
        
        // Invio al backend Laravel
        Task {
            try? await ApiClient.shared.post("/api/notifications/device-token", body: ["token": tokenHex])
        }
    }

    public func clearBadgeCount() {
        UNUserNotificationCenter.current().setBadgeCount(0)
        UIApplication.shared.applicationIconBadgeNumber = 0
        Task {
            try? await ApiClient.shared.post("/api/notifications/read-all")
        }
    }
}

5. Test Rapido del Funzionamento da Riga di Comando

Per validare istantaneamente se la chiave .p8 e il token registrato comunicano correttamente con i server di Apple, puoi lanciare il comando direttamente nel container Docker senza bisogno di attendere un evento in-app:

docker exec -it laravel_backend php artisan tinker --execute="var_dump(App\Services\ApnsService::sendToUser(App\Models\User::find(1), 'Test da Docker', 'Notifica push ad app chiusa funzionante!'));"

Se il comando restituisce bool(true) e i log registrano HTTP 200 via api.sandbox.push.apple.com, la notifica push apparirà immediatamente sulla schermata di blocco dell'iPhone anche se l'app è stata rimossa dall'App Switcher.

Sviluppo Backend Mobile & Cloud su Misura

Vuoi integrare un'infrastruttura di notifiche affidabile e scalabile?

Progettiamo e sviluppiamo architetture cloud scalabili con Docker, Laravel, WebSocket e integrazioni APNs/VoIP native per startup e aziende.

Diallo Yunus

Diallo Yunus

Sviluppatore iOS Indie & Consulente Cloud

Sviluppatore professionista con oltre 11 app pubblicate su Apple App Store e referente per la monetizzazione digitale internazionale. Con base a Parma (PR), Italia e partnership operativa con Group DMS in Africa.