diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceProxyFactory.java b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceProxyFactory.java
index 330456f500e..5733d3326f9 100644
--- a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceProxyFactory.java
+++ b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceProxyFactory.java
@@ -242,6 +242,7 @@ public final class HttpServiceProxyFactory {
resolvers.add(new RequestBodyArgumentResolver(this.exchangeAdapter));
resolvers.add(new PathVariableArgumentResolver(service));
resolvers.add(new RequestParamArgumentResolver(service));
+ resolvers.add(new ModelAttributeArgumentResolver(service));
resolvers.add(new RequestPartArgumentResolver(this.exchangeAdapter));
resolvers.add(new CookieValueArgumentResolver(service));
if (this.exchangeAdapter.supportsRequestAttributes()) {
diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/ModelAttributeArgumentResolver.java b/spring-web/src/main/java/org/springframework/web/service/invoker/ModelAttributeArgumentResolver.java
new file mode 100644
index 00000000000..40f631cc1bc
--- /dev/null
+++ b/spring-web/src/main/java/org/springframework/web/service/invoker/ModelAttributeArgumentResolver.java
@@ -0,0 +1,124 @@
+/*
+ * Copyright 2002-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.web.service.invoker;
+
+import org.jspecify.annotations.Nullable;
+import org.springframework.beans.BeanWrapper;
+import org.springframework.beans.PropertyAccessorFactory;
+import org.springframework.core.MethodParameter;
+import org.springframework.core.convert.ConversionService;
+import org.springframework.util.ObjectUtils;
+import org.springframework.web.bind.annotation.BindParam;
+import org.springframework.web.bind.annotation.ModelAttribute;
+
+import java.beans.PropertyDescriptor;
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Resolves {@link ModelAttribute}-annotated method parameters by expanding a bean
+ * into request parameters for an HTTP client.
+ *
+ *
Behavior:
+ *
+ * - Each readable bean property yields a request parameter named after the property.
+ * - {@link BindParam} can override the parameter name. It is supported on both fields and
+ * getter methods; if both are present, the getter annotation wins.
+ * - Null property values are skipped.
+ * - Values are converted to strings via the configured {@link ConversionService} when
+ * possible; otherwise, {@code toString()} is used as a fallback.
+ *
+ *
+ * @author Hermann Pencole
+ * @since 7.0
+ */
+public class ModelAttributeArgumentResolver extends AbstractNamedValueArgumentResolver {
+ private final ConversionService conversionService;
+
+ /**
+ * Constructor for a resolver to a String value.
+ * @param conversionService the {@link ConversionService} to use to format
+ * Object to String values
+ */
+ public ModelAttributeArgumentResolver(ConversionService conversionService) {
+ super();
+ this.conversionService = conversionService;
+ }
+
+
+ @Override
+ protected @Nullable NamedValueInfo createNamedValueInfo(MethodParameter parameter) {
+ ModelAttribute annot = parameter.getParameterAnnotation(ModelAttribute.class);
+ if (annot == null) {
+ return null;
+ }
+ return new NamedValueInfo(
+ annot.name(), false, null, "model attribute",
+ true);
+ }
+
+ @Override
+ protected void addRequestValue(String name, Object argument, MethodParameter parameter, HttpRequestValues.Builder requestValues) {
+ // Create a map to store custom parameter names
+ Map customParamNames = new HashMap<>();
+
+ // Retrieve all @BindParam annotations
+ Class> clazz = argument.getClass();
+ for (Field field : clazz.getDeclaredFields()) {
+ BindParam bindParam = field.getAnnotation(BindParam.class);
+ if (bindParam != null) {
+ customParamNames.put(field.getName(), bindParam.value());
+ }
+ }
+
+ // Convert object to query parameters
+ BeanWrapper wrapper = PropertyAccessorFactory.forBeanPropertyAccess(argument);
+ for (PropertyDescriptor descriptor : wrapper.getPropertyDescriptors()) {
+ String propertyName = descriptor.getName();
+ if (!"class".equals(propertyName)) {
+ Object value = wrapper.getPropertyValue(propertyName);
+ if (value != null) {
+ // Use a custom name if it exists, otherwise use the property name
+ String paramName = customParamNames.getOrDefault(propertyName, propertyName);
+ requestValues.addRequestParameter(paramName, convertSingleToString(value));
+ }
+ }
+ }
+ }
+
+ /**
+ * Convert an arbitrary value to a string using the configured {@link ConversionService}
+ * when possible, otherwise falls back to {@code toString()}.
+ */
+ private String convertSingleToString(Object value) {
+ try {
+ if (this.conversionService.canConvert(value.getClass(), String.class)) {
+ String converted = this.conversionService.convert(value, String.class);
+ return converted != null ? converted : "";
+ }
+ } catch (Exception ignore) {
+ // Fallback to toString below
+ }
+ return String.valueOf(value);
+ }
+
+
+
+}
diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/ModelAttributeArgumentResolverTests.java b/spring-web/src/test/java/org/springframework/web/service/invoker/ModelAttributeArgumentResolverTests.java
new file mode 100644
index 00000000000..05ade07f777
--- /dev/null
+++ b/spring-web/src/test/java/org/springframework/web/service/invoker/ModelAttributeArgumentResolverTests.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright 2002-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.web.service.invoker;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.core.convert.support.DefaultConversionService;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.bind.annotation.BindParam;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.service.annotation.GetExchange;
+import org.springframework.web.service.annotation.PostExchange;
+import org.springframework.web.util.UriComponents;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link ModelAttributeArgumentResolver}.
+ *
+ * Additional tests for this resolver:
+ *
+ * - Base class functionality in {@link NamedValueArgumentResolverTests}
+ *
- Form data vs query params in {@link HttpRequestValuesTests}
+ *
+ *
+ * @author Hermann Pencole
+ */
+class ModelAttributeArgumentResolverTests {
+
+ private final TestExchangeAdapter client = new TestExchangeAdapter();
+
+ private final HttpServiceProxyFactory.Builder builder = HttpServiceProxyFactory.builderFor(this.client);
+
+
+ @Test
+ void requestParam() {
+ Service service = builder.build().createClient(Service.class);
+ service.postForm(new MyBean1_2("value 1", true),new MyBean3_4("value 3", List.of("1", "2", "3")));
+
+ Object body = this.client.getRequestValues().getBodyValue();
+ assertThat(body).isInstanceOf(MultiValueMap.class);
+ assertThat((MultiValueMap) body).hasSize(4)
+ .containsEntry("param.1", List.of("value 1"))
+ .containsEntry("param2", List.of("true"))
+ .containsEntry("param.3", List.of("value 3"))
+ .containsEntry("param4", List.of("1,2,3"));
+ }
+
+ @Test
+ void requestParamWithDisabledFormattingCollectionValue() {
+ Service service = builder.build().createClient(Service.class);
+ service.getWithParams(new MyBean1_2("value 1", true),new MyBean3_4("value 3", List.of("1", "2", "3")));
+
+ HttpRequestValues values = this.client.getRequestValues();
+ String uriTemplate = values.getUriTemplate();
+ Map uriVariables = values.getUriVariables();
+ UriComponents uri = UriComponentsBuilder.fromUriString(uriTemplate).buildAndExpand(uriVariables).encode();
+ assertThat(uri.getQuery()).isEqualTo("param.1=value%201¶m2=true¶m.3=value%203¶m4=1,2,3");
+ }
+
+ private interface Service {
+
+ @PostExchange(contentType = "application/x-www-form-urlencoded")
+ void postForm(@ModelAttribute MyBean1_2 param1_2, @ModelAttribute MyBean3_4 param3_4);
+
+ @GetExchange
+ void getWithParams(@ModelAttribute MyBean1_2 param1_2, @ModelAttribute MyBean3_4 param3_4);
+ }
+
+ private record MyBean1_2 (
+ @BindParam("param.1") String param1,
+ Boolean param2
+ ){}
+
+ private record MyBean3_4 (
+ @BindParam("param.3") String param3,
+ List param4
+ ){}
+
+}