├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ └── main.yml ├── .gitignore ├── README.md ├── art └── screenshot.png ├── composer.json ├── dist └── validation.css ├── infection.json5 ├── package.json ├── phpstan.neon ├── phpunit.10.xml ├── phpunit.xml ├── postcss.config.js ├── resources ├── css │ └── validation.css └── views │ └── validation-errors.blade.php ├── src ├── Cards │ └── ValidationErrors.php ├── Recorders │ └── ValidationErrors.php ├── ValidationErrorsServiceProvider.php └── ValidationExceptionOccurred.php ├── tailwind.config.js ├── testbench.yaml ├── tests ├── CardTest.php ├── RecorderTest.php ├── TestCase.php └── TestClasses │ └── DummyComponent.php ├── vite.config.js └── workbench ├── app ├── Models │ └── .gitkeep └── Providers │ └── WorkbenchServiceProvider.php ├── bootstrap ├── .gitkeep └── app.php ├── database ├── factories │ └── .gitkeep ├── migrations │ └── .gitkeep └── seeders │ ├── .gitkeep │ └── DatabaseSeeder.php ├── resources └── views │ ├── .gitkeep │ └── vendor │ └── pulse │ └── dashboard.blade.php └── routes ├── .gitkeep ├── console.php └── web.php /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: composer 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "19:00" 8 | open-pull-requests-limit: 10 9 | - package-ecosystem: npm 10 | directory: "/" 11 | schedule: 12 | interval: daily 13 | time: "19:00" 14 | open-pull-requests-limit: 10 15 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v4 17 | 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: latest 21 | 22 | - name: Install NPM dependencies 23 | run: npm install 24 | 25 | - name: Compile assets 26 | run: npm run build 27 | 28 | - name: Commit compiled files 29 | uses: stefanzweifel/git-auto-commit-action@v5 30 | with: 31 | commit_message: Compile Assets 32 | file_pattern: dist/ 33 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | checks: 11 | runs-on: ubuntu-latest 12 | name: 'Check' 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | 17 | - name: Get composer cache directory 18 | id: composer-cache 19 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 20 | 21 | - name: Cache dependencies 22 | uses: actions/cache@v4 23 | with: 24 | path: ${{ steps.composer-cache.outputs.dir }} 25 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} 26 | restore-keys: ${{ runner.os }}-composer- 27 | 28 | - name: Setup PHP 29 | uses: shivammathur/setup-php@v2 30 | with: 31 | php-version: '8.3' 32 | coverage: pcov 33 | tools: infection, pint, phpstan 34 | 35 | - name: Install dependencies 36 | run: composer install 37 | 38 | - name: Check platform requirements 39 | run: composer check-platform-reqs 40 | 41 | - name: Pint 42 | run: pint --test 43 | 44 | # Pest does not support Infection. Might need to migrate to PHPUnit. 45 | - name: Infection 46 | run: infection --show-mutations 47 | 48 | - name: PHPStan 49 | run: phpstan -v 50 | 51 | tests: 52 | runs-on: ubuntu-latest 53 | name: 'PHP ${{ matrix.php }} Testbench ${{ matrix.testbench }} PHPUnit ${{ matrix.phpunit }}' 54 | strategy: 55 | matrix: 56 | php: ['8.1', '8.2', '8.3'] 57 | include: 58 | - php: '8.1' 59 | testbench: '8.0' 60 | phpunit: '10' 61 | - php: '8.2' 62 | testbench: '9.0' 63 | phpunit: '11' 64 | - php: '8.3' 65 | testbench: '9.0' 66 | phpunit: '11' 67 | steps: 68 | - name: Checkout code 69 | uses: actions/checkout@v4 70 | 71 | - name: Get composer cache directory 72 | id: composer-cache 73 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 74 | 75 | - name: Cache dependencies 76 | uses: actions/cache@v4 77 | with: 78 | path: ${{ steps.composer-cache.outputs.dir }} 79 | key: ${{ runner.os }}-php-${{ matrix.php }}-testbench-${{ matrix.testbench }}-composer-${{ hashFiles('**/composer.json') }} 80 | restore-keys: ${{ runner.os }}-php-${{ matrix.php }}-testbench-${{ matrix.testbench }}-composer- 81 | 82 | - name: Setup PHP 83 | uses: shivammathur/setup-php@v2 84 | with: 85 | php-version: ${{ matrix.php }} 86 | coverage: none 87 | 88 | - name: Install dependencies 89 | run: | 90 | composer require --no-update --dev \ 91 | orchestra/testbench:^${{ matrix.testbench }} 92 | composer update 93 | 94 | - name: Configure PHPUnit 95 | run: "if [ -f './phpunit.${{ matrix.phpunit }}.xml' ]; then cp ./phpunit.${{ matrix.phpunit }}.xml ./phpunit.xml; fi" 96 | 97 | - name: PHPUnit 98 | run: ./vendor/bin/phpunit --do-not-cache-result 99 | 100 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /composer.lock 2 | /node_modules 3 | /vendor 4 | /.phpunit.cache 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Validation errors card for Laravel Pulse 2 | 3 | A card for Laravel Pulse to show validation errors impacting users. 4 | 5 |

Validation errors card for Laravel Pulse

