├── .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 | [![Latest Version](https://img.shields.io/github/release/yieldstudio/laravel-expo-notifier?style=flat-square)](https://github.com/yieldstudio/laravel-expo-notifier/releases) 6 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/yieldstudio/laravel-expo-notifier/tests.yml?branch=main&style=flat-square)](https://github.com/yieldstudio/laravel-expo-notifier/actions/workflows/tests.yml) 7 | [![Total Downloads](https://img.shields.io/packagist/dt/yieldstudio/laravel-expo-notifier?style=flat-square)](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 | --------------------------------------------------------------------------------