Spring Boot
Form Handling
How a Spring MVC form works end to end: rendering the form, binding submitted data to a form object with @ModelAttribute, validating with Bean Validation, and the Post/Redirect/Get pattern. This page also covers what most tutorials skip: stopping users from posting fields they should not control, cross-field validation, duplicate-submit and race-condition handling, binding error messages, CSRF, and testing the controller.
The Form Handling Lifecycle
A form interaction is two requests that share one form object. A GET renders the form; a POST submits it. On the POST, Spring binds the submitted values to a Java object, validates it, and then either re-renders the form with errors (HTTP 200) or redirects (HTTP 302) after success.
The redirect is the Post/Redirect/Get (PRG) pattern. If you return a view directly after a successful POST, the browser's last request is still that POST, so a page refresh submits the form again and creates a duplicate record. Redirecting makes the last request a harmless GET.
PRG protects against refresh and the back button, not against a double click on the submit button. Two clicks send two POSTs before the first redirect arrives, so also disable the button on submit and back the business rule with a database constraint (see the controller section).
Shell
# ── Form lifecycle ───────────────────────────────────────────────────
#
# 1. Browser GET /users/new
# 2. Controller puts an empty form object in the model, returns view name
# 3. Thymeleaf renders the form (th:object / th:field)
# 4. User fills in the form and submits
# 5. Browser POST /users (application/x-www-form-urlencoded)
# 6. Spring binds the fields to the form object by name
# - type conversion problems (text in a number field) become binding errors
# 7. Bean Validation runs (@Valid); results land in BindingResult
# 8a. Errors -> return the view name again, HTTP 200, form shows the errors
# 8b. Valid -> save, add a flash message, return "redirect:/users"
# -> HTTP 302 with a Location header
# 9. Browser GET /users (a fresh request: refresh is now safe)
#
# Flash attributes (RedirectAttributes.addFlashAttribute) are stored in the
# session for exactly one following request, then removed. They are the
# standard way to show "Saved!" after a redirect.The Form Object (Never Bind Your Entity Directly)
The form object is a plain class whose property names match the HTML input names. Spring calls the setters for every submitted field that matches. That convenience is also a risk: a client can send any parameter name, not just the ones in your HTML. If you bind a JPA entity that has an admin flag or a balance, someone can post admin=true and set it. This is called mass assignment or over-posting.
Use a dedicated form class that contains only the fields the user is allowed to set, and copy them into the entity in the service. The same applies to role in this example: it is fine on an admin-only "create user" screen, but on a public sign-up form you must remove it from the form and assign the role on the server.
Cross-field rules such as "passwords must match" belong in a class-level constraint, not in the controller, so they run with every other validation and appear next to the right field. The validator below attaches its error to confirmPassword.
Java
// ── Form object: only what the user may set ──────────────────────────
@PasswordsMatch
public class CreateUserForm {
@NotBlank(message = "Name is required")
@Size(min = 2, max = 100, message = "Name must be 2-100 characters")
private String name;
@NotBlank(message = "Email is required")
@Email(message = "Must be a valid email address")
private String email;
@NotNull(message = "Role is required") // admin screen only!
private User.Role role;
@NotBlank(message = "Password is required")
@Size(min = 8, max = 72, message = "Password must be 8-72 characters")
private String password; // 72: BCrypt ignores bytes beyond this
@NotBlank(message = "Please confirm your password")
private String confirmPassword;
// getters and setters for every field (required for binding)
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public User.Role getRole() { return role; }
public void setRole(User.Role role) { this.role = role; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getConfirmPassword() { return confirmPassword; }
public void setConfirmPassword(String confirmPassword) { this.confirmPassword = confirmPassword; }
}
// ── Class-level constraint ───────────────────────────────────────────
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordsMatchValidator.class)
public @interface PasswordsMatch {
String message() default "Passwords do not match";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class PasswordsMatchValidator
implements ConstraintValidator<PasswordsMatch, CreateUserForm> {
@Override
public boolean isValid(CreateUserForm form, ConstraintValidatorContext ctx) {
// Empty password is reported by @NotBlank; do not report twice
if (form.getPassword() == null
|| form.getPassword().equals(form.getConfirmPassword())) {
return true;
}
ctx.disableDefaultConstraintViolation();
ctx.buildConstraintViolationWithTemplate(ctx.getDefaultConstraintMessageTemplate())
.addPropertyNode("confirmPassword") // error shows under this field
.addConstraintViolation();
return false;
}
}
// Required dependency (not included in spring-boot-starter-web):
// implementation 'org.springframework.boot:spring-boot-starter-validation'The Form Controller
Each form needs two handlers: a GET that supplies the form object and a POST that processes it. The BindingResult parameter must come immediately after the @ModelAttribute parameter it belongs to; otherwise Spring throws an exception on any error instead of letting you handle it.
A few details make this controller safer than the usual example. A @ModelAttribute method supplies the dropdown values for every handler, so you cannot forget them when re-rendering after an error. An @InitBinder whitelists the fields that may be bound and trims whitespace on text fields (but never on passwords, where spaces are legal).
For uniqueness, the early existsByEmail check gives a friendly message, but it is not safe on its own: two simultaneous requests can both pass it. The real guarantee is a unique constraint in the database, and the controller catches the resulting DataIntegrityViolationException and reports it as the same field error. On the edit form, the duplicate check must exclude the user being edited.
Java
@Controller
@RequestMapping("/users")
public class UserFormController {
private final UserService userService;
public UserFormController(UserService userService) {
this.userService = userService;
}
// Available to every handler and every re-render after an error
@ModelAttribute("roles")
public User.Role[] roles() {
return User.Role.values();
}
@InitBinder("userForm")
public void initBinder(WebDataBinder binder) {
binder.setAllowedFields("name", "email", "role",
"password", "confirmPassword");
StringTrimmerEditor trim = new StringTrimmerEditor(true); // "" -> null
binder.registerCustomEditor(String.class, "name", trim);
binder.registerCustomEditor(String.class, "email", trim);
// Do NOT trim passwords
}
// ── Create: GET ──────────────────────────────────────────────────
@GetMapping("/new")
public String newForm(Model model) {
model.addAttribute("userForm", new CreateUserForm());
return "users/form";
}
// ── Create: POST ─────────────────────────────────────────────────
@PostMapping
public String create(@Valid @ModelAttribute("userForm") CreateUserForm form,
BindingResult result, // directly after the form
RedirectAttributes redirect) {
// Business rule; skip if the email is already invalid
if (!result.hasFieldErrors("email")
&& userService.existsByEmail(form.getEmail())) {
result.rejectValue("email", "email.exists",
"This email address is already registered");
}
if (result.hasErrors()) {
return "users/form"; // HTTP 200, errors shown
}
try {
UserResponse created = userService.create(form); // hashes the password
redirect.addFlashAttribute("successMessage",
"User '" + created.name() + "' created");
return "redirect:/users"; // PRG
} catch (DataIntegrityViolationException e) {
// Lost the race: another request took this email a moment ago
result.rejectValue("email", "email.exists",
"This email address is already registered");
return "users/form";
}
}
// ── Edit: GET (pre-populated) ────────────────────────────────────
@GetMapping("/{id}/edit")
public String editForm(@PathVariable Long id, Model model) {
UserResponse user = userService.findById(id);
UpdateUserForm form = new UpdateUserForm();
form.setName(user.name());
form.setEmail(user.email());
form.setRole(user.role());
model.addAttribute("userForm", form);
model.addAttribute("userId", id);
return "users/edit-form";
}
// ── Edit: POST ───────────────────────────────────────────────────
@PostMapping("/{id}")
public String update(@PathVariable Long id,
@Valid @ModelAttribute("userForm") UpdateUserForm form,
BindingResult result,
Model model,
RedirectAttributes redirect) {
if (!result.hasFieldErrors("email")
&& userService.existsByEmailAndIdNot(form.getEmail(), id)) {
result.rejectValue("email", "email.exists",
"This email address is already registered");
}
if (result.hasErrors()) {
model.addAttribute("userId", id); // the template needs it
return "users/edit-form";
}
userService.update(id, form); // authorise here too: @PreAuthorize
redirect.addFlashAttribute("successMessage", "User updated");
return "redirect:/users/" + id;
}
}
// ── Edit form object: no password fields ─────────────────────────────
public class UpdateUserForm {
@NotBlank @Size(min = 2, max = 100) private String name;
@NotBlank @Email private String email;
@NotNull private User.Role role;
// getters and setters
}
// Edit template: same markup as form.html, minus the password inputs, with
// th:action="@{/users/{id}(id=${userId})}"
// and a "Save" button.The Thymeleaf Templates
th:object connects the form to the model attribute, and th:field generates the name, id and value attributes for each input. After a validation error, the same template is rendered again and th:field refills every input from the form object; password inputs are the exception and are always rendered empty, so the user retypes them.
Always use th:action rather than a plain action attribute. With Spring Security enabled, Thymeleaf adds the hidden CSRF token only to forms that use th:action; a form with a plain action posts without a token and gets 403.
The success message belongs in the template the user lands on after the redirect (here the user list), not in the form template. Flash attributes are consumed by the next request, so a message shown on the form page will never appear.
html
<!-- templates/users/form.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<form th:action="@{/users}" th:object="${userForm}" method="post">
<!-- th:action adds the CSRF token automatically (Spring Security) -->
<div>
<label for="name">Name</label>
<input type="text" th:field="*{name}" th:errorclass="is-invalid" />
<span th:if="${#fields.hasErrors('name')}"
th:errors="*{name}" class="error"></span>
</div>
<div>
<label for="email">Email</label>
<input type="email" th:field="*{email}" th:errorclass="is-invalid" />
<span th:if="${#fields.hasErrors('email')}"
th:errors="*{email}" class="error"></span>
</div>
<div>
<label for="role">Role</label>
<select th:field="*{role}">
<!-- empty value binds to null, so @NotNull reports "Role is required" -->
<option value="">-- Select role --</option>
<option th:each="r : ${roles}" th:value="${r}" th:text="${r}"></option>
</select>
<span th:if="${#fields.hasErrors('role')}"
th:errors="*{role}" class="error"></span>
</div>
<div>
<label for="password">Password</label>
<input type="password" th:field="*{password}" autocomplete="new-password" />
<span th:if="${#fields.hasErrors('password')}"
th:errors="*{password}" class="error"></span>
</div>
<div>
<label for="confirmPassword">Confirm password</label>
<input type="password" th:field="*{confirmPassword}" autocomplete="new-password" />
<span th:if="${#fields.hasErrors('confirmPassword')}"
th:errors="*{confirmPassword}" class="error"></span>
</div>
<!-- Errors not tied to a field (reject(...) instead of rejectValue) -->
<div th:if="${#fields.hasGlobalErrors()}">
<p th:each="err : ${#fields.globalErrors()}" th:text="${err}" class="error"></p>
</div>
<button type="submit">Create user</button>
<a th:href="@{/users}">Cancel</a>
</form>
</body>
</html>
<!-- templates/users/list.html (the page after the redirect) -->
<div th:if="${successMessage}" class="alert alert-success">
<p th:text="${successMessage}"></p>
</div>Select, Checkbox, Radio and Multi-Value Inputs
th:field handles every input type, but each one binds differently, and the differences cause most "my checkbox does nothing" questions.
An unchecked checkbox sends nothing at all, so a boolean field would never become false. To solve this, th:field adds a hidden marker input named with an underscore prefix (for a field called active, it is _active). When Spring sees the marker without the field, it binds false. Radio buttons need one input per option, each with th:value, and labels reference them through #ids.prev. Multi-selects and checkbox groups bind to a List field and Spring converts each submitted value to the list's element type.
html
<!-- ── Checkbox bound to: private boolean active; ─────────────────────
th:field renders the checkbox plus a hidden marker input "_active".
Unchecked: only _active is submitted -> Spring binds false. -->
<input type="checkbox" th:field="*{active}" />
<label th:for="${#ids.prev('active')}">Active</label>
<!-- ── Radio buttons bound to: private User.Role role; ──────────────── -->
<div th:each="r : ${roles}">
<input type="radio" th:field="*{role}" th:value="${r}" />
<label th:for="${#ids.prev('role')}" th:text="${r}"></label>
</div>
<!-- ── Multi-select bound to: private List<Long> selectedTags; ────────
Nothing selected -> the field is simply absent -> list stays empty. -->
<select th:field="*{selectedTags}" multiple>
<option th:each="tag : ${availableTags}"
th:value="${tag.id}" th:text="${tag.name}"></option>
</select>
<!-- ── Checkbox group bound to: private List<String> permissions; ───── -->
<div th:each="p : ${allPermissions}">
<input type="checkbox" th:field="*{permissions}" th:value="${p}" />
<label th:for="${#ids.prev('permissions')}" th:text="${p}"></label>
</div>
<!-- ── Form object for the examples above ─────────────────────────────
public class UserPermissionsForm {
private boolean active;
private User.Role role;
private List<Long> selectedTags = new ArrayList<>();
private List<String> permissions = new ArrayList<>();
// getters and setters
}
Initialise lists with new ArrayList<>() so the template never sees null.
If you whitelist fields with setAllowedFields, include these names. -->Binding Errors and Custom Error Messages
Validation is not the only way a field can fail. If the user types "abc" into a number field, Spring cannot convert it and records a binding error (code typeMismatch) before validation even runs. The default message ("Failed to convert property value...") is not something you want users to see.
Error messages come from a messages.properties file. Spring looks for the most specific key first: typeMismatch.userForm.age, then typeMismatch.age, then typeMismatch.java.lang.Integer, then typeMismatch. Bean Validation messages can also reference keys in curly braces, which keeps all user-facing text in one place and makes translation possible. Spring Boot wires its MessageSource into the validator, so this works without extra configuration.
Java
// ── Form field that can fail conversion ──────────────────────────────
@NotNull(message = "{age.required}")
@Min(value = 18, message = "{age.min}")
private Integer age;
// ── src/main/resources/messages.properties ───────────────────────────
// age.required=Please enter your age
// age.min=You must be at least 18
// typeMismatch.userForm.age=Age must be a whole number
// typeMismatch.java.lang.Integer=Please enter a valid number
// email.exists=This email address is already registered
//
// Add messages_ar.properties, messages_fr.properties ... for other languages.
// ── Look up the codes Spring tried (useful when a message is ignored) ─
// for (FieldError e : bindingResult.getFieldErrors()) {
// log.debug("{} -> {}", e.getField(), Arrays.toString(e.getCodes()));
// }
// Example output:
// age -> [typeMismatch.userForm.age, typeMismatch.age,
// typeMismatch.java.lang.Integer, typeMismatch]Testing the Form Controller
A controller test sends the same form parameters a browser would and asserts the two outcomes that matter: invalid input re-renders the form with field errors, and valid input redirects. If Spring Security is on the classpath, POST requests need a CSRF token, which MockMvc adds with csrf(). These tests also protect the over-posting fix: a parameter you did not whitelist must have no effect.
@MockitoBean requires Spring Framework 6.2 or later; on older versions use @MockBean.
Java
@WebMvcTest(UserFormController.class)
class UserFormControllerTest {
@Autowired MockMvc mvc;
@MockitoBean UserService userService;
@Test
@WithMockUser
void invalidFormReRendersWithFieldErrors() throws Exception {
mvc.perform(post("/users").with(csrf())
.param("name", "") // blank
.param("email", "not-an-email"))
.andExpect(status().isOk()) // 200, not a redirect
.andExpect(view().name("users/form"))
.andExpect(model().attributeHasFieldErrors(
"userForm", "name", "email", "role", "password"));
}
@Test
@WithMockUser
void mismatchedPasswordsReportedOnConfirmField() throws Exception {
mvc.perform(post("/users").with(csrf())
.param("name", "Alice").param("email", "a@example.com")
.param("role", "USER")
.param("password", "secret123")
.param("confirmPassword", "different"))
.andExpect(model().attributeHasFieldErrors("userForm", "confirmPassword"));
}
@Test
@WithMockUser
void validFormRedirects() throws Exception {
when(userService.existsByEmail("a@example.com")).thenReturn(false);
when(userService.create(any())).thenReturn(
new UserResponse(1L, "Alice", "a@example.com", User.Role.USER));
mvc.perform(post("/users").with(csrf())
.param("name", "Alice").param("email", "a@example.com")
.param("role", "USER")
.param("password", "secret123")
.param("confirmPassword", "secret123"))
.andExpect(status().is3xxRedirection()) // PRG
.andExpect(redirectedUrl("/users"))
.andExpect(flash().attributeExists("successMessage"));
}
@Test
@WithMockUser
void postWithoutCsrfTokenIsForbidden() throws Exception {
mvc.perform(post("/users").with(user("tester")))
.andExpect(status().isForbidden());
}
}Common Problems and Fixes
Most form bugs come from a short list of mistakes. Each entry below gives the symptom you see, the cause, and the fix.
Java
// ── 1. Validation annotations are ignored ────────────────────────────
// Cause: spring-boot-starter-validation is missing (the web starter does not
// include it), or @Valid is missing on the @ModelAttribute parameter.
// Fix: add the dependency; write @Valid @ModelAttribute("userForm") ...
// ── 2. "Neither BindingResult nor plain target object for bean name" ──
// Cause A: the view uses th:object="${userForm}" but the model has no
// "userForm" (typical on the GET handler, or after a redirect).
// Fix A: model.addAttribute("userForm", new CreateUserForm());
// Cause B: BindingResult is not directly after the @ModelAttribute parameter.
// Fix B: (@Valid @ModelAttribute("userForm") X form, BindingResult result, ...)
// ── 3. POST returns 403 Forbidden ────────────────────────────────────
// Cause: Spring Security CSRF protection; the form has no token because it
// uses a plain action="/users" instead of th:action.
// Fix: th:action="@{/users}" (do not disable CSRF for browser forms)
// ── 4. Fields are not bound (always null) ────────────────────────────
// Cause: missing setter, or the input name does not match the property name,
// or the field is not in setAllowedFields(...).
// Fix: compare th:field="*{name}" with setName(); check the binder whitelist.
// ── 5. Refreshing the page creates a duplicate record ────────────────
// Cause: the POST handler returned a view name instead of redirecting.
// Fix: return "redirect:/users";
// ── 6. The success message never appears ─────────────────────────────
// Cause: the message is rendered in the form template, but flash attributes
// are delivered to the page the redirect points to.
// Fix: render th:if="${successMessage}" in the redirect target (list page).
// ── 7. Error text shows "Failed to convert property value ..." ───────
// Cause: binding error (typeMismatch) with no custom message.
// Fix: add typeMismatch.* keys to messages.properties (see above).