Spring BootForm Handling
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
}