diff --git a/.gitattributes b/.gitattributes
index 9dec809..bdab6cb 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -2,3 +2,6 @@
/AGENTS.md export-ignore
/CLAUDE.md export-ignore
/.github export-ignore
+/phpunit.xml export-ignore
+/phpstan.neon export-ignore
+/pint.json export-ignore
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..029e094
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,11 @@
+version: 2
+updates:
+ - package-ecosystem: composer
+ directory: /
+ schedule:
+ interval: weekly
+ open-pull-requests-limit: 5
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: weekly
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..ff27d52
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,14 @@
+## What changes and why
+
+Describe the problem and the smallest complete solution.
+
+## Evidence
+
+List the commands run and their results. For native changes, include the real
+compiled app, crash/relaunch results and screenshots, identifying the platform
+and whether it was a simulator or physical device. CI does not prove native
+crash capture.
+
+## Compatibility
+
+Describe public API, event format or minimum-version changes, if any.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..9ef56bc
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,65 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ COMPOSER_ROOT_VERSION: 0.2.x-dev
+
+jobs:
+ quality:
+ name: Quality
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ with:
+ persist-credentials: false
+ - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # v2
+ with:
+ php-version: '8.4'
+ extensions: mbstring, pdo_sqlite
+ tools: composer:v2
+ coverage: none
+ - run: composer validate --strict
+ - run: composer update --no-interaction --prefer-dist --no-progress
+ - run: composer audit
+ - run: composer test:lint
+ - run: composer test:types
+ - run: composer test:type-coverage
+
+ tests:
+ name: Tests (PHP ${{ matrix.php }}, Laravel ${{ matrix.laravel }})
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ['8.4', '8.5']
+ laravel: ['12', '13']
+ steps:
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ with:
+ persist-credentials: false
+ - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: mbstring, pdo_sqlite
+ tools: composer:v2
+ coverage: none
+ - name: Select Laravel version
+ env:
+ LARAVEL_VERSION: ${{ matrix.laravel }}
+ run: composer require --dev "orchestra/testbench:^$((LARAVEL_VERSION - 2)).0" --no-update --no-interaction
+ - run: composer update --no-interaction --prefer-dist --no-progress
+ - run: composer test:unit
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7bcc21a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+/vendor/
+/composer.lock
+/.phpunit.cache/
+/.phpunit.result.cache
+/.phpstan-cache/
+/.php-cs-fixer.cache
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ce81a6a..0d1e3c2 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,30 +1,58 @@
-# Contributing to the NativeWatch SDKs
+# Contributing
-Use PHP 8.4 or newer and Composer 2. Open an issue in the affected SDK repository
-before a substantial feature or a change to the event format. Include the SDK,
-Laravel and NativePHP versions, a minimal reproduction and redacted logs.
+Use PHP 8.4 or newer and Composer 2. Open an issue before a substantial feature
+or event-format change. Include SDK, Laravel and NativePHP versions, a minimal
+reproduction and redacted logs. Never publish credentials or customer reports.
+Report vulnerabilities privately to a repository maintainer.
-Shared capture, redaction and delivery belong in `native-core`. Desktop and
-mobile integrations belong in their platform packages. Keep changes focused,
-classes final, types explicit and files within 300 lines. Preserve the original
-application error when reporting fails. Never include credentials or customer
-crash reports in an issue, test fixture or pull request.
+Shared capture, redaction and delivery belong in `native-core`. Platform hooks
+belong in `native-desktop` or `native-mobile`. Keep changes focused, classes
+final, types explicit and files within 300 lines. Reporting must never replace
+the original application error.
-Validate a package checkout with:
+## Run the checks
```sh
-composer validate --strict
-composer install
+COMPOSER_ROOT_VERSION=0.2.x-dev composer install
+composer test
```
-The SDK regression tests run with the server's integration suite in the
-maintainers' checkout. A standalone `composer install` does not run those tests.
-Include a regression test or reproduction with a fix; maintainers run the full
-suite before merging. Changes to native reporting also need a compiled Release
-app, a real crash and relaunch, server-event assertions and screenshots. Identify
-the platform and distinguish simulator evidence from a physical device.
-
-In a pull request, explain the failing behavior, the smallest complete fix and
-the commands you actually ran. Include compatibility changes and any remaining
-limits. Report suspected vulnerabilities privately to a repository maintainer
-instead of opening an issue containing exploit details or private data.
+The root version identifies the development checkout as part of the 0.2 line.
+Composer will resolve a lock file locally; libraries do not commit it. Tests use
+Orchestra Testbench and fake HTTP, without the private server, production keys
+or a running NativePHP app. `composer test` runs Pint, PHPStan, 100% PHP type coverage and Pest. GitHub
+also validates Composer metadata, audits dependencies and runs Pest on PHP 8.4
+and 8.5 with Laravel 12 and 13.
+
+For one Laravel version, select its matching Testbench before installing:
+
+```sh
+composer require --dev 'orchestra/testbench:^10.0' --no-update
+COMPOSER_ROOT_VERSION=0.2.x-dev composer update
+composer test
+```
+
+Testbench 10 tests Laravel 12; Testbench 11 tests Laravel 13. Do not commit a local matrix selection to `composer.json`.
+
+The runtime dependency constraint still accepts Laravel 11, but CI does not
+verify it: Composer blocks its current releases due to security advisories.
+No advisory ignores or disabled security gates are used.
+
+Native reporting changes also require a compiled Release app, a real crash and
+relaunch, receiver assertions and screenshots. Identify the platform and
+whether evidence comes from a simulator or physical device. PHP CI does not
+compile Swift or Kotlin or establish native crash capture.
+
+## Submit a pull request
+
+Explain the failing behavior, the smallest complete fix and the commands you
+actually ran. Include a regression test, compatibility changes and remaining
+limits. Use a branch and open a pull request against `main`. Merging requires
+one approving review from another maintainer, resolved conversations and all
+required CI checks passing against the latest `main`. New commits dismiss old
+approvals. Force pushes and deletion of `main` are blocked.
+
+Workflow actions are pinned to full commit hashes and updated by Dependabot.
+Keep workflow permissions read-only and never expose secrets to forked code or
+use `pull_request_target` to execute a contributor's checkout. Published `v*`
+tags cannot be rewritten or deleted. Fix a release with a new version.
diff --git a/composer.json b/composer.json
index c11a79e..1c61c05 100644
--- a/composer.json
+++ b/composer.json
@@ -21,5 +21,37 @@
"docs": "https://native.watch/docs",
"issues": "https://github.com/zinxan/native.core/issues",
"source": "https://github.com/zinxan/native.core"
+ },
+ "require-dev": {
+ "larastan/larastan": "^3.9",
+ "laravel/pint": "^1.27",
+ "orchestra/testbench": "^10.0|^11.0",
+ "pestphp/pest": "^3.8|^4.0|^5.0",
+ "pestphp/pest-plugin-laravel": "^3.0|^4.0|^5.0",
+ "pestphp/pest-plugin-type-coverage": "^3.0|^4.0|^5.0",
+ "zinxan/native-desktop": "^0.2"
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "ZXN\\NativeWatch\\Core\\Tests\\": "tests/"
+ }
+ },
+ "config": {
+ "allow-plugins": {
+ "pestphp/pest-plugin": true
+ },
+ "sort-packages": true
+ },
+ "scripts": {
+ "test": [
+ "@test:lint",
+ "@test:types",
+ "@test:type-coverage",
+ "@test:unit"
+ ],
+ "test:lint": "pint --test",
+ "test:types": "phpstan analyse --memory-limit=1G",
+ "test:unit": "pest --compact",
+ "test:type-coverage": "pest --type-coverage --min=100"
}
}
diff --git a/docs/review/sdk-ci.md b/docs/review/sdk-ci.md
new file mode 100644
index 0000000..84fbb76
--- /dev/null
+++ b/docs/review/sdk-ci.md
@@ -0,0 +1,37 @@
+# Standalone SDK CI evidence
+
+Verified on 3 November 2026 with PHP 8.4.27 and Laravel 13.
+
+- `composer validate --strict`: passed.
+- `composer audit`: no advisories or abandoned dependencies.
+- `composer test`: Pint and PHPStan level 7 passed, 100% PHP type coverage,
+ 110 Pest tests and 542 assertions passed.
+- `git diff --check`: passed.
+
+The workflow repeats Pest across PHP 8.4/8.5 and Laravel 12/13. Its Quality
+job validates Composer, audits dependencies and runs lint, static analysis and
+type coverage. Every job runs for every pull request, without path filters.
+Both Actions are pinned to full commit hashes; checkout credentials are not
+persisted, the token is read-only and the workflow uses no repository secrets.
+
+The public suite runs against Orchestra Testbench rather than the private
+NativeWatch server. Server acceptance and end-to-end native crash behavior
+still require their respective integration checks. This CI does not compile
+or execute Swift, Kotlin, an iOS simulator or an Android device.
+
+The first standalone run exposed 15 failures caused by private-server fixture,
+model, schema and path dependencies. Those are replaced by a package-local
+compiled Swift fixture, a notifiable Laravel model and package-relative paths.
+The long-message test asserts the 8,192-character limit and valid UTF-8;
+validation against the server's complete FormRequest stays in its integration
+suite. Existing capture, consent, retry, watcher, release and symbol-upload
+regressions run without a production endpoint.
+
+PHPStan also found redundant type guards around scalar console inputs whose
+Laravel command signatures already enforce their type. Only those guards were
+removed; optional source-root and CI credential validation remain in place.
+
+The initial GitHub Laravel 11 matrix jobs were blocked by Composer security
+advisories, including PKSA-b41t-wbzk-9q51 and PKSA-d5tc-s1qs-h781. CI covers
+Laravel 12 and 13 without disabling advisory blocking. Runtime constraints are
+unchanged, but this pipeline does not establish Laravel 11 compatibility.
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..c236e86
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,10 @@
+includes:
+ - vendor/larastan/larastan/extension.neon
+
+parameters:
+ level: 7
+ paths:
+ - src
+ tmpDir: .phpstan-cache
+ dynamicConstantNames:
+ - FILTER_FLAG_GLOBAL_RANGE
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..7efc43c
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,23 @@
+
+
+
+
+ tests
+
+
+
+ src
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pint.json b/pint.json
new file mode 100644
index 0000000..d4c8c61
--- /dev/null
+++ b/pint.json
@@ -0,0 +1,58 @@
+{
+ "preset": "laravel",
+ "rules": {
+ "array_push": true,
+ "backtick_to_shell_exec": true,
+ "date_time_immutable": true,
+ "declare_strict_types": true,
+ "lowercase_keywords": true,
+ "lowercase_static_reference": true,
+ "final_class": true,
+ "final_internal_class": true,
+ "final_public_method_for_abstract_class": true,
+ "fully_qualified_strict_types": true,
+ "global_namespace_import": {
+ "import_classes": true,
+ "import_constants": true,
+ "import_functions": true
+ },
+ "mb_str_functions": true,
+ "modernize_types_casting": true,
+ "new_with_parentheses": false,
+ "no_superfluous_elseif": true,
+ "no_useless_else": true,
+ "no_multiple_statements_per_line": true,
+ "ordered_class_elements": {
+ "order": [
+ "use_trait",
+ "case",
+ "constant",
+ "constant_public",
+ "constant_protected",
+ "constant_private",
+ "property_public",
+ "property_protected",
+ "property_private",
+ "construct",
+ "destruct",
+ "magic",
+ "phpunit",
+ "method_abstract",
+ "method_public_static",
+ "method_public",
+ "method_protected_static",
+ "method_protected",
+ "method_private_static",
+ "method_private"
+ ],
+ "sort_algorithm": "none"
+ },
+ "ordered_interfaces": true,
+ "ordered_traits": true,
+ "protected_to_private": true,
+ "self_accessor": true,
+ "self_static_accessor": true,
+ "strict_comparison": true,
+ "visibility_required": true
+ }
+}
diff --git a/src/Laravel/Commands/ReleaseCommand.php b/src/Laravel/Commands/ReleaseCommand.php
index 7a15be2..5258fd5 100644
--- a/src/Laravel/Commands/ReleaseCommand.php
+++ b/src/Laravel/Commands/ReleaseCommand.php
@@ -23,9 +23,6 @@ public function handle(BuildReleaseManifest $builder, CiConnection $connection):
{
$path = $this->option('path') ?? base_path();
$version = $this->argument('version');
- if (! is_string($path) || ! is_string($version)) {
- return self::FAILURE;
- }
try {
if ($this->option('id-only')) {
$this->line($builder->buildId($path, $version));
diff --git a/src/Laravel/Commands/SymbolsCommand.php b/src/Laravel/Commands/SymbolsCommand.php
index d7aa2ff..7e58544 100644
--- a/src/Laravel/Commands/SymbolsCommand.php
+++ b/src/Laravel/Commands/SymbolsCommand.php
@@ -23,7 +23,7 @@ public function handle(FindDebugSymbols $find, CiConnection $connection): int
try {
$path = $this->argument('path');
$root = $this->option('source-root');
- if (! is_string($path) || ! is_string($root) || $root === '') {
+ if (! is_string($root) || $root === '') {
throw new RuntimeException('Pass --source-root with the absolute checkout root used to compile this build.');
}
$request = $connection->request();
diff --git a/tests/ClientTest.php b/tests/ClientTest.php
index ab47937..0b71086 100644
--- a/tests/ClientTest.php
+++ b/tests/ClientTest.php
@@ -175,7 +175,7 @@
&& $request['breadcrumbs'] === []);
});
-it('keeps long exception messages within the ingest contract', function (bool $native): void {
+it('truncates long exception messages without splitting UTF-8 characters', function (bool $native): void {
Http::fake(['zxguard.test/*' => Http::response(status: 202)]);
$client = client();
$message = str_repeat('é', 8_193);
@@ -189,5 +189,5 @@
$client->flush();
Http::assertSent(fn (Request $request): bool => mb_strlen($request['exception']['value']) === 8_192
- && validator($request->data(), (new App\Http\Requests\Api\V1\StoreOccurrenceRequest)->rules())->passes());
+ && mb_check_encoding($request['exception']['value'], 'UTF-8'));
})->with([false, true]);
diff --git a/tests/Fixtures/Symbols/CrashFixture.dwarf b/tests/Fixtures/Symbols/CrashFixture.dwarf
new file mode 100644
index 0000000..56990d0
Binary files /dev/null and b/tests/Fixtures/Symbols/CrashFixture.dwarf differ
diff --git a/tests/Fixtures/Symbols/CrashFixture.swift b/tests/Fixtures/Symbols/CrashFixture.swift
new file mode 100644
index 0000000..a8667d1
--- /dev/null
+++ b/tests/Fixtures/Symbols/CrashFixture.swift
@@ -0,0 +1,7 @@
+@inline(never)
+func nativeWatchFixtureFailure(_ value: Int) -> Int {
+ precondition(value != 0, "NativeWatch symbolication fixture")
+ return 84 / value
+}
+
+print(nativeWatchFixtureFailure(CommandLine.arguments.count - 1))
diff --git a/tests/Fixtures/Symbols/README.md b/tests/Fixtures/Symbols/README.md
new file mode 100644
index 0000000..7bae467
--- /dev/null
+++ b/tests/Fixtures/Symbols/README.md
@@ -0,0 +1,28 @@
+# Compiled Swift symbol fixture
+
+`CrashFixture.dwarf` is real optimized Mach-O debug information built from
+`CrashFixture.swift`, using Xcode 27.1. It is not a fabricated symbol response.
+The tests run the installed LLVM symbolizer and official Swift demangler against
+its bytes on macOS and Linux CI.
+
+Recorded identity: `bf2dbb4a-b545-3baa-a8a7-8b40b5ea12c5`, arm64,
+preferred image base `0x100000000`. Offset `0x714` resolves to
+`nativeWatchFixtureFailure`, source line 4. A return address of `0x718` resolves
+to the same instruction after the architecture adjustment.
+
+To rebuild from the repository root on an Apple Silicon Mac:
+
+```sh
+xcrun swiftc -c -g -O -module-name NativeWatchSymbolFixture \
+ -debug-prefix-map "$PWD=/nativewatch-fixtures" \
+ tests/Fixtures/Symbols/CrashFixture.swift -o /tmp/nativewatch-symbol-fixture.o
+xcrun swiftc -g /tmp/nativewatch-symbol-fixture.o -o /tmp/nativewatch-symbol-fixture
+xcrun dsymutil /tmp/nativewatch-symbol-fixture -o /tmp/nativewatch-symbol-fixture.dSYM
+cp /tmp/nativewatch-symbol-fixture.dSYM/Contents/Resources/DWARF/nativewatch-symbol-fixture \
+ tests/Fixtures/Symbols/CrashFixture.dwarf
+xcrun dwarfdump --uuid tests/Fixtures/Symbols/CrashFixture.dwarf
+```
+
+A rebuild can change UUIDs and offsets; update the corresponding assertions
+from actual tool output. Prefix remapping keeps developer home paths out of
+this fixture. This fixture proves symbol resolution, not crash capture.
diff --git a/tests/Pest.php b/tests/Pest.php
new file mode 100644
index 0000000..dfac152
--- /dev/null
+++ b/tests/Pest.php
@@ -0,0 +1,75 @@
+extend(TestCase::class)->in(__DIR__);
+
+function temporaryOutbox(): string
+{
+ $path = sys_get_temp_dir().'/nativewatch-tests/'.bin2hex(random_bytes(6));
+ mkdir($path, 0755, true);
+
+ return $path;
+}
+
+/**
+ * Get the activity entries that the SDK sent, after the app finished its work.
+ *
+ * @return list>
+ */
+function sentActivities(?string $signal = null): array
+{
+ app()->terminate();
+
+ $entries = [];
+
+ foreach (Illuminate\Support\Facades\Http::recorded() as [$request]) {
+ if (str_ends_with($request->url(), '/api/v1/activities')) {
+ array_push($entries, ...$request['entries']);
+ }
+ }
+
+ return array_values(array_filter($entries, fn (array $entry): bool => $signal === null || $entry['signal'] === $signal));
+}
+
+/**
+ * Get the events that the SDK sent, after the app finished its work.
+ *
+ * @return list>
+ */
+function sentOccurrences(): array
+{
+ app()->terminate();
+
+ $events = [];
+
+ foreach (Illuminate\Support\Facades\Http::recorded() as [$request]) {
+ if (str_ends_with($request->url(), '/api/v1/occurrences')) {
+ $events[] = $request->data();
+ }
+ }
+
+ return $events;
+}
+
+/**
+ * Register the desktop SDK with a fake NativeWatch server, for watcher tests.
+ *
+ * @param array $config
+ */
+function fakeNativeWatch(array $config = []): void
+{
+ Illuminate\Support\Facades\Http::fake(['zxguard.test/*' => Illuminate\Support\Facades\Http::response(status: 202)]);
+
+ config([
+ 'nativewatch.key' => 'nw_test',
+ 'nativewatch.url' => 'https://zxguard.test',
+ 'nativewatch.outbox.path' => temporaryOutbox(),
+ 'nativewatch.consent.path' => temporaryOutbox().'/consent.json',
+ ...$config,
+ ]);
+
+ app()->register(ZXN\NativeWatch\Desktop\DesktopServiceProvider::class);
+}
diff --git a/tests/StackFramesTest.php b/tests/StackFramesTest.php
index 4fb381e..bb9df16 100644
--- a/tests/StackFramesTest.php
+++ b/tests/StackFramesTest.php
@@ -6,12 +6,12 @@
it('starts with the line that threw and marks frames of the app', function (): void {
$exception = new RuntimeException('Boom');
- $basePath = dirname(__DIR__, 3);
+ $basePath = dirname(__DIR__);
$frames = new StackFrames($basePath)->fromThrowable($exception);
expect($frames[0])
- ->file->toBe('packages/native-core/tests/StackFramesTest.php')
+ ->file->toBe('tests/StackFramesTest.php')
->line->toBe(__LINE__ - 7)
->in_app->toBeTrue();
@@ -32,7 +32,7 @@
$line = __LINE__ + 1;
$exception = new RuntimeException('Boom');
- $frames = new StackFrames(dirname(__DIR__, 3), codeLines: 2)->fromThrowable($exception);
+ $frames = new StackFrames(dirname(__DIR__), codeLines: 2)->fromThrowable($exception);
$code = $frames[0]['code'];
expect($code)->not->toBeNull()
@@ -46,7 +46,7 @@
});
it('sends no source code when it is turned off', function (): void {
- $frames = new StackFrames(dirname(__DIR__, 3), codeLines: 0)->fromThrowable(new RuntimeException('Boom'));
+ $frames = new StackFrames(dirname(__DIR__), codeLines: 0)->fromThrowable(new RuntimeException('Boom'));
expect(array_column($frames, 'code'))->each->toBeNull();
});
diff --git a/tests/SymbolsCommandTest.php b/tests/SymbolsCommandTest.php
index d0c3a9d..38747a9 100644
--- a/tests/SymbolsCommandTest.php
+++ b/tests/SymbolsCommandTest.php
@@ -12,7 +12,7 @@
$this->app->register(DesktopServiceProvider::class);
config(['nativewatch.url' => 'http://zxguard.test']);
putenv('NATIVEWATCH_RELEASE_TOKEN=nwr_ci-symbol-secret');
- $this->dwarf = __DIR__.'/../../../tests/Fixtures/Symbols/CrashFixture.dwarf';
+ $this->dwarf = __DIR__.'/Fixtures/Symbols/CrashFixture.dwarf';
$this->receipt = ['symbols' => [['imageUuid' => 'bf2dbb4a-b545-3baa-a8a7-8b40b5ea12c5', 'architecture' => ['value' => 'arm64'], 'sha256' => hash_file('sha256', $this->dwarf)]]];
});
diff --git a/tests/TestCase.php b/tests/TestCase.php
new file mode 100644
index 0000000..2e42f83
--- /dev/null
+++ b/tests/TestCase.php
@@ -0,0 +1,26 @@
+set([
+ 'app.key' => 'base64:'.base64_encode(str_repeat('a', 32)),
+ 'cache.default' => 'array',
+ 'database.default' => 'testing',
+ 'database.connections.testing' => ['driver' => 'sqlite', 'database' => ':memory:'],
+ 'mail.default' => 'array',
+ 'queue.default' => 'sync',
+ 'session.driver' => 'array',
+ 'nativewatch.key' => null,
+ ]);
+ }
+}
diff --git a/tests/Watchers/NotificationWatcherTest.php b/tests/Watchers/NotificationWatcherTest.php
index 3c0d7ab..6e529ac 100644
--- a/tests/Watchers/NotificationWatcherTest.php
+++ b/tests/Watchers/NotificationWatcherTest.php
@@ -27,7 +27,10 @@ public function toArray(object $notifiable): array
it('records sent and failed notifications with their channel', function (): void {
fakeNativeWatch();
- $user = App\Models\User::factory()->make();
+ $user = new class extends Illuminate\Foundation\Auth\User
+ {
+ use Illuminate\Notifications\Notifiable;
+ };
Illuminate\Support\Facades\Schema::create('notifications', function (Illuminate\Database\Schema\Blueprint $table): void {
$table->uuid('id')->primary();