Spring Boot
Form Handling
Form handling in Spring MVC covers the complete lifecycle of an HTML form: rendering an empty or pre-populated form, binding submitted data to a Java object with @ModelAttribute, validating it with Bean Validation, and responding correctly on success or failure. The Post/Redirect/Get pattern prevents duplicate submissions and is the standard implementation approach.
The Form Handling Lifecycle
A complete form interaction involves four steps: GET to display the empty form, POST to submit it, validation, and either re-rendering on error or redirecting on success. This is the Post/Redirect/Get (PRG) pattern — redirecting after a successful POST prevents the browser from re-submitting the form on refresh.
Shell
# ── Complete form lifecycle: ─────────────────────────────────────────
#
# 1. Browser GET /users/new
# ↓
# 2. Controller creates empty form object, adds to model, returns view name
# ↓
# 3. Template renders HTML form with Thymeleaf th:object / th:field
# ↓
# 4. User fills form, clicks Submit
# ↓
# 5. Browser POST /users (form data as application/x-www-form-urlencoded)
# ↓
# 6. Controller binds form data → @ModelAttribute object
# ↓
# 7. Validation runs (@Valid + BindingResult)
# ↓
# ├─ Errors? → 8a. Re-render the form with error messages (HTTP 200)
# └─ Valid? → 8b. Process, then redirect to success page (HTTP 302)
# ↓
# 9. Browser GET /users (fresh request — PRG complete)Form Command Object
The form command object (sometimes called a form bean) is a plain Java class whose fields correspond to the HTML form inputs. Spring MVC binds each submitted field to the matching field by name. Bean Validation annotations define the constraints.
Java
// ── Form command object ───────────────────────────────────────────────
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")
private User.Role role;
@NotBlank(message = "Password is required")
@Size(min = 8, message = "Password must be at least 8 characters")
private String password;
@NotBlank(message = "Please confirm your password")
private String confirmPassword;
// Custom cross-field validation — implemented in the controller
// or as a class-level @Constraint annotation:
public boolean passwordsMatch() {
return password != null && password.equals(confirmPassword);
}
// Standard getters and setters — required for Spring MVC 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 c) { this.confirmPassword = c; }
}Form Controller
The controller has two handler methods per form: a GET to render it and a POST to process it. The POST handler accepts the bound @ModelAttribute and a BindingResult — the BindingResult must immediately follow the @ModelAttribute parameter.
Java
@Controller
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserFormController {
private final UserService userService;
// ── GET — render the empty form: ──────────────────────────────────
@GetMapping("/new")
public String newForm(Model model) {
model.addAttribute("userForm", new CreateUserForm());
model.addAttribute("roles", User.Role.values());
return "users/form";
}
// ── POST — process the submitted form: ────────────────────────────
@PostMapping
public String create(
@ModelAttribute("userForm") @Valid CreateUserForm form,
BindingResult bindingResult, // MUST immediately follow @ModelAttribute
Model model,
RedirectAttributes redirectAttributes) {
// Custom cross-field validation (not covered by Bean Validation):
if (!form.passwordsMatch()) {
bindingResult.rejectValue("confirmPassword", "passwords.mismatch",
"Passwords do not match");
}
// Check for duplicate email (business rule — not a Bean Validation concern):
if (!bindingResult.hasErrors()
&& userService.existsByEmail(form.getEmail())) {
bindingResult.rejectValue("email", "email.exists",
"This email address is already registered");
}
if (bindingResult.hasErrors()) {
// Re-render the form — BindingResult keeps field values and errors:
model.addAttribute("roles", User.Role.values());
return "users/form"; // HTTP 200 with the form template
}
// Success — process and redirect (PRG pattern):
UserResponse created = userService.create(form);
redirectAttributes.addFlashAttribute("successMessage",
"User '" + created.name() + "' created successfully");
return "redirect:/users";
}
// ── GET — edit form (pre-populated with existing data): ───────────
@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);
model.addAttribute("roles", User.Role.values());
return "users/edit-form";
}
// ── POST — process the edit form: ─────────────────────────────────
@PostMapping("/{id}")
public String update(
@PathVariable Long id,
@ModelAttribute("userForm") @Valid UpdateUserForm form,
BindingResult bindingResult,
Model model,
RedirectAttributes redirectAttributes) {
if (bindingResult.hasErrors()) {
model.addAttribute("userId", id);
model.addAttribute("roles", User.Role.values());
return "users/edit-form";
}
userService.update(id, form);
redirectAttributes.addFlashAttribute("successMessage", "User updated");
return "redirect:/users/" + id;
}
}Thymeleaf Form Template
Thymeleaf's th:object and th:field attributes bind the template to the form command object. th:field generates the correct name, id, and value attributes automatically and re-populates field values on re-render after validation errors.
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:field generates: name="name" id="name" value="${userForm.name}" -->
<div>
<label for="name">Name</label>
<input type="text" th:field="*{name}"
th:errorclass="is-invalid" />
<!-- Display validation error for this field: -->
<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>
<!-- Select dropdown bound to enum: -->
<div>
<label for="role">Role</label>
<select th:field="*{role}">
<option value="">-- Select role --</option>
<option th:each="role : ${roles}"
th:value="${role}"
th:text="${role}"></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}"
th:errorclass="is-invalid" />
<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}"
th:errorclass="is-invalid" />
<span th:if="${#fields.hasErrors('confirmPassword')}"
th:errors="*{confirmPassword}" class="error"></span>
</div>
<!-- Global (non-field) errors: -->
<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>
<!-- Flash message from redirect: -->
<div th:if="${successMessage}" class="alert alert-success">
<p th:text="${successMessage}"></p>
</div>
</body>
</html>Handling Select, Checkbox, and Radio Inputs
Thymeleaf's th:field handles all HTML input types. Checkboxes, radio buttons, and multi-select lists require specific patterns to bind correctly to boolean, enum, and collection fields.
html
<!-- ── Checkbox — binds to boolean field: ─────────────────────────── -->
<input type="checkbox" th:field="*{active}" />
<!-- th:field on checkbox generates a hidden input with value="false"
so unchecked boxes submit false rather than nothing. -->
<!-- ── Radio buttons — binds to enum or String field: ──────────────── -->
<div th:each="role : ${roles}">
<input type="radio" th:field="*{role}" th:value="${role}" />
<label th:for="${#ids.prev('role')}" th:text="${role}"></label>
</div>
<!-- ── Multi-select — binds to List<T> field: ───────────────────────── -->
<select th:field="*{selectedTags}" multiple>
<option th:each="tag : ${availableTags}"
th:value="${tag.id}"
th:text="${tag.name}"></option>
</select>
<!-- ── Checkbox group — binds to List<T> field: ─────────────────────── -->
<div th:each="permission : ${allPermissions}">
<input type="checkbox"
th:field="*{permissions}"
th:value="undefined" />
<label th:for="${#ids.prev('permissions')}"
th:text="${permission}"></label>
</div>
// ── Corresponding form object fields: ────────────────────────────────
public class UserPermissionsForm {
private boolean active;
private User.Role role;
private List<Long> selectedTags = new ArrayList<>();
private List<String> permissions = new ArrayList<>();
// getters + setters
}