├── .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 |

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 |
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 |