6 | 7 | ## Installation 8 | 9 | First, install the package via composer: 10 | 11 | ```sh 12 | composer require timacdonald/pulse-validation-errors 13 | ``` 14 | 15 | Next, add the recorder to your `config/pulse.php`: 16 | 17 | ```php 18 | 19 | return [ 20 | // ... 21 | 22 | 'recorders' => [ 23 | TiMacDonald\Pulse\Recorders\ValidationErrors::class => [ 24 | 'enabled' => env('PULSE_VALIDATION_ERRORS_ENABLED', true), 25 | 'sample_rate' => env('PULSE_VALIDATION_ERRORS_SAMPLE_RATE', 1), 26 | 'capture_messages' => true, 27 | 'ignore' => [ 28 | // '#^/login$#', 29 | // '#^/register$#', 30 | // '#^/forgot-password$#', 31 | ], 32 | 'groups' => [ 33 | // '#^/products/.*$#' => '/products/{user}', 34 | ], 35 | ], 36 | 37 | // ... 38 | ], 39 | ]; 40 | ``` 41 | 42 | Next, add the card to your `resources/views/vendor/pulse/dashboard.php`: 43 | 44 | ```blade 45 | 46 | 47 | 48 | 49 | 50 | ``` 51 | 52 | Finally, get to improving your user experience. At LaraconUS I gave a [talk on how much our validation sucks](https://youtu.be/MMc2TzBY6l4?si=UEu8dLuRK4XT30yK). If you are here, you likely also care about how your users experience validation errors on your app, so I'd love you to give it a watch. 53 | 54 | 55 | ## Features 56 | 57 | - Supports session based validation errors 58 | - Supports API validation errors 59 | - Support Inertia validation errors 60 | - Support Livewire validation errors 61 | - Supports multiple error bags 62 | - Fallback for undetectable validation errors (based on 422 response status) 63 | - Capture generic validation exceptions for custom response types 64 | 65 | ## Ignore specific error messages 66 | 67 | You may ignore specific endpoints via the recorders `ignore` key, however in some situations you may need more complex ignore rules. You can use [Pulse's built in `Pulse::filter` method](https://laravel.com/docs/11.x/pulse#filtering) to achieve this. 68 | 69 | Here is an example where we are ignore a specific error message: 70 | 71 | ```php 72 | use Laravel\Pulse\Entry; 73 | use Laravel\Pulse\Facades\Pulse; 74 | use Laravel\Pulse\Value; 75 | 76 | /** 77 | * Bootstrap any application services. 78 | */ 79 | public function boot(): void 80 | { 81 | Pulse::filter(fn ($entry): bool => match ($entry->type) { 82 | 'validation_error' => ! Str::contains($entry->key, [ 83 | 'The password is incorrect.', 84 | 'Your password has appeared in a data leak.', 85 | // ... 86 | ]), 87 | // ... 88 | default => true, 89 | }); 90 | } 91 | ``` 92 | 93 | ## Capture validation errors for custom response formats 94 | 95 | If you are returning custom response formats, you may see `__laravel_unknown` in the dashboard instead of the input names and error messages. This is because the package parses the response body to determine the validation errors. When the body is in an unrecognised format it is unable to parse the keys and messages from the response. 96 | 97 | You should instead dispatch the `ValidationExceptionOccurred` event to pass the validation messages to the card's recorder. You may do this wherever you are converting your exceptions into responses. This usually happens in the `app/Exceptions/Handler`: 98 | 99 | ```php 100 | Event::dispatch(new ValidationExceptionOccurred($request, $e))); 118 | } 119 | 120 | // custom exception rendering logic... 121 | } 122 | } 123 | ``` 124 | -------------------------------------------------------------------------------- /art/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timacdonald/pulse-validation-errors/ef9883e99d27c1e749a62431c46e34f225534aec/art/screenshot.png -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "timacdonald/pulse-validation-errors", 3 | "description": "Validation errors card for Laravel Pulse", 4 | "keywords": [ 5 | "laravel", 6 | "pulse", 7 | "validation", 8 | "errors" 9 | ], 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Tim MacDonald", 14 | "email": "hello@timacdonald.me", 15 | "homepage": "https://timacdonald.me" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.1", 20 | "laravel/pulse": "^1.2" 21 | }, 22 | "require-dev": { 23 | "inertiajs/inertia-laravel": "^1.0", 24 | "orchestra/testbench": "^9.0", 25 | "phpunit/phpunit": "^10.0 || ^11.0" 26 | }, 27 | "config": { 28 | "preferred-install": "dist", 29 | "sort-packages": true 30 | }, 31 | "autoload": { 32 | "psr-4": { 33 | "TiMacDonald\\Pulse\\": "src" 34 | } 35 | }, 36 | "autoload-dev": { 37 | "psr-4": { 38 | "Tests\\": "tests/", 39 | "Workbench\\App\\": "workbench/app/", 40 | "Workbench\\Database\\Factories\\": "workbench/database/factories/", 41 | "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" 42 | } 43 | }, 44 | "minimum-stability": "stable", 45 | "prefer-stable": true, 46 | "extra": { 47 | "laravel": { 48 | "providers": [ 49 | "TiMacDonald\\Pulse\\ValidationErrorsServiceProvider" 50 | ] 51 | } 52 | }, 53 | "scripts": { 54 | "post-autoload-dump": [ 55 | "@php vendor/bin/testbench package:purge-skeleton --ansi", 56 | "@php vendor/bin/testbench package:discover --ansi" 57 | ], 58 | "serve": [ 59 | "Composer\\Config::disableProcessTimeout", 60 | "@php vendor/bin/testbench workbench:build --ansi", 61 | "@php vendor/bin/testbench serve" 62 | ] 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /dist/validation.css: -------------------------------------------------------------------------------- 1 | *,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }#validation-card :is(.mt-2){margin-top:.5rem}#validation-card :is(.block){display:block}#validation-card :is(.flex){display:flex}#validation-card :is(.h-2){height:.5rem}#validation-card :is(.h-6){height:1.5rem}#validation-card :is(.w-32){width:8rem}#validation-card :is(.w-6){width:1.5rem}#validation-card :is(.max-w-\[1px\]){max-width:1px}#validation-card :is(.gap-2){gap:.5rem}#validation-card :is(.space-y-2>:not([hidden])~:not([hidden])){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}#validation-card :is(.overflow-hidden){overflow:hidden}#validation-card :is(.truncate){overflow:hidden;text-overflow:ellipsis;white-space:nowrap}#validation-card :is(.text-center){text-align:center}#validation-card :is(.text-right){text-align:right}#validation-card :is(.text-xs){font-size:.75rem;line-height:1rem}#validation-card :is(.font-bold){font-weight:700}#validation-card :is(.font-medium){font-weight:500}#validation-card :is(.text-gray-400){--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity))}#validation-card :is(.text-gray-500){--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity))}#validation-card :is(.text-gray-700){--tw-text-opacity: 1;color:rgb(55 65 81 / var(--tw-text-opacity))}#validation-card :is(.text-gray-900){--tw-text-opacity: 1;color:rgb(17 24 39 / var(--tw-text-opacity))}#validation-card :is(.first\:h-0:first-child){height:0px}#validation-card :is(.dark\:text-gray-100:is(.dark *)){--tw-text-opacity: 1;color:rgb(243 244 246 / var(--tw-text-opacity))}#validation-card :is(.dark\:text-gray-300:is(.dark *)){--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity))}#validation-card :is(.dark\:text-gray-400:is(.dark *)){--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity))} 2 | -------------------------------------------------------------------------------- /infection.json5: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "vendor/infection/infection/resources/schema.json", 3 | "source": { 4 | "directories": [ 5 | "src" 6 | ] 7 | }, 8 | "mutators": { 9 | "@default": true, 10 | "@function_signature": false 11 | }, 12 | "minMsi": 100 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "scripts": { 4 | "build": "vite build", 5 | "watch": "vite build --watch" 6 | }, 7 | "devDependencies": { 8 | "autoprefixer": "^10.4.13", 9 | "postcss": "^8.4.31", 10 | "tailwindcss": "^3.4.4", 11 | "vite": "^5.2.13" 12 | } 13 | } 14 | 15 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: max 3 | paths: 4 | - src 5 | -------------------------------------------------------------------------------- /phpunit.10.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | tests 14 | 15 | 16 | 17 | 18 | src 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | tests 14 | 15 | 16 | 17 | 18 | src 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /resources/css/validation.css: -------------------------------------------------------------------------------- 1 | @config "../../tailwind.config.js"; 2 | @tailwind base; 3 | @tailwind components; 4 | @tailwind utilities; 5 | -------------------------------------------------------------------------------- /resources/views/validation-errors.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | @if ($errors->isEmpty()) 16 | 17 | @else 18 | 19 | 20 | 21 | Error 22 | Count 23 | 24 | 25 | 26 | @foreach ($errors->take(100) as $error) 27 | 28 | 29 | 30 |
31 | {{ $error->bag ? $error->bag.' @ ' : '' }}{{ $error->name }}{{ $error->message ? ':' : '' }} 32 | @if ($error->message) 33 | {{ $error->message }} 34 | @endif 35 |
36 |
37 | 38 | 39 | {{ $error->uri }} 40 | 41 |
42 |

43 | {{ $error->action }} 44 |

