├── .github
└── workflows
│ ├── styling.yml
│ ├── tests.yml
│ └── update-changelog.yml
├── CHANGELOG.md
├── LICENSE.md
├── README.md
├── composer.json
├── config
└── expo-notifications.php
├── database
└── migrations
│ ├── 2022_11_09_092443_create_expo_tokens_table.php
│ ├── 2022_11_10_141415_create_expo_notifications_table.php
│ ├── 2022_11_10_141416_create_expo_tickets_table.php
│ └── 2022_12_21_091846_add_unique_to_expo_tokens.php
├── phpunit.xml
├── pint.json
└── src
├── Commands
├── CheckTickets.php
└── SendPendingNotifications.php
├── Contracts
├── ExpoNotificationsServiceInterface.php
├── ExpoPendingNotificationStorageInterface.php
├── ExpoTicketStorageInterface.php
└── ExpoTokenStorageInterface.php
├── Dto
├── ExpoMessage.php
├── ExpoNotification.php
├── ExpoTicket.php
├── ExpoToken.php
├── PushReceiptResponse.php
└── PushTicketResponse.php
├── Enums
└── ExpoResponseStatus.php
├── Events
└── InvalidExpoToken.php
├── Exceptions
└── ExpoNotificationsException.php
├── ExpoNotificationsChannel.php
├── ExpoNotificationsService.php
├── ExpoNotificationsServiceProvider.php
├── FakeExpoNotificationsService.php
├── Jobs
├── CheckTickets.php
└── SendPendingNotifications.php
├── Listeners
└── DeleteInvalidExpoToken.php
├── Models
├── ExpoNotification.php
├── ExpoTicket.php
└── ExpoToken.php
└── Storage
├── ExpoPendingNotificationStorageMysql.php
├── ExpoTicketStorageMysql.php
└── ExpoTokenStorageMysql.php
/.github/workflows/styling.yml:
--------------------------------------------------------------------------------
1 | name: Check & fix styling
2 |
3 | on: [ push ]
4 |
5 | jobs:
6 | styling:
7 | runs-on: ubuntu-latest
8 |
9 | steps:
10 | - name: Checkout code
11 | uses: actions/checkout@v2
12 | with:
13 | ref: ${{ github.head_ref }}
14 |
15 | - name: Laravel Pint
16 | uses: aglipanci/laravel-pint-action@1.0.0
17 | with:
18 | testMode: true
19 |
--------------------------------------------------------------------------------
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
1 | name: tests
2 |
3 | on:
4 | push:
5 | pull_request:
6 |
7 | jobs:
8 | test:
9 | if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository
10 | runs-on: ubuntu-latest
11 | strategy:
12 | fail-fast: false
13 | matrix:
14 | php: ["8.4", "8.3", "8.2", "8.1"]
15 | laravel: ["^12.0", "^11.0", "^10.0", "^9.0"]
16 | dependency-version: [ prefer-lowest, prefer-stable ]
17 | exclude:
18 | - laravel: "^12.0"
19 | php: "8.1"
20 | - laravel: "^11.0"
21 | php: "8.1"
22 | include:
23 | - laravel: "^12.0"
24 | testbench: 10.*
25 | - laravel: "^11.0"
26 | testbench: 9.*
27 | - laravel: "^10.0"
28 | testbench: 8.*
29 | - laravel: "^9.0"
30 | testbench: 7.*
31 |
32 | name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }}
33 |
34 | steps:
35 | - name: Checkout code
36 | uses: actions/checkout@v4
37 |
38 | - name: Cache dependencies
39 | uses: actions/cache@v4
40 | with:
41 | path: ~/.composer/cache/files
42 | key: dependencies-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }}
43 |
44 | - name: Setup PHP
45 | uses: shivammathur/setup-php@v2
46 | with:
47 | php-version: ${{ matrix.php }}
48 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, mysql, mysqli, pdo_mysql, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo
49 | coverage: none
50 |
51 | - name: Install dependencies
52 | run: |
53 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" "symfony/console:>=4.3.4" --dev --no-interaction --no-update
54 | composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction
55 |
56 | - name: Execute tests
57 | run: vendor/bin/pest
58 |
--------------------------------------------------------------------------------
/.github/workflows/update-changelog.yml:
--------------------------------------------------------------------------------
1 | name: "Update Changelog"
2 |
3 | on:
4 | release:
5 | types: [ released ]
6 |
7 | jobs:
8 | update:
9 | runs-on: ubuntu-latest
10 |
11 | steps:
12 | - name: Checkout code
13 | uses: actions/checkout@v2
14 | with:
15 | ref: main
16 |
17 | - name: Update Changelog
18 | uses: stefanzweifel/changelog-updater-action@v1
19 | with:
20 | latest-version: ${{ github.event.release.name }}
21 | release-notes: ${{ github.event.release.body }}
22 |
23 | - name: Commit updated CHANGELOG
24 | uses: stefanzweifel/git-auto-commit-action@v4
25 | with:
26 | branch: main
27 | commit_message: Update CHANGELOG
28 | file_pattern: CHANGELOG.md
29 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to `laravel-expo-notifier` will be documented in this file.
4 |
5 | Updates should follow the [Keep a CHANGELOG](http://keepachangelog.com/) principles.
6 |
7 | ## 0.0.17 - 2025-04-09
8 |
9 | ### What's Changed
10 |
11 | * Update ExpoMessage.php by @rustemsarica in https://github.com/YieldStudio/laravel-expo-notifier/pull/39
12 |
13 | ### New Contributors
14 |
15 | * @rustemsarica made their first contribution in https://github.com/YieldStudio/laravel-expo-notifier/pull/39
16 |
17 | **Full Changelog**: https://github.com/YieldStudio/laravel-expo-notifier/compare/0.0.16...0.0.17
18 |
19 | ## 0.0.16 - 2025-04-09
20 |
21 | ### What's Changed
22 |
23 | * fix migration by @albertogferrario in https://github.com/YieldStudio/laravel-expo-notifier/pull/38
24 | * feat: Adds support for Expo access token by @lcrespin in https://github.com/YieldStudio/laravel-expo-notifier/pull/37
25 | * feat: Adds support for Laravel 12 by @dtangdev in https://github.com/YieldStudio/laravel-expo-notifier/pull/40
26 |
27 | ### New Contributors
28 |
29 | * @albertogferrario made their first contribution in https://github.com/YieldStudio/laravel-expo-notifier/pull/38
30 | * @lcrespin made their first contribution in https://github.com/YieldStudio/laravel-expo-notifier/pull/37
31 |
32 | **Full Changelog**: https://github.com/YieldStudio/laravel-expo-notifier/compare/0.0.15...0.0.16
33 |
34 | ## 0.0.15 - 2024-06-14
35 |
36 | ### What's Changed
37 |
38 | * fix: prevent duplicate ticket_id received from expo by @dtangdev in https://github.com/YieldStudio/laravel-expo-notifier/pull/34
39 |
40 | **Full Changelog**: https://github.com/YieldStudio/laravel-expo-notifier/compare/0.0.14...0.0.15
41 |
42 | ## 0.0.13 - 2024-03-28
43 |
44 | ### What's Changed
45 |
46 | * fix: set jsonData when creating ExpoMessage from JSON by @joemugen in https://github.com/YieldStudio/laravel-expo-notifier/pull/30
47 | * feat: adds support for legacy FPM API use by @joemugen in https://github.com/YieldStudio/laravel-expo-notifier/pull/31
48 |
49 | **Full Changelog**: https://github.com/YieldStudio/laravel-expo-notifier/compare/0.0.12...0.0.13
50 |
51 | ## 0.0.12 - 2024-03-07
52 |
53 | ### What's Changed
54 |
55 | * feat(config): makes the push notifications per request limit configurable by @joemugen in https://github.com/YieldStudio/laravel-expo-notifier/pull/26
56 |
57 | **Full Changelog**: https://github.com/YieldStudio/laravel-expo-notifier/compare/0.0.11...0.0.12
58 |
59 | ## 0.0.11 - 2024-02-16
60 |
61 | ### What's Changed
62 |
63 | * bugfix: adds tests and fix bug sending more than 100 not batched notifications by @joemugen in https://github.com/YieldStudio/laravel-expo-notifier/pull/25
64 |
65 | ### New Contributors
66 |
67 | * @joemugen made their first contribution in https://github.com/YieldStudio/laravel-expo-notifier/pull/25
68 |
69 | **Full Changelog**: https://github.com/YieldStudio/laravel-expo-notifier/compare/0.0.10...0.0.11
70 |
71 | ## 0.0.10 - 2023-11-15
72 |
73 | ### What's Changed
74 |
75 | - add missing params to json_decode by @dtangdev in https://github.com/YieldStudio/laravel-expo-notifier/pull/23
76 |
77 | **Full Changelog**: https://github.com/YieldStudio/laravel-expo-notifier/compare/0.0.9...0.0.10
78 |
79 | ## 0.0.9 - 2023-11-15
80 |
81 | ### What's Changed
82 |
83 | - Manage instable responseItem details format from API by @dtangdev in https://github.com/YieldStudio/laravel-expo-notifier/pull/21
84 |
85 | **Full Changelog**: https://github.com/YieldStudio/laravel-expo-notifier/compare/0.0.8...0.0.9
86 |
87 | ## 0.0.8 - 2023-11-14
88 |
89 | ### What's Changed
90 |
91 | - Update README.md to explicitly say publishing the configuration is a must by @andonovn in https://github.com/YieldStudio/laravel-expo-notifier/pull/17
92 | - Prevent json_decode on responseItem details which is array by @eightyfive in https://github.com/YieldStudio/laravel-expo-notifier/pull/20
93 |
94 | ### New Contributors
95 |
96 | - @andonovn made their first contribution in https://github.com/YieldStudio/laravel-expo-notifier/pull/17
97 |
98 | **Full Changelog**: https://github.com/YieldStudio/laravel-expo-notifier/compare/0.0.7...0.0.8
99 |
100 | ## 0.0.7 - 2023-05-29
101 |
102 | ### What's Changed
103 |
104 | - Laravel 10 support by @JamesHemery in https://github.com/YieldStudio/laravel-expo-notifier/pull/16
105 |
106 | **Full Changelog**: https://github.com/YieldStudio/laravel-expo-notifier/compare/0.0.6...0.0.7
107 |
108 | ## 0.0.6 - 2022-12-21
109 |
110 | ### What's Changed
111 |
112 | - fix: dto id type by @JamesHemery in https://github.com/YieldStudio/laravel-expo-notifier/pull/15
113 |
114 | **Full Changelog**: https://github.com/YieldStudio/laravel-expo-notifier/compare/0.0.5...0.0.6
115 |
116 | ## 0.0.5 - 2022-12-21
117 |
118 | ### What's Changed
119 |
120 | - chore: #2 replace cs fixer by pint by @JamesHemery in https://github.com/YieldStudio/laravel-expo-notifier/pull/13
121 | - fix: #11 unique token owner by @JamesHemery in https://github.com/YieldStudio/laravel-expo-notifier/pull/12
122 |
123 | **Full Changelog**: https://github.com/YieldStudio/laravel-expo-notifier/compare/0.0.4...0.0.5
124 |
125 | ## 0.0.4 - 2022-12-13
126 |
127 | ### What's Changed
128 |
129 | - Delete behavior that batch notifications by default
130 | - Delete UrgentExpoNotificationInterface
131 | - Add shouldBatch method on ExpoMessage
132 | - Refactor internal sending
133 | - Add InvalidExpoToken event and DeleteInvalidExpoToken listener (optionnal)
134 |
135 | **Full Changelog**: https://github.com/YieldStudio/laravel-expo-notifier/compare/0.0.3...0.0.4
136 |
137 | ## 0.0.3 - 2022-12-12
138 |
139 | ### What's Changed
140 |
141 | - Add subtitle to ExpoMessage
142 | - Add mutableContent to ExpoMessage
143 | - Fix ExpoMessage serialization
144 | - Channel no longer send notifications if `to` is empty
145 | - Fix notify method of service to serialize messages as Expo message object
146 |
147 | **Full Changelog**: https://github.com/YieldStudio/laravel-expo-notifier/compare/0.0.2...0.0.3
148 |
149 | ## 0.0.2 - 2022-11-23
150 |
151 | ### What's Changed
152 |
153 | - Add possibility to delete one value by @dtangdev in https://github.com/YieldStudio/laravel-expo-notifier/pull/5
154 |
155 | **Full Changelog**: https://github.com/YieldStudio/laravel-expo-notifier/compare/0.0.1...0.0.2
156 |
157 | ## 0.0.1 - 2022-11-18
158 |
159 | ### What's Changed
160 |
161 | - First release 🎉
162 | - Automatically batch non-urgent notifications
163 | - Check push receipts to clear bad tokens
164 |
165 | ### New Contributors
166 |
167 | - @dtangdev made their first contribution in https://github.com/YieldStudio/laravel-expo-notifier/pull/1
168 |
169 | **Full Changelog**: https://github.com/YieldStudio/laravel-expo-notifier/commits/0.0.1
170 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Yield Studio
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # laravel-expo-notifier
2 |
3 | Easily manage Expo notifications with Laravel. Support batched notifications.
4 |
5 | [](https://github.com/yieldstudio/laravel-expo-notifier/releases)
6 | [](https://github.com/yieldstudio/laravel-expo-notifier/actions/workflows/tests.yml)
7 | [](https://packagist.org/packages/yieldstudio/laravel-expo-notifier)
8 |
9 | > Major version zero (0.y.z) is for initial development. Anything MAY change at any time. The public API SHOULD NOT be considered stable.
10 |
11 | ## Installation
12 |
13 | composer require yieldstudio/laravel-expo-notifier
14 |
15 | ## Configure
16 |
17 | You must publish the configuration file with:
18 |
19 | ```shell
20 | php artisan vendor:publish --provider="YieldStudio\LaravelExpoNotifier\ExpoNotificationsServiceProvider" --tag="expo-notifications-config" --tag="expo-notifications-migration"
21 | ```
22 |
23 | ### Available environment variables
24 | - `EXPO_PUSH_NOTIFICATIONS_PER_REQUEST_LIMIT` : sets the max notifications sent on a bulk request. [The official documentation says the limit should be 100](https://docs.expo.dev/push-notifications/sending-notifications/#request-errors) but in fact it's failing. You can tweak it by setting a value under 100.
25 | - `EXPO_NOTIFICATIONS_ACCESS_TOKEN` : sets the Expo access token used to send notifications with an [additional layer of security](https://docs.expo.dev/push-notifications/sending-notifications/#additional-security). You can get your access token from [your Expo dashboard](https://expo.dev/accounts/[account]/settings/access-tokens).
26 |
27 | ## Usage
28 |
29 | ### Send notification
30 |
31 | ```php
32 | to([$notifiable->expoTokens->value])
51 | ->title('A beautiful title')
52 | ->body('This is a content')
53 | ->channelId('default');
54 | }
55 | }
56 | ```
57 |
58 | ### Commands usage
59 |
60 | Send database pending notifications
61 | ```
62 | php artisan expo:notifications:send
63 | ```
64 |
65 | Clean tickets from outdated tokens
66 | ```
67 | php artisan expo:tickets:check
68 | ```
69 |
70 | You may create schedules to execute these commands.
71 |
72 | ### Batch support
73 |
74 | You can send notification in the next batch :
75 | ```php
76 | (new ExpoMessage())
77 | ->to([$notifiable->expoTokens->value])
78 | ->title('A beautiful title')
79 | ->body('This is a content')
80 | ->channelId('default')
81 | ->shouldBatch();
82 | ```
83 |
84 | Don't forget to schedule the `expo:notifications:send` command.
85 |
86 | ## Unit tests
87 |
88 | To run the tests, just run `composer install` and `composer test`.
89 |
90 | ## Changelog
91 |
92 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently.
93 |
94 | ## Contributing
95 |
96 | Please see [CONTRIBUTING](https://raw.githubusercontent.com/YieldStudio/.github/main/CONTRIBUTING.md) for details.
97 |
98 | ### Security
99 |
100 | If you've found a bug regarding security please mail [contact@yieldstudio.fr](mailto:contact@yieldstudio.fr) instead of using the issue tracker.
101 |
102 | ## Credits
103 |
104 | - [David Tang](https://github.com/dtangdev)
105 | - [James Hemery](https://github.com/jameshemery)
106 |
107 | ## License
108 |
109 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information.
110 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "yieldstudio/laravel-expo-notifier",
3 | "description": "Easily send Expo notifications with Laravel.",
4 | "type": "plugin",
5 | "keywords": [
6 | "yieldstudio",
7 | "laravel",
8 | "notifier",
9 | "notification",
10 | "expo"
11 | ],
12 | "homepage": "https://github.com/YieldStudio/laravel-expo-notifier",
13 | "license": "MIT",
14 | "authors": [
15 | {
16 | "name": "James Hemery",
17 | "email": "james@yieldstudio.fr",
18 | "homepage": "https://yieldstudio.fr",
19 | "role": "Developer"
20 | },
21 | {
22 | "name": "David Tang",
23 | "email": "david@yieldstudio.fr",
24 | "homepage": "https://yieldstudio.fr",
25 | "role": "Developer"
26 | }
27 | ],
28 | "require": {
29 | "php": "^8.1",
30 | "illuminate/database": "^9.0|^10.0|^11.0|^12.0",
31 | "illuminate/support": "^9.0|^10.0|^11.0|^12.0",
32 | "nesbot/carbon": ">=2.62.1"
33 | },
34 | "require-dev": {
35 | "dg/bypass-finals": "^1.4",
36 | "guzzlehttp/guzzle": "^7.8",
37 | "laravel/pint": "^1.3",
38 | "orchestra/testbench": "^7.0|^8.0|^9.0.2|^10.0",
39 | "pestphp/pest": "^1.21|^2.0|^3.0",
40 | "phpunit/phpunit": "^9.4 || ^10.5 || ^11.0"
41 | },
42 | "autoload": {
43 | "psr-4": {
44 | "YieldStudio\\LaravelExpoNotifier\\": "src"
45 | }
46 | },
47 | "autoload-dev": {
48 | "psr-4": {
49 | "YieldStudio\\LaravelExpoNotifier\\Tests\\": "tests"
50 | }
51 | },
52 | "scripts": {
53 | "format": "pint",
54 | "test": "vendor/bin/pest",
55 | "post-autoload-dump": [
56 | "@php ./vendor/bin/testbench package:discover --ansi"
57 | ]
58 | },
59 | "extra": {
60 | "laravel": {
61 | "providers": [
62 | "YieldStudio\\LaravelExpoNotifier\\ExpoNotificationsServiceProvider"
63 | ]
64 | }
65 | },
66 | "config": {
67 | "sort-packages": true,
68 | "allow-plugins": {
69 | "pestphp/pest-plugin": true
70 | }
71 | },
72 | "minimum-stability": "stable",
73 | "prefer-stable": true
74 | }
75 |
--------------------------------------------------------------------------------
/config/expo-notifications.php:
--------------------------------------------------------------------------------
1 | true,
14 |
15 | 'drivers' => [
16 | 'token' => ExpoTokenStorageMysql::class,
17 | 'ticket' => ExpoTicketStorageMysql::class,
18 | 'notification' => ExpoPendingNotificationStorageMysql::class,
19 | ],
20 | 'database' => [
21 | 'tokens_table_name' => 'expo_tokens',
22 | 'tickets_table_name' => 'expo_tickets',
23 | 'notifications_table_name' => 'expo_notifications',
24 | ],
25 | 'service' => [
26 | 'api_url' => 'https://exp.host/--/api/v2/push',
27 | 'host' => 'exp.host',
28 | // https://docs.expo.dev/push-notifications/sending-notifications/#additional-security
29 | 'access_token' => env('EXPO_NOTIFICATIONS_ACCESS_TOKEN', null),
30 | 'limits' => [
31 | // https://docs.expo.dev/push-notifications/sending-notifications/#request-errors
32 | 'push_notifications_per_request' => (int) env('EXPO_PUSH_NOTIFICATIONS_PER_REQUEST_LIMIT', 99),
33 | ],
34 | // https://expo.dev/blog/expo-adds-support-for-fcm-http-v1-api
35 | 'use_fcm_legacy_api' => (bool) env('EXPO_USE_FCM_LEGACY_API', false),
36 | ],
37 | ];
38 |
--------------------------------------------------------------------------------
/database/migrations/2022_11_09_092443_create_expo_tokens_table.php:
--------------------------------------------------------------------------------
1 | id();
15 |
16 | $table->morphs('owner');
17 | $table->string('value');
18 |
19 | $table->timestamps();
20 | });
21 | }
22 |
23 | public function down(): void
24 | {
25 | Schema::dropIfExists(config('expo-notifications.database.tokens_table_name', 'expo_tokens'));
26 | }
27 | };
28 |
--------------------------------------------------------------------------------
/database/migrations/2022_11_10_141415_create_expo_notifications_table.php:
--------------------------------------------------------------------------------
1 | id();
15 |
16 | $table->json('data');
17 |
18 | $table->timestamps();
19 | });
20 | }
21 |
22 | public function down(): void
23 | {
24 | Schema::dropIfExists(config('expo-notifications.database.notifications_table_name', 'expo_notifications'));
25 | }
26 | };
27 |
--------------------------------------------------------------------------------
/database/migrations/2022_11_10_141416_create_expo_tickets_table.php:
--------------------------------------------------------------------------------
1 | id();
15 |
16 | $table->string('ticket_id')->unique();
17 | $table->string('token');
18 |
19 | $table->timestamps();
20 | });
21 | }
22 |
23 | public function down(): void
24 | {
25 | Schema::dropIfExists(config('expo-notifications.database.tickets_table_name', 'expo_tickets'));
26 | }
27 | };
28 |
--------------------------------------------------------------------------------
/database/migrations/2022_12_21_091846_add_unique_to_expo_tokens.php:
--------------------------------------------------------------------------------
1 | unique(['value', 'owner_type', 'owner_id']);
15 | });
16 | }
17 |
18 | public function down(): void
19 | {
20 | Schema::table(config('expo-notifications.database.tokens_table_name', 'expo_tokens'), function (Blueprint $table) {
21 | $table->dropUnique(['value', 'owner_type', 'owner_id']);
22 | });
23 | }
24 | };
25 |
--------------------------------------------------------------------------------
/phpunit.xml:
--------------------------------------------------------------------------------
1 |
2 |
12 |
13 |
14 | tests
15 |
16 |
17 |
18 |
19 | src/
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/pint.json:
--------------------------------------------------------------------------------
1 | {
2 | "preset": "laravel",
3 | "rules": {
4 | "declare_strict_types": true,
5 | "method_argument_space": {
6 | "on_multiline": "ensure_fully_multiline",
7 | "keep_multiple_spaces_after_comma": true
8 | },
9 | "blank_line_before_statement": {
10 | "statements": [
11 | "break",
12 | "continue",
13 | "declare",
14 | "return",
15 | "throw",
16 | "try"
17 | ]
18 | }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/Commands/CheckTickets.php:
--------------------------------------------------------------------------------
1 |
17 | */
18 | public function retrieve(int $amount = 100): Collection;
19 |
20 | public function delete(array $ids): void;
21 |
22 | public function count(): int;
23 | }
24 |
--------------------------------------------------------------------------------
/src/Contracts/ExpoTicketStorageInterface.php:
--------------------------------------------------------------------------------
1 |
16 | */
17 | public function retrieve(int $amount = 1000): Collection;
18 |
19 | public function delete(string|array $ticketIds): void;
20 |
21 | public function count(): int;
22 | }
23 |
--------------------------------------------------------------------------------
/src/Contracts/ExpoTokenStorageInterface.php:
--------------------------------------------------------------------------------
1 |
14 | * @author David Tang
15 | * @author James Hemery
16 | */
17 | final class ExpoMessage implements Arrayable, Jsonable
18 | {
19 | public array $to;
20 |
21 | public ?string $title = null;
22 |
23 | /** iOS only */
24 | public ?string $subtitle = null;
25 |
26 | public ?string $body = null;
27 |
28 | /** iOS only */
29 | public ?string $sound = null;
30 |
31 | /** iOS only */
32 | public ?int $badge = null;
33 |
34 | public ?int $ttl = null;
35 |
36 | /** Android only */
37 | public ?string $channelId = null;
38 |
39 | public ?string $jsonData = null;
40 |
41 | public string $priority = 'default';
42 |
43 | /** iOS only */
44 | public bool $mutableContent = false;
45 |
46 | public bool $shouldBatch = false;
47 |
48 | public static function create(): ExpoMessage
49 | {
50 | return new ExpoMessage;
51 | }
52 |
53 | public function to(?array $value): self
54 | {
55 | if (is_array($value)) {
56 | $this->to = $value;
57 | } else {
58 | $this->to[] = $value;
59 | }
60 |
61 | return $this;
62 | }
63 |
64 | public function title(?string $value): self
65 | {
66 | $this->title = $value;
67 |
68 | return $this;
69 | }
70 |
71 | public function subtitle(?string $value): self
72 | {
73 | $this->subtitle = $value;
74 |
75 | return $this;
76 | }
77 |
78 | public function body(?string $value): self
79 | {
80 | $this->body = $value;
81 |
82 | return $this;
83 | }
84 |
85 | public function enableSound(): self
86 | {
87 | $this->sound = 'default';
88 |
89 | return $this;
90 | }
91 |
92 | public function sound(string $value): self
93 | {
94 | $this->sound = $value;
95 |
96 | return $this;
97 | }
98 |
99 | public function disableSound(): self
100 | {
101 | $this->sound = null;
102 |
103 | return $this;
104 | }
105 |
106 | public function badge(?int $value): self
107 | {
108 | $this->badge = $value;
109 |
110 | return $this;
111 | }
112 |
113 | public function ttl(?int $ttl): self
114 | {
115 | $this->ttl = $ttl;
116 |
117 | return $this;
118 | }
119 |
120 | public function channelId(?string $channelId): self
121 | {
122 | $this->channelId = $channelId;
123 |
124 | return $this;
125 | }
126 |
127 | public function jsonData(array|string|null $data): self
128 | {
129 | if (is_string($data)) {
130 | // Check JSON validity
131 | json_decode($data, null, 512, JSON_THROW_ON_ERROR);
132 | } elseif (! is_null($data)) {
133 | $data = json_encode($data, JSON_THROW_ON_ERROR);
134 | }
135 |
136 | $this->jsonData = $data;
137 |
138 | return $this;
139 | }
140 |
141 | public function priority(string $priority): self
142 | {
143 | $this->priority = $priority;
144 |
145 | return $this;
146 | }
147 |
148 | public function mutableContent(bool $mutableContent = true): self
149 | {
150 | $this->mutableContent = $mutableContent;
151 |
152 | return $this;
153 | }
154 |
155 | public function shouldBatch(bool $shouldBatch = true): self
156 | {
157 | $this->shouldBatch = $shouldBatch;
158 |
159 | return $this;
160 | }
161 |
162 | public function toArray(): array
163 | {
164 | return [
165 | 'to' => $this->to,
166 | 'title' => $this->title,
167 | 'subtitle' => $this->subtitle,
168 | 'body' => $this->body,
169 | 'sound' => $this->sound,
170 | 'badge' => $this->badge,
171 | 'ttl' => $this->ttl,
172 | 'data' => $this->jsonData,
173 | 'priority' => $this->priority,
174 | 'channelId' => $this->channelId,
175 | 'mutableContent' => $this->mutableContent,
176 | ];
177 | }
178 |
179 | public function toExpoData(): array
180 | {
181 | return array_filter($this->toArray(), fn ($item) => ! is_null($item));
182 | }
183 |
184 | public function toJson($options = JSON_THROW_ON_ERROR): bool|string
185 | {
186 | return json_encode($this->toArray(), $options);
187 | }
188 |
189 | public static function fromJson($jsonData): ExpoMessage
190 | {
191 | $data = json_decode($jsonData, true);
192 |
193 | $expoMessage = new self;
194 | foreach ($data as $key => $value) {
195 | if ($key === 'data') {
196 | $expoMessage->jsonData($value);
197 | }
198 | $expoMessage->{$key} = $value;
199 | }
200 |
201 | return $expoMessage;
202 | }
203 | }
204 |
--------------------------------------------------------------------------------
/src/Dto/ExpoNotification.php:
--------------------------------------------------------------------------------
1 | id($id)
19 | ->message($message);
20 | }
21 |
22 | public function id(int|string $value): self
23 | {
24 | $this->id = $value;
25 |
26 | return $this;
27 | }
28 |
29 | public function message(ExpoMessage $value): self
30 | {
31 | $this->message = $value;
32 |
33 | return $this;
34 | }
35 |
36 | public function toArray(): array
37 | {
38 | return [
39 | 'id' => $this->id,
40 | 'expo_message' => $this->message,
41 | ];
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/Dto/ExpoTicket.php:
--------------------------------------------------------------------------------
1 | id($id)
19 | ->token($token);
20 | }
21 |
22 | public function id(int|string $id): self
23 | {
24 | $this->id = $id;
25 |
26 | return $this;
27 | }
28 |
29 | public function token(string $token): self
30 | {
31 | $this->token = $token;
32 |
33 | return $this;
34 | }
35 |
36 | public function toArray(): array
37 | {
38 | return [
39 | 'id' => $this->id,
40 | 'token' => $this->token,
41 | ];
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/Dto/ExpoToken.php:
--------------------------------------------------------------------------------
1 | id($id)
22 | ->value($value)
23 | ->owner($owner);
24 | }
25 |
26 | public function id(int|string $id): self
27 | {
28 | $this->id = $id;
29 |
30 | return $this;
31 | }
32 |
33 | public function value(string $value): self
34 | {
35 | $this->value = $value;
36 |
37 | return $this;
38 | }
39 |
40 | public function owner(Model $owner): self
41 | {
42 | $this->owner = $owner;
43 |
44 | return $this;
45 | }
46 |
47 | public function toArray(): array
48 | {
49 | return [
50 | 'id' => $this->id,
51 | 'value' => $this->value,
52 | 'owner_type' => get_class($this->owner),
53 | 'owner_id' => $this->owner->getKey(),
54 | ];
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/Dto/PushReceiptResponse.php:
--------------------------------------------------------------------------------
1 | status = $status;
22 |
23 | return $this;
24 | }
25 |
26 | public function id(string $id): self
27 | {
28 | $this->id = $id;
29 |
30 | return $this;
31 | }
32 |
33 | public function message(string $message): self
34 | {
35 | $this->message = $message;
36 |
37 | return $this;
38 | }
39 |
40 | public function details(array $details): self
41 | {
42 | $this->details = $details;
43 |
44 | return $this;
45 | }
46 |
47 | public function toArray(): array
48 | {
49 | $data = [
50 | 'id' => $this->id,
51 | 'status' => $this->status,
52 | ];
53 |
54 | if (filled($this->message)) {
55 | $data['message'] = $this->message;
56 | }
57 |
58 | if (filled($this->details)) {
59 | $data['details'] = $this->details;
60 | }
61 |
62 | return $data;
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/Dto/PushTicketResponse.php:
--------------------------------------------------------------------------------
1 | status = $status;
22 |
23 | return $this;
24 | }
25 |
26 | public function ticketId(string $ticketId): self
27 | {
28 | $this->ticketId = $ticketId;
29 |
30 | return $this;
31 | }
32 |
33 | public function message(string $message): self
34 | {
35 | $this->message = $message;
36 |
37 | return $this;
38 | }
39 |
40 | public function details(array $details): self
41 | {
42 | $this->details = $details;
43 |
44 | return $this;
45 | }
46 |
47 | public function toArray(): array
48 | {
49 | $data = [
50 | 'status' => $this->status,
51 | ];
52 |
53 | if (filled($this->ticketId)) {
54 | $data['ticket_id'] = $this->ticketId;
55 | }
56 |
57 | if (filled($this->message)) {
58 | $data['message'] = $this->message;
59 | }
60 |
61 | if (filled($this->details)) {
62 | $data['details'] = $this->details;
63 | }
64 |
65 | return $data;
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/Enums/ExpoResponseStatus.php:
--------------------------------------------------------------------------------
1 | response->getStatusCode(),
17 | $this->response->getBody()
18 | ));
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/ExpoNotificationsChannel.php:
--------------------------------------------------------------------------------
1 | toExpoNotification($notifiable);
25 | if (empty($expoMessage->to)) {
26 | return;
27 | }
28 |
29 | $this->expoNotificationsService->notify($expoMessage);
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/ExpoNotificationsService.php:
--------------------------------------------------------------------------------
1 | pushNotificationsPerRequestLimit = config('expo-notifications.service.limits.push_notifications_per_request');
48 |
49 | $headers = [
50 | 'host' => $host,
51 | 'accept' => 'application/json',
52 | 'accept-encoding' => 'gzip, deflate',
53 | 'content-type' => 'application/json',
54 | ];
55 |
56 | if ($accessToken) {
57 | $headers['Authorization'] = 'Bearer '.$accessToken;
58 | }
59 |
60 | $this->http = Http::withHeaders($headers)->baseUrl($apiUrl);
61 |
62 | // https://expo.dev/blog/expo-adds-support-for-fcm-http-v1-api
63 | if (config('expo-notifications.service.use_fcm_legacy_api')) {
64 | $this->http->withQueryParameters([
65 | self::USE_FCM_LEGACY_API_QUERY_PARAM => true,
66 | ]);
67 | }
68 |
69 | $this->tickets = collect();
70 | }
71 |
72 | /**
73 | * @param ExpoMessage|ExpoMessage[]|Collection $expoMessages
74 | * @return Collection
75 | */
76 | public function notify(ExpoMessage|Collection|array $expoMessages): Collection
77 | {
78 | /** @var Collection $expoMessages */
79 | $this->expoMessages = $expoMessages instanceof Collection ? $expoMessages : collect(Arr::wrap($expoMessages));
80 |
81 | return $this->storeNotificationsToSendInTheNextBatch()
82 | ->prepareNotificationsToSendNow()
83 | ->sendNotifications();
84 | }
85 |
86 | /**
87 | * @param Collection|array $tokenIds
88 | * @return Collection
89 | *
90 | * @throws ExpoNotificationsException
91 | */
92 | public function receipts(Collection|array $tokenIds): Collection
93 | {
94 | if ($tokenIds instanceof Collection) {
95 | $tokenIds = $tokenIds->toArray();
96 | }
97 |
98 | $response = $this->http->post('/getReceipts', ['ids' => $tokenIds]);
99 | if (! $response->successful()) {
100 | throw new ExpoNotificationsException($response->toPsrResponse());
101 | }
102 |
103 | $data = json_decode($response->body(), true);
104 | if (! empty($data['errors'])) {
105 | throw new ExpoNotificationsException($response->toPsrResponse());
106 | }
107 |
108 | return collect($data['data'])->map(function ($responseItem, $id) {
109 | $data = (new PushReceiptResponse)
110 | ->id($id)
111 | ->status($responseItem['status']);
112 |
113 | if ($responseItem['status'] === ExpoResponseStatus::ERROR->value) {
114 | $responseItemDetails = is_string($responseItem['details']) ? json_decode($responseItem['details'], true) : $responseItem['details'];
115 |
116 | $data
117 | ->message($responseItem['message'])
118 | ->details($responseItemDetails);
119 | }
120 |
121 | return $data;
122 | });
123 | }
124 |
125 | public function getNotificationChunks(): Collection
126 | {
127 | return $this->notificationChunks ?? collect();
128 | }
129 |
130 | /**
131 | * @param Collection $tokens
132 | */
133 | private function checkAndStoreTickets(Collection $tokens): void
134 | {
135 | $this->tickets
136 | ->intersectByKeys($tokens)
137 | ->each(function (PushTicketResponse $ticket, $index) use ($tokens) {
138 | if ($ticket->status === ExpoResponseStatus::ERROR->value) {
139 | if (
140 | is_array($ticket->details) &&
141 | array_key_exists('error', $ticket->details) &&
142 | $ticket->details['error'] === ExpoResponseStatus::DEVICE_NOT_REGISTERED->value
143 | ) {
144 | event(new InvalidExpoToken($tokens->get($index)));
145 | }
146 | } else {
147 | $this->ticketStorage->store($ticket->ticketId, $tokens->get($index));
148 | }
149 | });
150 | }
151 |
152 | private function storeNotificationsToSendInTheNextBatch(): ExpoNotificationsService
153 | {
154 | $this->expoMessages
155 | ->filter(fn (ExpoMessage $message) => $message->shouldBatch)
156 | ->each(fn (ExpoMessage $message) => $this->notificationStorage->store($message));
157 |
158 | return $this;
159 | }
160 |
161 | private function prepareNotificationsToSendNow(): ExpoNotificationsService
162 | {
163 | $this->notificationsToSend = $this->expoMessages
164 | ->reject(fn (ExpoMessage $message) => $message->shouldBatch)
165 | ->map(fn (ExpoMessage $message) => $message->toExpoData())
166 | ->values();
167 |
168 | // Splits into multiples chunks of max limitation
169 | $this->notificationChunks = $this->notificationsToSend->chunk($this->pushNotificationsPerRequestLimit);
170 |
171 | return $this;
172 | }
173 |
174 | private function sendNotifications(): Collection
175 | {
176 | if ($this->notificationsToSend->isEmpty()) {
177 | return collect();
178 | }
179 |
180 | $this->notificationChunks
181 | ->each(
182 | fn ($chunk, $index) => $this->sendNotificationsChunk($chunk->toArray())
183 | );
184 |
185 | $this->checkAndStoreTickets($this->notificationsToSend->pluck('to')->flatten());
186 |
187 | return $this->tickets;
188 | }
189 |
190 | private function handleSendNotificationsResponse(Response $response): void
191 | {
192 | $data = json_decode($response->body(), true, 512, JSON_THROW_ON_ERROR);
193 | if (! empty($data['errors'])) {
194 | throw new ExpoNotificationsException($response->toPsrResponse());
195 | }
196 |
197 | $this->setTicketsFromData($data);
198 | }
199 |
200 | private function setTicketsFromData(array $data): ExpoNotificationsService
201 | {
202 | collect($data['data'])
203 | ->each(function ($responseItem) {
204 | if ($responseItem['status'] === ExpoResponseStatus::ERROR->value) {
205 | $this->tickets->push(
206 | (new PushTicketResponse)
207 | ->status($responseItem['status'])
208 | ->message($responseItem['message'])
209 | ->details($responseItem['details'])
210 | );
211 | } else {
212 | $this->tickets->push(
213 | (new PushTicketResponse)
214 | ->status($responseItem['status'])
215 | ->ticketId($responseItem['id'])
216 | );
217 | }
218 | });
219 |
220 | return $this;
221 | }
222 |
223 | private function sendNotificationsChunk(array $chunk)
224 | {
225 | $response = $this->http->post(self::SEND_NOTIFICATION_ENDPOINT, $chunk);
226 |
227 | if (! $response->successful()) {
228 | throw new ExpoNotificationsException($response->toPsrResponse());
229 | }
230 |
231 | $this->handleSendNotificationsResponse($response);
232 | }
233 | }
234 |
--------------------------------------------------------------------------------
/src/ExpoNotificationsServiceProvider.php:
--------------------------------------------------------------------------------
1 | app->bind(ExpoTokenStorageInterface::class, config('expo-notifications.drivers.token'));
23 | $this->app->bind(ExpoTicketStorageInterface::class, config('expo-notifications.drivers.ticket'));
24 | $this->app->bind(ExpoPendingNotificationStorageInterface::class, config('expo-notifications.drivers.notification'));
25 |
26 | $this->app->bind(ExpoNotificationsServiceInterface::class, function ($app) {
27 | $apiUrl = config('expo-notifications.service.api_url');
28 | $host = config('expo-notifications.service.host');
29 | $accessToken = config('expo-notifications.service.access_token');
30 |
31 | return new ExpoNotificationsService(
32 | $apiUrl,
33 | $host,
34 | $accessToken,
35 | $app->make(ExpoPendingNotificationStorageInterface::class),
36 | $app->make(ExpoTicketStorageInterface::class)
37 | );
38 | });
39 | }
40 |
41 | public function boot(): void
42 | {
43 | $this->loadMigrationsFrom(__DIR__.'/../database/migrations');
44 |
45 | $this->publishes([
46 | __DIR__.'/../config' => config_path(),
47 | ], 'expo-notifications-config');
48 |
49 | $this->publishes([
50 | __DIR__.'/../database/migrations' => database_path('migrations'),
51 | ], 'expo-notifications-migration');
52 |
53 | $this->commands([
54 | SendPendingNotifications::class,
55 | CheckTickets::class,
56 | ]);
57 |
58 | if (config('expo-notifications.automatically_delete_token', false)) {
59 | Event::listen(
60 | InvalidExpoToken::class,
61 | [DeleteInvalidExpoToken::class, 'handle']
62 | );
63 | }
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/FakeExpoNotificationsService.php:
--------------------------------------------------------------------------------
1 | tickets = collect();
47 | }
48 |
49 | public function notify(ExpoMessage|Collection|array $expoMessages): Collection
50 | {
51 | /** @var Collection $expoMessages */
52 | $this->expoMessages = $expoMessages instanceof Collection ? $expoMessages : collect(Arr::wrap($expoMessages));
53 |
54 | return $this->storeNotificationsToSendInTheNextBatch()
55 | ->prepareNotificationsToSendNow()
56 | ->sendNotifications();
57 | }
58 |
59 | public function receipts(array|Collection $tokenIds): Collection
60 | {
61 | // TODO: Implement receipts() method.
62 | }
63 |
64 | public function getNotificationChunks(): Collection
65 | {
66 | return $this->notificationChunks ?? collect();
67 | }
68 |
69 | private function prepareNotificationsToSendNow(): FakeExpoNotificationsService
70 | {
71 | $this->notificationsToSend = $this->expoMessages
72 | ->reject(fn (ExpoMessage $message) => $message->shouldBatch)
73 | ->map(fn (ExpoMessage $message) => $message->toExpoData())
74 | ->values();
75 |
76 | // Splits into multiples chunks of max limitation
77 | $this->notificationChunks = $this->notificationsToSend->chunk(self::PUSH_NOTIFICATIONS_PER_REQUEST_LIMIT);
78 |
79 | return $this;
80 | }
81 |
82 | private function storeNotificationsToSendInTheNextBatch(): FakeExpoNotificationsService
83 | {
84 | $this->expoMessages
85 | ->filter(fn (ExpoMessage $message) => $message->shouldBatch)
86 | ->each(fn (ExpoMessage $message) => $this->notificationStorage->store($message));
87 |
88 | return $this;
89 | }
90 |
91 | private function sendNotifications(): Collection
92 | {
93 | if ($this->notificationsToSend->isEmpty()) {
94 | return collect();
95 | }
96 |
97 | $this->notificationChunks
98 | ->each(
99 | fn ($chunk, $index) => $this->sendNotificationsChunk($chunk->toArray(), $index)
100 | );
101 |
102 | $this->checkAndStoreTickets($this->notificationsToSend->pluck('to')->flatten());
103 |
104 | return $this->tickets;
105 | }
106 |
107 | private function sendNotificationsChunk(array $chunk, int $chunkId): void
108 | {
109 | $data = [];
110 | foreach ($chunk as $notification) {
111 | $data[] = [
112 | 'id' => Str::orderedUuid()->toString(),
113 | 'status' => ExpoResponseStatus::OK->value,
114 | '__notification' => $notification,
115 | ];
116 | }
117 |
118 | $response = Http::fake([
119 | 'api-push/'.$chunkId => Http::response([
120 | 'data' => $data,
121 | ]),
122 | ])->get('/api-push/'.$chunkId);
123 |
124 | if (! $response->successful()) {
125 | throw new ExpoNotificationsException($response->toPsrResponse());
126 | }
127 |
128 | $this->handleSendNotificationsResponse($response);
129 | }
130 |
131 | private function handleSendNotificationsResponse(Response $response): void
132 | {
133 | $data = json_decode($response->body(), true, 512, JSON_THROW_ON_ERROR);
134 | if (! empty($data['errors'])) {
135 | throw new ExpoNotificationsException($response->toPsrResponse());
136 | }
137 |
138 | $this->setTicketsFromData($data);
139 | }
140 |
141 | private function setTicketsFromData(array $data): FakeExpoNotificationsService
142 | {
143 | collect($data['data'])
144 | ->each(function ($responseItem) {
145 | if ($responseItem['status'] === ExpoResponseStatus::ERROR->value) {
146 | $this->tickets->push(
147 | (new PushTicketResponse)
148 | ->status($responseItem['status'])
149 | ->message($responseItem['message'])
150 | ->details($responseItem['details'])
151 | );
152 | } else {
153 | $this->tickets->push(
154 | (new PushTicketResponse)
155 | ->status($responseItem['status'])
156 | ->ticketId($responseItem['id'])
157 | );
158 | }
159 | });
160 |
161 | return $this;
162 | }
163 |
164 | /**
165 | * @param Collection $tokens
166 | */
167 | private function checkAndStoreTickets(Collection $tokens): void
168 | {
169 | $this->tickets
170 | ->intersectByKeys($tokens)
171 | ->each(function (PushTicketResponse $ticket, $index) use ($tokens) {
172 | if ($ticket->status === ExpoResponseStatus::ERROR->value) {
173 | if (
174 | is_array($ticket->details) &&
175 | array_key_exists('error', $ticket->details) &&
176 | $ticket->details['error'] === ExpoResponseStatus::DEVICE_NOT_REGISTERED->value
177 | ) {
178 | event(new InvalidExpoToken($tokens->get($index)));
179 | }
180 | } else {
181 | $this->ticketStorage->store($ticket->ticketId, $tokens->get($index));
182 | }
183 | });
184 | }
185 | }
186 |
--------------------------------------------------------------------------------
/src/Jobs/CheckTickets.php:
--------------------------------------------------------------------------------
1 | count() > 0) {
28 | $tickets = $ticketStorage->retrieve();
29 | $ticketIds = $tickets->pluck('id')->toArray();
30 |
31 | $response = $expoNotificationsService->receipts($ticketIds);
32 | if ($response->isEmpty()) {
33 | break;
34 | }
35 |
36 | $this->check($ticketStorage, $tickets, $response);
37 | }
38 | }
39 |
40 | protected function check(ExpoTicketStorageInterface $ticketStorage, Collection $tickets, Collection $receipts): void
41 | {
42 | $ticketsToDelete = [];
43 |
44 | $tickets->each(function (ExpoTicket $ticket) use ($receipts, &$ticketsToDelete) {
45 | $receipt = $receipts->get($ticket->id);
46 |
47 | if (! is_null($receipt) && in_array($receipt->status, [ExpoResponseStatus::OK->value, ExpoResponseStatus::ERROR->value])) {
48 | if (
49 | is_array($receipt->details) &&
50 | array_key_exists('error', $receipt->details) &&
51 | $receipt->details['error'] === ExpoResponseStatus::DEVICE_NOT_REGISTERED->value
52 | ) {
53 | event(new InvalidExpoToken($ticket->token));
54 |
55 | return;
56 | }
57 |
58 | $ticketsToDelete[] = $ticket->id;
59 | }
60 | });
61 |
62 | $ticketStorage->delete($ticketsToDelete);
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/Jobs/SendPendingNotifications.php:
--------------------------------------------------------------------------------
1 | count() > 0) {
27 | $notifications = $expoNotification
28 | ->retrieve()
29 | // Avoid double sending in case of deletion error.
30 | ->reject(fn (ExpoNotification $notification) => $sent->contains($notification->id));
31 |
32 | if ($notifications->isEmpty()) {
33 | break;
34 | }
35 |
36 | $expoMessages = $notifications->pluck('message');
37 | $ids = $notifications->pluck('id');
38 |
39 | $expoNotificationsService->notify($expoMessages);
40 |
41 | $sent = $sent->merge($ids);
42 | $expoNotification->delete($ids->toArray());
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/Listeners/DeleteInvalidExpoToken.php:
--------------------------------------------------------------------------------
1 | tokenStorage->delete($event->token);
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/Models/ExpoNotification.php:
--------------------------------------------------------------------------------
1 | belongsTo(ExpoNotification::class);
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/Models/ExpoToken.php:
--------------------------------------------------------------------------------
1 | morphTo();
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/Storage/ExpoPendingNotificationStorageMysql.php:
--------------------------------------------------------------------------------
1 | $expoMessage->toJson(),
19 | ]);
20 |
21 | return ExpoNotificationDto::make($notification->id, $expoMessage);
22 | }
23 |
24 | public function retrieve(int $amount = 100): Collection
25 | {
26 | return ExpoNotification::query()
27 | ->take($amount)
28 | ->get()
29 | ->map(function ($notification) {
30 | return ExpoNotificationDto::make($notification->id, ExpoMessage::fromJson($notification->data));
31 | });
32 | }
33 |
34 | public function delete(array $ids): void
35 | {
36 | ExpoNotification::query()->whereIn('id', $ids)->delete();
37 | }
38 |
39 | public function count(): int
40 | {
41 | return ExpoNotification::query()->count();
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/Storage/ExpoTicketStorageMysql.php:
--------------------------------------------------------------------------------
1 | take($amount)
19 | ->get()
20 | ->map(fn ($ticket) => ExpoTicketDto::make($ticket->ticket_id, $ticket->token));
21 | }
22 |
23 | public function store(string $ticketId, string $token): ExpoTicketDto
24 | {
25 | $expoTicket = ExpoTicket::firstOrCreate([
26 | 'ticket_id' => $ticketId,
27 | ], [
28 | 'ticket_id' => $ticketId,
29 | 'token' => $token,
30 | ]);
31 |
32 | return ExpoTicketDto::make($expoTicket->ticket_id, $expoTicket->token);
33 | }
34 |
35 | public function delete(string|array $ticketIds): void
36 | {
37 | $ticketIds = Arr::wrap($ticketIds);
38 | ExpoTicket::query()->whereIn('ticket_id', $ticketIds)->delete();
39 | }
40 |
41 | public function count(): int
42 | {
43 | return ExpoTicket::query()->count();
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/Storage/ExpoTokenStorageMysql.php:
--------------------------------------------------------------------------------
1 | updateOrCreate([
18 | 'value' => $token,
19 | 'owner_type' => $owner->getMorphClass(),
20 | 'owner_id' => $owner->getKey(),
21 | ]);
22 |
23 | return ExpoTokenDto::make($token->id, $token->value, $owner);
24 | }
25 |
26 | public function delete(string|array $tokens): void
27 | {
28 | $tokens = Arr::wrap($tokens);
29 | ExpoToken::query()->whereIn('value', $tokens)->delete();
30 | }
31 | }
32 |
--------------------------------------------------------------------------------