When Laravel validation fails on a regular form submission, you can use the @error directive in Blade to display errors. But for AJAX requests, Laravel returns errors in JSON format, and you need to handle the frontend display yourself.
The typical approach:
1
2
3
4
5
6
7
8
9
10
| <label for="email">Email</label>
<input id="title"
type="email"
name="email"
class="@error('email') is-invalid @enderror">
@error('email')
<div class="alert alert-danger">{{ $message }}</div>
@enderror
|
But when the request is AJAX, Laravel returns JSON:
1
2
3
4
5
6
7
| {
"errors": {
"email": ["The email field must be a valid email address."],
"name": ["The name field is required."]
},
"message": "The name field is required. (and 1 more error)"
}
|
To avoid having to learn a separate frontend error handling approach, I wrote an Alpine.js plugin that provides an API similar to Laravel’s MessageBag.
Alpine.js errors plugin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
| // errors.js
class MessageBag {
constructor(errors = {}) {
this.errors = errors;
}
set(key, value) {
return this.put(key, value);
}
put(key, value) {
const values = typeof key === 'object' ? key : {[key]: value};
for (const x in values) {
let val = values[x];
this.errors[x] = typeof val === 'string' ? [val] : val;
}
return this;
}
get(key) {
if (!this.errors.hasOwnProperty(key) || !this.errors[key]) {
this.put(key, null);
}
return this.errors[key];
}
has(key) {
return this.get(key) !== null;
}
first(key) {
if (!this.has(key)) {
return null;
}
const value = this.get(key);
return value instanceof Array ? value[0] : value;
}
remove(...keys) {
keys.forEach(key => this.put(key, null));
}
clear() {
this.remove(...Object.keys(this.errors));
}
all() {
return Object.keys(this.errors).reduce((acc, key) => {
const value = this.get(key);
return value === null ? acc : {...acc, [key]: value};
}, {});
}
registerAxiosInterceptor(axios) {
const beforeRequest = (config) => {
this.clear();
return config;
};
const onError = (err) => {
const {status, data} = err.response;
if (status === 422) {
this.set(data.errors);
}
return Promise.reject(err);
};
axios.interceptors.request.use(beforeRequest, err => Promise.reject(err));
axios.interceptors.response.use(response => response, onError);
}
}
export default function (Alpine) {
const errors = Alpine.reactive(new MessageBag({}));
Alpine.magic('errors', () => errors);
Object.defineProperty(Alpine, 'errors', {get: () => errors});
}
|
By using registerAxiosInterceptor to hook into Axios interceptors, errors are automatically populated into the MessageBag on a 422 response and automatically cleared when a new request is sent.
Initialization
1
2
3
4
5
6
7
8
9
10
11
| // bootstrap.js
import Alpine from 'alpinejs';
import Axios from 'axios';
import errors from './errors';
errors(Alpine);
Alpine.start();
window.axios = Axios.create();
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
Alpine.errors.registerAxiosInterceptor(window.axios);
|
Usage in Blade
Inside an Alpine component, you can use the $errors magic property directly, similar to Laravel’s Blade @error:
1
2
3
4
5
6
7
8
9
| <div x-data>
<input type="text" name="email"
:class="{'text-red-900': $errors.has('email')}"
@keyup="$errors.remove('email')">
<template x-if="$errors.has('email')">
<p x-text="$errors.first('email')" class="text-red-600"></p>
</template>
</div>
|
Testing
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
| // errors.test.js
import { fireEvent, screen } from '@testing-library/dom';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import Alpine from 'alpinejs';
import plugin from './errors';
describe('Alpine $errors', () => {
const givenComponent = (name) => {
const component = document.createElement('div');
component.innerHTML = `
<div x-data>
<div>
<label for="${name}">${name}</label>
<div>
<input type="text" name="${name}" id="${name}"
:class="{'text-red-900': $errors.has('${name}')}"
@keyup="$errors.remove('${name}')"
role="input">
</div>
<template x-if="$errors.has('${name}')">
<p x-text="$errors.first('${name}')" role="error-message"></p>
</template>
</div>
</div>
`;
document.body.append(component);
return component;
};
beforeAll(() => {
plugin(Alpine);
const mock = new MockAdapter(axios);
mock.onPost('/users').reply(422, {
'message': 'The email field must be a valid email address.',
'errors': {'email': ['The email field must be a valid email address.']},
});
Alpine.$errors.registerAxiosInterceptor(axios);
Alpine.start();
});
afterAll(() => Alpine.stopObservingMutations());
beforeEach(() => {
document.body.innerHTML = '';
givenComponent('email');
givenComponent('password');
});
afterEach(() => document.body.innerHTML = '');
const getInvalidInputs = () => screen.queryAllByRole('input').filter(el => el.classList.contains('text-red-900'));
const getErrorMessages = () => screen.queryAllByRole('error-message');
async function expectShowError() {
try {
await axios.post('/users');
} catch (e) {
}
expect(getInvalidInputs()).toHaveLength(1);
expect(getErrorMessages()).toHaveLength(1);
expect(getErrorMessages()[0].innerHTML).toContain('The email field must be a valid email address.');
}
it('show errors', async () => {
await expectShowError();
});
it('clear errors', async () => {
await expectShowError();
await Alpine.nextTick(() => Alpine.$errors.clear());
expect(getInvalidInputs()).toHaveLength(0);
expect(getErrorMessages()).toHaveLength(0);
});
it('key up clear input error', async () => {
await expectShowError();
const invalidInput = getInvalidInputs()[0];
await Alpine.nextTick(() => fireEvent.keyUp(invalidInput));
expect(invalidInput.classList).not.toContain('text-red-900');
});
});
|