tokensFor($notifiable, $notification); if ($tokens->isEmpty()) { return; } $payload = $this->payloadFor($notifiable, $notification); $tokens ->chunk(self::MAX_MESSAGES_PER_REQUEST) ->each(fn (Collection $chunk) => $this->sendChunk($chunk, $payload)); } private function tokensFor($notifiable, Notification $notification): Collection { $tokens = method_exists($notifiable, 'routeNotificationFor') ? $notifiable->routeNotificationFor('expoPush', $notification) : null; return collect($tokens) ->filter(fn ($token) => is_string($token) && $token !== '') ->unique() ->values(); } private function payloadFor($notifiable, Notification $notification): array { if (! method_exists($notification, 'toExpoPush')) { throw new InvalidArgumentException(sprintf( 'Notification [%s] is missing a toExpoPush($notifiable) method.', $notification::class, )); } $payload = $notification->toExpoPush($notifiable); if ($payload instanceof Arrayable) { $payload = $payload->toArray(); } if (! is_array($payload)) { throw new InvalidArgumentException(sprintf( 'Notification [%s] must return an array or Arrayable from toExpoPush($notifiable).', $notification::class, )); } return collect($payload) ->except('to') ->reject(fn ($value) => $value === null) ->all(); } private function sendChunk(Collection $tokens, array $payload): void { $messages = $tokens ->map(fn (string $token) => ['to' => $token] + $payload) ->values() ->all(); try { $response = Http::acceptJson() ->asJson() ->timeout(10) ->connectTimeout(5) ->when(config('services.expo.access_token'), fn ($request, string $accessToken) => $request->withToken($accessToken)) ->retry( 3, 250, fn ($exception) => $this->shouldRetry($exception), throw: false, ) ->post(self::EXPO_PUSH_ENDPOINT, $messages); } catch (ConnectionException $exception) { Log::warning('Unable to connect to Expo Push API.', [ 'exception' => $exception->getMessage(), 'tokens_count' => $tokens->count(), ]); return; } if ($response->failed()) { Log::warning('Expo Push API rejected a notification request.', [ 'status' => $response->status(), 'response' => $response->json(), 'tokens_count' => $tokens->count(), ]); return; } $body = $response->json(); $this->handleTickets(is_array($body) ? $body : null, $tokens); } private function shouldRetry($exception): bool { if ($exception instanceof ConnectionException) { return true; } if (! $exception instanceof RequestException) { return false; } return $exception->response->status() === 429 || $exception->response->serverError(); } private function handleTickets(?array $response, Collection $tokens): void { foreach (Arr::get($response, 'data', []) as $index => $ticket) { if (($ticket['status'] ?? null) !== 'error') { continue; } $token = $tokens->values()->get($index); $error = Arr::get($ticket, 'details.error'); if ($error === 'DeviceNotRegistered' && is_string($token)) { DeviceToken::query() ->where('expo_push_token', $token) ->delete(); } Log::warning('Expo Push API rejected a notification.', [ 'error' => $error, 'message' => $ticket['message'] ?? null, 'token' => $token, ]); } } }