diff --git a/include/libcamera/internal/request.h b/include/libcamera/internal/request.h
index 643f67cabf5778f7515c0117d06d4e0a3fdec4f8..4807036b7ae22c0e90886889ff5741fd9c5c0e9f 100644
--- a/include/libcamera/internal/request.h
+++ b/include/libcamera/internal/request.h
@@ -36,6 +36,7 @@ public:
 	Camera *camera() const { return camera_; }
 	bool hasPendingBuffers() const;
 
+	ControlList &controls();
 	ControlList &metadata() { return *metadata_; }
 
 	bool completeBuffer(FrameBuffer *buffer);
diff --git a/include/libcamera/request.h b/include/libcamera/request.h
index c9aeddb62680923dc3490a23dc6c3f70f70cc075..31860e1e1e7680ded00148b7d96ca03f820d1ebc 100644
--- a/include/libcamera/request.h
+++ b/include/libcamera/request.h
@@ -49,7 +49,7 @@ public:
 
 	void reuse(ReuseFlag flags = Default);
 
-	ControlList &controls() { return *controls_; }
+	const ControlList &controls() const { return *controls_; }
 	const ControlList &metadata() const;
 	const BufferMap &buffers() const { return bufferMap_; }
 	int addBuffer(const Stream *stream, FrameBuffer *buffer,
@@ -64,6 +64,31 @@ public:
 
 	std::string toString() const;
 
+	template<typename T, typename V>
+	void setControl(const Control<T> &ctrl, const V &value)
+	{
+		controls_->set(ctrl, value);
+	}
+
+#ifndef __DOXYGEN__
+	template<typename T, typename V, std::size_t Size>
+	void setControl(const Control<Span<const T, Size>> &ctrl,
+			const Span<const V, Size> &values)
+	{
+		controls_->set(ctrl, values);
+	}
+#endif
+
+	void setControls(const ControlList &other)
+	{
+		controls_->merge(other);
+	}
+
+	void setControl(unsigned int id, const ControlValue &value)
+	{
+		controls_->set(id, value);
+	}
+
 private:
 	LIBCAMERA_DISABLE_COPY(Request)
 
diff --git a/src/android/camera_device.cpp b/src/android/camera_device.cpp
index 80ff248c2aa316d2f7ccc59d4621a7c86770bf5f..80145f6d103546b4214316f58ecf283accd108ff 100644
--- a/src/android/camera_device.cpp
+++ b/src/android/camera_device.cpp
@@ -804,19 +804,19 @@ int CameraDevice::processControls(Camera3RequestDescriptor *descriptor)
 		return 0;
 
 	/* Translate the Android request settings to libcamera controls. */
-	ControlList &controls = descriptor->request_->controls();
+	Request *req = descriptor->request_.get();
 	camera_metadata_ro_entry_t entry;
 	if (settings.getEntry(ANDROID_SCALER_CROP_REGION, &entry)) {
 		const int32_t *data = entry.data.i32;
 		Rectangle cropRegion{ data[0], data[1],
 				      static_cast<unsigned int>(data[2]),
 				      static_cast<unsigned int>(data[3]) };
-		controls.set(controls::ScalerCrop, cropRegion);
+		req->setControl(controls::ScalerCrop, cropRegion);
 	}
 
 	if (settings.getEntry(ANDROID_STATISTICS_FACE_DETECT_MODE, &entry)) {
 		const uint8_t *data = entry.data.u8;
-		controls.set(controls::draft::FaceDetectMode, data[0]);
+		req->setControl(controls::draft::FaceDetectMode, data[0]);
 	}
 
 	if (settings.getEntry(ANDROID_SENSOR_TEST_PATTERN_MODE, &entry)) {
@@ -854,7 +854,7 @@ int CameraDevice::processControls(Camera3RequestDescriptor *descriptor)
 			return -EINVAL;
 		}
 
-		controls.set(controls::draft::TestPatternMode, testPatternMode);
+		req->setControl(controls::draft::TestPatternMode, testPatternMode);
 	}
 
 	return 0;
diff --git a/src/apps/cam/camera_session.cpp b/src/apps/cam/camera_session.cpp
index 1596a25a3abed9c2d93e6657b92e35fdfd3d1a26..246806a0502cec465ac85989f7c93d5885427767 100644
--- a/src/apps/cam/camera_session.cpp
+++ b/src/apps/cam/camera_session.cpp
@@ -447,7 +447,7 @@ int CameraSession::queueRequest(Request *request)
 		return 0;
 
 	if (script_)
-		request->controls() = script_->frameControls(queueCount_);
+		request->setControls(script_->frameControls(queueCount_));
 
 	queueCount_++;
 
diff --git a/src/gstreamer/gstlibcamera-controls.cpp.in b/src/gstreamer/gstlibcamera-controls.cpp.in
index 6faf3ee7a6c3401bb42c3720505ea9d5d6f4186a..40a543e755e29f29b797417a58537fc2d05fa9ea 100644
--- a/src/gstreamer/gstlibcamera-controls.cpp.in
+++ b/src/gstreamer/gstlibcamera-controls.cpp.in
@@ -276,7 +276,7 @@ void GstCameraControls::setCamera(const std::shared_ptr<libcamera::Camera> &cam)
 
 void GstCameraControls::applyControls(std::unique_ptr<libcamera::Request> &request)
 {
-	request->controls().merge(controls_);
+	request->setControls(controls_);
 	controls_.clear();
 }
 
