From fbe808b935c76f7a8441d01ef72da4d1682c89e1 Mon Sep 17 00:00:00 2001 From: Dwight Watson Date: Sun, 16 Nov 2025 18:57:37 +1000 Subject: [PATCH 1/3] Introduce FcmTopicChannel --- README.md | 12 +++ composer.json | 1 + src/FcmChannel.php | 12 +-- src/FcmMessage.php | 21 ++-- src/FcmTopicChannel.php | 59 +++++++++++ src/Resources/FcmResource.php | 3 - tests/FcmChannelTest.php | 10 +- tests/FcmMessageTest.php | 28 ++--- tests/FcmTopicChannelTest.php | 188 ++++++++++++++++++++++++++++++++++ 9 files changed, 292 insertions(+), 42 deletions(-) create mode 100644 src/FcmTopicChannel.php create mode 100644 tests/FcmTopicChannelTest.php diff --git a/README.md b/README.md index c06348f..786ec22 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ This package makes it easy to send notifications using [Firebase Cloud Messaging - [Setting up the FCM service](#setting-up-the-fcm-service) - [Usage](#usage) - [Available message methods](#available-message-methods) + - [Topic notifications](#topic-notifications) - [Custom clients](#custom-clients) - [Handling errors](#handling-errors) - [Changelog](#changelog) @@ -156,6 +157,17 @@ FcmMessage::create() ->custom(['notification' => []]); ``` +## Topic notifications + +You can also send FCM notifications to topics with the `FcmTopicChannel`. Use an on-demand notification to to route the notification to the topic, or you can set the topic on the message itself in the `toFcm` method. + +```php +use NotificationChannels\Fcm\FcmTopicChannel; + +Notification::route(FcmTopicChannel::class, 'news') + ->notify(new BlogCreated($blog)); +``` + ## Custom clients You can change the underlying Firebase Messaging client on the fly if required. Provide an instance of `Kreait\Firebase\Contract\Messaging` to your FCM message and it will be used in place of the default client. diff --git a/composer.json b/composer.json index cb6c473..5e7cc52 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ "kreait/laravel-firebase": "^6.0" }, "require-dev": { + "laravel/pint": "^1.25", "mockery/mockery": "^1.6.0", "phpunit/phpunit": "^11.0" }, diff --git a/src/FcmChannel.php b/src/FcmChannel.php index b0d667d..0e91824 100644 --- a/src/FcmChannel.php +++ b/src/FcmChannel.php @@ -14,11 +14,9 @@ class FcmChannel { /** - * The maximum number of tokens we can use in a single request - * - * @var int + * The maximum number of tokens we can use in a single request. */ - const TOKENS_PER_REQUEST = 500; + const int TOKENS_PER_REQUEST = 500; /** * Create a new channel instance. @@ -39,11 +37,13 @@ public function send(mixed $notifiable, Notification $notification): ?Collection return null; } - $fcmMessage = $notification->toFcm($notifiable); + $message = $notification->toFcm($notifiable); + + $client = $message->client ?? $this->client; return Collection::make($tokens) ->chunk(self::TOKENS_PER_REQUEST) - ->map(fn ($tokens) => ($fcmMessage->client ?? $this->client)->sendMulticast($fcmMessage, $tokens->all())) + ->map(fn ($tokens) => $client->sendMulticast($message, $tokens->all())) ->map(fn (MulticastSendReport $report) => $this->checkReportForFailures($notifiable, $notification, $report)); } diff --git a/src/FcmMessage.php b/src/FcmMessage.php index 5062231..7788e37 100644 --- a/src/FcmMessage.php +++ b/src/FcmMessage.php @@ -21,7 +21,7 @@ public function __construct( public ?string $topic = null, public ?string $condition = null, public ?array $data = [], - public array $custom = [], + public ?array $custom = [], public ?Notification $notification = null, public ?Messaging $client = null, ) { @@ -81,7 +81,7 @@ public function condition(?string $condition): self */ public function data(?array $data): self { - if (! empty(array_filter($data, fn($value) => ! is_string($value)))) { + if (! empty(array_filter($data, fn ($value) => ! is_string($value)))) { throw new InvalidArgumentException('Data values must be strings.'); } @@ -103,12 +103,9 @@ public function custom(?array $custom = []): self /** * Set Aandroid specific custom options. */ - public function android(array $options = []): self + public function android(?array $options = []): self { - $this->custom([ - ...$this->custom, - 'android' => $options, - ]); + $this->custom([...$this->custom, 'android' => $options]); return $this; } @@ -116,13 +113,9 @@ public function android(array $options = []): self /** * Set APNs-specific custom options. */ - public function ios(array $options = []): self + public function ios(?array $options = []): self { - $this->custom([ - ...$this->custom, - 'apns' => $options, - 'apns' => $options, - ]); + $this->custom([...$this->custom, 'apns' => $options]); return $this; } @@ -150,7 +143,7 @@ public function usingClient(Messaging $client): self /** * Get the array represenation of the message. */ - public function toArray() + public function toArray(): array { return array_filter([ 'name' => $this->name, diff --git a/src/FcmTopicChannel.php b/src/FcmTopicChannel.php new file mode 100644 index 0000000..23f4be8 --- /dev/null +++ b/src/FcmTopicChannel.php @@ -0,0 +1,59 @@ +toFcm($notifiable); + + $topic = $notifiable instanceof AnonymousNotifiable + ? $notifiable->routeNotificationFor('fcm-topic') + : $message->topic; + + if (empty($topic)) { + return null; + } + + $message->topic($topic); + + $client = $message->client ?? $this->client; + + try { + return $client->send($message); + } catch (MessagingException $e) { + $this->dispatchFailedNotification($notifiable, $notification, $e); + + return null; + } + } + + /** + * Dispatch failed event. + */ + protected function dispatchFailedNotification(mixed $notifiable, Notification $notification, MessagingException $exception): void + { + $this->events->dispatch(new NotificationFailed($notifiable, $notification, self::class, [ + 'exception' => $exception, + ])); + } +} diff --git a/src/Resources/FcmResource.php b/src/Resources/FcmResource.php index 3c9b9e5..a6e41de 100644 --- a/src/Resources/FcmResource.php +++ b/src/Resources/FcmResource.php @@ -4,9 +4,6 @@ abstract class FcmResource { - /** - * @return static - */ public static function create(...$args): static { return new static(...$args); diff --git a/tests/FcmChannelTest.php b/tests/FcmChannelTest.php index 132262e..a034d4d 100644 --- a/tests/FcmChannelTest.php +++ b/tests/FcmChannelTest.php @@ -18,12 +18,12 @@ class FcmChannelTest extends TestCase { - public function tearDown(): void + protected function tearDown(): void { Mockery::close(); } - public function test_it_can_be_instantiated() + public function test_it_can_be_instantiated(): void { $events = Mockery::mock(Dispatcher::class); $firebase = Mockery::mock(Messaging::class); @@ -33,7 +33,7 @@ public function test_it_can_be_instantiated() $this->assertInstanceOf(FcmChannel::class, $channel); } - public function test_it_can_send_notifications() + public function test_it_can_send_notifications(): void { $events = Mockery::mock(Dispatcher::class); $events->shouldNotReceive('dispatch'); @@ -53,7 +53,7 @@ public function test_it_can_send_notifications() $this->assertInstanceOf(MulticastSendReport::class, $result->first()); } - public function test_it_can_send_notifications_with_custom_client() + public function test_it_can_send_notifications_with_custom_client(): void { $events = Mockery::mock(Dispatcher::class); $events->shouldNotReceive('dispatch'); @@ -75,7 +75,7 @@ public function test_it_can_send_notifications_with_custom_client() $this->assertInstanceOf(Collection::class, $result); } - public function test_it_can_dispatch_events() + public function test_it_can_dispatch_events(): void { $events = Mockery::mock(Dispatcher::class); $events->shouldReceive('dispatch')->once(); diff --git a/tests/FcmMessageTest.php b/tests/FcmMessageTest.php index 0736475..97655d3 100644 --- a/tests/FcmMessageTest.php +++ b/tests/FcmMessageTest.php @@ -10,7 +10,7 @@ class FcmMessageTest extends TestCase { - public function test_it_can_be_instantiated() + public function test_it_can_be_instantiated(): void { $message = new FcmMessage(name: 'name'); @@ -19,7 +19,7 @@ public function test_it_can_be_instantiated() $this->assertEquals('name', $message->name); } - public function test_it_can_be_created() + public function test_it_can_be_created(): void { $message = FcmMessage::create(name: 'name'); @@ -28,49 +28,49 @@ public function test_it_can_be_created() $this->assertEquals('name', $message->name); } - public function test_it_can_set_name() + public function test_it_can_set_name(): void { $message = FcmMessage::create()->name('name'); $this->assertEquals(['name' => 'name'], $message->toArray()); } - public function test_it_can_set_token() + public function test_it_can_set_token(): void { $message = FcmMessage::create()->token('token'); $this->assertEquals(['token' => 'token'], $message->toArray()); } - public function test_it_can_set_topic() + public function test_it_can_set_topic(): void { $message = FcmMessage::create()->topic('topic'); $this->assertEquals(['topic' => 'topic'], $message->toArray()); } - public function test_it_can_set_condition() + public function test_it_can_set_condition(): void { $message = FcmMessage::create()->condition('condition'); $this->assertEquals(['condition' => 'condition'], $message->toArray()); } - public function test_it_can_set_data() + public function test_it_can_set_data(): void { $message = FcmMessage::create()->data(['a' => 'b']); $this->assertEquals(['data' => ['a' => 'b']], $message->toArray()); } - public function test_it_throws_exception_on_invalid_data() + public function test_it_throws_exception_on_invalid_data(): void { $this->expectException(\InvalidArgumentException::class); FcmMessage::create()->data(['a' => 1]); } - public function test_it_can_set_custom_attributes() + public function test_it_can_set_custom_attributes(): void { $message = FcmMessage::create() ->name('name') @@ -90,7 +90,7 @@ public function test_it_can_set_custom_attributes() $this->assertEquals($expected, $message->toArray()); } - public function test_it_can_set_notification() + public function test_it_can_set_notification(): void { $notification = Notification::create()->title('title'); @@ -101,7 +101,7 @@ public function test_it_can_set_notification() ], $message->toArray()); } - public function test_it_can_set_client() + public function test_it_can_set_client(): void { $client = Mockery::mock(Messaging::class); @@ -110,7 +110,7 @@ public function test_it_can_set_client() $this->assertSame($client, $message->client); } - public function test_appends_android_options_into_custom() + public function test_appends_android_options_into_custom(): void { $message = FcmMessage::create() ->notification(new Notification(title: 'title', body: 'body')) @@ -123,7 +123,7 @@ public function test_appends_android_options_into_custom() $this->assertEquals('channel_id', $payload['android']['notification']['channel_id']); } - public function test_appends_ios_options_into_custom() + public function test_appends_ios_options_into_custom(): void { $message = FcmMessage::create() ->ios(['payload' => ['aps' => ['sound' => 'sound']]]); @@ -135,7 +135,7 @@ public function test_appends_ios_options_into_custom() $this->assertEquals('sound', $payload['apns']['payload']['aps']['sound']); } - public function test_preserves_existing_custom_keys_when_using_helpers() + public function test_preserves_existing_custom_keys_when_using_helpers(): void { $message = FcmMessage::create() ->custom(['meta' => ['a' => 1]]) diff --git a/tests/FcmTopicChannelTest.php b/tests/FcmTopicChannelTest.php new file mode 100644 index 0000000..29efd50 --- /dev/null +++ b/tests/FcmTopicChannelTest.php @@ -0,0 +1,188 @@ +assertInstanceOf(FcmTopicChannel::class, $channel); + } + + public function test_it_can_send_notifications_with_on_demand_anonymous_notifiable(): void + { + $events = Mockery::mock(Dispatcher::class); + $events->shouldNotReceive('dispatch'); + + $response = ['response' => 'array']; + + $firebase = Mockery::mock(Messaging::class); + $firebase->shouldReceive('send') + ->once() + ->with(Mockery::on(fn ($message) => $message instanceof FcmMessage && $message->topic === 'news')) + ->andReturn($response); + + $channel = new FcmTopicChannel($events, $firebase); + + $notifiable = new AnonymousNotifiable; + $notifiable->route('fcm-topic', 'news'); + + $result = $channel->send($notifiable, new TopicNotification); + + $this->assertEquals($response, $result); + } + + public function test_it_can_send_notifications_with_topic_on_message(): void + { + $events = Mockery::mock(Dispatcher::class); + $events->shouldNotReceive('dispatch'); + + $response = ['response' => 'array']; + + $firebase = Mockery::mock(Messaging::class); + $firebase->shouldReceive('send') + ->once() + ->with(Mockery::on(fn ($message) => $message instanceof FcmMessage && $message->topic === 'sports')) + ->andReturn($response); + + $channel = new FcmTopicChannel($events, $firebase); + + $notifiable = new TopicNotifiableWithMessage; + + $result = $channel->send($notifiable, new TopicNotificationWithTopic); + + $this->assertEquals($response, $result); + } + + public function test_it_can_send_notifications_with_custom_client(): void + { + $events = Mockery::mock(Dispatcher::class); + $events->shouldNotReceive('dispatch'); + + $defaultFirebase = Mockery::mock(Messaging::class); + $defaultFirebase->shouldNotReceive('send'); + + $response = ['response' => 'array']; + + $customFirebase = Mockery::mock(Messaging::class); + $customFirebase->shouldReceive('send') + ->once() + ->with(Mockery::on(fn ($message) => $message instanceof FcmMessage && $message->topic === 'breaking-news')) + ->andReturn($response); + + $channel = new FcmTopicChannel($events, $defaultFirebase); + + $notifiable = new AnonymousNotifiable; + $notifiable->route('fcm-topic', 'breaking-news'); + + $result = $channel->send($notifiable, new TopicNotificationWithCustomClient($customFirebase)); + + $this->assertEquals($response, $result); + } + + public function test_it_dispatches_notification_failed_event_when_exception_is_thrown(): void + { + $events = Mockery::mock(Dispatcher::class); + $events->shouldReceive('dispatch') + ->once() + ->with(Mockery::on(function ($event) { + return $event instanceof NotificationFailed + && $event->channel === FcmTopicChannel::class + && isset($event->data['exception']) + && $event->data['exception'] instanceof MessagingException; + })); + + $firebase = Mockery::mock(Messaging::class); + $firebase->shouldReceive('send') + ->once() + ->andThrow(new MessagingError('Test error')); + + $channel = new FcmTopicChannel($events, $firebase); + + $notifiable = new AnonymousNotifiable; + $notifiable->route('fcm-topic', 'error-topic'); + + $result = $channel->send($notifiable, new TopicNotification); + + $this->assertNull($result); + } + + public function test_it_returns_null_when_no_topic_is_provided(): void + { + $events = Mockery::mock(Dispatcher::class); + $events->shouldNotReceive('dispatch'); + + $firebase = Mockery::mock(Messaging::class); + $firebase->shouldNotReceive('send'); + + $channel = new FcmTopicChannel($events, $firebase); + + // Not routing to fcm-topic + $notifiable = new AnonymousNotifiable; + + $result = $channel->send($notifiable, new TopicNotification); + + $this->assertNull($result); + } +} + +class TopicNotification extends Notification +{ + public function toFcm($notifiable) + { + return FcmMessage::create(); + } +} + +class TopicNotificationWithTopic extends Notification +{ + public function toFcm($notifiable) + { + return FcmMessage::create()->topic('sports'); + } +} + +class TopicNotificationWithCustomClient extends Notification +{ + public function __construct(public Messaging $client) + { + // + } + + public function toFcm($notifiable) + { + return FcmMessage::create()->usingClient($this->client); + } +} + +class TopicNotifiableWithMessage +{ + public function routeNotificationFor($channel, $notification = null) + { + // This simulates a model-based notifiable that doesn't use AnonymousNotifiable + // The topic will come from the message itself + return null; + } +} From 12fc0d3d53b2e6badd024650c941a5bce8657839 Mon Sep 17 00:00:00 2001 From: Dwight Watson Date: Sun, 16 Nov 2025 22:08:17 +1100 Subject: [PATCH 2/3] Update versions --- .github/workflows/run-tests.yml | 4 ++-- composer.json | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 75f1b04..887f785 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -11,8 +11,8 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - php: [8.2, 8.3, 8.4] - laravel: ['11.*', '12.*'] + php: [8.3, 8.4, 8.5] + laravel: ["12.*"] stability: [prefer-lowest, prefer-stable] name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} diff --git a/composer.json b/composer.json index 5e7cc52..5224091 100644 --- a/composer.json +++ b/composer.json @@ -12,16 +12,16 @@ } ], "require": { - "php": "^8.2", + "php": "^8.3", "guzzlehttp/guzzle": "^7.0", - "illuminate/notifications": "^11.0|^12.0", - "illuminate/support": "^11.0|^12.0", + "illuminate/notifications": "^12.0", + "illuminate/support": "^12.0", "kreait/laravel-firebase": "^6.0" }, "require-dev": { "laravel/pint": "^1.25", "mockery/mockery": "^1.6.0", - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.0" }, "autoload": { "psr-4": { From bacbdec3bb611915d389ee88c7bf7000f93fc1c3 Mon Sep 17 00:00:00 2001 From: Dwight Watson Date: Sun, 16 Nov 2025 22:11:20 +1100 Subject: [PATCH 3/3] Don't test against PHP 8.5 yet --- .github/workflows/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 887f785..8f1a1da 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -11,7 +11,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - php: [8.3, 8.4, 8.5] + php: [8.3, 8.4] laravel: ["12.*"] stability: [prefer-lowest, prefer-stable]