├── .github ├── envs │ ├── .env.testing.sqlite │ ├── .env.testing.mysql │ └── .env.testing.pgsql ├── arch.png ├── logo.png └── workflows │ ├── try-installation.yml │ └── build.yml ├── src ├── Core │ ├── LifecycleEventEnum.php │ └── Lifecycle.php ├── Routes │ └── inbox_routes.php ├── Repositories │ ├── AbstractRepository.php │ ├── RunningInboxRepository.php │ └── InboxMessageRepository.php ├── Http │ ├── Requests │ │ ├── DefaultCustomInboxRequest.php │ │ └── AbstractInboxRequest.php │ └── Controllers │ │ └── InboxController.php ├── Database │ └── Migrations │ │ ├── 2023_07_15_000002_create_running_inboxes_table.php │ │ └── 2023_07_15_000001_create_inbox_messages_table.php ├── Entities │ └── InboxMessage.php ├── Configs │ └── inbox.php ├── Functions │ └── inbox_functions.php ├── InboxProcessServiceProvider.php ├── InboxProcessSetup.php ├── Handlers │ └── InboxMessageHandler.php └── Commands │ └── InboxWorkCommand.php ├── .editorconfig ├── .gitignore ├── tests ├── Unit │ ├── Functions │ │ └── FunctionsTest.php │ ├── Core │ │ └── LifecycleTest.php │ ├── Repositories │ │ ├── RunningInboxRepositoryTest.php │ │ └── InboxMessageRepositoryTest.php │ └── Entities │ │ └── InboxMessageTest.php ├── Integration │ ├── __fixtures__ │ │ ├── stripe_customer_updated.json │ │ ├── stripe_invoice_paid.json │ │ └── stripe_invoice_payment_succeed.json │ ├── Http │ │ └── InboxControllerTest.php │ └── Commands │ │ └── InboxWorkCommandTest.php └── TestCase.php ├── phpunit.xml ├── LICENSE ├── composer.json ├── README.md └── .php-cs-fixer.php /.github/envs/.env.testing.sqlite: -------------------------------------------------------------------------------- 1 | DB_CONNECTION=sqlite 2 | -------------------------------------------------------------------------------- /.github/arch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shipsaas/laravel-inbox-process/HEAD/.github/arch.png -------------------------------------------------------------------------------- /.github/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shipsaas/laravel-inbox-process/HEAD/.github/logo.png -------------------------------------------------------------------------------- /.github/envs/.env.testing.mysql: -------------------------------------------------------------------------------- 1 | DB_CONNECTION=mysql 2 | DB_HOST=127.0.0.1 3 | DB_PORT=3306 4 | DB_DATABASE=inbox 5 | DB_USERNAME=root 6 | DB_PASSWORD=root 7 | -------------------------------------------------------------------------------- /.github/envs/.env.testing.pgsql: -------------------------------------------------------------------------------- 1 | DB_CONNECTION=pgsql 2 | DB_HOST=localhost 3 | DB_PORT=5432 4 | DB_DATABASE=inbox 5 | DB_USERNAME=postgres 6 | DB_PASSWORD=postgres 7 | -------------------------------------------------------------------------------- /src/Core/LifecycleEventEnum.php: -------------------------------------------------------------------------------- 1 | name('inbox.topic'); 11 | } 12 | -------------------------------------------------------------------------------- /src/Repositories/AbstractRepository.php: -------------------------------------------------------------------------------- 1 | string('topic')->primary(); 12 | }); 13 | } 14 | 15 | public function down(): void 16 | { 17 | Schema::dropIfExists('running_inboxes'); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /src/Entities/InboxMessage.php: -------------------------------------------------------------------------------- 1 | id = intval($rawDbRecord->id); 15 | $inboxMsg->externalId = $rawDbRecord->external_id; 16 | $inboxMsg->rawPayload = $rawDbRecord->payload ?: '{}'; 17 | 18 | return $inboxMsg; 19 | } 20 | 21 | public function getParsedPayload(): array 22 | { 23 | return json_decode($this->rawPayload, true); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Http/Requests/AbstractInboxRequest.php: -------------------------------------------------------------------------------- 1 | all(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Repositories/RunningInboxRepository.php: -------------------------------------------------------------------------------- 1 | makeDbClient() 13 | ->table('running_inboxes') 14 | ->insert(['topic' => $topic]); 15 | 16 | return true; 17 | } catch (Throwable) { 18 | return false; 19 | } 20 | } 21 | 22 | public function unlock(string $topic): void 23 | { 24 | $this->makeDbClient() 25 | ->table('running_inboxes') 26 | ->where('topic', $topic) 27 | ->delete(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Configs/inbox.php: -------------------------------------------------------------------------------- 1 | true, 8 | 9 | /** 10 | * Customize route path if you don't want to use the default "inbox/{topic}" 11 | * 12 | * E.g.: 13 | * - inbox => inbox/{topic} 14 | * - this-is/my-inbox/pro-max => this-is/my-inbox/pro-max/{topic} 15 | * 16 | * You can use hardcoded code here or ENV(...) 17 | */ 18 | 'route_path' => 'inbox', 19 | 20 | /** 21 | * The DB connection that Inbox Process should use 22 | * 23 | * Default: null - use Laravel default connection 24 | * 25 | * Recommendation: use a dedicated DB for inbox, for high availability purposes. 26 | */ 27 | 'db_connection' => null, 28 | ]; 29 | -------------------------------------------------------------------------------- /tests/Unit/Functions/FunctionsTest.php: -------------------------------------------------------------------------------- 1 | assertDatabaseHas('inbox_messages', [ 15 | 'topic' => 'test', 16 | 'external_id' => 'test', 17 | ]); 18 | } 19 | 20 | public function testAppendInboxMessageThrowsErrorForDuplicatedEntry() 21 | { 22 | $this->expectException(QueryException::class); 23 | 24 | appendInboxMessage('test', 'test', ['hehe']); 25 | appendInboxMessage('test', 'test', ['hehe']); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Functions/inbox_functions.php: -------------------------------------------------------------------------------- 1 | append($topic, $externalId, $payload); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Unit/Core/LifecycleTest.php: -------------------------------------------------------------------------------- 1 | app->make(Lifecycle::class); 14 | $this->assertTrue($lifecycle->isRunning()); 15 | } 16 | 17 | public function testApplicationOnTerminatingWouldRunTheBoundLifecycleCallbacks() 18 | { 19 | $lifecycle = $this->app->make(Lifecycle::class); 20 | 21 | $hehe = false; 22 | $lifecycle->on(LifecycleEventEnum::CLOSING, function () use (&$hehe) { 23 | $hehe = true; 24 | }); 25 | 26 | $this->app->terminate(); 27 | 28 | $this->assertTrue($hehe); 29 | $this->assertFalse($lifecycle->isRunning()); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./src 6 | 7 | 8 | ./src/Database 9 | ./src/Configs 10 | ./src/InboxProcessServiceProvider.php 11 | 12 | 13 | 14 | 15 | ./tests/ 16 | ./tests/TestCase.php 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 ShipSaaS x SethPhat 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 | -------------------------------------------------------------------------------- /.github/workflows/try-installation.yml: -------------------------------------------------------------------------------- 1 | name: Try Install Package (Laravel 10 & 11) 2 | 3 | env: 4 | LOCAL_ENV: ${{ secrets.LOCAL_ENV }} 5 | on: 6 | push: 7 | branches: 8 | - 'main' 9 | 10 | jobs: 11 | build: 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | version: [ '^10.0', '^11.0' ] 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Setup PHP with coverage driver 19 | uses: shivammathur/setup-php@v2 20 | with: 21 | php-version: 8.2 22 | coverage: pcov 23 | 24 | - name: Setup and install package on Laravel 25 | if: success() 26 | run: | 27 | sudo service mysql start 28 | mysql -uroot -proot -e "CREATE DATABASE priority_queue;" 29 | composer create-project laravel/laravel:${{ matrix.version }} laravel 30 | cd laravel 31 | composer require shipsaas/laravel-inbox-process 32 | php artisan vendor:publish --tag=laravel-inbox-process 33 | -------------------------------------------------------------------------------- /tests/Unit/Repositories/RunningInboxRepositoryTest.php: -------------------------------------------------------------------------------- 1 | acquireLock('test'); 15 | 16 | $this->assertTrue($acquiredLock); 17 | 18 | $this->assertDatabaseHas('running_inboxes', [ 19 | 'topic' => 'test', 20 | ]); 21 | } 22 | 23 | public function testAcquireLockFailed() 24 | { 25 | $repo = new RunningInboxRepository(); 26 | 27 | $repo->acquireLock('test'); 28 | $acquiredLock = $repo->acquireLock('test'); 29 | 30 | $this->assertFalse($acquiredLock); 31 | } 32 | 33 | public function testUnlockSuccessfully() 34 | { 35 | $repo = new RunningInboxRepository(); 36 | 37 | $repo->acquireLock('test'); 38 | $repo->unlock('test'); 39 | 40 | $this->assertDatabaseMissing('running_inboxes', [ 41 | 'topic' => 'test', 42 | ]); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/InboxProcessServiceProvider.php: -------------------------------------------------------------------------------- 1 | ['Version' => 'v1.1.1'] 17 | ); 18 | 19 | $this->mergeConfigFrom( 20 | __DIR__ . '/Configs/inbox.php', 21 | 'inbox' 22 | ); 23 | 24 | $this->loadRoutesFrom(__DIR__ . '/Routes/inbox_routes.php'); 25 | 26 | if ($this->app->runningInConsole()) { 27 | $this->publishes([ 28 | __DIR__ . '/Database/Migrations/' => database_path('migrations'), 29 | __DIR__ . '/Configs/inbox.php' => config_path('inbox.php'), 30 | ], 'laravel-inbox-process'); 31 | 32 | $this->commands([ 33 | InboxWorkCommand::class, 34 | ]); 35 | 36 | $this->app->booted(fn () => $this->app->make(Lifecycle::class)); 37 | } 38 | } 39 | 40 | public function register(): void 41 | { 42 | $this->app->singleton(Lifecycle::class); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/Unit/Repositories/InboxMessageRepositoryTest.php: -------------------------------------------------------------------------------- 1 | travelTo('2023-05-05 11:02:33.004'); 13 | appendInboxMessage('test', '2', ['1']); 14 | 15 | $this->travelTo('2023-05-05 11:02:33.001'); 16 | appendInboxMessage('test', '1', ['2']); 17 | 18 | $this->travelTo('2023-05-05 11:02:33.005'); 19 | appendInboxMessage('test', '3', ['3']); 20 | 21 | $repo = new InboxMessageRepository(); 22 | $msgs = $repo->pullMessages('test'); 23 | 24 | // ordering is good 25 | $this->assertSame('["2"]', $msgs[0]->rawPayload); 26 | $this->assertSame('["1"]', $msgs[1]->rawPayload); 27 | $this->assertSame('["3"]', $msgs[2]->rawPayload); 28 | } 29 | 30 | public function testMarkMessageAsProcessed() 31 | { 32 | appendInboxMessage('process', 'fake-1', ['1']); 33 | 34 | $repo = new InboxMessageRepository(); 35 | 36 | $msgs = $repo->pullMessages('process'); 37 | 38 | $this->assertCount(1, $msgs); 39 | 40 | $this->travelTo('2023-05-05 23:59:59'); 41 | $repo->markAsProcessed($msgs[0]->id); 42 | 43 | $this->assertDatabaseHas('inbox_messages', [ 44 | 'id' => $msgs[0]->id, 45 | 'processed_at' => '2023-05-05 23:59:59', 46 | ]); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/Integration/__fixtures__/stripe_customer_updated.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "evt_1Nh2fp2eZvKYlo2CzbNockEM", 3 | "data": { 4 | "object": { 5 | "id": "cus_9s6XKzkNRiz8i3", 6 | "object": "customer", 7 | "address": null, 8 | "balance": 0, 9 | "created": 1483565364, 10 | "currency": "usd", 11 | "default_source": "card_1NZex82eZvKYlo2CZR21ocY1", 12 | "delinquent": false, 13 | "description": "My First Test Customer (created for API docs at https://www.stripe.com/docs/api)", 14 | "discount": null, 15 | "email": "test@test.com", 16 | "invoice_prefix": "28278FC", 17 | "invoice_settings": { 18 | "custom_fields": null, 19 | "default_payment_method": null, 20 | "footer": null, 21 | "rendering_options": null 22 | }, 23 | "livemode": false, 24 | "metadata": { 25 | "order_id": "1234" 26 | }, 27 | "name": null, 28 | "next_invoice_sequence": 77, 29 | "phone": null, 30 | "preferred_locales": [], 31 | "shipping": null, 32 | "tax_exempt": "none", 33 | "test_clock": null 34 | } 35 | }, 36 | "type": "customer.updated", 37 | "object": "event", 38 | "created": 1689987898, 39 | "request": { 40 | "id": null, 41 | "idempotency_key": null 42 | }, 43 | "livemode": false, 44 | "api_version": "2022-11-15", 45 | "pending_webhooks": 2 46 | } 47 | -------------------------------------------------------------------------------- /src/Repositories/InboxMessageRepository.php: -------------------------------------------------------------------------------- 1 | makeDbClient() 16 | ->table('inbox_messages') 17 | ->insert([ 18 | 'topic' => $topic, 19 | 'external_id' => $externalId, 20 | 'payload' => json_encode($payload), 21 | 'created_at' => $now->toDateTimeString(), 22 | 'created_at_unix_ms' => $now->getTimestampMs() 23 | ]); 24 | } 25 | 26 | /** 27 | * @return Collection 28 | */ 29 | public function pullMessages(string $topic, int $limit = 10): Collection 30 | { 31 | return $this->makeDbClient() 32 | ->table('inbox_messages') 33 | ->whereNull('processed_at') 34 | ->where('topic', $topic) 35 | ->orderBy('created_at_unix_ms', 'ASC') 36 | ->limit($limit) 37 | ->get(['id', 'external_id', 'payload']) 38 | ->map(InboxMessage::make(...)); 39 | } 40 | 41 | public function markAsProcessed(int $messageId): void 42 | { 43 | $this->makeDbClient() 44 | ->table('inbox_messages') 45 | ->where('id', $messageId) 46 | ->update([ 47 | 'processed_at' => Carbon::now(), 48 | ]); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/Unit/Entities/InboxMessageTest.php: -------------------------------------------------------------------------------- 1 | 1000, 14 | 'external_id' => 'fake-id', 15 | 'payload' => '{"hello": "world"}', 16 | ]); 17 | 18 | $this->assertSame(1000, $inboxMsg->id); 19 | $this->assertSame('fake-id', $inboxMsg->externalId); 20 | $this->assertSame('{"hello": "world"}', $inboxMsg->rawPayload); 21 | } 22 | 23 | public function testMakeReturnsInboxMessageWithNoPayload() 24 | { 25 | $inboxMsg = InboxMessage::make((object) [ 26 | 'id' => 1000, 27 | 'external_id' => 'fake-id', 28 | 'payload' => null, 29 | ]); 30 | 31 | $this->assertSame(1000, $inboxMsg->id); 32 | $this->assertSame('fake-id', $inboxMsg->externalId); 33 | $this->assertSame('{}', $inboxMsg->rawPayload); 34 | } 35 | 36 | public function testGetParsedPayloadReturnsAnArray() 37 | { 38 | $inboxMsg = InboxMessage::make((object) [ 39 | 'id' => 1000, 40 | 'external_id' => 'fake-id', 41 | 'payload' => '{"hello": "world"}', 42 | ]); 43 | 44 | $this->assertSame([ 45 | 'hello' => 'world', 46 | ], $inboxMsg->getParsedPayload()); 47 | } 48 | 49 | public function testGetParsedPayloadReturnsAnEmptyArray() 50 | { 51 | $inboxMsg = InboxMessage::make((object) [ 52 | 'id' => 1000, 53 | 'payload' => null, 54 | 'external_id' => 'fake-id', 55 | ]); 56 | 57 | $this->assertSame([], $inboxMsg->getParsedPayload()); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Database/Migrations/2023_07_15_000001_create_inbox_messages_table.php: -------------------------------------------------------------------------------- 1 | create('inbox_messages', function (Blueprint $table) { 16 | $table->id(); 17 | $table->string('topic')->index(); 18 | $table->string('external_id')->index(); 19 | $table->jsonb('payload'); 20 | $table->timestamp('created_at'); 21 | $table->bigInteger('created_at_unix_ms'); 22 | $table->timestamp('processed_at')->nullable(); 23 | 24 | $table->unique([ 25 | 'topic', 26 | 'external_id', 27 | ], 'unq_inbox_topic_external_id'); 28 | }); 29 | 30 | $dbConnection = DB::connection($connection); 31 | 32 | if ($dbConnection instanceof MySqlConnection) { 33 | DB::statement(' 34 | ALTER TABLE inbox_messages 35 | ADD INDEX idx_inbox_pull_msgs (topic, processed_at, created_at_unix_ms ASC); 36 | '); 37 | } elseif ($dbConnection instanceof PostgresConnection) { 38 | DB::statement(' 39 | CREATE INDEX idx_inbox_pull_msgs 40 | ON inbox_messages (topic, processed_at, created_at_unix_ms ASC); 41 | '); 42 | } 43 | } 44 | 45 | public function down(): void 46 | { 47 | $connection = config('inbox.db_connection'); 48 | 49 | Schema::connection($connection) 50 | ->dropIfExists('inbox_messages'); 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shipsaas/laravel-inbox-process", 3 | "type": "library", 4 | "version": "1.1.2", 5 | "description": "Inbox pattern process implementation for your Laravel Applications", 6 | "keywords": [ 7 | "laravel library", 8 | "laravel inbox process", 9 | "laravel inbox pattern", 10 | "inbox pattern", 11 | "webhook", 12 | "inbox", 13 | "inbox process", 14 | "laravel" 15 | ], 16 | "authors": [ 17 | { 18 | "name": "Phat Tran (Seth Phat)", 19 | "email": "me@sethphat.com", 20 | "homepage": "https://github.com/sethsandaru", 21 | "role": "Sr.SWE" 22 | } 23 | ], 24 | "license": "MIT", 25 | "require": { 26 | "php": "^8.2", 27 | "laravel/framework": "^10|^11|dev-master", 28 | "ext-pcntl": "*" 29 | }, 30 | "require-dev": { 31 | "fakerphp/faker": "^v1.20.0", 32 | "mockery/mockery": "^1.6", 33 | "phpunit/phpunit": "^10", 34 | "orchestra/testbench": "^8|^9", 35 | "phpunit/php-code-coverage": "^10", 36 | "friendsofphp/php-cs-fixer": "^3.10" 37 | }, 38 | "extra": { 39 | "laravel": { 40 | "providers": [ 41 | "ShipSaasInboxProcess\\InboxProcessServiceProvider" 42 | ] 43 | } 44 | }, 45 | "autoload": { 46 | "psr-4": { 47 | "ShipSaasInboxProcess\\": "src/" 48 | }, 49 | "files": [ 50 | "./src/Functions/inbox_functions.php" 51 | ] 52 | }, 53 | "autoload-dev": { 54 | "psr-4": { 55 | "ShipSaasInboxProcess\\Tests\\": "tests/" 56 | } 57 | }, 58 | "scripts": { 59 | "test-coverage": [ 60 | "@php vendor/bin/phpunit --coverage-clover coverage.xml" 61 | ], 62 | "test": [ 63 | "@php vendor/bin/phpunit" 64 | ] 65 | }, 66 | "minimum-stability": "dev", 67 | "prefer-stable": true 68 | } 69 | -------------------------------------------------------------------------------- /src/InboxProcessSetup.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | private static array $topicRequestMap = []; 15 | 16 | /** 17 | * @var \ArrayAccess 18 | */ 19 | private static array $topicResponseMap = []; 20 | 21 | /** 22 | * @var \ArrayAccess 23 | */ 24 | private static array $topicHandlersMap = []; 25 | 26 | public static function addRequest(string $topic, AbstractInboxRequest $request): void 27 | { 28 | self::$topicRequestMap[$topic] = $request; 29 | } 30 | 31 | public static function getRequest(string $topic): AbstractInboxRequest 32 | { 33 | if (!isset(self::$topicRequestMap[$topic])) { 34 | return new DefaultCustomInboxRequest(); 35 | } 36 | 37 | return self::$topicRequestMap[$topic]; 38 | } 39 | 40 | public static function addResponse(string $topic, callable $responseGenerator): void 41 | { 42 | self::$topicResponseMap[$topic] = $responseGenerator; 43 | } 44 | 45 | public static function getResponse(string $topic): callable 46 | { 47 | return self::$topicResponseMap[$topic] 48 | ?? fn () => new JsonResponse('ok'); 49 | } 50 | 51 | /** 52 | * Register an inbox handler for the given topic 53 | * 54 | * @param string $topic The topic to register 55 | * @param string|callable $processor The handler, can be a classpath or a closure 56 | */ 57 | public static function addProcessor(string $topic, string|callable $processor): void 58 | { 59 | self::$topicHandlersMap[$topic][] = $processor; 60 | } 61 | 62 | public static function getProcessors(string $topic): array 63 | { 64 | return self::$topicHandlersMap[$topic] ?? []; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Core/Lifecycle.php: -------------------------------------------------------------------------------- 1 | [], 16 | 'closed' => [], 17 | ]; 18 | 19 | public function __construct(Application $consoleApp) 20 | { 21 | $signals = new Signals($consoleApp->getSignalRegistry()); 22 | $this->initLifecycle($signals); 23 | } 24 | 25 | public function isRunning(): bool 26 | { 27 | return $this->isRunning; 28 | } 29 | 30 | public function on(LifecycleEventEnum $event, callable $handler): void 31 | { 32 | $this->listeners[$event->value][] = $handler; 33 | } 34 | 35 | /** 36 | * @note this will run the closing/closed callbacks, so be highly aware 37 | */ 38 | public function forceClose(): void 39 | { 40 | $this->signalHandler(); 41 | } 42 | 43 | public function initLifecycle(Signals $signal): void 44 | { 45 | if ($this->isInitialized) { 46 | return; 47 | } 48 | 49 | collect([SIGTERM, SIGQUIT, SIGINT]) 50 | ->each( 51 | fn ($sigId) => $signal->register( 52 | $sigId, 53 | static::signalHandler(...) 54 | ) 55 | ); 56 | 57 | app()->terminating(static::signalHandler(...)); 58 | 59 | $this->isInitialized = true; 60 | } 61 | 62 | private function signalHandler(): void 63 | { 64 | if ($this->isTerminated) { 65 | return; 66 | } 67 | 68 | $this->isRunning = false; 69 | 70 | collect($this->listeners['closing'])->each( 71 | fn (callable $callback) => app()->call($callback) 72 | ); 73 | 74 | collect($this->listeners['closed'])->each( 75 | fn (callable $callback) => app()->call($callback) 76 | ); 77 | 78 | $this->isTerminated = true; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build & Test (MySQL & PgSQL) 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - 'main' 7 | types: [ opened, synchronize, reopened, ready_for_review ] 8 | push: 9 | branches: 10 | - 'main' 11 | 12 | jobs: 13 | build: 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | db: ['mysql', 'pgsql', 'sqlite'] 18 | runs-on: ubuntu-latest 19 | services: 20 | postgresql: 21 | image: postgres:14 22 | env: 23 | POSTGRES_DB: inbox 24 | POSTGRES_USER: postgres 25 | POSTGRES_PASSWORD: postgres 26 | ports: 27 | - 5432:5432 28 | options: --health-cmd=pg_isready --health-interval=10s --health-timeout=5s --health-retries=3 29 | steps: 30 | - uses: actions/checkout@v3 31 | if: success() 32 | 33 | - name: Setup PHP with coverage driver 34 | uses: shivammathur/setup-php@v2 35 | with: 36 | php-version: 8.2 37 | coverage: pcov 38 | 39 | - name: Start MySQL Database 40 | if: matrix.db == 'mysql' 41 | run: | 42 | sudo service mysql start 43 | mysql -uroot -proot -e "CREATE DATABASE inbox;" 44 | cp .github/envs/.env.testing.mysql .env.testing 45 | 46 | - name: Start PGSQL Database 47 | if: matrix.db == 'pgsql' 48 | run: | 49 | cp .github/envs/.env.testing.pgsql .env.testing 50 | 51 | - name: Start SQLite Database 52 | if: matrix.db == 'sqlite' 53 | run: | 54 | cp .github/envs/.env.testing.pgsql .env.testing 55 | touch database.sqlite 56 | DB_DATABASE="$(pwd)/database.sqlite" 57 | echo $DB_DATABASE 58 | 59 | - name: Bootstrap project 60 | if: success() 61 | run: | 62 | php -v 63 | composer install --no-interaction 64 | 65 | - name: PHPUnit tests with coverage 66 | if: success() 67 | run: | 68 | composer test-coverage 69 | 70 | - name: upload coverage to codecov.io 71 | if: success() 72 | uses: codecov/codecov-action@v3 73 | with: 74 | token: ${{ secrets.CODECOV_TOKEN }} 75 | file: ./coverage.xml 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ShipSaaS - Laravel Inbox Process 2 | 3 | [![Build & Test (MySQL & PgSQL)](https://github.com/shipsaas/laravel-inbox-process/actions/workflows/build.yml/badge.svg)](https://github.com/shipsaas/laravel-inbox-process/actions/workflows/build.yml) 4 | [![codecov](https://codecov.io/gh/shipsaas/laravel-inbox-process/graph/badge.svg?token=3Z1X9S69C4)](https://codecov.io/gh/shipsaas/laravel-inbox-process) 5 | 6 |

7 | 8 |

9 | 10 | Talking about distributed computers & servers, it is quite normal nowadays to communicate between servers. 11 | 12 | Unlike a regular conversation though, there's no guarantee the message gets delivered only once, arrives in the right order, or even gets a "got it!" reply. 13 | 14 | Thus, we have **Inbox Pattern** to help us to achieve that. 15 | 16 | ## What is the Inbox Pattern 17 | 18 | **The Inbox Pattern** is a popular design pattern in the microservice architecture that ensures: 19 | 20 | - High availability ✅ 21 | - Guaranteed webhook deliverance, no msg lost ✅ 22 | - Guaranteed **exactly-once/unique** webhook requests ✅ 23 | - Execute webhook requests **in ORDER/sequence** ✅ 24 | - (Optional) High visibility & debug all prev requests ✅ 25 | 26 | And with that being said: 27 | 28 | **Laravel Inbox Process (powered by ShipSaaS)** ships everything out-of-the-box and 29 | helps you to roll out the inbox process in no time 😎🚀. 30 | 31 | ## Supports 32 | - Laravel 10 & 11 33 | - PHP 8.2+ 34 | - MySQL 8, MariaDB, Postgres 13+ and SQLite 35 | 36 | ## Architecture Diagram 37 | 38 | ![ShipSaaS - Laravel Inbox Process](./.github/arch.png) 39 | 40 | ## Installation 41 | 42 | Install the library: 43 | 44 | ```bash 45 | composer require shipsaas/laravel-inbox-process 46 | ``` 47 | 48 | Export config & migration files and then run the migration: 49 | 50 | ```bash 51 | php artisan vendor:publish --tag=laravel-inbox-process 52 | php artisan migrate 53 | ``` 54 | 55 | ## Documentation & Usage 56 | 57 | Visit: [ShipSaaS Inbox Documentation](https://inbox.shipsaas.tech) 58 | 59 | Best practices, usage & notes are well documented too 😎! 60 | 61 | ## Testing 62 | 63 | Run `composer test` 😆 64 | 65 | Available Tests: 66 | 67 | - Unit Testing 💪 68 | - Integration Testing against MySQL & PostgreSQL for the `inbox:work` command 😎 69 | - Human validation (lol) 🔥 70 | 71 | ShipSaaS loves tests, we won't ship sh!tty libraries 🌹 72 | 73 | ## Contributors 74 | - Seth Phat 75 | 76 | ## Contributions & Support the Project 77 | 78 | Feel free to submit any PR, please follow PSR-1/PSR-12 coding conventions and testing is a must. 79 | 80 | If this package is helpful, please give it a ⭐️⭐️⭐️. Thank you! 81 | 82 | ## License 83 | MIT License 84 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | load(); 37 | 38 | $connection = env('DB_CONNECTION'); 39 | // setup configs 40 | $app['config']->set('database.default', $connection); 41 | 42 | if ($connection === 'mysql') { 43 | $app['config']->set("database.connections.$connection", [ 44 | 'driver' => $connection, 45 | 'host' => env('DB_HOST'), 46 | 'port' => env('DB_PORT'), 47 | 'database' => env('DB_DATABASE'), 48 | 'username' => env('DB_USERNAME'), 49 | 'password' => env('DB_PASSWORD'), 50 | 'charset' => 'utf8mb4', 51 | 'collation' => 'utf8mb4_unicode_ci', 52 | 'prefix' => '', 53 | 'strict' => true, 54 | 'engine' => null, 55 | ]); 56 | } else { 57 | $app['config']->set("database.connections.$connection", [ 58 | 'driver' => $connection, 59 | 'host' => env('DB_HOST'), 60 | 'port' => env('DB_PORT'), 61 | 'database' => env('DB_DATABASE'), 62 | 'username' => env('DB_USERNAME'), 63 | 'password' => env('DB_PASSWORD'), 64 | 'charset' => 'utf8', 65 | 'prefix' => '', 66 | 'prefix_indexes' => true, 67 | 'search_path' => 'public', 68 | 'sslmode' => 'prefer', 69 | ]); 70 | } 71 | 72 | $app['db'] 73 | ->connection($connection) 74 | ->getSchemaBuilder() 75 | ->dropAllTables(); 76 | 77 | $migrationFiles = [ 78 | __DIR__ . '/../src/Database/Migrations/2023_07_15_000001_create_inbox_messages_table.php', 79 | __DIR__ . '/../src/Database/Migrations/2023_07_15_000002_create_running_inboxes_table.php', 80 | ]; 81 | 82 | foreach ($migrationFiles as $migrationFile) { 83 | $migrateInstance = include $migrationFile; 84 | $migrateInstance->up(); 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Http/Controllers/InboxController.php: -------------------------------------------------------------------------------- 1 | setContainer(Container::getInstance()); 31 | $inboxRequest->setRedirector(Container::getInstance()->get('redirect')); 32 | 33 | // to ensure we have legit data before inserting 34 | // - authorize 35 | // - validate 36 | $inboxRequest->validateResolved(); 37 | 38 | // insert inbox msg 39 | try { 40 | appendInboxMessage( 41 | $topic, 42 | $inboxRequest->getInboxExternalId(), 43 | $inboxRequest->getInboxPayload() 44 | ); 45 | } catch (QueryException $exception) { 46 | if ( 47 | // 23000: mysql unique 48 | // 23505: pgsql unique_violation 49 | // SQLITE_CONSTRAINT: sqlite 50 | in_array($exception->getCode(), ['23000', '23505', 'SQLITE_CONSTRAINT']) 51 | 52 | // SQLite (just in case the above does not work) 53 | || Str::contains($exception->getMessage(), 'UNIQUE constraint failed', true) 54 | ) { 55 | return new JsonResponse(['error' => 'duplicated'], 409); 56 | } 57 | 58 | // probably DB has some issues? better to throw 59 | throw $exception; 60 | } catch (Throwable $throwable) { 61 | // gratefully log for recovery purpose 62 | $exceptionHandler->report($throwable); 63 | $logger->warning('Failed to append inbox message', [ 64 | 'topic' => $topic, 65 | 'external_id' => $inboxRequest->getInboxExternalId(), 66 | 'payload' => $inboxRequest->getInboxPayload(), 67 | ]); 68 | 69 | // returns 400 to indicate retries from 3rd-party 70 | // many parties would do up-to-5-times retry 71 | return new JsonResponse(['error' => 'unknown'], 400); 72 | } 73 | 74 | $response = InboxProcessSetup::getResponse($topic); 75 | 76 | return call_user_func_array($response, [$inboxRequest]); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | in([ 8 | __DIR__ . '/src/', 9 | __DIR__ . '/tests/', 10 | ]); 11 | 12 | $config = new Config(); 13 | 14 | return $config->setFinder($finder) 15 | ->setRules([ 16 | '@PSR12' => true, 17 | 'array_syntax' => ['syntax' => 'short'], 18 | 'combine_consecutive_unsets' => true, 19 | 'multiline_whitespace_before_semicolons' => true, 20 | 'single_quote' => true, 21 | 'binary_operator_spaces' => ['default' => 'single_space'], 22 | 'blank_line_before_statement' => ['statements' => ['return']], 23 | 'braces' => [ 24 | 'allow_single_line_closure' => true, 25 | 'position_after_anonymous_constructs' => 'same', 26 | 'position_after_control_structures' => 'same', 27 | 'position_after_functions_and_oop_constructs' => 'next', 28 | ], 29 | 'combine_consecutive_issets' => true, 30 | 'class_attributes_separation' => ['elements' => ['method' => 'one']], 31 | 'concat_space' => ['spacing' => 'one'], 32 | 'include' => true, 33 | 'no_extra_blank_lines' => [ 34 | 'tokens' => [ 35 | 'curly_brace_block', 36 | 'extra', 37 | 'parenthesis_brace_block', 38 | 'square_brace_block', 39 | 'throw', 40 | 'use', 41 | ], 42 | ], 43 | 'no_multiline_whitespace_around_double_arrow' => true, 44 | 'no_spaces_around_offset' => true, 45 | 'no_unused_imports' => true, 46 | 'no_whitespace_before_comma_in_array' => true, 47 | 'object_operator_without_whitespace' => true, 48 | 'php_unit_fqcn_annotation' => true, 49 | 'phpdoc_no_package' => true, 50 | 'phpdoc_scalar' => true, 51 | 'phpdoc_single_line_var_spacing' => true, 52 | 'protected_to_private' => true, 53 | 'return_assignment' => true, 54 | 'no_useless_return' => true, 55 | 'simplified_null_return' => true, 56 | 'single_line_after_imports' => true, 57 | 'single_line_comment_style' => ['comment_types' => ['hash']], 58 | 'single_class_element_per_statement' => true, 59 | 'trailing_comma_in_multiline' => ['elements' => ['arrays']], 60 | 'trim_array_spaces' => true, 61 | 'unary_operator_spaces' => true, 62 | 'whitespace_after_comma_in_array' => true, 63 | 'no_null_property_initialization' => true, 64 | 65 | 'function_typehint_space' => true, 66 | 'method_argument_space' => ['on_multiline' => 'ensure_fully_multiline'], 67 | 'no_empty_statement' => true, 68 | 'no_leading_namespace_whitespace' => true, 69 | 'return_type_declaration' => ['space_before' => 'none'], 70 | 71 | 'method_chaining_indentation' => true, 72 | 'align_multiline_comment' => ['comment_type' => 'all_multiline'], 73 | 'no_superfluous_phpdoc_tags' => [ 74 | 'allow_mixed' => false, 75 | 'remove_inheritdoc' => false, 76 | 'allow_unused_params' => false, 77 | ], 78 | 'phpdoc_trim_consecutive_blank_line_separation' => true, 79 | 'phpdoc_trim' => true, 80 | 'no_empty_phpdoc' => true, 81 | 'clean_namespace' => true, 82 | 'array_indentation' => true, 83 | 'elseif' => true, 84 | 'phpdoc_order' => true, 85 | 'global_namespace_import' => [ 86 | 'import_classes' => true, 87 | 'import_constants' => false, 88 | 'import_functions' => false, 89 | ], 90 | 'fully_qualified_strict_types' => true, 91 | 'no_leading_import_slash' => true, 92 | ]) 93 | ->setLineEnding("\n"); 94 | -------------------------------------------------------------------------------- /src/Handlers/InboxMessageHandler.php: -------------------------------------------------------------------------------- 1 | topic = $topic; 27 | 28 | return $this; 29 | } 30 | 31 | public function setHandleWriteLog(?Closure $handleWriteLog): self 32 | { 33 | $this->handleWriteLog = $handleWriteLog; 34 | 35 | return $this; 36 | } 37 | 38 | public function process(int $limit = 10): int 39 | { 40 | $messages = $this->inboxMessageRepo->pullMessages($this->topic, $limit); 41 | if ($messages->isEmpty()) { 42 | return 0; 43 | } 44 | 45 | $processed = 0; 46 | foreach ($messages as $message) { 47 | if (!$this->lifecycle->isRunning()) { 48 | break; 49 | } 50 | 51 | try { 52 | call_user_func( 53 | $this->handleWriteLog, 54 | sprintf( 55 | '[MsgId: %s] Handling message with externalId: "%s"', 56 | $message->id, 57 | $message->externalId 58 | ) 59 | ); 60 | 61 | $this->processMessage($message); 62 | $processed++; 63 | 64 | call_user_func( 65 | $this->handleWriteLog, 66 | sprintf( 67 | '[MsgId: %s] Handled message with externalId: "%s"', 68 | $message->id, 69 | $message->externalId 70 | ) 71 | ); 72 | } catch (Throwable $e) { 73 | call_user_func( 74 | $this->handleWriteLog, 75 | sprintf( 76 | '[MsgId: %s] Failed to handle message with externalId: "%s" - Process will be aborted', 77 | $message->id, 78 | $message->externalId 79 | ) 80 | ); 81 | 82 | // something really bad happens, we need to stop the process 83 | Log::error('Failed to process inbox message', [ 84 | 'error' => [ 85 | 'msg' => $e->getMessage(), 86 | 'traces' => $e->getTrace(), 87 | ], 88 | ]); 89 | 90 | $this->lifecycle->forceClose(); 91 | 92 | throw $e; 93 | } 94 | } 95 | 96 | return $processed; 97 | } 98 | 99 | private function processMessage(InboxMessage $inboxMessage): void 100 | { 101 | $payload = $inboxMessage->getParsedPayload(); 102 | 103 | collect(InboxProcessSetup::getProcessors($this->topic)) 104 | ->map( 105 | fn (string|callable $processorClass) => 106 | is_callable($processorClass) 107 | ? $processorClass 108 | : app($processorClass) 109 | ) 110 | ->each(function (object|callable $processor) use ($payload) { 111 | if (is_callable($processor)) { 112 | call_user_func_array($processor, [$payload]); 113 | return; 114 | } 115 | 116 | method_exists($processor, 'handle') 117 | ? $processor->handle($payload) 118 | : $processor->__invoke($payload); 119 | }); 120 | 121 | $this->inboxMessageRepo->markAsProcessed($inboxMessage->id); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Commands/InboxWorkCommand.php: -------------------------------------------------------------------------------- 1 | alert('Laravel Inbox Process powered by ShipSaaS!!'); 28 | $this->info('Thank you for choosing and using our Inbox Process'); 29 | $this->info('We hope this would scale up and bring the reliability to your application.'); 30 | $this->info('Feel free to report any issue here: https://github.com/shipsaas/laravel-inbox-process/issues'); 31 | 32 | $this->topic = $this->argument('topic'); 33 | 34 | // acquire lock first 35 | $this->line('Acquiring the lock for topic: ' . $this->topic); 36 | if (!$runningInboxRepo->acquireLock($this->topic)) { 37 | $this->error(sprintf( 38 | 'Unable to lock the "%s" topic, are you sure it is not running?', 39 | $this->topic 40 | )); 41 | 42 | return 1; 43 | } 44 | 45 | $this->line('Locked topic: ' . $this->topic); 46 | $this->line('Starting up the inbox process for topic: ' . $this->topic); 47 | $this->registerLifecycle($runningInboxRepo, $inboxMessageHandler, $lifecycle); 48 | 49 | $inboxMessageHandler->setTopic($this->topic); 50 | $inboxMessageHandler->setHandleWriteLog($this->writeTraceLog(...)); 51 | $this->runInboxProcess($inboxMessageHandler, $lifecycle); 52 | 53 | return 0; 54 | } 55 | 56 | private function registerLifecycle( 57 | RunningInboxRepository $runningInboxRepo, 58 | InboxMessageHandler $inboxMessageHandler, 59 | Lifecycle $lifecycle 60 | ): void { 61 | $lifecycle->on(LifecycleEventEnum::CLOSING, function () use ($runningInboxRepo, $inboxMessageHandler) { 62 | $this->warn('Terminate request received. Inbox process will clean up before closing.'); 63 | 64 | $this->line('Unlocking topic "'.$this->topic.'"...'); 65 | $runningInboxRepo->unlock($this->topic); 66 | $this->line('Unlocked topic "'.$this->topic.'".'); 67 | 68 | $this->info('The Inbox Process stopped. See you again!'); 69 | }); 70 | } 71 | 72 | private function writeTraceLog(string $log): void 73 | { 74 | $this->option('log') && $this->line($log); 75 | } 76 | 77 | private function runInboxProcess( 78 | InboxMessageHandler $inboxMessageHandler, 79 | Lifecycle $lifecycle 80 | ): void { 81 | $limit = intval($this->option('limit')) ?: 10; 82 | $wait = intval($this->option('wait')) ?: 5; 83 | $maxProcessingTime = intval($this->option('max-processing-time')) ?: 3600; 84 | 85 | $processNeedToCloseAt = Carbon::now()->timestamp + $maxProcessingTime; 86 | 87 | while ($lifecycle->isRunning()) { 88 | $totalProcessed = $inboxMessageHandler->process($limit); 89 | 90 | // sleep and retry when there is no msg 91 | if (!$totalProcessed) { 92 | if ($this->option('stop-on-empty')) { 93 | $this->writeTraceLog('[Info] No message found. Stopping...'); 94 | 95 | break; 96 | } 97 | 98 | if (Carbon::now()->timestamp >= $processNeedToCloseAt) { 99 | $this->writeTraceLog('[Info] Reached max processing time. Closing the process.'); 100 | 101 | break; 102 | } 103 | 104 | $this->writeTraceLog('[Info] No message found. Sleeping...'); 105 | sleep($wait); 106 | continue; 107 | } 108 | 109 | $this->writeTraceLog(sprintf( 110 | '[%s] Processed %s inbox messages', 111 | Carbon::now()->toDateTimeString(), 112 | $totalProcessed 113 | )); 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /tests/Integration/Http/InboxControllerTest.php: -------------------------------------------------------------------------------- 1 | input('id'); 25 | } 26 | }); 27 | 28 | InboxProcessSetup::addResponse('stripe', function () { 29 | return new JsonResponse('OK MAN'); 30 | }); 31 | 32 | $this->json( 33 | 'POST', 34 | route('inbox.topic', ['topic' => 'stripe']), 35 | $body 36 | )->assertOk()->assertSee('OK MAN'); 37 | 38 | $this->assertDatabaseHas('inbox_messages', [ 39 | 'topic' => 'stripe', 40 | 'external_id' => 'evt_1NWX0RBGIr5C5v4TpncL2sCf', 41 | ]); 42 | } 43 | 44 | public function testRecordNewMessageUseDefaultResponse() 45 | { 46 | $body = json_decode( 47 | file_get_contents(__DIR__ . '/../__fixtures__/stripe_invoice_payment_succeed.json'), 48 | true 49 | ); 50 | 51 | InboxProcessSetup::addRequest('stripe', new class() extends AbstractInboxRequest { 52 | public function getInboxExternalId(): string 53 | { 54 | return $this->input('id'); 55 | } 56 | }); 57 | 58 | $this->json( 59 | 'POST', 60 | route('inbox.topic', ['topic' => 'stripe']), 61 | $body 62 | )->assertOk()->assertSee('OK'); 63 | 64 | $this->assertDatabaseHas('inbox_messages', [ 65 | 'topic' => 'stripe', 66 | 'external_id' => 'evt_1NWX0RBGIr5C5v4TpncL2sCf', 67 | ]); 68 | } 69 | 70 | public function testRecordDuplicatedMessageReturns409() 71 | { 72 | appendInboxMessage('stripe', 'evt_1NWX0RBGIr5C5v4TpncL2sCf', []); 73 | 74 | $body = json_decode( 75 | file_get_contents(__DIR__ . '/../__fixtures__/stripe_invoice_payment_succeed.json'), 76 | true 77 | ); 78 | 79 | InboxProcessSetup::addRequest('stripe', new class() extends AbstractInboxRequest { 80 | public function getInboxExternalId(): string 81 | { 82 | return $this->input('id'); 83 | } 84 | }); 85 | 86 | $this->json( 87 | 'POST', 88 | route('inbox.topic', ['topic' => 'stripe']), 89 | $body 90 | )->assertStatus(409); 91 | } 92 | 93 | public function testRecordUnknownIssueReturns400() 94 | { 95 | appendInboxMessage('stripe', 'evt_1NWX0RBGIr5C5v4TpncL2sCf', []); 96 | 97 | $body = json_decode( 98 | file_get_contents(__DIR__ . '/../__fixtures__/stripe_invoice_payment_succeed.json'), 99 | true 100 | ); 101 | 102 | InboxProcessSetup::addRequest('stripe', new class() extends AbstractInboxRequest { 103 | public function getInboxExternalId(): string 104 | { 105 | return $this->input('id'); 106 | } 107 | }); 108 | 109 | $mockRepo = $this->createMock(InboxMessageRepository::class); 110 | $mockRepo->expects($this->once()) 111 | ->method('append') 112 | ->willThrowException(new Error('Heehe')); 113 | $this->app->offsetSet(InboxMessageRepository::class, $mockRepo); 114 | 115 | $this->json( 116 | 'POST', 117 | route('inbox.topic', ['topic' => 'stripe']), 118 | $body 119 | )->assertStatus(400); 120 | } 121 | 122 | public function testInboxWillNotRecordIfAuthorizeReturnsFalse() 123 | { 124 | $body = json_decode( 125 | file_get_contents(__DIR__ . '/../__fixtures__/stripe_invoice_payment_succeed.json'), 126 | true 127 | ); 128 | 129 | InboxProcessSetup::addRequest('stripe', new class() extends AbstractInboxRequest { 130 | public function authorize(): bool 131 | { 132 | return false; 133 | } 134 | 135 | public function getInboxExternalId(): string 136 | { 137 | return $this->input('id'); 138 | } 139 | }); 140 | 141 | InboxProcessSetup::addResponse('stripe', function () { 142 | return new JsonResponse('OK MAN'); 143 | }); 144 | 145 | $this->json( 146 | 'POST', 147 | route('inbox.topic', ['topic' => 'stripe']), 148 | $body 149 | )->assertForbidden(); 150 | 151 | $this->assertDatabaseMissing('inbox_messages', [ 152 | 'topic' => 'stripe', 153 | 'external_id' => 'evt_1NWX0RBGIr5C5v4TpncL2sCf', 154 | ]); 155 | } 156 | 157 | public function testInboxWillNotRecordIfValidatesFail() 158 | { 159 | $body = json_decode( 160 | file_get_contents(__DIR__ . '/../__fixtures__/stripe_invoice_payment_succeed.json'), 161 | true 162 | ); 163 | 164 | InboxProcessSetup::addRequest('stripe', new class() extends AbstractInboxRequest { 165 | public function authorize(): bool 166 | { 167 | return true; 168 | } 169 | 170 | public function rules(): array 171 | { 172 | return [ 173 | 'this-is-a-fake-field-to-obtain-error' => 'required', 174 | ]; 175 | } 176 | 177 | public function getInboxExternalId(): string 178 | { 179 | return $this->input('id'); 180 | } 181 | }); 182 | 183 | InboxProcessSetup::addResponse('stripe', function () { 184 | return new JsonResponse('OK MAN'); 185 | }); 186 | 187 | $this->json( 188 | 'POST', 189 | route('inbox.topic', ['topic' => 'stripe']), 190 | $body 191 | )->assertUnprocessable(); 192 | 193 | $this->assertDatabaseMissing('inbox_messages', [ 194 | 'topic' => 'stripe', 195 | 'external_id' => 'evt_1NWX0RBGIr5C5v4TpncL2sCf', 196 | ]); 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /tests/Integration/Commands/InboxWorkCommandTest.php: -------------------------------------------------------------------------------- 1 | assertSame(0, $code); 56 | $this->assertStringContainsString('Processed 3 inbox messages', $result); 57 | 58 | $this->assertStringContainsString('Handling message with externalId: "evt_1NWX0RBGIr5C5v4TpncL2sCf"', $result); 59 | $this->assertStringContainsString('Handled message with externalId: "evt_1NWX0RBGIr5C5v4TpncL2sCf"', $result); 60 | 61 | $this->assertStringContainsString('Handling message with externalId: "evt_1NWUFiBGIr5C5v4TptQhGyW3"', $result); 62 | $this->assertStringContainsString('Handled message with externalId: "evt_1NWUFiBGIr5C5v4TptQhGyW3"', $result); 63 | 64 | $this->assertStringContainsString('Handling message with externalId: "evt_1Nh2fp2eZvKYlo2CzbNockEM"', $result); 65 | $this->assertStringContainsString('Handled message with externalId: "evt_1Nh2fp2eZvKYlo2CzbNockEM"', $result); 66 | 67 | $this->assertStringContainsString('[Info] No message found. Stopping...', $result); 68 | 69 | Event::assertDispatched( 70 | InvoicePaymentSucceedEvent::class, 71 | fn (InvoicePaymentSucceedEvent $event) => $event->invoiceId === 'in_1NVRYnBGIr5C5v4T6gwKxkt9' 72 | ); 73 | Event::assertDispatched( 74 | InvoicePaidEvent::class, 75 | fn (InvoicePaidEvent $event) => $event->invoiceId === 'in_1NVOkaBGIr5C5v4TZgwGUo0Q' 76 | ); 77 | Event::assertDispatched( 78 | CustomerUpdatedEvent::class, 79 | fn (CustomerUpdatedEvent $event) => $event->customerId === 'cus_9s6XKzkNRiz8i3' 80 | ); 81 | } 82 | 83 | public function testCommandDoNothingWhenThereIsNoMessage() 84 | { 85 | $code = Artisan::call('inbox:work test --stop-on-empty'); 86 | $result = Artisan::output(); 87 | 88 | $this->assertSame(0, $code); 89 | 90 | $this->assertStringContainsString('Locked topic: test', $result); 91 | $this->assertStringContainsString('[Info] No message found. Stopping...', $result); 92 | } 93 | 94 | public function testCommandStopsWhenUnableToAcquireLock() 95 | { 96 | DB::table('running_inboxes') 97 | ->insert(['topic' => 'seth']); 98 | 99 | $code = Artisan::call('inbox:work seth --stop-on-empty'); 100 | $result = Artisan::output(); 101 | 102 | $this->assertSame(1, $code); 103 | 104 | $this->assertStringContainsString('Unable to lock the "seth" topic', $result); 105 | } 106 | 107 | public function testCommandShouldUnlockTopicAfterStopped() 108 | { 109 | $code = Artisan::call('inbox:work testlock --stop-on-empty'); 110 | 111 | $this->assertSame(0, $code); 112 | 113 | /** 114 | * A bit tricky because we can't hit the SIGINT or SIGQUIT 115 | * So here we'll terminate, but the closing has been registered to the application's lifecycle 116 | * => it will unlock the topic 117 | */ 118 | $this->app->terminate(); 119 | 120 | $this->assertDatabaseMissing('running_inboxes', [ 121 | 'topic' => 'testlock', 122 | ]); 123 | } 124 | 125 | public function testCommandThrowsErrorWhenFailedToProcessAMessage() 126 | { 127 | InboxProcessSetup::addProcessor('with_err_msg', function () { 128 | throw new Error('Cannot process'); 129 | }); 130 | Log::expects('error')->atLeast()->once(); 131 | 132 | appendInboxMessage('with_err_msg', '1', []); 133 | 134 | $exception = null; 135 | try { 136 | $this->artisan('inbox:work with_err_msg --stop-on-empty'); 137 | } catch (Throwable $e) { 138 | $exception = $e; 139 | } 140 | 141 | $this->assertNotNull($exception); 142 | $this->assertInstanceOf(Error::class, $exception); 143 | 144 | $this->assertDatabaseMissing('running_inboxes', [ 145 | 'topic' => 'with_err_msg', 146 | ]); 147 | } 148 | 149 | public function testCommandStopsAfterAnAmountOfTime() 150 | { 151 | $beginAt = time(); 152 | 153 | $code = Artisan::call('inbox:work test --max-processing-time=10'); 154 | $result = Artisan::output(); 155 | 156 | $finishedAt = time(); 157 | 158 | $this->assertSame(0, $code); 159 | 160 | $this->assertStringContainsString('[Info] Reached max processing time. Closing the process.', $result); 161 | 162 | $this->assertGreaterThanOrEqual(10, $finishedAt - $beginAt); 163 | } 164 | } 165 | 166 | class InvoicePaymentSucceedEvent 167 | { 168 | public function __construct(public string $invoiceId) 169 | { 170 | } 171 | } 172 | 173 | class InvoicePaidEvent 174 | { 175 | public function __construct(public string $invoiceId) 176 | { 177 | } 178 | } 179 | 180 | class StripeInvoicePaidHandler 181 | { 182 | public function handle(array $payload): void 183 | { 184 | if ($payload['type'] !== 'invoice.paid') { 185 | return; 186 | } 187 | 188 | $invoiceId = data_get($payload, 'data.object.id'); 189 | Event::dispatch(new InvoicePaidEvent($invoiceId)); 190 | } 191 | } 192 | 193 | class CustomerUpdatedEvent 194 | { 195 | public function __construct(public string $customerId) 196 | { 197 | } 198 | } 199 | 200 | class StripCustomerUpdatedHandler 201 | { 202 | public function __invoke(array $payload): void 203 | { 204 | if ($payload['type'] !== 'customer.updated') { 205 | return; 206 | } 207 | 208 | $cusId = data_get($payload, 'data.object.id'); 209 | Event::dispatch(new CustomerUpdatedEvent($cusId)); 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /tests/Integration/__fixtures__/stripe_invoice_paid.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "evt_1NWUFiBGIr5C5v4TptQhGyW3", 3 | "data": { 4 | "object": { 5 | "id": "in_1NVOkaBGIr5C5v4TZgwGUo0Q", 6 | "tax": 1318, 7 | "paid": true, 8 | "lines": { 9 | "url": "/v1/invoices/in_1NVOkaBGIr5C5v4TZgwGUo0Q/lines", 10 | "data": [ 11 | { 12 | "id": "il_1NVOkaBGIr5C5v4TWDBViNry", 13 | "plan": null, 14 | "type": "invoiceitem", 15 | "price": { 16 | "id": "price_1NTEpJBGIr5C5v4TlW8AY7Ki", 17 | "type": "one_time", 18 | "active": false, 19 | "object": "price", 20 | "created": 1689213617, 21 | "product": "prod_OAvQ2QqE5w9qqQ", 22 | "currency": "aud", 23 | "livemode": false, 24 | "metadata": {}, 25 | "nickname": null, 26 | "recurring": null, 27 | "lookup_key": null, 28 | "tiers_mode": null, 29 | "unit_amount": 13180, 30 | "tax_behavior": "unspecified", 31 | "billing_scheme": "per_unit", 32 | "custom_unit_amount": null, 33 | "transform_quantity": null, 34 | "unit_amount_decimal": "13180" 35 | }, 36 | "amount": 13180, 37 | "object": "line_item", 38 | "period": { 39 | "end": 1689728420, 40 | "start": 1689728420 41 | }, 42 | "currency": "aud", 43 | "livemode": false, 44 | "metadata": {}, 45 | "quantity": 1, 46 | "discounts": [], 47 | "proration": false, 48 | "tax_rates": [ 49 | { 50 | "id": "txr_1NLLgXBGIr5C5v4TqHwHWSn6", 51 | "state": null, 52 | "active": true, 53 | "object": "tax_rate", 54 | "country": null, 55 | "created": 1687333357, 56 | "livemode": false, 57 | "metadata": {}, 58 | "tax_type": null, 59 | "inclusive": false, 60 | "percentage": 10, 61 | "description": "Default 10% GST exclusive tax rate", 62 | "display_name": "GST", 63 | "jurisdiction": null, 64 | "effective_percentage": 10 65 | } 66 | ], 67 | "description": "Same Day", 68 | "tax_amounts": [ 69 | { 70 | "amount": 1318, 71 | "tax_rate": "txr_1NLLgXBGIr5C5v4TqHwHWSn6", 72 | "inclusive": false, 73 | "taxable_amount": 13180, 74 | "taxability_reason": null 75 | } 76 | ], 77 | "discountable": true, 78 | "invoice_item": "ii_1NVOkaBGIr5C5v4TWaUbOWvu", 79 | "subscription": null, 80 | "discount_amounts": [], 81 | "proration_details": { 82 | "credited_items": null 83 | }, 84 | "amount_excluding_tax": 13180, 85 | "unit_amount_excluding_tax": "13180" 86 | } 87 | ], 88 | "object": "list", 89 | "has_more": false, 90 | "total_count": 1 91 | }, 92 | "quote": null, 93 | "total": 14498, 94 | "charge": "ch_3NVOkcBGIr5C5v4T1NvpeXmj", 95 | "footer": null, 96 | "number": "60FF5552-0110", 97 | "object": "invoice", 98 | "status": "paid", 99 | "created": 1689728420, 100 | "currency": "aud", 101 | "customer": "cus_O9rUsymo0k47a9", 102 | "discount": null, 103 | "due_date": null, 104 | "livemode": false, 105 | "metadata": {}, 106 | "subtotal": 13180, 107 | "attempted": true, 108 | "discounts": [], 109 | "amount_due": 14498, 110 | "period_end": 1691047938, 111 | "test_clock": null, 112 | "amount_paid": 14498, 113 | "application": null, 114 | "description": null, 115 | "invoice_pdf": "https://pay.stripe.com/invoice/acct_1MU0uJBGIr5C5v4T/test_YWNjdF8xTVUwdUpCR0lyNUM1djRULF9PSHlpQVVCcjUwR2R4ejB6REpkeHRrZzNXb2UzcndILDgwNTI4Njk40200gUnoJMLj/pdf?s=ap", 116 | "account_name": "SHIPSAAS", 117 | "auto_advance": false, 118 | "effective_at": 1689728421, 119 | "from_invoice": null, 120 | "on_behalf_of": null, 121 | "period_start": 1688369538, 122 | "subscription": null, 123 | "attempt_count": 1, 124 | "automatic_tax": { 125 | "status": null, 126 | "enabled": false 127 | }, 128 | "custom_fields": null, 129 | "customer_name": "The Customer PRO", 130 | "shipping_cost": null, 131 | "transfer_data": null, 132 | "billing_reason": "manual", 133 | "customer_email": null, 134 | "customer_phone": null, 135 | "default_source": null, 136 | "ending_balance": 0, 137 | "payment_intent": "pi_3NVOkcBGIr5C5v4T10CUqIMR", 138 | "receipt_number": null, 139 | "account_country": "AU", 140 | "account_tax_ids": null, 141 | "amount_shipping": 0, 142 | "latest_revision": null, 143 | "amount_remaining": 0, 144 | "customer_address": null, 145 | "customer_tax_ids": [], 146 | "paid_out_of_band": false, 147 | "payment_settings": { 148 | "default_mandate": null, 149 | "payment_method_types": null, 150 | "payment_method_options": null 151 | }, 152 | "shipping_details": null, 153 | "starting_balance": 0, 154 | "collection_method": "charge_automatically", 155 | "customer_shipping": null, 156 | "default_tax_rates": [ 157 | { 158 | "id": "txr_1NLLgXBGIr5C5v4TqHwHWSn6", 159 | "state": null, 160 | "active": true, 161 | "object": "tax_rate", 162 | "country": null, 163 | "created": 1687333357, 164 | "livemode": false, 165 | "metadata": {}, 166 | "tax_type": null, 167 | "inclusive": false, 168 | "percentage": 10, 169 | "description": "Default 10% GST exclusive tax rate", 170 | "display_name": "GST", 171 | "jurisdiction": null, 172 | "effective_percentage": 10 173 | } 174 | ], 175 | "rendering_options": null, 176 | "total_tax_amounts": [ 177 | { 178 | "amount": 1318, 179 | "tax_rate": "txr_1NLLgXBGIr5C5v4TqHwHWSn6", 180 | "inclusive": false, 181 | "taxable_amount": 13180, 182 | "taxability_reason": null 183 | } 184 | ], 185 | "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1MU0uJBGIr5C5v4T/test_YWNjdF8xTVUwdUpCR0lyNUM1djRULF9PSHlpQVVCcjUwR2R4ejB6REpkeHRrZzNXb2UzcndILDgwNTI4Njk40200gUnoJMLj?s=ap", 186 | "status_transitions": { 187 | "paid_at": 1689987896, 188 | "voided_at": null, 189 | "finalized_at": 1689728421, 190 | "marked_uncollectible_at": null 191 | }, 192 | "customer_tax_exempt": "none", 193 | "total_excluding_tax": 13180, 194 | "next_payment_attempt": null, 195 | "statement_descriptor": null, 196 | "webhooks_delivered_at": null, 197 | "application_fee_amount": null, 198 | "default_payment_method": null, 199 | "subtotal_excluding_tax": 13180, 200 | "total_discount_amounts": [], 201 | "last_finalization_error": null, 202 | "pre_payment_credit_notes_amount": 0, 203 | "post_payment_credit_notes_amount": 0 204 | } 205 | }, 206 | "type": "invoice.paid", 207 | "object": "event", 208 | "created": 1689987898, 209 | "request": { 210 | "id": null, 211 | "idempotency_key": null 212 | }, 213 | "livemode": false, 214 | "api_version": "2022-11-15", 215 | "pending_webhooks": 2 216 | } 217 | -------------------------------------------------------------------------------- /tests/Integration/__fixtures__/stripe_invoice_payment_succeed.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "evt_1NWX0RBGIr5C5v4TpncL2sCf", 3 | "data": { 4 | "object": { 5 | "id": "in_1NVRYnBGIr5C5v4T6gwKxkt9", 6 | "tax": 3427, 7 | "paid": true, 8 | "lines": { 9 | "url": "/v1/invoices/in_1NVRYnBGIr5C5v4T6gwKxkt9/lines", 10 | "data": [ 11 | { 12 | "id": "il_1NVRYnBGIr5C5v4T7ghcYILT", 13 | "plan": null, 14 | "type": "invoiceitem", 15 | "price": { 16 | "id": "price_1NQOl7BGIr5C5v4TX4CGTDgd", 17 | "type": "one_time", 18 | "active": false, 19 | "object": "price", 20 | "created": 1688536813, 21 | "product": "prod_OAvQ2QqE5w9qqQ", 22 | "currency": "aud", 23 | "livemode": false, 24 | "metadata": {}, 25 | "nickname": null, 26 | "recurring": null, 27 | "lookup_key": null, 28 | "tiers_mode": null, 29 | "unit_amount": 34268, 30 | "tax_behavior": "unspecified", 31 | "billing_scheme": "per_unit", 32 | "custom_unit_amount": null, 33 | "transform_quantity": null, 34 | "unit_amount_decimal": "34268" 35 | }, 36 | "amount": 34268, 37 | "object": "line_item", 38 | "period": { 39 | "end": 1689739221, 40 | "start": 1689739221 41 | }, 42 | "currency": "aud", 43 | "livemode": false, 44 | "metadata": {}, 45 | "quantity": 1, 46 | "discounts": [], 47 | "proration": false, 48 | "tax_rates": [ 49 | { 50 | "id": "txr_1NLLgXBGIr5C5v4TqHwHWSn6", 51 | "state": null, 52 | "active": true, 53 | "object": "tax_rate", 54 | "country": null, 55 | "created": 1687333357, 56 | "livemode": false, 57 | "metadata": {}, 58 | "tax_type": null, 59 | "inclusive": false, 60 | "percentage": 10, 61 | "description": "Default 10% GST exclusive tax rate", 62 | "display_name": "GST", 63 | "jurisdiction": null, 64 | "effective_percentage": 10 65 | } 66 | ], 67 | "description": "Same Day", 68 | "tax_amounts": [ 69 | { 70 | "amount": 3427, 71 | "tax_rate": "txr_1NLLgXBGIr5C5v4TqHwHWSn6", 72 | "inclusive": false, 73 | "taxable_amount": 34268, 74 | "taxability_reason": null 75 | } 76 | ], 77 | "discountable": true, 78 | "invoice_item": "ii_1NVRYnBGIr5C5v4Trb8RU9Nn", 79 | "subscription": null, 80 | "discount_amounts": [], 81 | "proration_details": { 82 | "credited_items": null 83 | }, 84 | "amount_excluding_tax": 34268, 85 | "unit_amount_excluding_tax": "34268" 86 | } 87 | ], 88 | "object": "list", 89 | "has_more": false, 90 | "total_count": 1 91 | }, 92 | "quote": null, 93 | "total": 37695, 94 | "charge": "ch_3NVRYoBGIr5C5v4T1EcxOREM", 95 | "footer": null, 96 | "number": "60FF5552-0113", 97 | "object": "invoice", 98 | "status": "paid", 99 | "created": 1689739221, 100 | "currency": "aud", 101 | "customer": "cus_O9rUsymo0k47a9", 102 | "discount": null, 103 | "due_date": null, 104 | "livemode": false, 105 | "metadata": {}, 106 | "subtotal": 34268, 107 | "attempted": true, 108 | "discounts": [], 109 | "amount_due": 37695, 110 | "period_end": 1691047938, 111 | "test_clock": null, 112 | "amount_paid": 37695, 113 | "application": null, 114 | "description": null, 115 | "invoice_pdf": "https://pay.stripe.com/invoice/acct_1MU0uJBGIr5C5v4T/test_YWNjdF8xTVUwdUpCR0lyNUM1djRULF9PSTFjMkZLR3JtVWx3bXZ0VEtUUkNDY01aYzJ4d25aLDgwNTM5Mjgz0200FrAxVg6v/pdf?s=ap", 116 | "account_name": "SHIPSAAS", 117 | "auto_advance": false, 118 | "effective_at": 1689739221, 119 | "from_invoice": null, 120 | "on_behalf_of": null, 121 | "period_start": 1688369538, 122 | "subscription": null, 123 | "attempt_count": 1, 124 | "automatic_tax": { 125 | "status": null, 126 | "enabled": false 127 | }, 128 | "custom_fields": null, 129 | "customer_name": "The Customer PRO", 130 | "shipping_cost": null, 131 | "transfer_data": null, 132 | "billing_reason": "manual", 133 | "customer_email": null, 134 | "customer_phone": null, 135 | "default_source": null, 136 | "ending_balance": 0, 137 | "payment_intent": "pi_3NVRYoBGIr5C5v4T1Wuq47lN", 138 | "receipt_number": null, 139 | "account_country": "AU", 140 | "account_tax_ids": null, 141 | "amount_shipping": 0, 142 | "latest_revision": null, 143 | "amount_remaining": 0, 144 | "customer_address": null, 145 | "customer_tax_ids": [], 146 | "paid_out_of_band": false, 147 | "payment_settings": { 148 | "default_mandate": null, 149 | "payment_method_types": null, 150 | "payment_method_options": null 151 | }, 152 | "shipping_details": null, 153 | "starting_balance": 0, 154 | "collection_method": "charge_automatically", 155 | "customer_shipping": null, 156 | "default_tax_rates": [ 157 | { 158 | "id": "txr_1NLLgXBGIr5C5v4TqHwHWSn6", 159 | "state": null, 160 | "active": true, 161 | "object": "tax_rate", 162 | "country": null, 163 | "created": 1687333357, 164 | "livemode": false, 165 | "metadata": {}, 166 | "tax_type": null, 167 | "inclusive": false, 168 | "percentage": 10, 169 | "description": "Default 10% GST exclusive tax rate", 170 | "display_name": "GST", 171 | "jurisdiction": null, 172 | "effective_percentage": 10 173 | } 174 | ], 175 | "rendering_options": null, 176 | "total_tax_amounts": [ 177 | { 178 | "amount": 3427, 179 | "tax_rate": "txr_1NLLgXBGIr5C5v4TqHwHWSn6", 180 | "inclusive": false, 181 | "taxable_amount": 34268, 182 | "taxability_reason": null 183 | } 184 | ], 185 | "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1MU0uJBGIr5C5v4T/test_YWNjdF8xTVUwdUpCR0lyNUM1djRULF9PSTFjMkZLR3JtVWx3bXZ0VEtUUkNDY01aYzJ4d25aLDgwNTM5Mjgz0200FrAxVg6v?s=ap", 186 | "status_transitions": { 187 | "paid_at": 1689998481, 188 | "voided_at": null, 189 | "finalized_at": 1689739221, 190 | "marked_uncollectible_at": null 191 | }, 192 | "customer_tax_exempt": "none", 193 | "total_excluding_tax": 34268, 194 | "next_payment_attempt": null, 195 | "statement_descriptor": null, 196 | "webhooks_delivered_at": null, 197 | "application_fee_amount": null, 198 | "default_payment_method": null, 199 | "subtotal_excluding_tax": 34268, 200 | "total_discount_amounts": [], 201 | "last_finalization_error": null, 202 | "pre_payment_credit_notes_amount": 0, 203 | "post_payment_credit_notes_amount": 0 204 | } 205 | }, 206 | "type": "invoice.payment_succeeded", 207 | "object": "event", 208 | "created": 1689998483, 209 | "request": { 210 | "id": null, 211 | "idempotency_key": null 212 | }, 213 | "livemode": false, 214 | "api_version": "2022-11-15", 215 | "pending_webhooks": 2 216 | } 217 | --------------------------------------------------------------------------------