diff --git a/src/libcamera/camera.cpp b/src/libcamera/camera.cpp
index 2e1e146a25b13b94f3a0df5935c0861f78c949ed..1918c6f4340d790e93ac23baa906f038cd30f0bc 100644
--- a/src/libcamera/camera.cpp
+++ b/src/libcamera/camera.cpp
@@ -1366,7 +1366,7 @@ int Camera::queueRequest(Request *request)
 	}
 
 	/* Pre-process AeEnable. */
-	patchControlList(request->controls());
+	patchControlList(request->_d()->controls());
 
 	d->pipe_->invokeMethod(&PipelineHandler::queueRequest,
 			       ConnectionTypeQueued, request);
diff --git a/src/libcamera/request.cpp b/src/libcamera/request.cpp
index a661b2f5c8ae9ae2bcbab2dcdceeef7dcb8d0930..c8c47f4c75cad116efcdd95032b5def19c900d95 100644
--- a/src/libcamera/request.cpp
+++ b/src/libcamera/request.cpp
@@ -87,6 +87,16 @@ bool Request::Private::hasPendingBuffers() const
 	return !pending_.empty();
 }
 
+/**
+ * \fn Request::Private::controls()
+ * \brief Retrieve the request's ControlList
+ * \return A reference to the ControlList in this request
+ */
+ControlList &Request::Private::controls()
+{
+	return *_o<Request>()->controls_;
+}
+
 /**
  * \fn Request::Private::metadata()
  * \brief Retrieve the request's metadata
@@ -414,19 +424,9 @@ void Request::reuse(ReuseFlag flags)
 }
 
 /**
- * \fn Request::controls()
- * \brief Retrieve the request's ControlList
- *
- * Requests store a list of controls to be applied to all frames captured for
- * the request. They are created with an empty list of controls that can be
- * accessed through this function. Control values can be retrieved using
- * ControlList::get() and updated using ControlList::set().
- *
- * Only controls supported by the camera to which this request will be
- * submitted shall be included in the controls list. Attempting to add an
- * unsupported control causes undefined behaviour.
- *
- * \return A reference to the ControlList in this request
+ * \fn Request::controls() const
+ * \brief Retrieve a const reference to the request's ControlList
+ * \return A const reference to the ControlList in this request
  */
 
 /**
@@ -623,4 +623,24 @@ std::ostream &operator<<(std::ostream &out, const Request &r)
 	return out;
 }
 
+/**
+ * \fn Request::setControl(const Control<T> &ctrl, const V &value)
+ * \brief Set control \a ctrl in the Request
+ * \param[in] ctrl The control
+ * \param[in] value The control value
+ */
+
+/**
+ * \fn Request::setControls(const ControlList &other)
+ * \brief Merge the control list \a other in the Request
+ * \param[in] other The control list to add to the Request
+ */
+
+/**
+ * \fn Request::setControl(unsigned int id, const ControlValue &value)
+ * \brief Set control \a id in the Request to \a value
+ * \param[in] id The control numerical id
+ * \param[in] value The control value
+ */
+
 } /* namespace libcamera */
diff --git a/src/py/libcamera/py_main.cpp b/src/py/libcamera/py_main.cpp
index a983ea75c35669cc5d1ff433eddfdd99e9388e7c..97cc6cfbe7fbc4ece5c7a779a9ae6c8c7d23cf7b 100644
--- a/src/py/libcamera/py_main.cpp
+++ b/src/py/libcamera/py_main.cpp
@@ -463,7 +463,7 @@ PYBIND11_MODULE(_libcamera, m)
 		.def_property_readonly("sequence", &Request::sequence)
 		.def_property_readonly("has_pending_buffers", &Request::hasPendingBuffers)
 		.def("set_control", [](Request &self, const ControlId &id, py::object value) {
-			self.controls().set(id.id(), pyToControlValue(value, id.type()));
+			self.setControl(id.id(), pyToControlValue(value, id.type()));
 		})
 		.def_property_readonly("metadata", [](Request &self) {
 			/* Convert ControlList to std container */
diff --git a/src/v4l2/v4l2_camera.cpp b/src/v4l2/v4l2_camera.cpp
index 94d138cd5710b9bd5c83cacd3cbd963f71aeb3c7..7e918cc3e803cdad67043f1dbdc212d5b57e37f3 100644
--- a/src/v4l2/v4l2_camera.cpp
+++ b/src/v4l2/v4l2_camera.cpp
@@ -269,7 +269,7 @@ int V4L2Camera::qbuf(unsigned int index)
 		return 0;
 	}
 
-	request->controls().merge(std::move(controls_));
+	request->setControls(controls_);
 
 	ret = camera_->queueRequest(request);
 	if (ret < 0) {
