Client and server wired together properly, never just one side
Magento's old jQuery based validation.js does not fit a theme without jQuery, nor a strict Content Security Policy. Alpine based live validation closes that gap, but it never replaces server side checking, since every client side rule can be bypassed with JavaScript disabled or a direct POST request.
Table of Contents
- 1. Why Magento's jQuery validation.js no longer fits Hyva
- 2. Alpine based live validation: the basic concept
- 3. Practical example: live validation on a registration form
- 4. Accessible error output: aria-invalid, aria-describedby, role=alert
- 5. Why server side validation remains mandatory
- 6. Sending server errors back: session messages and the Ajax pattern
- 7. Practical example: checkout address form with country dependent validation
- 8. Reusable validation logic across forms
- 9. Testing forms: keyboard, screen reader, automated
- 10. Summary
- 11. FAQ
1. Why Magento's jQuery validation.js no longer fits Hyva
Magento's Luma theme ships with mage/validation, a jQuery plugin that checks form fields based on data-validate attributes and automatically displays error messages on violation. Hyva deliberately skips jQuery and Knockout.js entirely, which means this plugin simply is not available, even if it could technically be loaded back in.
Even if jQuery were added back in, that would contradict Hyva's core idea of shipping as little JavaScript baggage as possible, and would additionally clash with the Content Security Policy, since mage/validation relies on dynamic DOM manipulation in several places that does not sit well with Hyva's CSP requirements. The consistent alternative is therefore a lean, Alpine based solution.
2. Alpine based live validation: the basic concept
The basic principle is simple: every field is bound to an Alpine data object through x-model, and a validation function checks on every change or on leaving the field whether the current value satisfies a defined rule. The result is a simple errors object holding either an error message or an empty string per field name, evaluated directly in the template.
The timing of the first check matters for perception: a field should not already be marked invalid when the page loads, but only after the user has left it once, triggered on blur. Only after that does immediate checking on input, with a light debounce, kick in, so validation does not run on every single keystroke.
function formValidation(rules) {
return {
values: {},
errors: {},
touched: {},
validateField(name) {
const rule = rules[name];
if (!rule) return;
this.errors[name] = rule.test(this.values[name]) ? '' : rule.message;
},
onBlur(name) {
this.touched[name] = true;
this.validateField(name);
},
onInput(name) {
if (this.touched[name]) this.validateField(name);
},
isValid() {
return Object.values(this.errors).every((error) => !error);
},
};
}
3. Practical example: live validation on a registration form
A registration form with email, password, and password confirmation demonstrates the pattern well, since it needs two rule types at once: simple format checking through a regular expression for the email address, and a field dependent rule for password confirmation that needs to know another field's value. Both rules fit into the same rules object without touching the validation function itself.
The submit button stays disabled until isValid() returns true, on top of the server side check that runs regardless after submission. This double safeguard prevents unnecessary form round trips for obvious mistakes, without leaving the actual, authoritative check up to the client.
<form x-data="formValidation({
email: { test: v => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v || ''), message: 'Please enter a valid email address' },
password_confirmation: { test: v => v === values.password, message: 'Passwords do not match' },
})"
method="post" action="/customer/account/createpost">
<input type="email" name="email" x-model="values.email"
@blur="onBlur('email')" @input="onInput('email')"
:aria-invalid="errors.email ? 'true' : 'false'">
<button type="submit" :disabled="!isValid()">Create account</button>
</form>
4. Accessible error output: aria-invalid, aria-describedby, role=alert
A red border alone is not enough for screen reader users, an error also needs to be linked to the field programmatically. The aria-invalid attribute signals the error state itself, while aria-describedby points to the ID of the error text, so a screen reader automatically reads out the field's error message when it receives focus.
The error text container itself additionally gets role="alert", so newly appearing error messages get announced even when focus is not on the affected field at all, for instance after a server side validation error following submission. Without this attribute, errors that appear later often go completely unnoticed by screen reader users.
<label for="email">Email address</label>
<input id="email" type="email" name="email" x-model="values.email"
@blur="onBlur('email')"
:aria-invalid="errors.email ? 'true' : 'false'"
aria-describedby="email-error">
<p id="email-error" role="alert" x-show="errors.email" x-text="errors.email"
class="text-sm text-red-600"></p>
5. Why server side validation remains mandatory
Every client side rule is ultimately just a convenience feature, not a security mechanism. An attacker can disable JavaScript, tamper with the Alpine code, or simply send a POST request straight to the endpoint without the form ever being rendered in a browser at all. Magento's data and form validators on the server therefore need to check every rule independently, regardless of the client.
In practice that means maintaining the same business rule twice: once as an Alpine expression for immediate UI feedback, and once in a \Magento\Framework\Validator or a data patch based input filter in server code. Redundancy here is not a design flaw, it is the only way to guarantee good UX and real security at the same time.
<?php
declare(strict_types=1);
namespace Mironsoft\Core\Model\Validator;
use Magento\Framework\Validator\AbstractValidator;
/**
* Server side check for password confirmation, independent of the client.
*/
class PasswordConfirmationValidator extends AbstractValidator
{
/**
* Checks whether password and confirmation match.
*
* @param mixed $value
* @return bool
*/
public function isValid($value): bool
{
$isValid = $value['password'] === $value['password_confirmation'];
if (!$isValid) {
$this->_addMessages(['Passwords do not match.']);
}
return $isValid;
}
}
6. Sending server errors back: session messages and the Ajax pattern
With a classic, full form submit without Ajax, the controller processes the input, attaches a session message on error, and redirects back to the form, and the Hyva template reads the message through the familiar messages block and displays it above the form. This pattern also works with no JavaScript at all and is the most robust fallback layer.
With an Ajax based form, the controller instead returns a structured JSON response with an errors object per field name, which the Alpine component merges directly into its existing errors object. That way server side errors land in exactly the same UI display as client side errors, without maintaining two separate error displays in the same form.
async function submitForm() {
const response = await fetch(this.$el.action, { method: 'POST', body: new FormData(this.$el) });
const data = await response.json();
if (!response.ok && data.errors) {
this.errors = { ...this.errors, ...data.errors };
return;
}
window.location.href = data.redirectUrl;
}
7. Practical example: checkout address form with country dependent validation
Checkout address forms are a good example of rules that change based on the selected country, such as different postcode formats or a region field that is mandatory in some countries and does not exist at all in others. The Alpine component holds the selected country as a reactive value and picks the matching postcode pattern from a rule map based on it.
The same principle applies here too: Magento's directory module already delivers the same country and region logic server side, so the client side rule map ideally gets generated from that same configuration data, instead of creating a second, independently maintained source that can drift out of sync over time.
const postcodePatterns = {
DE: /^\d{5}$/,
AT: /^\d{4}$/,
NL: /^\d{4}\s?[A-Z]{2}$/,
};
function addressValidation(countryId) {
return {
country: countryId,
validatePostcode(value) {
const pattern = postcodePatterns[this.country];
return pattern ? pattern.test(value) : true;
},
};
}
8. Reusable validation logic across forms
Once several forms share similar rules, such as email format used identically in registration, newsletter signup, and a contact form, a central rule library as its own JavaScript module pays off, with every form component importing the rules it needs, instead of maintaining slightly different versions of the same regular expression in three places.
This central library can additionally be registered as its own Alpine store for shared validation functions when several components need to access the same rules at once. What matters is that this reuse applies only at the client layer, server side checking stays entirely separate and independent of it.
9. Testing forms: keyboard, screen reader, automated
A manual, keyboard only test with no mouse quickly surfaces the most common accessibility problems: does focus land on the first invalid field after an error, is every field reachable through Tab, and does an error message actually get read out once it appears. A short test with a real screen reader like NVDA or VoiceOver confirms whether aria-describedby and role="alert" actually work as expected.
For automated testing, end to end tests that deliberately enter invalid values and check both that the client side error display appears and that a direct POST request with no JavaScript gets correctly rejected by the server work well. Only both paths together confirm that client and server validation actually stay in sync.
| Layer | Purpose | Bypassable | UX benefit | Mandatory |
|---|---|---|---|---|
| Alpine live validation | Immediate feedback while typing | Yes, with JavaScript disabled | Very high, errors become visible instantly | No, pure convenience feature |
| HTML5 native validation | Basic format checking in the browser | Yes, via disabled validation or a direct POST | Medium, display varies by browser | No, an extra safeguard |
| Magento server validation | Authoritative, business level checking | No, runs server side | Low directly, but prevents bad data | Yes, always mandatory |
| Database constraints | Final technical safeguard | No, database level | No direct UX relevance | Yes, as a last line of defense |
| Accessible error output | Making errors perceivable for all user groups | No, concerns display not security | Very high for screen reader users | Yes, for legal and ethical reasons |
Mironsoft
Hyvä theme development and Luma migration
Still running Luma, or a Hyvä theme that just doesn't feel right?
We build Hyvä themes for Magento from scratch or migrate existing Luma shops cleanly, with Tailwind CSS, Alpine.js, and none of the unnecessary JavaScript baggage.
Luma-to-Hyvä Migration
Move an existing shop to Hyvä in a structured way, without losing functionality.
Custom Theme Development
Build a custom Hyvä theme from scratch based on your design.
Performance Optimization
Improve Core Web Vitals and load times in the Hyvä frontend with purpose.
10. Summary
Form Validation in Hyva
Alpine instead of legacy jQuery
Live validation through x-model and simple rule functions replaces Magento's mage/validation without jQuery and without CSP conflicts.
Accessibility built in from the start
aria-invalid, aria-describedby, and role=alert make sure errors are perceivable for screen reader users too.
The server stays the only authority
Every client side rule needs an independent server side re-check, since client validation can be bypassed at any time.
Keep error formats in sync
Server side Ajax error responses land in the same errors object as client side errors, avoiding two separate displays.