├── .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 | [](https://github.com/shipsaas/laravel-inbox-process/actions/workflows/build.yml)
4 | [](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 | 
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 |
--------------------------------------------------------------------------------