45 |
46 | 47 | @if ($config['sample_rate'] !== 1) 48 | ~{{ number_format($error->count * (1 / $config['sample_rate'])) }} 49 | @else 50 | {{ number_format($error->count) }} 51 | @endif 52 | 53 | 54 | @endforeach 55 | 56 |
57 | 58 | @if ($errors->count() > 100) 59 |
Limited to 100 entries
60 | @endif 61 | @endif 62 |
63 |
64 | -------------------------------------------------------------------------------- /src/Cards/ValidationErrors.php: -------------------------------------------------------------------------------- 1 | remember( 29 | fn () => Pulse::aggregate( 30 | 'validation_error', 31 | ['count'], 32 | $this->periodAsInterval(), 33 | )->map(function (object $row) { // @phpstan-ignore argument.type 34 | /** @var object{ key: string, count: int } $row */ 35 | [$method, $uri, $action, $bag, $name, $message] = json_decode($row->key, flags: JSON_THROW_ON_ERROR) + [5 => null]; 36 | 37 | return (object) [ 38 | 'bag' => match ($bag) { 39 | 'default' => null, 40 | default => $bag, 41 | }, 42 | 'uri' => $uri, 43 | 'name' => $name, 44 | 'action' => $action, 45 | 'method' => $method, 46 | 'message' => $message, 47 | 'count' => $row->count, 48 | 'key_hash' => md5($row->key), 49 | ]; 50 | })); 51 | 52 | return View::make('timacdonald::validation-errors', [ 53 | 'time' => $time, 54 | 'runAt' => $runAt, 55 | 'errors' => $errors, 56 | 'config' => [ 57 | 'sample_rate' => 1, 58 | ...Config::get('pulse.recorders.'.ValidationErrorsRecorder::class, []), // @phpstan-ignore arrayUnpacking.nonIterable 59 | ], 60 | ]); 61 | } 62 | 63 | /** 64 | * Define any CSS that should be loaded for the component. 65 | * 66 | * @return string|\Illuminate\Contracts\Support\Htmlable|array|null 67 | */ 68 | protected function css() 69 | { 70 | return __DIR__.'/../../dist/validation.css'; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Recorders/ValidationErrors.php: -------------------------------------------------------------------------------- 1 | 42 | */ 43 | public array $listen = [ 44 | RequestHandled::class, 45 | ValidationExceptionOccurred::class, 46 | ]; 47 | 48 | /** 49 | * Create a new recorder instance. 50 | */ 51 | public function __construct( 52 | protected Pulse $pulse, 53 | protected Repository $config, 54 | ) { 55 | // 56 | } 57 | 58 | public function register(callable $record, Application $app): void 59 | { 60 | $this->afterResolving($app, 'livewire', function (LivewireManager $livewire) use ($record, $app) { 61 | $livewire->listen('exception', function (Component $component, Throwable $exception) use ($record, $app) { 62 | if (! $exception instanceof ValidationException) { 63 | return; 64 | } 65 | 66 | with($app['request'], function (Request $request) use ($record, $exception) { // @phpstan-ignore argument.type 67 | // Livewire can reuse the same request instance when polling or 68 | // performing grouped requests. 69 | /** @infection-ignore-all */ 70 | $request->attributes->remove('pulse_validation_messages_recorded'); 71 | 72 | $record(new ValidationExceptionOccurred($request, $exception)); 73 | }); 74 | }); 75 | }); 76 | } 77 | 78 | /** 79 | * Record validation errors. 80 | */ 81 | public function record(ValidationExceptionOccurred|RequestHandled $event): void 82 | { 83 | if ( 84 | $event->request->route() === null || 85 | ! $this->shouldSample() 86 | ) { 87 | return; 88 | } 89 | 90 | $this->pulse->lazy(function () use ($event) { 91 | if ($event->request->attributes->get('pulse_validation_messages_recorded')) { 92 | return; 93 | } 94 | 95 | [$path, $via] = $this->resolveRoutePath($event->request); 96 | 97 | if ($this->shouldIgnore($path)) { 98 | return; 99 | } 100 | 101 | $event->request->attributes->set('pulse_validation_messages_recorded', true); 102 | 103 | $path = $this->group($path); 104 | 105 | $this->parseValidationErrors($event)->each(fn (array $values) => $this->pulse->record( 106 | 'validation_error', 107 | json_encode([$event->request->method(), $path, $via, ...$values], flags: JSON_THROW_ON_ERROR), 108 | )->count()); 109 | }); 110 | } 111 | 112 | /** 113 | * Parse validation errors. 114 | * 115 | * @return \Illuminate\Support\Collection 116 | */ 117 | protected function parseValidationErrors(ValidationExceptionOccurred|RequestHandled $event): Collection 118 | { 119 | if ($event instanceof ValidationExceptionOccurred) { 120 | return $this->parseValidationExceptionMessages($event->request, $event->exception); 121 | } 122 | 123 | /** @infection-ignore-all */ 124 | return $this->parseSessionValidationErrors($event->request, $event->response) 125 | ?? $this->parseJsonValidationErrors($event->request, $event->response) 126 | ?? $this->parseUnknownValidationErrors($event->request, $event->response) 127 | ?? collect([]); 128 | } 129 | 130 | /** 131 | * Parse session validation errors. 132 | * 133 | * @return null|\Illuminate\Support\Collection 134 | */ 135 | protected function parseSessionValidationErrors(Request $request, SymfonyResponse $response): ?Collection 136 | { 137 | if ( 138 | ! $request->hasSession() || 139 | ! in_array($response->getStatusCode(), [302, 303]) || 140 | ! ($errors = $request->session()->get('errors', null)) instanceof ViewErrorBag 141 | ) { 142 | return null; 143 | } 144 | 145 | if ($this->shouldCaptureMessages()) { 146 | return collect($errors->getBags()) 147 | ->flatMap(fn (MessageBag $bag, string $bagName) => collect($bag->getMessages()) 148 | ->flatMap(fn (array $messages, string $inputName) => array_map( 149 | fn (string $message) => [$bagName, $inputName, $message], $messages) 150 | )); 151 | } 152 | 153 | return collect($errors->getBags())->flatMap( 154 | fn (MessageBag $bag, string $bagName) => array_map(fn (string $inputName) => [$bagName, $inputName], $bag->keys()) 155 | ); 156 | } 157 | 158 | /** 159 | * Parse validation exception errors. 160 | * 161 | * @return \Illuminate\Support\Collection 162 | */ 163 | protected function parseValidationExceptionMessages(Request $request, ValidationException $exception): Collection 164 | { 165 | if ($this->shouldCaptureMessages()) { 166 | return collect($exception->validator->errors()) 167 | // Livewire is adding all the errors in a "list" merged in with 168 | // the expected validation errors. We will reject any of those 169 | // with "list" keys and just maintain those with input name 170 | // keys. 171 | ->reject(fn (array|string $value, int|string $key) => ! is_string($key)) 172 | ->flatMap(fn (array $messages, string $inputName) => array_map( // @phpstan-ignore argument.type 173 | fn (string $message) => [$exception->errorBag, $inputName, $message], $messages) 174 | ); 175 | } 176 | 177 | return collect($exception->validator->errors()->keys()) 178 | ->map(fn (string $inputName) => [$exception->errorBag, $inputName]); 179 | } 180 | 181 | /** 182 | * Parse JSON validation errors. 183 | * 184 | * @return null|\Illuminate\Support\Collection 185 | */ 186 | protected function parseJsonValidationErrors(Request $request, SymfonyResponse $response): ?Collection 187 | { 188 | if ( 189 | $response->getStatusCode() !== 422 || 190 | ! $response instanceof JsonResponse || 191 | ! is_array($response->original) || 192 | ! array_key_exists('errors', $response->original) || 193 | ! is_array($response->original['errors']) || 194 | array_is_list($errors = $response->original['errors']) 195 | ) { 196 | return null; 197 | } 198 | 199 | if ($this->shouldCaptureMessages()) { 200 | return collect($errors)->flatMap(fn (array $messages, string $inputName) => array_map( 201 | fn (string $message) => ['default', $inputName, $message], $messages) 202 | ); 203 | } 204 | 205 | return collect($errors)->keys()->map(fn (string $inputName) => ['default', $inputName]); 206 | } 207 | 208 | /** 209 | * Parse unknown validation errors. 210 | * 211 | * @return null|\Illuminate\Support\Collection 212 | */ 213 | protected function parseUnknownValidationErrors(Request $request, SymfonyResponse $response): ?Collection 214 | { 215 | if ($response->getStatusCode() !== 422) { 216 | return null; 217 | } 218 | 219 | return collect([[ // @phpstan-ignore return.type 220 | 'default', 221 | '__laravel_unknown', 222 | ...($this->shouldCaptureMessages() ? ['__laravel_unknown'] : []), 223 | ]]); 224 | } 225 | 226 | /** 227 | * Determine if the card should capture messages. 228 | */ 229 | protected function shouldCaptureMessages(): bool 230 | { 231 | return $this->config->get('pulse.recorders.'.static::class.'.capture_messages', true); // @phpstan-ignore return.type 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/ValidationErrorsServiceProvider.php: -------------------------------------------------------------------------------- 1 | loadViewsFrom(__DIR__.'/../resources/views', 'timacdonald'); 28 | 29 | $this->callAfterResolving('livewire', function (LivewireManager $livewire) { 30 | $livewire->component('pulse.validation-errors', ValidationErrors::class); 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/ValidationExceptionOccurred.php: -------------------------------------------------------------------------------- 1 | count(); 26 | Pulse::ingest(); 27 | 28 | Livewire::test(ValidationErrors::class, ['lazy' => false]) 29 | ->assertViewHas('errors', function ($errors) { 30 | $this->assertCount(1, $errors); 31 | 32 | $this->assertEquals($errors[0], (object) [ 33 | 'method' => 'POST', 34 | 'uri' => '/register', 35 | 'action' => 'App\Http\Controllers\RegisterController@store', 36 | 'bag' => null, 37 | 'name' => 'email', 38 | 'message' => null, 39 | 'count' => 1, 40 | 'key_hash' => md5('["POST","\/register","App\\\\Http\\\\Controllers\\\\RegisterController@store","default","email"]'), 41 | ]); 42 | 43 | return true; 44 | }); 45 | } 46 | 47 | public function test_it_optionally_supports_messages() 48 | { 49 | Pulse::record( 50 | type: 'validation_error', 51 | key: json_encode([ 52 | 'POST', 53 | '/register', 54 | 'App\Http\Controllers\RegisterController@store', 55 | 'default', 56 | 'email', 57 | 'The email field is required.', 58 | ], flags: JSON_THROW_ON_ERROR), 59 | )->count(); 60 | Pulse::ingest(); 61 | 62 | Livewire::test(ValidationErrors::class, ['lazy' => false]) 63 | ->assertViewHas('errors', function ($errors) { 64 | $this->assertCount(1, $errors); 65 | 66 | $this->assertEquals($errors[0], (object) [ 67 | 'method' => 'POST', 68 | 'uri' => '/register', 69 | 'action' => 'App\Http\Controllers\RegisterController@store', 70 | 'bag' => null, 71 | 'name' => 'email', 72 | 'message' => 'The email field is required.', 73 | 'count' => 1, 74 | 'key_hash' => md5('["POST","\/register","App\\\\Http\\\\Controllers\\\\RegisterController@store","default","email","The email field is required."]'), 75 | ]); 76 | 77 | return true; 78 | }); 79 | } 80 | 81 | public function test_it_supports_error_bags() 82 | { 83 | Pulse::record( 84 | type: 'validation_error', 85 | key: json_encode([ 86 | 'POST', 87 | '/register', 88 | 'App\Http\Controllers\RegisterController@store', 89 | 'custom_1', 90 | 'email', 91 | 'The email field is required.', 92 | ], flags: JSON_THROW_ON_ERROR), 93 | )->count(); 94 | Pulse::ingest(); 95 | 96 | Livewire::test(ValidationErrors::class, ['lazy' => false]) 97 | ->assertViewHas('errors', function ($errors) { 98 | $this->assertCount(1, $errors); 99 | 100 | $this->assertEquals($errors[0], (object) [ 101 | 'method' => 'POST', 102 | 'uri' => '/register', 103 | 'action' => 'App\Http\Controllers\RegisterController@store', 104 | 'bag' => 'custom_1', 105 | 'name' => 'email', 106 | 'message' => 'The email field is required.', 107 | 'count' => 1, 108 | 'key_hash' => md5('["POST","\/register","App\\\\Http\\\\Controllers\\\\RegisterController@store","custom_1","email","The email field is required."]'), 109 | ]); 110 | 111 | return true; 112 | }); 113 | } 114 | 115 | public function test_it_shows_sample_rate_when_active() 116 | { 117 | for ($i = 0; $i < 99; $i++) { 118 | Pulse::record( 119 | type: 'validation_error', 120 | key: json_encode([ 121 | 'POST', 122 | '/register', 123 | 'App\Http\Controllers\RegisterController@store', 124 | 'custom_1', 125 | 'email', 126 | 'The email field is required.', 127 | ], flags: JSON_THROW_ON_ERROR), 128 | )->count(); 129 | } 130 | Pulse::ingest(); 131 | 132 | Livewire::test(ValidationErrors::class, ['lazy' => false]) 133 | ->assertSee(' 99') 134 | ->assertDontSee('Sample rate'); 135 | 136 | Config::set('pulse.recorders.'.ValidationErrorsRecorder::class.'.sample_rate', 0.3); 137 | 138 | Livewire::test(ValidationErrors::class, ['lazy' => false]) 139 | ->assertSeeHtml('title="Sample rate: 0.3, Raw value: 99">~330'); 140 | } 141 | 142 | public function test_it_provides_custom_css() 143 | { 144 | /** @var ValidationErrors */ 145 | $card = app(ValidationErrors::class); 146 | 147 | $card->dehydrate(); 148 | 149 | $this->assertStringContainsString('#validation-card', Pulse::css()); 150 | } 151 | 152 | public function test_it_registers_the_card() 153 | { 154 | $registery = app(ComponentRegistry::class); 155 | 156 | $name = $registery->getName(ValidationErrors::class); 157 | 158 | $this->assertSame('pulse.validation-errors', $name); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /tests/RecorderTest.php: -------------------------------------------------------------------------------- 1 | throw $e); 31 | 32 | Config::set([ 33 | 'pulse.ingest.trim.lottery' => [1, 1], 34 | 'pulse.recorders.'.ValidationErrors::class => [], 35 | ]); 36 | } 37 | 38 | protected function tearDown(): void 39 | { 40 | if (Pulse::wantsIngesting()) { 41 | throw new RuntimeException('There are pending entries.'); 42 | } 43 | 44 | parent::tearDown(); 45 | } 46 | 47 | public function test_it_captures_validation_errors_from_the_session() 48 | { 49 | Config::set('pulse.recorders.'.ValidationErrors::class.'.capture_messages', false); 50 | Route::post('users', fn () => Request::validate([ 51 | 'email' => 'required', 52 | ]))->middleware('web'); 53 | 54 | $response = $this->post('users'); 55 | 56 | $response->assertStatus(302); 57 | $response->assertInvalid('email'); 58 | $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->where('type', 'validation_error')->get()); 59 | $this->assertCount(1, $entries); 60 | $this->assertSame('["POST","\/users","Closure","default","email"]', $entries[0]->key); 61 | $aggregates = Pulse::ignore(fn () => DB::table('pulse_aggregates')->where('type', 'validation_error')->orderBy('period')->get()); 62 | $this->assertSame(array_fill(0, 4, '["POST","\/users","Closure","default","email"]'), $aggregates->pluck('key')->all()); 63 | $this->assertSame(array_fill(0, 4, 'count'), $aggregates->pluck('aggregate')->all()); 64 | $this->assertTrue($aggregates->pluck('value')->every(fn ($value) => $value == 1.0)); 65 | } 66 | 67 | public function test_it_captures_validation_errors_from_the_session_with_dedicated_bags() 68 | { 69 | Config::set('pulse.recorders.'.ValidationErrors::class.'.capture_messages', false); 70 | Route::post('users', fn () => Request::validateWithBag('foo', [ 71 | 'email' => 'required', 72 | ]))->middleware('web'); 73 | 74 | $response = $this->post('users'); 75 | 76 | $response->assertStatus(302); 77 | $response->assertInvalid('email', 'foo'); 78 | $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->where('type', 'validation_error')->get()); 79 | $this->assertCount(1, $entries); 80 | $this->assertSame('["POST","\/users","Closure","foo","email"]', $entries[0]->key); 81 | $aggregates = Pulse::ignore(fn () => DB::table('pulse_aggregates')->where('type', 'validation_error')->orderBy('period')->get()); 82 | $this->assertSame(array_fill(0, 4, '["POST","\/users","Closure","foo","email"]'), $aggregates->pluck('key')->all()); 83 | $this->assertSame(array_fill(0, 4, 'count'), $aggregates->pluck('aggregate')->all()); 84 | $this->assertTrue($aggregates->pluck('value')->every(fn ($value) => $value == 1.0)); 85 | } 86 | 87 | public function test_it_captures_validation_errors_from_the_session_with_multiple_bags() 88 | { 89 | Config::set('pulse.recorders.'.ValidationErrors::class.'.capture_messages', false); 90 | Route::post('users', function () { 91 | return Redirect::back()->withErrors(['email' => 'The email field is required.']) 92 | ->withErrors(['email' => 'The email field is required.'], 'custom_1') 93 | ->withErrors(['email' => 'The email field is required.'], 'custom_2'); 94 | })->middleware('web'); 95 | 96 | $response = $this->post('users'); 97 | 98 | $response->assertStatus(302); 99 | $response->assertInvalid('email'); 100 | $response->assertInvalid('email', 'custom_1'); 101 | $response->assertInvalid('email', 'custom_2'); 102 | $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->where('type', 'validation_error')->get()); 103 | $this->assertCount(3, $entries); 104 | $this->assertSame('["POST","\/users","Closure","default","email"]', $entries[0]->key); 105 | $this->assertSame('["POST","\/users","Closure","custom_1","email"]', $entries[1]->key); 106 | $this->assertSame('["POST","\/users","Closure","custom_2","email"]', $entries[2]->key); 107 | $aggregates = Pulse::ignore(fn () => DB::table('pulse_aggregates')->where('type', 'validation_error')->orderBy('period')->get()); 108 | $this->assertSame([ 109 | '["POST","\/users","Closure","default","email"]', 110 | '["POST","\/users","Closure","custom_1","email"]', 111 | '["POST","\/users","Closure","custom_2","email"]', 112 | '["POST","\/users","Closure","default","email"]', 113 | '["POST","\/users","Closure","custom_1","email"]', 114 | '["POST","\/users","Closure","custom_2","email"]', 115 | '["POST","\/users","Closure","default","email"]', 116 | '["POST","\/users","Closure","custom_1","email"]', 117 | '["POST","\/users","Closure","custom_2","email"]', 118 | '["POST","\/users","Closure","default","email"]', 119 | '["POST","\/users","Closure","custom_1","email"]', 120 | '["POST","\/users","Closure","custom_2","email"]', 121 | ], $aggregates->pluck('key')->all()); 122 | $this->assertSame(array_fill(0, 12, 'count'), $aggregates->pluck('aggregate')->all()); 123 | $this->assertTrue($aggregates->pluck('value')->every(fn ($value) => $value == 1.0)); 124 | } 125 | 126 | public function test_it_captures_validation_error_keys_from_livewire_components() 127 | { 128 | Config::set('pulse.recorders.'.ValidationErrors::class.'.capture_messages', false); 129 | Livewire::component('dummy', DummyComponent::class); 130 | 131 | Str::createRandomStringsUsing(fn () => 'random-string'); 132 | Livewire::test(DummyComponent::class) 133 | ->call('save') 134 | ->assertHasErrors('email'); 135 | 136 | $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->where('type', 'validation_error')->get()); 137 | $this->assertCount(1, $entries); 138 | $this->assertSame('["POST","\/livewire-unit-test-endpoint\/random-string","via \/livewire\/update","default","email"]', $entries[0]->key); 139 | $aggregates = Pulse::ignore(fn () => DB::table('pulse_aggregates')->where('type', 'validation_error')->orderBy('period')->get()); 140 | $this->assertSame(array_fill(0, 4, '["POST","\/livewire-unit-test-endpoint\/random-string","via \/livewire\/update","default","email"]'), $aggregates->pluck('key')->all()); 141 | $this->assertSame(array_fill(0, 4, 'count'), $aggregates->pluck('aggregate')->all()); 142 | $this->assertTrue($aggregates->pluck('value')->every(fn ($value) => $value == 1.0)); 143 | } 144 | 145 | public function test_it_ignore_livewire_exceptions_that_are_not_validation_exceptions() 146 | { 147 | Livewire::component('dummy', DummyComponent::class); 148 | 149 | try { 150 | Livewire::test(DummyComponent::class)->call('throw'); 151 | $this->fail(); 152 | } catch (RuntimeException $e) { 153 | $this->assertSame('Whoops!', $e->getMessage()); 154 | } 155 | 156 | $count = Pulse::ignore(fn () => DB::table('pulse_entries')->where('type', 'validation_error')->count()); 157 | $this->assertSame(0, $count); 158 | $count = Pulse::ignore(fn () => DB::table('pulse_aggregates')->where('type', 'validation_error')->orderBy('period')->count()); 159 | $this->assertSame(0, $count); 160 | } 161 | 162 | public function test_it_captures_validation_error_messages_from_livewire_components() 163 | { 164 | Livewire::component('dummy', DummyComponent::class); 165 | 166 | Str::createRandomStringsUsing(fn () => 'random-string'); 167 | Livewire::test(DummyComponent::class) 168 | ->call('save') 169 | ->assertHasErrors('email'); 170 | 171 | $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->where('type', 'validation_error')->get()); 172 | $this->assertCount(1, $entries); 173 | $this->assertSame('["POST","\/livewire-unit-test-endpoint\/random-string","via \/livewire\/update","default","email","The email field is required."]', $entries[0]->key); 174 | $aggregates = Pulse::ignore(fn () => DB::table('pulse_aggregates')->where('type', 'validation_error')->orderBy('period')->get()); 175 | $this->assertSame(array_fill(0, 4, '["POST","\/livewire-unit-test-endpoint\/random-string","via \/livewire\/update","default","email","The email field is required."]'), $aggregates->pluck('key')->all()); 176 | $this->assertSame(array_fill(0, 4, 'count'), $aggregates->pluck('aggregate')->all()); 177 | $this->assertTrue($aggregates->pluck('value')->every(fn ($value) => $value == 1.0)); 178 | } 179 | 180 | public function test_it_does_not_capture_validation_errors_from_redirects_when_there_is_no_session() 181 | { 182 | Config::set('pulse.recorders.'.ValidationErrors::class.'.capture_messages', false); 183 | Route::post('users', fn () => Request::validate([ 184 | 'email' => 'required', 185 | ])); 186 | 187 | $response = $this->post('users'); 188 | 189 | $response->assertStatus(302); 190 | $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->where('type', 'validation_error')->get()); 191 | $this->assertCount(0, $entries); 192 | $aggregates = Pulse::ignore(fn () => DB::table('pulse_aggregates')->where('type', 'validation_error')->orderBy('period')->get()); 193 | $this->assertCount(0, $aggregates); 194 | } 195 | 196 | public function test_it_does_not_capture_validation_errors_from_redirects_when_the_errors_key_is_not_a_ViewErrorBag_with_session() 197 | { 198 | Config::set('pulse.recorders.'.ValidationErrors::class.'.capture_messages', false); 199 | Route::post('users', fn () => redirect()->back()->with('errors', 'Something happened!'))->middleware('web'); 200 | 201 | $response = $this->post('users'); 202 | 203 | $response->assertStatus(302); 204 | $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->where('type', 'validation_error')->get()); 205 | $this->assertCount(0, $entries); 206 | $aggregates = Pulse::ignore(fn () => DB::table('pulse_aggregates')->where('type', 'validation_error')->orderBy('period')->get()); 207 | $this->assertCount(0, $aggregates); 208 | } 209 | 210 | public function test_it_captures_one_entry_for_a_field_when_multiple_errors_are_present_for_the_given_field_from_the_session() 211 | { 212 | Config::set('pulse.recorders.'.ValidationErrors::class.'.capture_messages', false); 213 | Route::post('users', fn () => Request::validate([ 214 | 'email' => 'string|min:5', 215 | ]))->middleware('web'); 216 | 217 | $response = $this->post('users', [ 218 | 'email' => 4, 219 | ]); 220 | 221 | $response->assertStatus(302); 222 | $response->assertInvalid([ 223 | 'email' => [ 224 | 'The email field must be a string.', 225 | 'The email field must be at least 5 characters.', 226 | ], 227 | ]); 228 | $response->assertInvalid(['email' => 'The email field must be at least 5 characters.']); 229 | $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->where('type', 'validation_error')->get()); 230 | $this->assertCount(1, $entries); 231 | $this->assertSame('["POST","\/users","Closure","default","email"]', $entries[0]->key); 232 | $aggregates = Pulse::ignore(fn () => DB::table('pulse_aggregates')->where('type', 'validation_error')->orderBy('period')->get()); 233 | $this->assertSame(array_fill(0, 4, '["POST","\/users","Closure","default","email"]'), $aggregates->pluck('key')->all()); 234 | $this->assertSame(array_fill(0, 4, 'count'), $aggregates->pluck('aggregate')->all()); 235 | $this->assertTrue($aggregates->pluck('value')->every(fn ($value) => $value == 1.0)); 236 | } 237 | 238 | public function test_it_captures_a_generic_error_when_it_is_unable_to_parse_the_validation_error_fields_from_the_session() 239 | { 240 | Config::set('pulse.recorders.'.ValidationErrors::class.'.capture_messages', false); 241 | Route::post('users', fn () => response('

An error occurred.

', 422))->middleware('web'); 242 | 243 | $response = $this->post('users'); 244 | 245 | $response->assertStatus(422); 246 | $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->where('type', 'validation_error')->get()); 247 | $this->assertCount(1, $entries); 248 | $this->assertSame('["POST","\/users","Closure","default","__laravel_unknown"]', $entries[0]->key); 249 | $aggregates = Pulse::ignore(fn () => DB::table('pulse_aggregates')->where('type', 'validation_error')->orderBy('period')->get()); 250 | $this->assertSame(array_fill(0, 4, '["POST","\/users","Closure","default","__laravel_unknown"]'), $aggregates->pluck('key')->all()); 251 | $this->assertSame(array_fill(0, 4, 'count'), $aggregates->pluck('aggregate')->all()); 252 | $this->assertTrue($aggregates->pluck('value')->every(fn ($value) => $value == 1.0)); 253 | } 254 | 255 | public function test_it_captures_API_validation_errors() 256 | { 257 | Config::set('pulse.recorders.'.ValidationErrors::class.'.capture_messages', false); 258 | Route::post('users', fn () => Request::validate([ 259 | 'email' => 'required', 260 | ]))->middleware('api'); 261 | 262 | $response = $this->postJson('users'); 263 | 264 | $response->assertStatus(422); 265 | $response->assertInvalid('email'); 266 | $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->where('type', 'validation_error')->get()); 267 | $this->assertCount(1, $entries); 268 | $this->assertSame('["POST","\/users","Closure","default","email"]', $entries[0]->key); 269 | $aggregates = Pulse::ignore(fn () => DB::table('pulse_aggregates')->where('type', 'validation_error')->orderBy('period')->get()); 270 | $this->assertSame(array_fill(0, 4, '["POST","\/users","Closure","default","email"]'), $aggregates->pluck('key')->all()); 271 | $this->assertSame(array_fill(0, 4, 'count'), $aggregates->pluck('aggregate')->all()); 272 | $this->assertTrue($aggregates->pluck('value')->every(fn ($value) => $value == 1.0)); 273 | } 274 | 275 | public function test_it_captures_unknown_API_validation_error_for_non_Illuminate_Json_responses() 276 | { 277 | Config::set('pulse.recorders.'.ValidationErrors::class.'.capture_messages', false); 278 | Route::post('users', fn () => new SymfonyJsonResponse(['errors' => ['email' => 'Is required.']], 422)) 279 | ->middleware('api'); 280 | 281 | $response = $this->postJson('users'); 282 | 283 | $response->assertStatus(422); 284 | $response->assertInvalid('email'); 285 | $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->where('type', 'validation_error')->get()); 286 | $this->assertCount(1, $entries); 287 | $this->assertSame('["POST","\/users","Closure","default","__laravel_unknown"]', $entries[0]->key); 288 | $aggregates = Pulse::ignore(fn () => DB::table('pulse_aggregates')->where('type', 'validation_error')->orderBy('period')->get()); 289 | $this->assertSame(array_fill(0, 4, '["POST","\/users","Closure","default","__laravel_unknown"]'), $aggregates->pluck('key')->all()); 290 | $this->assertSame(array_fill(0, 4, 'count'), $aggregates->pluck('aggregate')->all()); 291 | $this->assertTrue($aggregates->pluck('value')->every(fn ($value) => $value == 1.0)); 292 | } 293 | 294 | public function test_it_captures_unknown_API_validation_error_for_non_array_Json_content() 295 | { 296 | Config::set('pulse.recorders.'.ValidationErrors::class.'.capture_messages', false); 297 | Route::post('users', fn () => new IlluminateJsonResponse('An error occurred.', 422)) 298 | ->middleware('api'); 299 | 300 | $response = $this->postJson('users'); 301 | 302 | $response->assertStatus(422); 303 | $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->where('type', 'validation_error')->get()); 304 | $this->assertCount(1, $entries); 305 | $this->assertSame('["POST","\/users","Closure","default","__laravel_unknown"]', $entries[0]->key); 306 | $aggregates = Pulse::ignore(fn () => DB::table('pulse_aggregates')->where('type', 'validation_error')->orderBy('period')->get()); 307 | $this->assertSame(array_fill(0, 4, '["POST","\/users","Closure","default","__laravel_unknown"]'), $aggregates->pluck('key')->all()); 308 | $this->assertSame(array_fill(0, 4, 'count'), $aggregates->pluck('aggregate')->all()); 309 | $this->assertTrue($aggregates->pluck('value')->every(fn ($value) => $value == 1.0)); 310 | } 311 | 312 | public function test_it_captures_unknown_API_validation_error_for_array_content_mising_errors_key() 313 | { 314 | Config::set('pulse.recorders.'.ValidationErrors::class.'.capture_messages', false); 315 | Route::post('users', fn () => new IlluminateJsonResponse(['An error occurred.'], 422)) 316 | ->middleware('api'); 317 | 318 | $response = $this->postJson('users'); 319 | 320 | $response->assertStatus(422); 321 | $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->where('type', 'validation_error')->get()); 322 | $this->assertCount(1, $entries); 323 | $this->assertSame('["POST","\/users","Closure","default","__laravel_unknown"]', $entries[0]->key); 324 | $aggregates = Pulse::ignore(fn () => DB::table('pulse_aggregates')->where('type', 'validation_error')->orderBy('period')->get()); 325 | $this->assertSame(array_fill(0, 4, '["POST","\/users","Closure","default","__laravel_unknown"]'), $aggregates->pluck('key')->all()); 326 | $this->assertSame(array_fill(0, 4, 'count'), $aggregates->pluck('aggregate')->all()); 327 | $this->assertTrue($aggregates->pluck('value')->every(fn ($value) => $value == 1.0)); 328 | } 329 | 330 | public function test_it_captures_unknown_API_validation_error_for_errors_key_that_does_not_contain_an_array() 331 | { 332 | Config::set('pulse.recorders.'.ValidationErrors::class.'.capture_messages', false); 333 | Route::post('users', fn () => new IlluminateJsonResponse(['errors' => 'An error occurred.'], 422)) 334 | ->middleware('api'); 335 | 336 | $response = $this->postJson('users'); 337 | 338 | $response->assertStatus(422); 339 | $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->where('type', 'validation_error')->get()); 340 | $this->assertCount(1, $entries); 341 | $this->assertSame('["POST","\/users","Closure","default","__laravel_unknown"]', $entries[0]->key); 342 | $aggregates = Pulse::ignore(fn () => DB::table('pulse_aggregates')->where('type', 'validation_error')->orderBy('period')->get()); 343 | $this->assertSame(array_fill(0, 4, '["POST","\/users","Closure","default","__laravel_unknown"]'), $aggregates->pluck('key')->all()); 344 | $this->assertSame(array_fill(0, 4, 'count'), $aggregates->pluck('aggregate')->all()); 345 | $this->assertTrue($aggregates->pluck('value')->every(fn ($value) => $value == 1.0)); 346 | } 347 | 348 | public function test_it_captures_unknown_API_validation_error_for_errors_key_that_contains_a_list() 349 | { 350 | Config::set('pulse.recorders.'.ValidationErrors::class.'.capture_messages', false); 351 | Route::post('users', fn () => new IlluminateJsonResponse(['errors' => ['An error occurred.']], 422)) 352 | ->middleware('api'); 353 | 354 | $response = $this->postJson('users'); 355 | 356 | $response->assertStatus(422); 357 | $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->where('type', 'validation_error')->get()); 358 | $this->assertCount(1, $entries); 359 | $this->assertSame('["POST","\/users","Closure","default","__laravel_unknown"]', $entries[0]->key); 360 | $aggregates = Pulse::ignore(fn () => DB::table('pulse_aggregates')->where('type', 'validation_error')->orderBy('period')->get()); 361 | $this->assertSame(array_fill(0, 4, '["POST","\/users","Closure","default","__laravel_unknown"]'), $aggregates->pluck('key')->all()); 362 | $this->assertSame(array_fill(0, 4, 'count'), $aggregates->pluck('aggregate')->all()); 363 | $this->assertTrue($aggregates->pluck('value')->every(fn ($value) => $value == 1.0)); 364 | } 365 | 366 | public function test_it_captures_inertia_validation_errors() 367 | { 368 | Config::set('pulse.recorders.'.ValidationErrors::class.'.capture_messages', false); 369 | Route::post('users', fn () => Request::validate([ 370 | 'email' => 'required', 371 | ]))->middleware(['web', InertiaMiddleware::class]); 372 | 373 | $response = $this->post('users', [], ['X-Inertia' => '1']); 374 | 375 | $response->assertStatus(302); 376 | $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->where('type', 'validation_error')->get()); 377 | $this->assertCount(1, $entries); 378 | $this->assertSame('["POST","\/users","Closure","default","email"]', $entries[0]->key); 379 | $aggregates = Pulse::ignore(fn () => DB::table('pulse_aggregates')->where('type', 'validation_error')->orderBy('period')->get()); 380 | $this->assertSame(array_fill(0, 4, '["POST","\/users","Closure","default","email"]'), $aggregates->pluck('key')->all()); 381 | $this->assertSame(array_fill(0, 4, 'count'), $aggregates->pluck('aggregate')->all()); 382 | $this->assertTrue($aggregates->pluck('value')->every(fn ($value) => $value == 1.0)); 383 | } 384 | 385 | public function test_it_captures_inertia_validation_non_post_errors() 386 | { 387 | Config::set('pulse.recorders.'.ValidationErrors::class.'.capture_messages', false); 388 | Route::put('users/{user}', fn () => Request::validate([ 389 | 'email' => 'required', 390 | ]))->middleware(['web', InertiaMiddleware::class]); 391 | 392 | $response = $this->put('users/5', [], ['X-Inertia' => '1']); 393 | 394 | $response->assertStatus(303); 395 | $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->where('type', 'validation_error')->get()); 396 | $this->assertCount(1, $entries); 397 | $this->assertSame('["PUT","\/users\/{user}","Closure","default","email"]', $entries[0]->key); 398 | $aggregates = Pulse::ignore(fn () => DB::table('pulse_aggregates')->where('type', 'validation_error')->orderBy('period')->get()); 399 | $this->assertSame(array_fill(0, 4, '["PUT","\/users\/{user}","Closure","default","email"]'), $aggregates->pluck('key')->all()); 400 | $this->assertSame(array_fill(0, 4, 'count'), $aggregates->pluck('aggregate')->all()); 401 | $this->assertTrue($aggregates->pluck('value')->every(fn ($value) => $value == 1.0)); 402 | } 403 | 404 | public function test_it_captures_inertia_validation_errors_with_multiple_bags() 405 | { 406 | Config::set('pulse.recorders.'.ValidationErrors::class.'.capture_messages', false); 407 | Route::post('users', function () { 408 | return Redirect::back()->withErrors(['email' => 'The email field is required.']) 409 | ->withErrors(['email' => 'The email field is required.'], 'custom_1') 410 | ->withErrors(['email' => 'The email field is required.'], 'custom_2'); 411 | })->middleware(['web', InertiaMiddleware::class]); 412 | 413 | $response = $this->post('users', [], ['X-Inertia' => '1']); 414 | 415 | $response->assertStatus(302); 416 | $response->assertInvalid('email'); 417 | $response->assertInvalid('email', 'custom_1'); 418 | $response->assertInvalid('email', 'custom_2'); 419 | $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->where('type', 'validation_error')->get()); 420 | $this->assertCount(3, $entries); 421 | $this->assertSame('["POST","\/users","Closure","default","email"]', $entries[0]->key); 422 | $this->assertSame('["POST","\/users","Closure","custom_1","email"]', $entries[1]->key); 423 | $this->assertSame('["POST","\/users","Closure","custom_2","email"]', $entries[2]->key); 424 | $aggregates = Pulse::ignore(fn () => DB::table('pulse_aggregates')->where('type', 'validation_error')->orderBy('period')->get()); 425 | $this->assertSame([ 426 | '["POST","\/users","Closure","default","email"]', 427 | '["POST","\/users","Closure","custom_1","email"]', 428 | '["POST","\/users","Closure","custom_2","email"]', 429 | '["POST","\/users","Closure","default","email"]', 430 | '["POST","\/users","Closure","custom_1","email"]', 431 | '["POST","\/users","Closure","custom_2","email"]', 432 | '["POST","\/users","Closure","default","email"]', 433 | '["POST","\/users","Closure","custom_1","email"]', 434 | '["POST","\/users","Closure","custom_2","email"]', 435 | '["POST","\/users","Closure","default","email"]', 436 | '["POST","\/users","Closure","custom_1","email"]', 437 | '["POST","\/users","Closure","custom_2","email"]', 438 | ], $aggregates->pluck('key')->all()); 439 | $this->assertSame(array_fill(0, 12, 'count'), $aggregates->pluck('aggregate')->all()); 440 | $this->assertTrue($aggregates->pluck('value')->every(fn ($value) => $value == 1.0)); 441 | } 442 | 443 | public function test_it_can_capture_messages_for_session_based_validation_errors() 444 | { 445 | Route::post('users', fn () => Request::validate([ 446 | 'email' => 'required', 447 | ]))->middleware('web'); 448 | 449 | Config::set('pulse.recorders.'.ValidationErrors::class.'.capture_messages', true); 450 | $response = $this->post('users'); 451 | 452 | $response->assertStatus(302); 453 | $response->assertInvalid('email'); 454 | $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->where('type', 'validation_error')->get()); 455 | $this->assertCount(1, $entries); 456 | $this->assertSame('["POST","\/users","Closure","default","email","The email field is required."]', $entries[0]->key); 457 | $aggregates = Pulse::ignore(fn () => DB::table('pulse_aggregates')->where('type', 'validation_error')->orderBy('period')->get()); 458 | $this->assertSame(array_fill(0, 4, '["POST","\/users","Closure","default","email","The email field is required."]'), $aggregates->pluck('key')->all()); 459 | $this->assertSame(array_fill(0, 4, 'count'), $aggregates->pluck('aggregate')->all()); 460 | $this->assertTrue($aggregates->pluck('value')->every(fn ($value) => $value == 1.0)); 461 | } 462 | 463 | public function test_it_can_capture_messages_for_API_based_validation_errors() 464 | { 465 | Route::post('users', fn () => Request::validate([ 466 | 'email' => 'required', 467 | ]))->middleware('api'); 468 | 469 | Config::set('pulse.recorders.'.ValidationErrors::class.'.capture_messages', true); 470 | $response = $this->postJson('users'); 471 | 472 | $response->assertStatus(422); 473 | $response->assertInvalid('email'); 474 | $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->where('type', 'validation_error')->get()); 475 | $this->assertCount(1, $entries); 476 | $this->assertSame('["POST","\/users","Closure","default","email","The email field is required."]', $entries[0]->key); 477 | $aggregates = Pulse::ignore(fn () => DB::table('pulse_aggregates')->where('type', 'validation_error')->orderBy('period')->get()); 478 | $this->assertSame(array_fill(0, 4, '["POST","\/users","Closure","default","email","The email field is required."]'), $aggregates->pluck('key')->all()); 479 | $this->assertSame(array_fill(0, 4, 'count'), $aggregates->pluck('aggregate')->all()); 480 | $this->assertTrue($aggregates->pluck('value')->every(fn ($value) => $value == 1.0)); 481 | } 482 | 483 | public function test_it_can_capture_messages_for_inertia_based_validation_errors() 484 | { 485 | Route::post('users', fn () => Request::validate([ 486 | 'email' => 'required', 487 | ]))->middleware(['web', InertiaMiddleware::class]); 488 | 489 | Config::set('pulse.recorders.'.ValidationErrors::class.'.capture_messages', true); 490 | $response = $this->post('users', [], ['X-Inertia' => '1']); 491 | 492 | $response->assertStatus(302); 493 | $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->where('type', 'validation_error')->get()); 494 | $this->assertCount(1, $entries); 495 | $this->assertSame('["POST","\/users","Closure","default","email","The email field is required."]', $entries[0]->key); 496 | $aggregates = Pulse::ignore(fn () => DB::table('pulse_aggregates')->where('type', 'validation_error')->orderBy('period')->get()); 497 | $this->assertSame(array_fill(0, 4, '["POST","\/users","Closure","default","email","The email field is required."]'), $aggregates->pluck('key')->all()); 498 | $this->assertSame(array_fill(0, 4, 'count'), $aggregates->pluck('aggregate')->all()); 499 | $this->assertTrue($aggregates->pluck('value')->every(fn ($value) => $value == 1.0)); 500 | } 501 | 502 | public function test_it_can_capture_message_for_inertia_based_validation_errors_for_mutliple_bags() 503 | { 504 | Route::post('users', function () { 505 | return Redirect::back()->withErrors(['email' => 'The email field is required.']) 506 | ->withErrors(['email' => 'The email field is required.'], 'custom_1') 507 | ->withErrors(['email' => 'The email field is required.'], 'custom_2'); 508 | })->middleware(['web', InertiaMiddleware::class]); 509 | 510 | Config::set('pulse.recorders.'.ValidationErrors::class.'.capture_messages', true); 511 | $response = $this->post('users', [], ['X-Inertia' => '1']); 512 | 513 | $response->assertStatus(302); 514 | $response->assertInvalid('email'); 515 | $response->assertInvalid('email', 'custom_1'); 516 | $response->assertInvalid('email', 'custom_2'); 517 | $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->where('type', 'validation_error')->get()); 518 | $this->assertSame('["POST","\/users","Closure","default","email","The email field is required."]', $entries[0]->key); 519 | $this->assertSame('["POST","\/users","Closure","custom_1","email","The email field is required."]', $entries[1]->key); 520 | $this->assertSame('["POST","\/users","Closure","custom_2","email","The email field is required."]', $entries[2]->key); 521 | $aggregates = Pulse::ignore(fn () => DB::table('pulse_aggregates')->where('type', 'validation_error')->orderBy('period')->get()); 522 | $this->assertSame([ 523 | '["POST","\/users","Closure","default","email","The email field is required."]', 524 | '["POST","\/users","Closure","custom_1","email","The email field is required."]', 525 | '["POST","\/users","Closure","custom_2","email","The email field is required."]', 526 | '["POST","\/users","Closure","default","email","The email field is required."]', 527 | '["POST","\/users","Closure","custom_1","email","The email field is required."]', 528 | '["POST","\/users","Closure","custom_2","email","The email field is required."]', 529 | '["POST","\/users","Closure","default","email","The email field is required."]', 530 | '["POST","\/users","Closure","custom_1","email","The email field is required."]', 531 | '["POST","\/users","Closure","custom_2","email","The email field is required."]', 532 | '["POST","\/users","Closure","default","email","The email field is required."]', 533 | '["POST","\/users","Closure","custom_1","email","The email field is required."]', 534 | '["POST","\/users","Closure","custom_2","email","The email field is required."]', 535 | ], $aggregates->pluck('key')->all()); 536 | $this->assertSame(array_fill(0, 12, 'count'), $aggregates->pluck('aggregate')->all()); 537 | $this->assertTrue($aggregates->pluck('value')->every(fn ($value) => $value == 1.0)); 538 | } 539 | 540 | public function test_it_can_capture_messages_for_generic_validation_errors() 541 | { 542 | Route::post('users', fn () => response('

An error occurred.

', 422))->middleware('web'); 543 | 544 | Config::set('pulse.recorders.'.ValidationErrors::class.'.capture_messages', true); 545 | $response = $this->post('users'); 546 | 547 | $response->assertStatus(422); 548 | $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->where('type', 'validation_error')->get()); 549 | $this->assertCount(1, $entries); 550 | $this->assertSame('["POST","\/users","Closure","default","__laravel_unknown","__laravel_unknown"]', $entries[0]->key); 551 | $aggregates = Pulse::ignore(fn () => DB::table('pulse_aggregates')->where('type', 'validation_error')->orderBy('period')->get()); 552 | $this->assertSame(array_fill(0, 4, '["POST","\/users","Closure","default","__laravel_unknown","__laravel_unknown"]'), $aggregates->pluck('key')->all()); 553 | $this->assertSame(array_fill(0, 4, 'count'), $aggregates->pluck('aggregate')->all()); 554 | $this->assertTrue($aggregates->pluck('value')->every(fn ($value) => $value == 1.0)); 555 | } 556 | 557 | public function test_it_ignores_unknown_routes() 558 | { 559 | $this->get('unknown-route')->assertNotFound(); 560 | } 561 | 562 | public function test_it_can_sample() 563 | { 564 | Config::set('pulse.recorders.'.ValidationErrors::class.'.sample_rate', 0.1); 565 | Route::post('users', fn () => Request::validate([ 566 | 'email' => 'required', 567 | ]))->middleware('web'); 568 | 569 | $this->post('users'); 570 | $this->post('users'); 571 | $this->post('users'); 572 | $this->post('users'); 573 | $this->post('users'); 574 | $this->post('users'); 575 | $this->post('users'); 576 | $this->post('users'); 577 | $this->post('users'); 578 | $this->post('users'); 579 | 580 | $count = Pulse::ignore(fn () => DB::table('pulse_entries')->where('type', 'validation_error')->count()); 581 | $this->assertEqualsWithDelta(1, $count, 4); 582 | } 583 | 584 | public function test_it_can_group_URLs() 585 | { 586 | Config::set('pulse.recorders.'.ValidationErrors::class, [ 587 | 'sample_rate' => 1, 588 | 'groups' => [ 589 | '#^/users/.*$#' => '/users/{user}', 590 | ], 591 | ]); 592 | 593 | Route::post('users/timacdonald', fn () => Request::validate([ 594 | 'email' => 'required', 595 | ]))->middleware('web'); 596 | 597 | $response = $this->post('users/timacdonald'); 598 | 599 | $response->assertStatus(302); 600 | $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->where('type', 'validation_error')->get()); 601 | $this->assertCount(1, $entries); 602 | $this->assertSame('["POST","\/users\/{user}","Closure","default","email","The email field is required."]', $entries[0]->key); 603 | $aggregates = Pulse::ignore(fn () => DB::table('pulse_aggregates')->where('type', 'validation_error')->orderBy('period')->get()); 604 | $this->assertSame(array_fill(0, 4, '["POST","\/users\/{user}","Closure","default","email","The email field is required."]'), $aggregates->pluck('key')->all()); 605 | $this->assertSame(array_fill(0, 4, 'count'), $aggregates->pluck('aggregate')->all()); 606 | $this->assertTrue($aggregates->pluck('value')->every(fn ($value) => $value == 1.0)); 607 | } 608 | 609 | public function test_it_can_ignore_entries_based_on_the_error_message() 610 | { 611 | Route::post('users', fn () => Request::validate([ 612 | 'name' => 'required', 613 | 'email' => 'required', 614 | ]))->middleware('web'); 615 | 616 | Pulse::filter(fn ($entry) => match ($entry->type) { 617 | 'validation_error' => ! Str::contains($entry->key, [ 618 | '"The email field is required."', 619 | ]), 620 | // ... 621 | }); 622 | 623 | $response = $this->post('users'); 624 | 625 | $response->assertStatus(302); 626 | $response->assertInvalid(['name', 'email']); 627 | $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->where('type', 'validation_error')->get()); 628 | $this->assertCount(1, $entries); 629 | $this->assertSame('["POST","\/users","Closure","default","name","The name field is required."]', $entries[0]->key); 630 | $aggregates = Pulse::ignore(fn () => DB::table('pulse_aggregates')->where('type', 'validation_error')->orderBy('period')->get()); 631 | $this->assertSame(array_fill(0, 4, '["POST","\/users","Closure","default","name","The name field is required."]'), $aggregates->pluck('key')->all()); 632 | $this->assertSame(array_fill(0, 4, 'count'), $aggregates->pluck('aggregate')->all()); 633 | $this->assertTrue($aggregates->pluck('value')->every(fn ($value) => $value == 1.0)); 634 | } 635 | 636 | public function test_it_captures_validation_errors_from_custom_event() 637 | { 638 | Route::post('users', function () { 639 | try { 640 | Request::validate([ 641 | 'name' => 'required', 642 | 'email' => 'required', 643 | ]); 644 | } catch (ValidationException $e) { 645 | Event::dispatch(new ValidationExceptionOccurred(request(), $e)); 646 | 647 | throw $e; 648 | } 649 | })->middleware('web'); 650 | 651 | $response = $this->post('users'); 652 | 653 | $response->assertStatus(302); 654 | $response->assertInvalid(['name', 'email']); 655 | $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->where('type', 'validation_error')->get()); 656 | $this->assertCount(2, $entries); 657 | $this->assertSame('["POST","\/users","Closure","default","name","The name field is required."]', $entries[0]->key); 658 | $this->assertSame('["POST","\/users","Closure","default","email","The email field is required."]', $entries[1]->key); 659 | $aggregates = Pulse::ignore(fn () => DB::table('pulse_aggregates')->where('type', 'validation_error')->orderBy('period')->get()); 660 | $this->assertSame([ 661 | '["POST","\/users","Closure","default","name","The name field is required."]', 662 | '["POST","\/users","Closure","default","email","The email field is required."]', 663 | '["POST","\/users","Closure","default","name","The name field is required."]', 664 | '["POST","\/users","Closure","default","email","The email field is required."]', 665 | '["POST","\/users","Closure","default","name","The name field is required."]', 666 | '["POST","\/users","Closure","default","email","The email field is required."]', 667 | '["POST","\/users","Closure","default","name","The name field is required."]', 668 | '["POST","\/users","Closure","default","email","The email field is required."]', 669 | ], $aggregates->pluck('key')->all()); 670 | $this->assertSame(array_fill(0, 8, 'count'), $aggregates->pluck('aggregate')->all()); 671 | $this->assertTrue($aggregates->pluck('value')->every(fn ($value) => $value == 1.0)); 672 | } 673 | } 674 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | validate(['email' => 'required']); 15 | } 16 | 17 | public function throw(): void 18 | { 19 | throw new RuntimeException('Whoops!'); 20 | } 21 | 22 | public function render(): string 23 | { 24 | return '
Dummy
'; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('vite').UserConfig} */ 2 | export default { 3 | build: { 4 | assetsDir: "", 5 | rollupOptions: { 6 | input: ["resources/css/validation.css"], 7 | output: { 8 | assetFileNames: "[name][extname]", 9 | entryFileNames: "[name].js", 10 | }, 11 | }, 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /workbench/app/Models/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timacdonald/pulse-validation-errors/ef9883e99d27c1e749a62431c46e34f225534aec/workbench/app/Models/.gitkeep -------------------------------------------------------------------------------- /workbench/app/Providers/WorkbenchServiceProvider.php: -------------------------------------------------------------------------------- 1 | [ 18 | ValidationErrors::class => [ 19 | // ... 20 | ], 21 | ], 22 | ]); 23 | } 24 | 25 | /** 26 | * Bootstrap services. 27 | */ 28 | public function boot(): void 29 | { 30 | // 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /workbench/bootstrap/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timacdonald/pulse-validation-errors/ef9883e99d27c1e749a62431c46e34f225534aec/workbench/bootstrap/.gitkeep -------------------------------------------------------------------------------- /workbench/bootstrap/app.php: -------------------------------------------------------------------------------- 1 | withRouting( 11 | web: __DIR__.'/../routes/web.php', 12 | commands: __DIR__.'/../routes/console.php', 13 | ) 14 | ->withMiddleware(function (Middleware $middleware) { 15 | // 16 | }) 17 | ->withExceptions(function (Exceptions $exceptions) { 18 | // 19 | })->create(); 20 | -------------------------------------------------------------------------------- /workbench/database/factories/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timacdonald/pulse-validation-errors/ef9883e99d27c1e749a62431c46e34f225534aec/workbench/database/factories/.gitkeep -------------------------------------------------------------------------------- /workbench/database/migrations/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timacdonald/pulse-validation-errors/ef9883e99d27c1e749a62431c46e34f225534aec/workbench/database/migrations/.gitkeep -------------------------------------------------------------------------------- /workbench/database/seeders/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timacdonald/pulse-validation-errors/ef9883e99d27c1e749a62431c46e34f225534aec/workbench/database/seeders/.gitkeep -------------------------------------------------------------------------------- /workbench/database/seeders/DatabaseSeeder.php: -------------------------------------------------------------------------------- 1 | errors); 113 | $now = CarbonImmutable::now(); 114 | 115 | foreach ($this->errors as $error) { 116 | $start = rand($start, $start + 175); 117 | 118 | for ($i = 0; $i < $start; $i++) { 119 | Pulse::record( 120 | type: 'validation_error', 121 | timestamp: $now, 122 | key: json_encode($error, flags: JSON_THROW_ON_ERROR), 123 | )->count(); 124 | } 125 | 126 | foreach ([2, 7, 25] as $hours) { 127 | $lt = rand($start - 175, $start); 128 | 129 | for ($i = 0; $i < $lt; $i++) { 130 | Pulse::record( 131 | type: 'validation_error', 132 | key: json_encode($error, flags: JSON_THROW_ON_ERROR), 133 | timestamp: $now->subHours($hours), 134 | )->count(); 135 | } 136 | } 137 | } 138 | 139 | foreach ($this->featureErrors as $error) { 140 | Pulse::record( 141 | type: 'validation_error', 142 | key: json_encode($error, flags: JSON_THROW_ON_ERROR), 143 | )->count(); 144 | } 145 | 146 | Pulse::ingest(); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /workbench/resources/views/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timacdonald/pulse-validation-errors/ef9883e99d27c1e749a62431c46e34f225534aec/workbench/resources/views/.gitkeep -------------------------------------------------------------------------------- /workbench/resources/views/vendor/pulse/dashboard.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /workbench/routes/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timacdonald/pulse-validation-errors/ef9883e99d27c1e749a62431c46e34f225534aec/workbench/routes/.gitkeep -------------------------------------------------------------------------------- /workbench/routes/console.php: -------------------------------------------------------------------------------- 1 | comment(Inspiring::quote()); 8 | })->purpose('Display an inspiring quote')->hourly(); 9 | -------------------------------------------------------------------------------- /workbench/routes/web.php: -------------------------------------------------------------------------------- 1 |