Commit 7d2a2e03d44 for woocommerce

commit 7d2a2e03d4493735104c2ecc879d4c8d1eae4b6b
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date:   Thu Oct 8 14:12:02 2026 +0300

    Refactor Order Fulfillments REST API request field handling (#69564)

    * Refactor Order Fulfillments REST API request field handling

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Re-pin new fulfillment identity before applying create metadata

    * Strengthen Order Fulfillments REST regression test assertions

    * Resolve fulfillment REST identity from the route, not the request body

    * Drop @since from private fulfillment REST helpers

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/69564-wooplug-7936-refactor-order-fulfillments-rest-api-request-field-handling b/plugins/woocommerce/changelog/69564-wooplug-7936-refactor-order-fulfillments-rest-api-request-field-handling
new file mode 100644
index 00000000000..a8470a16358
--- /dev/null
+++ b/plugins/woocommerce/changelog/69564-wooplug-7936-refactor-order-fulfillments-rest-api-request-field-handling
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Ensure Order Fulfillments REST API requests operate on the fulfillment and order identified by the route.
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php
index b56953aa635..f3bf5412473 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php
@@ -164,7 +164,7 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
 		// Fetch the order first if there's an order_id in the request.
 		$order = null;
 		if ( $request->has_param( 'order_id' ) ) {
-			$order_id = (int) $request->get_param( 'order_id' );
+			$order_id = $this->resolve_route_id( $request, 'order_id' );
 			$order    = wc_get_order( $order_id );

 			if ( ! $order ) {
@@ -204,6 +204,38 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
 		);
 	}

+	/**
+	 * Resolve a route identifier (order or fulfillment id) from the matched URL.
+	 *
+	 * The native v3 routes carry both ids as URL path segments; the v4 facade injects them
+	 * via set_param() after deriving them from the fulfillment. Reading the URL-captured
+	 * value first keeps the handler bound to the route it matched, and falls back to the
+	 * injected param for the v4 path.
+	 *
+	 * @param WP_REST_Request $request The request object.
+	 * @param string          $key     Either 'order_id' or 'fulfillment_id'.
+	 * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+	 * @return int The resolved identifier, or 0 when absent.
+	 */
+	private function resolve_route_id( WP_REST_Request $request, string $key ): int {
+		$url_params = $request->get_url_params();
+
+		return (int) ( $url_params[ $key ] ?? $request->get_param( $key ) );
+	}
+
+	/**
+	 * Pick the fields a write request is allowed to change from the body.
+	 *
+	 * Identity fields (id, entity_id, entity_type) and raw storage props are controller-owned,
+	 * so they are dropped here and never reach set_props() where they could redirect the write.
+	 *
+	 * @param array<string, mixed> $params The request body params.
+	 * @return array<string, mixed> The mutable subset of the params.
+	 */
+	private function mutable_request_props( array $params ): array {
+		return array_intersect_key( $params, array_flip( array( 'status', 'is_fulfilled' ) ) );
+	}
+
 	/**
 	 * Get the fulfillments for the order.
 	 *
@@ -215,7 +247,7 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
 	 * @return WP_REST_Response The fulfillments for the order, or an error if the request fails.
 	 */
 	public function get_fulfillments( WP_REST_Request $request ): WP_REST_Response {
-		$order_id     = (int) $request->get_param( 'order_id' );
+		$order_id     = $this->resolve_route_id( $request, 'order_id' );
 		$fulfillments = array();

 		// Fetch fulfillments for the order.
@@ -257,7 +289,7 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
 	 * @return WP_REST_Response The created fulfillment, or an error if the request fails.
 	 */
 	public function create_fulfillment( WP_REST_Request $request ) {
-		$order_id        = (int) $request->get_param( 'order_id' );
+		$order_id        = $this->resolve_route_id( $request, 'order_id' );
 		$notify_customer = (bool) $request->get_param( 'notify_customer' );

 		$order = wc_get_order( $order_id );
@@ -275,13 +307,17 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
 		// Create a new fulfillment.
 		try {
 			$fulfillment = new Fulfillment();
-			$params      = $request->get_json_params();
-			$fulfillment->set_props( $params );
+			$params      = (array) $request->get_json_params();
+			// Only mutable fields come from the body. Identity (id, entity_id, entity_type) and raw storage
+			// props are controller-owned and set below, so a body cannot turn create into an update or
+			// attach the fulfillment to another entity.
+			$fulfillment->set_props( $this->mutable_request_props( $params ) );
+			$fulfillment->set_id( 0 );
+			$fulfillment->set_entity_type( WC_Order::class );
+			$fulfillment->set_entity_id( "$order_id" );
 			if ( isset( $params['meta_data'] ) ) {
 				$this->apply_request_meta_data( $params['meta_data'], $fulfillment );
 			}
-			$fulfillment->set_entity_type( WC_Order::class );
-			$fulfillment->set_entity_id( "$order_id" );

 			$fulfillment->save();

@@ -338,8 +374,8 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
 	 * @throws \Exception If the fulfillment is not found or is deleted.
 	 */
 	public function get_fulfillment( WP_REST_Request $request ): WP_REST_Response {
-		$order_id       = (int) $request->get_param( 'order_id' );
-		$fulfillment_id = (int) $request->get_param( 'fulfillment_id' );
+		$order_id       = $this->resolve_route_id( $request, 'order_id' );
+		$fulfillment_id = $this->resolve_route_id( $request, 'fulfillment_id' );

 		// Fetch the fulfillment for the order.
 		try {
@@ -376,8 +412,8 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
 	 * @return WP_REST_Response The updated fulfillment, or an error if the request fails.
 	 */
 	public function update_fulfillment( WP_REST_Request $request ): WP_REST_Response {
-		$order_id          = (int) $request->get_param( 'order_id' );
-		$fulfillment_id    = (int) $request->get_param( 'fulfillment_id' );
+		$order_id          = $this->resolve_route_id( $request, 'order_id' );
+		$fulfillment_id    = $this->resolve_route_id( $request, 'fulfillment_id' );
 		$notify_customer   = (bool) $request->get_param( 'notify_customer' );
 		$customer_note_raw = $request->get_param( 'customer_note' );
 		$customer_note     = is_string( $customer_note_raw ) ? $customer_note_raw : '';
@@ -400,11 +436,18 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
 			$previous_status = $fulfillment->get_status() ?? 'unfulfilled';
 			$this->validate_fulfillment( $fulfillment, $fulfillment_id, $order_id );

-			$fulfillment->set_props( $request->get_json_params() );
+			$params = (array) $request->get_json_params();
+			// Only mutable fields come from the body. Identity comes from the route and is set explicitly
+			// below, so no field (including a props/props_from_storage payload) can redirect the write to
+			// another row or move the fulfillment to another order.
+			$fulfillment->set_props( $this->mutable_request_props( $params ) );
+			$fulfillment->set_id( $fulfillment_id );
+			$fulfillment->set_entity_type( WC_Order::class );
+			$fulfillment->set_entity_id( (string) $order_id );
 			$next_state = $fulfillment->get_is_fulfilled();

-			if ( isset( $request->get_json_params()['meta_data'] ) ) {
-				$meta_data       = $request->get_json_params()['meta_data'];
+			if ( isset( $params['meta_data'] ) ) {
+				$meta_data       = $params['meta_data'];
 				$normalized_keys = $this->apply_request_meta_data( $meta_data, $fulfillment );

 				// Remove meta keys not in the request. Skip if all entries were malformed
@@ -489,8 +532,8 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
 	 * @return WP_REST_Response The deleted fulfillment, or an error if the request fails.
 	 */
 	public function delete_fulfillment( WP_REST_Request $request ) {
-		$order_id        = (int) $request->get_param( 'order_id' );
-		$fulfillment_id  = (int) $request->get_param( 'fulfillment_id' );
+		$order_id        = $this->resolve_route_id( $request, 'order_id' );
+		$fulfillment_id  = $this->resolve_route_id( $request, 'fulfillment_id' );
 		$notify_customer = (bool) $request->get_param( 'notify_customer' );

 		// Delete the fulfillment for the order.
@@ -547,8 +590,8 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
 	 * @return WP_REST_Response The metadata for the fulfillment, or an error if the request fails.
 	 */
 	public function get_fulfillment_meta( WP_REST_Request $request ): WP_REST_Response {
-		$order_id       = (int) $request->get_param( 'order_id' );
-		$fulfillment_id = (int) $request->get_param( 'fulfillment_id' );
+		$order_id       = $this->resolve_route_id( $request, 'order_id' );
+		$fulfillment_id = $this->resolve_route_id( $request, 'fulfillment_id' );

 		// Fetch the metadata for the fulfillment.
 		try {
@@ -576,8 +619,8 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
 	 * @return WP_REST_Response The updated metadata for the fulfillment, or an error if the request fails.
 	 */
 	public function update_fulfillment_meta( WP_REST_Request $request ): WP_REST_Response {
-		$order_id       = (int) $request->get_param( 'order_id' );
-		$fulfillment_id = (int) $request->get_param( 'fulfillment_id' );
+		$order_id       = $this->resolve_route_id( $request, 'order_id' );
+		$fulfillment_id = $this->resolve_route_id( $request, 'fulfillment_id' );

 		// Update the metadata for the fulfillment.
 		try {
@@ -627,8 +670,8 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
 	 * @return WP_REST_Response The deleted metadata for the fulfillment, or an error if the request fails.
 	 */
 	public function delete_fulfillment_meta( WP_REST_Request $request ) {
-		$order_id       = (int) $request->get_param( 'order_id' );
-		$fulfillment_id = (int) $request->get_param( 'fulfillment_id' );
+		$order_id       = $this->resolve_route_id( $request, 'order_id' );
+		$fulfillment_id = $this->resolve_route_id( $request, 'fulfillment_id' );

 		// Delete the metadata for the fulfillment.
 		try {
@@ -666,7 +709,7 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
 	 * @return WP_REST_Response The tracking number details, or an error if the request fails.
 	 */
 	public function get_tracking_number_details( WP_REST_Request $request ) {
-		$order_id        = (int) $request->get_param( 'order_id' );
+		$order_id        = $this->resolve_route_id( $request, 'order_id' );
 		$tracking_number = sanitize_text_field( $request->get_param( 'tracking_number' ) );

 		if ( empty( $tracking_number ) ) {
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Fulfillments/Controller.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Fulfillments/Controller.php
index 1e603632642..5734e12f35f 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Fulfillments/Controller.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Fulfillments/Controller.php
@@ -124,7 +124,7 @@ class Controller extends AbstractController {
 					'methods'             => WP_REST_Server::EDITABLE,
 					'callback'            => array( $this, 'update_fulfillment' ),
 					'permission_callback' => array( $this, 'check_permission_for_fulfillments' ),
-					'args'                => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
+					'args'                => $this->get_update_args(),
 				),
 				array(
 					'methods'             => WP_REST_Server::DELETABLE,
@@ -341,7 +341,14 @@ class Controller extends AbstractController {
 			if ( $fulfillment_id ) {
 				try {
 					$fulfillment = new Fulfillment( $fulfillment_id );
-					$order       = wc_get_order( (int) $fulfillment->get_entity_id() );
+					if ( $fulfillment->get_id() && WC_Order::class !== $fulfillment->get_entity_type() ) {
+						return new WP_Error(
+							'woocommerce_rest_invalid_entity_type',
+							esc_html__( 'The entity type must be "order".', 'woocommerce' ),
+							array( 'status' => WP_Http::BAD_REQUEST )
+						);
+					}
+					$order = wc_get_order( (int) $fulfillment->get_entity_id() );
 				} catch ( ApiException $ex ) {
 					return new WP_Error(
 						$ex->getErrorCode(),
@@ -419,6 +426,21 @@ class Controller extends AbstractController {
 		return $this->item_schema->get_item_schema();
 	}

+	/**
+	 * Get the writable args for the update route.
+	 *
+	 * A fulfillment's identity and parent order come from the route, not the body, so entity_id and
+	 * entity_type are not writable on edit. They stay required on create, where the parent is taken
+	 * from the body.
+	 *
+	 * @return array The update endpoint args.
+	 */
+	private function get_update_args(): array {
+		$args = $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE );
+		unset( $args['entity_id'], $args['entity_type'] );
+		return $args;
+	}
+
 	/**
 	 * Get the item response for a fulfillment.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/OrderFulfillmentsRestControllerTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/OrderFulfillmentsRestControllerTest.php
index d21485e6442..a9eabba07f0 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/OrderFulfillmentsRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/OrderFulfillmentsRestControllerTest.php
@@ -4,6 +4,7 @@ declare( strict_types = 1 );
 namespace Automattic\WooCommerce\Tests\Admin\Features\Fulfillments;

 use Automattic\WooCommerce\Admin\Features\Fulfillments\DataStore\FulfillmentsDataStore;
+use Automattic\WooCommerce\Admin\Features\Fulfillments\Fulfillment;
 use Automattic\WooCommerce\Admin\Features\Fulfillments\OrderFulfillmentsRestController;
 use Automattic\WooCommerce\Tests\Admin\Features\Fulfillments\Helpers\FulfillmentsHelper;
 use WC_Helper_Order;
@@ -354,6 +355,64 @@ class OrderFulfillmentsRestControllerTest extends WC_REST_Unit_Test_Case {
 		$this->assertEquals( WP_Http::UNAUTHORIZED, $data['data']['status'] );
 	}

+	/**
+	 * @testdox Create ignores an id in the body: it inserts a new fulfillment and applies metadata to it, not to the supplied id.
+	 */
+	public function test_create_fulfillment_ignores_body_id(): void {
+		wp_set_current_user( 1 );
+
+		$order_a           = WC_Helper_Order::create_order();
+		$order_b           = WC_Helper_Order::create_order();
+		$other_fulfillment = FulfillmentsHelper::create_fulfillment( array( 'entity_id' => (string) $order_b->get_id() ) );
+
+		$request = new WP_REST_Request( 'POST', '/wc/v3/orders/' . $order_a->get_id() . '/fulfillments' );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body(
+			wp_json_encode(
+				array(
+					'id'           => $other_fulfillment->get_id(),
+					'status'       => 'unfulfilled',
+					'is_fulfilled' => false,
+					'meta_data'    => array(
+						array(
+							'id'    => 0,
+							'key'   => 'request_create_meta',
+							'value' => 'new_fulfillment',
+						),
+						array(
+							'id'    => 0,
+							'key'   => '_items',
+							'value' => array(
+								array(
+									'item_id' => 1,
+									'qty'     => 2,
+								),
+							),
+						),
+					),
+				)
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$this->assertEquals( WP_Http::CREATED, $response->get_status() );
+
+		$created = $response->get_data();
+		$this->assertNotEquals( $other_fulfillment->get_id(), $created['id'], 'Create must insert a new fulfillment, not reuse the id from the body.' );
+		$this->assertEquals( (string) $order_a->get_id(), (string) $created['entity_id'], 'The new fulfillment must belong to the routed order.' );
+
+		$response_meta_keys = wp_list_pluck( $created['meta_data'], 'key' );
+		$this->assertContains( 'request_create_meta', $response_meta_keys, 'Request metadata must be applied to the new fulfillment.' );
+		$this->assertNotContains( 'test_meta_key', $response_meta_keys, 'The new fulfillment must not inherit metadata from the fulfillment whose id was supplied in the body.' );
+
+		// The metadata must persist on the saved row, not just appear in the response.
+		$created_reloaded = new Fulfillment( (int) $created['id'] );
+		$this->assertSame( 'new_fulfillment', $created_reloaded->get_meta( 'request_create_meta' ), 'Request metadata must be saved on the new fulfillment row.' );
+
+		$other_reloaded = new Fulfillment( $other_fulfillment->get_id() );
+		$this->assertSame( (string) $order_b->get_id(), $other_reloaded->get_entity_id(), 'The fulfillment whose id was supplied in the body must be untouched.' );
+	}
+
 	/**
 	 * Test creating a fulfillment (user is admin).
 	 */
@@ -877,6 +936,313 @@ class OrderFulfillmentsRestControllerTest extends WC_REST_Unit_Test_Case {
 		wp_set_current_user( self::$created_user_id );
 	}

+	/**
+	 * @testdox The v3 update route keeps the fulfillment on its own order and ignores entity_id in the body.
+	 */
+	public function test_update_fulfillment_does_not_reparent_via_entity_id(): void {
+		wp_set_current_user( 1 );
+
+		$order_a     = WC_Helper_Order::create_order();
+		$order_b     = WC_Helper_Order::create_order();
+		$fulfillment = FulfillmentsHelper::create_fulfillment( array( 'entity_id' => (string) $order_a->get_id() ) );
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/orders/' . $order_a->get_id() . '/fulfillments/' . $fulfillment->get_id() );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body(
+			wp_json_encode(
+				array(
+					'entity_id'    => (string) $order_b->get_id(),
+					'entity_type'  => WC_Order::class,
+					'status'       => 'fulfilled',
+					'is_fulfilled' => true,
+					'meta_data'    => array(
+						array(
+							'id'    => 0,
+							'key'   => '_items',
+							'value' => array(
+								array(
+									'item_id' => 1,
+									'qty'     => 2,
+								),
+							),
+						),
+					),
+				)
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$this->assertEquals( WP_Http::OK, $response->get_status(), 'The update must succeed so the reparenting guard is actually exercised.' );
+
+		$reloaded = new Fulfillment( $fulfillment->get_id() );
+		$this->assertSame(
+			(string) $order_a->get_id(),
+			$reloaded->get_entity_id(),
+			'A v3 PUT must not move the fulfillment to a different order via the request body.'
+		);
+	}
+
+	/**
+	 * @testdox The v3 update route ignores an id in the body and never writes to a different fulfillment row.
+	 */
+	public function test_update_fulfillment_ignores_body_id(): void {
+		wp_set_current_user( 1 );
+
+		$order_a       = WC_Helper_Order::create_order();
+		$order_b       = WC_Helper_Order::create_order();
+		$fulfillment_a = FulfillmentsHelper::create_fulfillment( array( 'entity_id' => (string) $order_a->get_id() ) );
+		$fulfillment_b = FulfillmentsHelper::create_fulfillment( array( 'entity_id' => (string) $order_b->get_id() ) );
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/orders/' . $order_a->get_id() . '/fulfillments/' . $fulfillment_a->get_id() );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body(
+			wp_json_encode(
+				array(
+					'id'           => $fulfillment_b->get_id(),
+					'status'       => 'fulfilled',
+					'is_fulfilled' => true,
+					'meta_data'    => array(
+						array(
+							'id'    => 0,
+							'key'   => '_items',
+							'value' => array(
+								array(
+									'item_id' => 1,
+									'qty'     => 2,
+								),
+							),
+						),
+					),
+				)
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$this->assertEquals( WP_Http::OK, $response->get_status(), 'The update targeted by the route must succeed.' );
+
+		$other_reloaded = new Fulfillment( $fulfillment_b->get_id() );
+		$this->assertSame(
+			(string) $order_b->get_id(),
+			$other_reloaded->get_entity_id(),
+			'A body id must not redirect the update onto a different fulfillment row.'
+		);
+		$this->assertNotSame(
+			'fulfilled',
+			$other_reloaded->get_status(),
+			'A body id must not change the other fulfillment status.'
+		);
+	}
+
+	/**
+	 * @testdox The v3 update route resolves the order from the URL and ignores a different order_id in the body.
+	 */
+	public function test_update_fulfillment_ignores_body_order_id(): void {
+		wp_set_current_user( 1 );
+
+		$order_a     = WC_Helper_Order::create_order();
+		$order_b     = WC_Helper_Order::create_order();
+		$fulfillment = FulfillmentsHelper::create_fulfillment( array( 'entity_id' => (string) $order_a->get_id() ) );
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/orders/' . $order_a->get_id() . '/fulfillments/' . $fulfillment->get_id() );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body(
+			wp_json_encode(
+				array(
+					'order_id'     => $order_b->get_id(),
+					'status'       => 'fulfilled',
+					'is_fulfilled' => true,
+					'meta_data'    => array(
+						array(
+							'id'    => 0,
+							'key'   => '_items',
+							'value' => array(
+								array(
+									'item_id' => 1,
+									'qty'     => 2,
+								),
+							),
+						),
+					),
+				)
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$this->assertEquals( WP_Http::OK, $response->get_status(), 'The update must resolve order_id from the URL, not from the body param.' );
+
+		$reloaded = new Fulfillment( $fulfillment->get_id() );
+		$this->assertSame(
+			(string) $order_a->get_id(),
+			$reloaded->get_entity_id(),
+			'A different order_id in the body must not change which order the fulfillment belongs to.'
+		);
+	}
+
+	/**
+	 * @testdox The v3 update route ignores a nested props.id payload and never writes to a different fulfillment row.
+	 */
+	public function test_update_fulfillment_ignores_nested_props_id(): void {
+		wp_set_current_user( 1 );
+
+		$order_a       = WC_Helper_Order::create_order();
+		$order_b       = WC_Helper_Order::create_order();
+		$fulfillment_a = FulfillmentsHelper::create_fulfillment( array( 'entity_id' => (string) $order_a->get_id() ) );
+		$fulfillment_b = FulfillmentsHelper::create_fulfillment( array( 'entity_id' => (string) $order_b->get_id() ) );
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/orders/' . $order_a->get_id() . '/fulfillments/' . $fulfillment_a->get_id() );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body(
+			wp_json_encode(
+				array(
+					'props'        => array( 'id' => $fulfillment_b->get_id() ),
+					'status'       => 'fulfilled',
+					'is_fulfilled' => true,
+					'meta_data'    => array(
+						array(
+							'id'    => 0,
+							'key'   => '_items',
+							'value' => array(
+								array(
+									'item_id' => 1,
+									'qty'     => 2,
+								),
+							),
+						),
+					),
+				)
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$this->assertEquals( WP_Http::OK, $response->get_status(), 'The update targeted by the route must succeed.' );
+
+		$other_reloaded = new Fulfillment( $fulfillment_b->get_id() );
+		$this->assertSame(
+			(string) $order_b->get_id(),
+			$other_reloaded->get_entity_id(),
+			'A nested props.id must not redirect the update onto a different fulfillment row.'
+		);
+		$this->assertNotSame(
+			'fulfilled',
+			$other_reloaded->get_status(),
+			'A nested props.id must not change the other fulfillment status.'
+		);
+	}
+
+	/**
+	 * @testdox The v3 update route ignores a props_from_storage payload and does not reparent the fulfillment.
+	 */
+	public function test_update_fulfillment_ignores_props_from_storage_entity(): void {
+		wp_set_current_user( 1 );
+
+		$order_a     = WC_Helper_Order::create_order();
+		$order_b     = WC_Helper_Order::create_order();
+		$fulfillment = FulfillmentsHelper::create_fulfillment( array( 'entity_id' => (string) $order_a->get_id() ) );
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/orders/' . $order_a->get_id() . '/fulfillments/' . $fulfillment->get_id() );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body(
+			wp_json_encode(
+				array(
+					'props_from_storage' => array(
+						'entity_id'   => (string) $order_b->get_id(),
+						'entity_type' => WC_Order::class,
+					),
+					'status'             => 'fulfilled',
+					'is_fulfilled'       => true,
+					'meta_data'          => array(
+						array(
+							'id'    => 0,
+							'key'   => '_items',
+							'value' => array(
+								array(
+									'item_id' => 1,
+									'qty'     => 2,
+								),
+							),
+						),
+					),
+				)
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$this->assertEquals( WP_Http::OK, $response->get_status(), 'The update must succeed so the re-pin is exercised.' );
+
+		$reloaded = new Fulfillment( $fulfillment->get_id() );
+		$this->assertSame(
+			(string) $order_a->get_id(),
+			$reloaded->get_entity_id(),
+			'A props_from_storage payload must not reparent the fulfillment to another order.'
+		);
+	}
+
+	/**
+	 * @testdox The v3 update route ignores a props_from_storage id in the body and reports the routed fulfillment id everywhere.
+	 */
+	public function test_update_fulfillment_ignores_props_from_storage_id(): void {
+		wp_set_current_user( 1 );
+
+		$order_a           = WC_Helper_Order::create_order();
+		$order_b           = WC_Helper_Order::create_order();
+		$fulfillment_a     = FulfillmentsHelper::create_fulfillment( array( 'entity_id' => (string) $order_a->get_id() ) );
+		$other_fulfillment = FulfillmentsHelper::create_fulfillment( array( 'entity_id' => (string) $order_b->get_id() ) );
+
+		$hook_id = null;
+		add_action(
+			'woocommerce_fulfillment_after_update',
+			function ( $fulfillment ) use ( &$hook_id ) {
+				$hook_id = $fulfillment->get_id();
+			}
+		);
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/orders/' . $order_a->get_id() . '/fulfillments/' . $fulfillment_a->get_id() );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body(
+			wp_json_encode(
+				array(
+					'props_from_storage' => array( 'id' => $other_fulfillment->get_id() ),
+					'status'             => 'fulfilled',
+					'is_fulfilled'       => true,
+					'meta_data'          => array(
+						array(
+							'id'    => 0,
+							'key'   => '_items',
+							'value' => array(
+								array(
+									'item_id' => 1,
+									'qty'     => 2,
+								),
+							),
+						),
+					),
+				)
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$this->assertEquals( WP_Http::OK, $response->get_status() );
+
+		$data = $response->get_data();
+		$this->assertSame(
+			$fulfillment_a->get_id(),
+			$data['id'],
+			'The response must report the routed fulfillment id, not an id supplied through props_from_storage.'
+		);
+		$this->assertSame(
+			$fulfillment_a->get_id(),
+			$hook_id,
+			'The after_update hook must receive the routed fulfillment, not the id supplied through props_from_storage.'
+		);
+
+		$other_reloaded = new Fulfillment( $other_fulfillment->get_id() );
+		$this->assertNotSame(
+			'fulfilled',
+			$other_reloaded->get_status(),
+			'The other fulfillment must not be modified.'
+		);
+	}
+
 	/**
 	 * Test updating a fulfillment with an invalid order ID.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Fulfillments/ControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Fulfillments/ControllerTest.php
index 25257250f07..071d10bcff5 100644
--- a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Fulfillments/ControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Fulfillments/ControllerTest.php
@@ -317,6 +317,40 @@ class ControllerTest extends WC_Unit_Test_Case {
 		$this->assertEquals( 'woocommerce_rest_fulfillment_invalid_id', $data['code'] );
 	}

+	/**
+	 * @testdox PUT keeps the fulfillment on its own order and ignores a different entity_id in the body.
+	 */
+	public function test_update_fulfillment_does_not_reparent_via_entity_id(): void {
+		wp_set_current_user( self::$admin_user_id );
+
+		$other_order = WC_Helper_Order::create_order( self::$customer_user_id );
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v4/fulfillments/' . $this->test_fulfillment->get_id() );
+		$request->set_header( 'Content-Type', 'application/json' );
+		$request->set_body(
+			wp_json_encode(
+				$this->get_test_fulfillment_data(
+					array( 'entity_id' => (string) $other_order->get_id() )
+				)
+			)
+		);
+
+		$response = rest_get_server()->dispatch( $request );
+		$this->assertEquals( 200, $response->get_status(), 'The update must succeed so the reparenting guard is actually exercised.' );
+
+		$reloaded = new Fulfillment( $this->test_fulfillment->get_id() );
+		$this->assertSame(
+			(string) $this->test_order->get_id(),
+			$reloaded->get_entity_id(),
+			'A PUT must not move the fulfillment to a different order via the request body.'
+		);
+		$this->assertSame(
+			WC_Order::class,
+			$reloaded->get_entity_type(),
+			'A PUT must not change the fulfillment entity type via the request body.'
+		);
+	}
+
 	/**
 	 * Test delete_fulfillment endpoint
 	 */
@@ -492,6 +526,31 @@ class ControllerTest extends WC_Unit_Test_Case {
 		$this->assertArrayHasKey( 'entity_type', $post_endpoint['args'] );
 	}

+	/**
+	 * @testdox The update endpoint schema does not expose the route-derived identity fields as writable.
+	 */
+	public function test_update_fulfillment_schema_excludes_identity_fields(): void {
+		wp_set_current_user( self::$admin_user_id );
+
+		$request  = new WP_REST_Request( 'OPTIONS', '/wc/v4/fulfillments/' . $this->test_fulfillment->get_id() );
+		$response = rest_get_server()->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertArrayHasKey( 'endpoints', $data );
+		$put_endpoint = array_filter(
+			$data['endpoints'],
+			function ( $endpoint ) {
+				return in_array( 'PUT', $endpoint['methods'], true );
+			}
+		);
+		$this->assertNotEmpty( $put_endpoint );
+		$put_endpoint = reset( $put_endpoint );
+		$this->assertArrayHasKey( 'args', $put_endpoint );
+		$this->assertArrayNotHasKey( 'entity_id', $put_endpoint['args'], 'A fulfillment cannot be reparented on edit, so entity_id must not be a writable update field.' );
+		$this->assertArrayNotHasKey( 'entity_type', $put_endpoint['args'], 'A fulfillment cannot be reparented on edit, so entity_type must not be a writable update field.' );
+		$this->assertArrayHasKey( 'status', $put_endpoint['args'], 'Mutable fields such as status must remain writable on edit.' );
+	}
+
 	/**
 	 * Test error response format
 	 */