Commit 8e33afaf2e0 for woocommerce

commit 8e33afaf2e00767344a4490be2f3da3d4681fbc2
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date:   Tue Mar 24 11:48:01 2026 +0300

    Add fulfillments tracking code (#60205)

    * Add fulfillments tracking code

    * Add changefile(s) from automation for the following project(s): woocommerce, woocommerce/client/admin

    * Add changefile(s) from automation for the following project(s): woocommerce, woocommerce/client/admin

    * Wire up remaining fulfillment telemetry events and add tests

    - Add track_fulfillment_modal_opened method and rename JS event to
      fulfillment_modal_opened to match the spec
    - Wire up track_fulfillment_tracking_added in REST controller create
      and update flows via maybe_track_tracking_added helper
    - Add url_generated param to tracking lookup attempt event
    - Hook fulfillment email template save actions to track customization
    - Add determine_tracking_entry_method helper for mapping shipping
      option meta to entry method values
    - Add comprehensive docblock comments to all tracker methods
    - Add FulfillmentsTrackerTest (21 tests) covering entry method logic
      and method signatures
    - Add REST controller tests for tracking wiring and request source
    - Add FulfillmentsManager test for email template tracking hooks

    * Fix PHPStan and lint errors in fulfillment tracking code

    - Cast $order->get_item_count() to int for track_fulfillment_creation
    - Default nullable get_status() to 'unfulfilled' for tracker calls
    - Simplify catch-block action to 'update' since $next_state is not
      available when an exception is thrown before assignment
    - Add @phpstan-ignore for WP_REST_Request generics on new method

    * Fix TypeError: cast Exception::getCode() to string for tracker calls

    Exception::getCode() returns int, but track_fulfillment_validation_error
    expects string. Cast to (string) in all three catch blocks.

    * Fix array alignment in FulfillmentsTrackerTest data provider

    * Address PR review comments

    - Fix legacy order edit page detection by also checking for 'post'
      query param in isOrderDetailsPage (index.tsx)
    - Guard get_item_count() against malformed _items metadata by
      validating each entry is an array with a numeric qty (Fulfillment.php)
    - Move filter tracking call after validation and add tracking to the
      legacy orders list query path (FulfillmentsRenderer.php)
    - Fix $item_count docblock to reflect it is total quantity, not
      unique line item count (FulfillmentsTracker.php)
    - Fix test fixture meta key from _shipment_provider to
      _shipping_provider to match the Fulfillment model accessor

    * Fix merge conflict style errors

    * Use typed accessors instead of raw get_meta for fulfillment properties

    - Replace get_meta('_shipment_provider') with get_shipping_provider()
      in FulfillmentsRenderer, fixing the meta key mismatch
    - Revert unnecessary defensive checks in get_item_count() since items
      are validated before being written
    - Update FulfillmentsRendererTest to use set_tracking_number(),
      set_tracking_url(), and set_shipping_provider() typed setters

    * Address code review feedback for fulfillment telemetry

    - Add @since 10.7.0 annotation to public get_item_count() method
    - Inline $action variable in update_fulfillment catch blocks
    - Capture fulfillment status before delete() call for accurate tracking

    * Gate fulfillment filter tracking on explicit filter submission

    Only track fulfillment_filter_used when the user explicitly clicks
    the Filter button (filter_action present), not on pagination, sorting,
    or page refresh where fulfillment_status persists in the URL.

    * Address telemetry review feedback and fix fulfillment change tracking

    - Fix blank provider name for auto-lookup by adding FulfillmentUtils::resolve_provider_name()
    - Fix empty changed_fields by migrating Fulfillment setters to set_prop/get_prop and
      adding meta snapshot-based change detection via get_changes() override
    - Fix @since tags on FulfillmentsTracker (10.1.0 -> 10.7.0) and add to all public methods
    - Replace data store snapshot_tracked_state/compute_changed_props with Fulfillment::get_changes()
    - Simplify determine_tracking_entry_method to 2 params (ui_manual replaces custom/select split)
    - Gate fulfillment_tracking_added on actual tracking meta changes during updates
    - Update woocommerce_fulfillment_after_update hook to pass $changes and $previous_status
    - Fix test flakiness: by-ref parameter errors, stale DB table flag, feature flag pollution
    - Add tests for get_changes(), resolve_provider_name, and updated hook signature

    * Align shipment provider meta key to _shipment_provider across backend

    The frontend uses _shipment_provider but the backend Fulfillment model
    was reading _shipping_provider, causing the provider to always be empty
    for UI-created fulfillments. Rename the meta key, model methods, and all
    references to use _shipment_provider consistently.

    * Add return type to Fulfillment::apply_changes() for PHPStan

    ---------

    Co-authored-by: github-actions <github-actions@github.com>
    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
    Co-authored-by: Ayush Pahwa <ayush.pahwa@automattic.com>

diff --git a/plugins/woocommerce/changelog/60205-add-60040-telemetry-for-fulfillments b/plugins/woocommerce/changelog/60205-add-60040-telemetry-for-fulfillments
new file mode 100644
index 00000000000..9fa509700f6
--- /dev/null
+++ b/plugins/woocommerce/changelog/60205-add-60040-telemetry-for-fulfillments
@@ -0,0 +1,4 @@
+Significance: patch
+Type: add
+
+Add telemetry for fulfillment feature adoption and usage tracking
\ No newline at end of file
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/data/store.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/data/store.ts
index 790870aac1d..2dcea287db8 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/data/store.ts
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/data/store.ts
@@ -113,6 +113,10 @@ const publicActions = {
 					),
 					method: 'POST',
 					data: fulfillment,
+					headers: {
+						'Content-Type': 'application/json',
+						'X-WC-Fulfillments-UI': 'true',
+					},
 				} );
 				if ( ! saved.id ) {
 					throw new Error( 'Fulfillment ID is missing in response' );
@@ -153,6 +157,10 @@ const publicActions = {
 					),
 					method: 'PUT',
 					data: fulfillment,
+					headers: {
+						'Content-Type': 'application/json',
+						'X-WC-Fulfillments-UI': 'true',
+					},
 				} );
 				if ( ! updated.id ) {
 					throw new Error( 'Fulfillment ID is missing in response' );
@@ -183,6 +191,10 @@ const publicActions = {
 						{ notify_customer: notifyCustomer }
 					),
 					method: 'DELETE',
+					headers: {
+						'Content-Type': 'application/json',
+						'X-WC-Fulfillments-UI': 'true',
+					},
 				} );
 				dispatch.deleteFulfillmentRecord( orderId, fulfillmentId );
 			} catch ( error: unknown ) {
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/index.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/index.tsx
index 4b31afaef43..263fa432fd5 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/index.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/index.tsx
@@ -4,6 +4,7 @@
 import React, { useCallback, useLayoutEffect, useState } from 'react';
 import { createRoot } from '@wordpress/element';
 import { getQuery } from '@woocommerce/navigation';
+import { recordEvent } from '@woocommerce/tracks';

 /**
  * Internal dependencies
@@ -14,6 +15,10 @@ import FulfillmentDrawer from './components/user-interface/fulfillment-drawer/fu
 function FulfillmentsController() {
 	const [ isOpen, setIsOpen ] = useState( false );
 	const [ orderId, setOrderId ] = useState< number | null >( null );
+	const query = getQuery();
+	const isOrderDetailsPage =
+		Object.prototype.hasOwnProperty.call( query, 'id' ) ||
+		Object.prototype.hasOwnProperty.call( query, 'post' );

 	const deselectOrderRow = useCallback( () => {
 		document.querySelectorAll( '.type-shop_order' ).forEach( ( row ) => {
@@ -30,6 +35,20 @@ function FulfillmentsController() {
 		[ deselectOrderRow ]
 	);

+	const openFulfillmentDrawer = useCallback(
+		( id: number ) => {
+			setOrderId( id );
+			setIsOpen( true );
+			recordEvent( 'fulfillment_modal_opened', {
+				source: isOrderDetailsPage
+					? 'order_detail_page'
+					: 'orders_list',
+				order_id: id,
+			} );
+		},
+		[ setOrderId, setIsOpen, isOrderDetailsPage ]
+	);
+
 	useLayoutEffect( () => {
 		const handleClick = ( e: Event ) => {
 			const target = e.target as HTMLElement;
@@ -42,8 +61,7 @@ function FulfillmentsController() {
 					e.preventDefault();
 					e.stopPropagation();
 					selectOrderRow( button );
-					setOrderId( id );
-					setIsOpen( true );
+					openFulfillmentDrawer( id );
 				}
 			}
 		};
@@ -53,10 +71,7 @@ function FulfillmentsController() {
 		return () => {
 			document.body.removeEventListener( 'click', handleClick );
 		};
-	}, [ selectOrderRow ] );
-
-	const query = getQuery();
-	const isOrderDetailsPage = query.hasOwnProperty( 'id' );
+	}, [ selectOrderRow, openFulfillmentDrawer ] );

 	return (
 		<FulfillmentDrawer
diff --git a/plugins/woocommerce/client/admin/docs/features/fulfillments-hooks.md b/plugins/woocommerce/client/admin/docs/features/fulfillments-hooks.md
index ddf17abd4a0..ba9e3b64d2f 100644
--- a/plugins/woocommerce/client/admin/docs/features/fulfillments-hooks.md
+++ b/plugins/woocommerce/client/admin/docs/features/fulfillments-hooks.md
@@ -53,24 +53,30 @@ function update_inventory_system( $fulfillment ) {

 Fired after a fulfillment is successfully updated in the database.

-**File:** `src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStore.php:262`
+**File:** `src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStore.php`

 **Parameters:**

 -   `$data` (Fulfillment) - The updated fulfillment object
--   `$changed_props` (array) - List of tracked property keys that changed (e.g. `'status'`, `'items'`, `'_tracking_number'`, `'_tracking_url'`, `'_shipping_provider'`)
--   `$old_state` (array) - Snapshot of tracked property values before the update
+-   `$changes` (array) - The changes that were applied, as returned by `Fulfillment::get_changes()` before save. Core data properties (e.g. `status`, `is_fulfilled`) sit at the top level; meta-based changes (e.g. `_tracking_number`, `_shipment_provider`, `_items`) are nested under the `meta_data` key.
+-   `$previous_status` (string) - The fulfillment status before the update (e.g. `'unfulfilled'`)

-**Purpose:** Allows plugins to perform actions after a fulfillment is updated. Only tracked properties (status, items, tracking number, tracking URL, shipping provider) are compared; changes to other metadata do not appear in `$changed_props`.
+**Purpose:** Allows plugins to perform actions after a fulfillment is updated. All core data and metadata changes are included in `$changes`.

 ```php
 add_action( 'woocommerce_fulfillment_after_update', 'sync_fulfillment_changes', 10, 3 );

-function sync_fulfillment_changes( $fulfillment, $changed_props, $old_state ) {
-    // Only sync when tracking info changes
-    if ( in_array( '_tracking_number', $changed_props, true ) ) {
+function sync_fulfillment_changes( $fulfillment, $changes, $previous_status ) {
+    // Check if tracking info changed
+    $meta_changes = $changes['meta_data'] ?? array();
+    if ( array_key_exists( '_tracking_number', $meta_changes ) ) {
         // Sync tracking to external service
     }
+
+    // Check if status changed
+    if ( array_key_exists( 'status', $changes ) ) {
+        // Status changed from $previous_status to $changes['status']
+    }
 }
 ```

diff --git a/plugins/woocommerce/client/admin/docs/features/fulfillments-rest-api.md b/plugins/woocommerce/client/admin/docs/features/fulfillments-rest-api.md
index 29a6d1be903..3f53c4162c2 100644
--- a/plugins/woocommerce/client/admin/docs/features/fulfillments-rest-api.md
+++ b/plugins/woocommerce/client/admin/docs/features/fulfillments-rest-api.md
@@ -69,7 +69,7 @@ Authorization: Basic <base64_encoded_credentials>
             },
             {
                 "id": 3,
-                "key": "_shipping_provider",
+                "key": "_shipment_provider",
                 "value": "ups"
             }
         ]
@@ -124,7 +124,7 @@ Authorization: Basic <base64_encoded_credentials>
       "value": "1Z999AA1234567890"
     },
     {
-      "key": "_shipping_provider",
+      "key": "_shipment_provider",
       "value": "ups"
     },
     {
@@ -168,7 +168,7 @@ Authorization: Basic <base64_encoded_credentials>
         },
         {
             "id": 5,
-            "key": "_shipping_provider",
+            "key": "_shipment_provider",
             "value": "ups"
         },
         {
@@ -221,7 +221,7 @@ Authorization: Basic <base64_encoded_credentials>
         },
         {
             "id": 4,
-            "key": "_shipping_provider",
+            "key": "_shipment_provider",
             "value": "ups"
         }
     ]
@@ -277,7 +277,7 @@ Authorization: Basic <base64_encoded_credentials>
       "value": "1Z999AA1234567890"
     },
     {
-      "key": "_shipping_provider",
+      "key": "_shipment_provider",
       "value": "ups"
     },
     {
@@ -321,7 +321,7 @@ Authorization: Basic <base64_encoded_credentials>
         },
         {
             "id": 5,
-            "key": "_shipping_provider",
+            "key": "_shipment_provider",
             "value": "ups"
         },
         {
@@ -411,7 +411,7 @@ Authorization: Basic <base64_encoded_credentials>
     },
     {
         "id": 5,
-        "key": "_shipping_provider",
+        "key": "_shipment_provider",
         "value": "ups"
     },
     {
@@ -464,7 +464,7 @@ Authorization: Basic <base64_encoded_credentials>
         "value": "1Z999AA9876543210"
     },
     {
-        "key": "_shipping_provider",
+        "key": "_shipment_provider",
         "value": "fedex"
     },
     {
@@ -499,7 +499,7 @@ Authorization: Basic <base64_encoded_credentials>
     },
     {
         "id": 5,
-        "key": "_shipping_provider",
+        "key": "_shipment_provider",
         "value": "fedex"
     },
     {
@@ -647,12 +647,12 @@ Metadata objects have the following structure:
 ### Optional Private Metadata Keys

 -   `_tracking_number` - Shipment tracking number
--   `_shipping_provider` - Shipping provider key (ups, fedex, dhl, etc.)
+-   `_shipment_provider` - Shipment provider key (ups, fedex, dhl, etc.)
 -   `_tracking_url` - URL to track the shipment
 -   `_is_locked` - Whether the fulfillment is locked for merchant modification
 -   `_lock_message` - What to show as the lock message for a locked fulfillment

-**Note:** The tracking number, shipping provider, and tracking URL metadata keys have typed convenience methods on the `Fulfillment` object: `get_tracking_number()` / `set_tracking_number()`, `get_shipping_provider()` / `set_shipping_provider()`, and `get_tracking_url()` / `set_tracking_url()`. These are recommended over direct `get_meta()` / `update_meta_data()` calls for better type safety and IDE autocompletion.
+**Note:** The tracking number, shipping provider, and tracking URL metadata keys have typed convenience methods on the `Fulfillment` object: `get_tracking_number()` / `set_tracking_number()`, `get_shipment_provider()` / `set_shipment_provider()`, and `get_tracking_url()` / `set_tracking_url()`. These are recommended over direct `get_meta()` / `update_meta_data()` calls for better type safety and IDE autocompletion.

 ### Items Structure

diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStore.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStore.php
index 924f6565e5c..94d5bb29d7b 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStore.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStore.php
@@ -24,11 +24,6 @@ if ( ! defined( 'ABSPATH' ) ) {
  */
 class FulfillmentsDataStore extends \WC_Data_Store_WP implements \WC_Object_Data_Store_Interface, FulfillmentsDataStoreInterface {

-	/**
-	 * Tracked meta keys for detecting meaningful fulfillment property changes.
-	 */
-	private const TRACKED_META_KEYS = array( '_tracking_number', '_tracking_url', '_shipping_provider' );
-
 	/**
 	 * Method to create a new fulfillment in the database.
 	 *
@@ -162,6 +157,7 @@ class FulfillmentsDataStore extends \WC_Data_Store_WP implements \WC_Object_Data
 		$data->set_id( (int) $fulfillment_data['fulfillment_id'] );
 		$data->read_meta_data( true );
 		$data->set_object_read( true );
+		$data->snapshot_meta();
 	}

 	/**
@@ -188,9 +184,6 @@ class FulfillmentsDataStore extends \WC_Data_Store_WP implements \WC_Object_Data

 		$this->validate_items( $data );

-		// Snapshot tracked properties before the update so we can detect changes.
-		$old_state = $this->snapshot_tracked_state( $data_id );
-
 		/**
 		 * Filter to modify the fulfillment data before it is updated.
 		 *
@@ -221,6 +214,11 @@ class FulfillmentsDataStore extends \WC_Data_Store_WP implements \WC_Object_Data

 		global $wpdb;

+		// Capture changes and previous status before set_date_updated (which always
+		// changes) and before apply_changes resets the tracking.
+		$changes         = $data->get_changes();
+		$previous_status = $data->get_data()['status'] ?? 'unfulfilled';
+
 		$data->set_date_updated( current_time( 'mysql' ) );

 		$wpdb->update(
@@ -253,20 +251,19 @@ class FulfillmentsDataStore extends \WC_Data_Store_WP implements \WC_Object_Data
 		$data->set_object_read( true );

 		if ( ! doing_action( 'woocommerce_fulfillment_after_update' ) ) {
-			$changed_props = $this->compute_changed_props( $data, $old_state );
-
 			/**
 			 * Action to perform after a fulfillment is updated.
 			 *
-			 * @param Fulfillment $data          The fulfillment object that was updated.
-			 * @param array       $changed_props List of tracked property keys that changed
-			 *                                   (e.g. 'status', 'items', '_tracking_number').
-			 * @param array       $old_state     Snapshot of tracked property values before the update.
+			 * @param Fulfillment $data            The fulfillment object that was updated.
+			 * @param array       $changes         The changes that were applied, as returned by
+			 *                                     Fulfillment::get_changes() before save. Core data
+			 *                                     props at top level, meta changes under 'meta_data'.
+			 * @param string      $previous_status The fulfillment status before the update.
 			 *
 			 * @since 10.1.0
-			 * @since 10.7.0 Added $changed_props and $old_state parameters.
+			 * @since 10.7.0 Added $changes and $previous_status parameters.
 			 */
-			do_action( 'woocommerce_fulfillment_after_update', $data, $changed_props, $old_state );
+			do_action( 'woocommerce_fulfillment_after_update', $data, $changes, $previous_status );
 		}

 		if ( $is_fulfill_action && ! doing_action( 'woocommerce_fulfillment_after_fulfill' ) ) {
@@ -281,58 +278,6 @@ class FulfillmentsDataStore extends \WC_Data_Store_WP implements \WC_Object_Data
 		}
 	}

-	/**
-	 * Snapshot tracked properties of a fulfillment from the database.
-	 *
-	 * @param int $fulfillment_id The fulfillment ID.
-	 * @return array The snapshot of tracked property values.
-	 */
-	private function snapshot_tracked_state( int $fulfillment_id ): array {
-		$old = new Fulfillment( (string) $fulfillment_id );
-
-		$state = array(
-			'status' => $old->get_status() ?? 'unfulfilled',
-			'items'  => $old->get_items(),
-		);
-
-		foreach ( self::TRACKED_META_KEYS as $key ) {
-			$value         = $old->get_meta( $key, true );
-			$state[ $key ] = is_string( $value ) ? $value : '';
-		}
-
-		return $state;
-	}
-
-	/**
-	 * Compute which tracked properties changed between the old state and the updated fulfillment.
-	 *
-	 * @param Fulfillment $fulfillment The updated fulfillment object.
-	 * @param array       $old_state   The previous state snapshot.
-	 * @return array List of changed property keys (e.g. 'status', 'items', '_tracking_number').
-	 */
-	private function compute_changed_props( Fulfillment $fulfillment, array $old_state ): array {
-		$changed = array();
-
-		$new_status = $fulfillment->get_status() ?? 'unfulfilled';
-		if ( $new_status !== $old_state['status'] ) {
-			$changed[] = 'status';
-		}
-
-		if ( $fulfillment->get_items() !== $old_state['items'] ) {
-			$changed[] = 'items';
-		}
-
-		foreach ( self::TRACKED_META_KEYS as $key ) {
-			$new_value = $fulfillment->get_meta( $key, true );
-			$new_value = is_string( $new_value ) ? $new_value : '';
-			if ( $new_value !== $old_state[ $key ] ) {
-				$changed[] = $key;
-			}
-		}
-
-		return $changed;
-	}
-
 	/**
 	 * Method to delete a fulfillment from the database.
 	 *
@@ -614,6 +559,7 @@ class FulfillmentsDataStore extends \WC_Data_Store_WP implements \WC_Object_Data

 			// Read the metadata for the fulfillment.
 			$fulfillment->read_meta_data( true );
+			$fulfillment->snapshot_meta();

 			$fulfillments[] = $fulfillment;
 		}
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/Fulfillment.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/Fulfillment.php
index 1960986852a..31fd8f9def9 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/Fulfillment.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/Fulfillment.php
@@ -22,6 +22,30 @@ defined( 'ABSPATH' ) || exit;
  * @since 10.1.0
  */
 class Fulfillment extends \WC_Data {
+
+	/**
+	 * Core data for this object. Name/value pairs.
+	 *
+	 * @var array
+	 */
+	protected $data = array(
+		'id'           => 0,
+		'entity_type'  => null,
+		'entity_id'    => null,
+		'status'       => null,
+		'is_fulfilled' => false,
+		'date_updated' => null,
+		'date_deleted' => null,
+	);
+
+	/**
+	 * Snapshot of meta values taken after the object is read from the database.
+	 * Used by get_changes() to detect meta-based field changes.
+	 *
+	 * @var array<string, mixed>
+	 */
+	private $meta_snapshot = array();
+
 	/**
 	 * Fulfillment constructor. Loads fulfillment data.
 	 *
@@ -51,6 +75,74 @@ class Fulfillment extends \WC_Data {
 		}
 	}

+	/**
+	 * Capture a snapshot of all current meta values.
+	 *
+	 * Called by the data store after reading so that get_changes() can detect
+	 * meta modifications alongside core data property changes.
+	 *
+	 * @since 10.7.0
+	 */
+	public function snapshot_meta(): void {
+		$this->meta_snapshot = array();
+		foreach ( $this->get_meta_data() as $meta ) {
+			$this->meta_snapshot[ $meta->key ] = $meta->value;
+		}
+	}
+
+	/**
+	 * Return data changes including meta-based field changes.
+	 *
+	 * Core data props are tracked by set_prop(); meta-based fields are detected
+	 * by comparing current meta values against the snapshot taken on read.
+	 *
+	 * @since 10.7.0
+	 *
+	 * @return array
+	 */
+	public function get_changes(): array {
+		$changes = parent::get_changes();
+
+		$current_meta = array();
+		foreach ( $this->get_meta_data() as $meta ) {
+			$current_meta[ $meta->key ] = $meta->value;
+		}
+
+		$meta_changes = array();
+
+		// Detect changed or added meta.
+		foreach ( $current_meta as $key => $value ) {
+			if ( ! array_key_exists( $key, $this->meta_snapshot ) || $this->meta_snapshot[ $key ] !== $value ) {
+				$meta_changes[ $key ] = $value;
+			}
+		}
+
+		// Detect deleted meta.
+		foreach ( $this->meta_snapshot as $key => $value ) {
+			if ( ! array_key_exists( $key, $current_meta ) ) {
+				$meta_changes[ $key ] = null;
+			}
+		}
+
+		if ( ! empty( $meta_changes ) ) {
+			$changes['meta_data'] = $meta_changes;
+		}
+
+		return $changes;
+	}
+
+	/**
+	 * Merge changes with data, clear changes, and refresh the meta snapshot.
+	 *
+	 * @since 10.7.0
+	 *
+	 * @return void
+	 */
+	public function apply_changes(): void {
+		parent::apply_changes();
+		$this->snapshot_meta();
+	}
+
 	/**
 	 * Get the fulfillment ID.
 	 *
@@ -76,7 +168,7 @@ class Fulfillment extends \WC_Data {
 	 * @return string|null Entity type.
 	 */
 	public function get_entity_type(): ?string {
-		return $this->data['entity_type'] ?? null;
+		return $this->get_prop( 'entity_type' );
 	}

 	/**
@@ -85,7 +177,7 @@ class Fulfillment extends \WC_Data {
 	 * @param class-string|null $entity_type Entity type.
 	 */
 	public function set_entity_type( ?string $entity_type ): void {
-		$this->data['entity_type'] = $entity_type;
+		$this->set_prop( 'entity_type', $entity_type );
 	}

 	/**
@@ -94,7 +186,7 @@ class Fulfillment extends \WC_Data {
 	 * @return string|null Entity ID.
 	 */
 	public function get_entity_id(): ?string {
-		return $this->data['entity_id'] ?? null;
+		return $this->get_prop( 'entity_id' );
 	}

 	/**
@@ -103,7 +195,7 @@ class Fulfillment extends \WC_Data {
 	 * @param string|null $entity_id Entity ID.
 	 */
 	public function set_entity_id( ?string $entity_id ): void {
-		$this->data['entity_id'] = $entity_id;
+		$this->set_prop( 'entity_id', $entity_id );
 	}

 	/**
@@ -123,8 +215,7 @@ class Fulfillment extends \WC_Data {
 		}
 		// Set the fulfillment status.
 		$this->set_is_fulfilled( $statuses[ $status ]['is_fulfilled'] ?? false );
-		// Set the status in the data array.
-		$this->data['status'] = $status;
+		$this->set_prop( 'status', $status );
 	}

 	/**
@@ -133,7 +224,7 @@ class Fulfillment extends \WC_Data {
 	 * @return string|null Fulfillment status.
 	 */
 	public function get_status(): ?string {
-		return $this->data['status'] ?? null;
+		return $this->get_prop( 'status' );
 	}

 	/**
@@ -144,7 +235,7 @@ class Fulfillment extends \WC_Data {
 	 *  @return void
 	 */
 	private function set_is_fulfilled( bool $is_fulfilled ): void {
-		$this->data['is_fulfilled'] = $is_fulfilled;
+		$this->set_prop( 'is_fulfilled', $is_fulfilled );
 	}

 	/**
@@ -153,7 +244,7 @@ class Fulfillment extends \WC_Data {
 	 * @return bool Whether the fulfillment is fulfilled.
 	 */
 	public function get_is_fulfilled(): bool {
-		return $this->data['is_fulfilled'] ?? false;
+		return (bool) $this->get_prop( 'is_fulfilled' );
 	}

 	/**
@@ -198,7 +289,7 @@ class Fulfillment extends \WC_Data {
 	 * @return string|null Date updated.
 	 */
 	public function get_date_updated(): ?string {
-		return $this->data['date_updated'] ?? null;
+		return $this->get_prop( 'date_updated' );
 	}

 	/**
@@ -207,7 +298,7 @@ class Fulfillment extends \WC_Data {
 	 * @param string|null $date_updated Date updated.
 	 */
 	public function set_date_updated( ?string $date_updated ): void {
-		$this->data['date_updated'] = $date_updated;
+		$this->set_prop( 'date_updated', $date_updated );
 	}

 	/**
@@ -232,7 +323,7 @@ class Fulfillment extends \WC_Data {
 	 * @return string|null Date deleted.
 	 */
 	public function get_date_deleted(): ?string {
-		return $this->data['date_deleted'] ?? null;
+		return $this->get_prop( 'date_deleted' );
 	}

 	/**
@@ -242,7 +333,7 @@ class Fulfillment extends \WC_Data {
 	 * @return void
 	 */
 	public function set_date_deleted( ?string $date_deleted ): void {
-		$this->data['date_deleted'] = $date_deleted;
+		$this->set_prop( 'date_deleted', $date_deleted );
 	}

 	/**
@@ -264,6 +355,24 @@ class Fulfillment extends \WC_Data {
 		$this->update_meta_data( '_items', array_values( $items ) );
 	}

+	/**
+	 * Get the item count for the fulfillment.
+	 *
+	 * This method calculates the total quantity of items in the fulfillment.
+	 *
+	 * @since 10.7.0
+	 * @return int Total quantity of items in the fulfillment.
+	 */
+	public function get_item_count(): int {
+		return array_reduce(
+			$this->get_items(),
+			function ( int $carry, array $item ) {
+				return $carry + (int) $item['qty'];
+			},
+			0
+		);
+	}
+
 	/**
 	 * Get the tracking number.
 	 *
@@ -291,13 +400,13 @@ class Fulfillment extends \WC_Data {
 	}

 	/**
-	 * Get the shipping provider.
+	 * Get the shipment provider.
 	 *
 	 * @since 10.7.0
-	 * @return string|null Shipping provider slug.
+	 * @return string|null Shipment provider slug.
 	 */
-	public function get_shipping_provider(): ?string {
-		$value = $this->get_meta( '_shipping_provider', true );
+	public function get_shipment_provider(): ?string {
+		$value = $this->get_meta( '_shipment_provider', true );
 		if ( ! is_scalar( $value ) ) {
 			return null;
 		}
@@ -307,13 +416,13 @@ class Fulfillment extends \WC_Data {
 	}

 	/**
-	 * Set the shipping provider.
+	 * Set the shipment provider.
 	 *
 	 * @since 10.7.0
-	 * @param string $shipping_provider Shipping provider slug.
+	 * @param string $shipment_provider Shipment provider slug.
 	 */
-	public function set_shipping_provider( string $shipping_provider ): void {
-		$this->update_meta_data( '_shipping_provider', $shipping_provider );
+	public function set_shipment_provider( string $shipment_provider ): void {
+		$this->update_meta_data( '_shipment_provider', $shipment_provider );
 	}

 	/**
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentOrderNotes.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentOrderNotes.php
index 57aeb0b7c45..775cbe5a400 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentOrderNotes.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentOrderNotes.php
@@ -92,12 +92,13 @@ class FulfillmentOrderNotes {
 	 * tracking number, tracking URL, shipping provider). If the status
 	 * changed, a dedicated status change note is added instead.
 	 *
-	 * @param Fulfillment $fulfillment   The fulfillment object.
-	 * @param array       $changed_props List of tracked property keys that changed.
-	 * @param array       $old_state     Snapshot of tracked property values before the update.
+	 * @param Fulfillment $fulfillment     The fulfillment object (post-update).
+	 * @param array       $changes         Changes as returned by Fulfillment::get_changes() before
+	 *                                     save. Core data props at top level, meta under 'meta_data'.
+	 * @param string      $previous_status The fulfillment status before the update.
 	 */
-	public function add_fulfillment_updated_note( Fulfillment $fulfillment, array $changed_props = array(), array $old_state = array() ): void {
-		if ( empty( $changed_props ) ) {
+	public function add_fulfillment_updated_note( Fulfillment $fulfillment, array $changes = array(), string $previous_status = 'unfulfilled' ): void {
+		if ( empty( $changes ) ) {
 			return;
 		}

@@ -107,10 +108,9 @@ class FulfillmentOrderNotes {
 		}

 		// If status changed, add a dedicated status change note.
-		if ( in_array( 'status', $changed_props, true ) ) {
-			$old_status = $old_state['status'] ?? 'unfulfilled';
-			$new_status = $fulfillment->get_status() ?? 'unfulfilled';
-			$this->add_fulfillment_status_changed_note( $fulfillment, $order, $old_status, $new_status );
+		if ( array_key_exists( 'status', $changes ) ) {
+			$new_status = $changes['status'] ?? 'unfulfilled';
+			$this->add_fulfillment_status_changed_note( $fulfillment, $order, $previous_status, $new_status );
 			return;
 		}

@@ -324,7 +324,7 @@ class FulfillmentOrderNotes {
 	 */
 	private function format_tracking( Fulfillment $fulfillment ): string {
 		$tracking_number   = $fulfillment->get_tracking_number();
-		$shipping_provider = $fulfillment->get_shipping_provider();
+		$shipping_provider = $fulfillment->get_shipment_provider();
 		$tracking_url      = $fulfillment->get_tracking_url();

 		if ( null === $tracking_number ) {
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentUtils.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentUtils.php
index ecc4580544a..a88afb2225b 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentUtils.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentUtils.php
@@ -729,4 +729,30 @@ class FulfillmentUtils {
 		}
 		return intval( $tracking_number[11] ) === $check;
 	}
+
+	/**
+	 * Resolve the provider name for a fulfillment.
+	 *
+	 * For custom providers ("other"), the display name from _provider_name meta is used.
+	 * For known providers, the slug from _shipment_provider meta is preferred, but the
+	 * display name is used as a fallback when the slug is empty (e.g., when auto-lookup
+	 * identified the provider but did not set the slug).
+	 *
+	 * @since 10.7.0
+	 *
+	 * @param Fulfillment $fulfillment The fulfillment object.
+	 *
+	 * @return string The resolved provider name.
+	 */
+	public static function resolve_provider_name( Fulfillment $fulfillment ): string {
+		$shipment_provider = $fulfillment->get_shipment_provider() ?? '';
+		$provider_name     = $fulfillment->get_meta( '_provider_name', true );
+		$provider_name     = ! empty( $provider_name ) ? (string) $provider_name : '';
+
+		if ( 'other' === $shipment_provider ) {
+			return $provider_name;
+		}
+
+		return ! empty( $shipment_provider ) ? $shipment_provider : $provider_name;
+	}
 }
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsController.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsController.php
index 8e9999b4787..ab27e5d7bff 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsController.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsController.php
@@ -93,8 +93,11 @@ class FulfillmentsController {
 		global $wpdb;

 		if ( get_option( 'woocommerce_fulfillments_db_tables_created', false ) ) {
-			// The tables already exist, no need to create them again.
-			return;
+			// Verify the tables actually exist (the option may be stale after DB resets in tests).
+			$table_exists = $wpdb->get_var( "SHOW TABLES LIKE '{$wpdb->prefix}wc_order_fulfillments'" );
+			if ( $table_exists ) {
+				return;
+			}
 		}

 		// Drop the tables if they exist, to ensure a clean slate.
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsManager.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsManager.php
index cd43d4f4d9c..abf7e6c84b6 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsManager.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsManager.php
@@ -38,6 +38,7 @@ class FulfillmentsManager {

 		$this->init_fulfillment_status_hooks();
 		$this->init_refund_hooks();
+		$this->init_email_template_tracking_hooks();
 		$this->init_order_deletion_hooks();

 		if ( ! $this->fulfillment_order_notes ) {
@@ -69,6 +70,29 @@ class FulfillmentsManager {
 		add_action( 'woocommerce_delete_order_refund', array( $this, 'update_fulfillment_status_after_refund_deleted' ), 10, 1 );
 	}

+	/**
+	 * Initialize hooks to track when fulfillment email templates are customized.
+	 *
+	 * Hooks into the WooCommerce email settings save action for each fulfillment email type
+	 * so we can track when merchants customize these templates.
+	 */
+	private function init_email_template_tracking_hooks(): void {
+		$fulfillment_email_ids = array(
+			'customer_fulfillment_created',
+			'customer_fulfillment_updated',
+			'customer_fulfillment_deleted',
+		);
+
+		foreach ( $fulfillment_email_ids as $email_id ) {
+			add_action(
+				'woocommerce_update_options_email_' . $email_id,
+				function () use ( $email_id ) {
+					FulfillmentsTracker::track_fulfillment_email_template_customized( $email_id );
+				}
+			);
+		}
+	}
+
 	/**
 	 * Initialize order deletion hooks.
 	 *
@@ -483,6 +507,14 @@ class FulfillmentsManager {
 			$results['possibilities'] = $possibilities; // Include all possibilities for reference.
 		}

+		if ( isset( $results['shipping_provider'] ) ) {
+			// Record the tracking lookup attempt with url_generated indicating if a tracking URL was constructed.
+			FulfillmentsTracker::track_fulfillment_tracking_lookup_attempt( 'success', $results['shipping_provider'], ! empty( $results['tracking_url'] ) );
+		} else {
+			// If no provider could parse the tracking number, record a failure.
+			FulfillmentsTracker::track_fulfillment_tracking_lookup_attempt( 'not_found', '', false );
+		}
+
 		return $results;
 	}

diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsRenderer.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsRenderer.php
index 29cdb79cc08..bfddf24fc85 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsRenderer.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsRenderer.php
@@ -175,7 +175,7 @@ class FulfillmentsRenderer {
 	private function render_shipment_provider_column_row_data( WC_Order $order, array $fulfillments ) {
 		$providers = array();
 		foreach ( $fulfillments as $fulfillment ) {
-			$providers[] = $fulfillment->get_meta( '_shipment_provider' ) ?? null;
+			$providers[] = $fulfillment->get_shipment_provider();
 		}

 		$providers = array_filter(
@@ -256,6 +256,7 @@ class FulfillmentsRenderer {
 	 */
 	public function handle_fulfillment_bulk_actions( $redirect_to, $action, $post_ids ) {
 		if ( 'fulfill' === $action ) {
+			FulfillmentsTracker::track_fulfillment_bulk_action_used( 'fulfill_orders', count( $post_ids ) );
 			foreach ( $post_ids as $post_id ) {
 				$order = wc_get_order( $post_id );
 				if ( ! $order ) {
@@ -469,6 +470,10 @@ class FulfillmentsRenderer {

 			// Ensure the fulfillment status is one of the allowed values.
 			if ( FulfillmentUtils::is_valid_order_fulfillment_status( $fulfillment_status ) ) {
+				// Only track when the filter is explicitly submitted, not on pagination/refresh.
+				if ( isset( $_GET['filter_action'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
+					FulfillmentsTracker::track_fulfillment_filter_used( 'fulfillment_status', $fulfillment_status );
+				}
 				$meta_query = FulfillmentUtils::get_order_fulfillment_status_meta_query( $fulfillment_status );
 				if ( ! empty( $meta_query ) ) {
 					if ( ! isset( $args['meta_query'] ) ) {
@@ -497,6 +502,10 @@ class FulfillmentsRenderer {
 			$status = sanitize_text_field( wp_unslash( $_GET['fulfillment_status'] ) ); // phpcs:ignore WordPress.Security.NonceVerification
 			// Ensure the fulfillment status is one of the allowed values.
 			if ( FulfillmentUtils::is_valid_order_fulfillment_status( $status ) ) {
+				// Only track when the filter is explicitly submitted, not on pagination/refresh.
+				if ( isset( $_GET['filter_action'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
+					FulfillmentsTracker::track_fulfillment_filter_used( 'fulfillment_status', $status );
+				}
 				$query->set(
 					'meta_query',
 					'no_fulfillments' === $status ?
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsTracker.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsTracker.php
new file mode 100644
index 00000000000..8112108e3d4
--- /dev/null
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsTracker.php
@@ -0,0 +1,384 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Admin\Features\Fulfillments;
+
+use WC_Tracks;
+
+/**
+ * FulfillmentsTracker class.
+ *
+ * Centralizes all telemetry for the Fulfillments feature. Every tracked event is recorded via
+ * WC_Tracks::record_event() which sends it to the analytics pipeline with a "wcadmin_" prefix.
+ *
+ * Tracked events (WOOPLUG-5197):
+ *
+ * 1. Core Funnel (Adoption):
+ *    - fulfillment_modal_opened   : Merchant opens the fulfillment editor drawer.         (Frontend only)
+ *    - fulfillment_created        : A new fulfillment is saved.                            (REST controller)
+ *    - fulfillment_updated        : An existing fulfillment is modified.                   (REST controller)
+ *    - fulfillment_deleted        : A fulfillment is deleted.                              (REST controller)
+ *
+ * 2. Tracking Information (Usage Patterns):
+ *    - fulfillment_tracking_added            : Tracking info is attached to a fulfillment. (REST controller)
+ *    - fulfillment_tracking_lookup_attempted : Tracking number auto-lookup is attempted.   (FulfillmentsManager)
+ *
+ * 3. Efficiency / Power-User:
+ *    - fulfillment_bulk_action_used : Bulk fulfill/unfulfill from the orders list.         (FulfillmentsRenderer)
+ *    - fulfillment_filter_used      : Orders list filtered by fulfillment status/provider. (FulfillmentsRenderer)
+ *
+ * 4. Customer Communication:
+ *    - fulfillment_notification_sent          : A fulfillment email is queued to the customer.    (REST controller)
+ *    - fulfillment_email_template_customized  : Merchant saves fulfillment email template settings. (FulfillmentsManager)
+ *
+ * 5. Friction / Errors:
+ *    - fulfillment_validation_error : A create/update/delete action fails validation.      (REST controller)
+ *
+ * @since 10.7.0
+ */
+class FulfillmentsTracker {
+
+	// ──────────────────────────────────────────────
+	// 1. Core Funnel: Fulfillment Creation & Management
+	// ──────────────────────────────────────────────
+
+	/**
+	 * Track when a merchant opens the fulfillment editor modal/sidebar.
+	 *
+	 * Tracked from: Frontend (JS recordEvent).
+	 * Measures: Feature discoverability and adoption.
+	 *
+	 * @since 10.7.0
+	 *
+	 * @param string $source  Where the modal was opened from ("orders_list" or "order_detail_page").
+	 * @param int    $order_id The ID of the order being viewed.
+	 *
+	 * @return void
+	 */
+	public static function track_fulfillment_modal_opened( string $source, int $order_id ): void {
+		WC_Tracks::record_event(
+			'fulfillment_modal_opened',
+			array(
+				'source'   => $source,
+				'order_id' => $order_id,
+			)
+		);
+	}
+
+	/**
+	 * Track when a new fulfillment is successfully saved.
+	 *
+	 * Tracked from: OrderFulfillmentsRestController::create_fulfillment().
+	 * Measures: Core adoption; whether merchants create full vs. partial shipments.
+	 *
+	 * @since 10.7.0
+	 *
+	 * @param string $source           The source of the fulfillment ("fulfillments_modal", "bulk_action", or "api").
+	 * @param string $initial_status   The initial status of the fulfillment ("draft" or "fulfilled").
+	 * @param string $fulfillment_type Whether all remaining items were included ("full" or "partial").
+	 * @param int    $item_count       Total quantity of items in the fulfillment (sum of item quantities).
+	 * @param int    $total_quantity   Total quantity of all items in the order.
+	 * @param bool   $notification_sent Whether the customer notification was requested.
+	 * @return void
+	 */
+	public static function track_fulfillment_creation( string $source, string $initial_status, string $fulfillment_type, int $item_count, int $total_quantity, bool $notification_sent ): void {
+		WC_Tracks::record_event(
+			'fulfillment_created',
+			array(
+				'source'            => $source,
+				'initial_status'    => $initial_status,
+				'fulfillment_type'  => $fulfillment_type,
+				'item_count'        => $item_count,
+				'total_quantity'    => $total_quantity,
+				'notification_sent' => $notification_sent,
+			)
+		);
+	}
+
+	/**
+	 * Track when an existing fulfillment is successfully updated.
+	 *
+	 * Tracked from: OrderFulfillmentsRestController::update_fulfillment().
+	 * Measures: How often merchants modify fulfillments and which fields change most.
+	 *
+	 * @since 10.7.0
+	 *
+	 * @param string $source            The source of the update ("fulfillments_modal" or "api").
+	 * @param int    $fulfillment_id    The ID of the fulfillment being updated.
+	 * @param string $original_status   The status before the update ("draft" or "fulfilled").
+	 * @param array  $changed_fields    The changes as returned by Fulfillment::get_changes(). Core data
+	 *                                  props (e.g. 'status') at top level, meta changes under 'meta_data'.
+	 *                                  Serialized as JSON by WC_Tracks.
+	 * @param bool   $notification_sent Whether a customer re-notification was requested.
+	 *
+	 * @return void
+	 */
+	public static function track_fulfillment_update( string $source, int $fulfillment_id, string $original_status, array $changed_fields, bool $notification_sent ): void {
+		WC_Tracks::record_event(
+			'fulfillment_updated',
+			array(
+				'source'            => $source,
+				'fulfillment_id'    => $fulfillment_id,
+				'original_status'   => $original_status,
+				'changed_fields'    => $changed_fields,
+				'notification_sent' => $notification_sent,
+			)
+		);
+	}
+
+	/**
+	 * Track when a fulfillment is successfully deleted.
+	 *
+	 * Tracked from: OrderFulfillmentsRestController::delete_fulfillment().
+	 * Measures: How often merchants remove fulfillments and at what stage.
+	 *
+	 * @since 10.7.0
+	 *
+	 * @param string $source              The source of the deletion ("fulfillments_modal" or "api").
+	 * @param int    $fulfillment_id      The ID of the fulfillment being deleted.
+	 * @param string $status_at_deletion  The status at the time of deletion ("draft" or "fulfilled").
+	 * @param bool   $notification_sent   Whether a deletion notification was requested.
+	 *
+	 * @return void
+	 */
+	public static function track_fulfillment_deletion( string $source, int $fulfillment_id, string $status_at_deletion, bool $notification_sent ): void {
+		WC_Tracks::record_event(
+			'fulfillment_deleted',
+			array(
+				'source'             => $source,
+				'fulfillment_id'     => $fulfillment_id,
+				'status_at_deletion' => $status_at_deletion,
+				'notification_sent'  => $notification_sent,
+			)
+		);
+	}
+
+	// ──────────────────────────────────────────────
+	// 2. Tracking Information Workflow
+	// ──────────────────────────────────────────────
+
+	/**
+	 * Track when tracking information is successfully added to a fulfillment.
+	 *
+	 * Tracked from: OrderFulfillmentsRestController (create and update flows).
+	 * Measures: How merchants add tracking info (auto-lookup vs. manual vs. API) and which
+	 *           carriers are used. The provider_name property for custom providers is used to
+	 *           identify the most frequently added custom carriers, informing the roadmap for
+	 *           expanding native carrier support.
+	 *
+	 * @since 10.7.0
+	 *
+	 * @param int    $fulfillment_id    The ID of the fulfillment to which tracking was added.
+	 * @param string $entry_method      How the tracking was added ("ui_auto_lookup", "ui_manual_select", "ui_manual_custom", or "api").
+	 * @param string $provider_name     The name/key of the shipping provider (e.g., "usps", "fedex").
+	 * @param bool   $is_custom_provider Whether the provider is a custom (non-native) provider.
+	 *
+	 * @return void
+	 */
+	public static function track_fulfillment_tracking_added( int $fulfillment_id, string $entry_method, string $provider_name, bool $is_custom_provider ): void {
+		WC_Tracks::record_event(
+			'fulfillment_tracking_added',
+			array(
+				'fulfillment_id'     => $fulfillment_id,
+				'entry_method'       => $entry_method,
+				'provider_name'      => $provider_name,
+				'is_custom_provider' => $is_custom_provider,
+			)
+		);
+	}
+
+	/**
+	 * Track when a tracking number auto-lookup is attempted.
+	 *
+	 * Tracked from: FulfillmentsManager::try_parse_tracking_number().
+	 * Measures: Effectiveness of auto-detection. A high failure rate indicates the need to improve
+	 *           carrier detection logic. The url_generated flag checks if a functional tracking URL
+	 *           was constructed (a success requires both provider identification AND URL generation).
+	 *
+	 * @since 10.7.0
+	 *
+	 * @param string $lookup_status       The lookup result ("success" or "not_found").
+	 * @param string $provider_identified The standardized carrier name identified (e.g., "usps"). Empty if not found.
+	 * @param bool   $url_generated       Whether the system successfully constructed a tracking URL.
+	 *
+	 * @return void
+	 */
+	public static function track_fulfillment_tracking_lookup_attempt( string $lookup_status, string $provider_identified, bool $url_generated = false ): void {
+		WC_Tracks::record_event(
+			'fulfillment_tracking_lookup_attempted',
+			array(
+				'lookup_status'       => $lookup_status,
+				'provider_identified' => $provider_identified,
+				'url_generated'       => $url_generated,
+			)
+		);
+	}
+
+	// ──────────────────────────────────────────────
+	// 3. Efficiency & Power-User Features
+	// ──────────────────────────────────────────────
+
+	/**
+	 * Track when a merchant applies a fulfillment-related bulk action from the orders list.
+	 *
+	 * Tracked from: FulfillmentsRenderer::handle_fulfillment_bulk_actions().
+	 * Measures: Whether merchants use the time-saving bulk-fulfill feature.
+	 *
+	 * @since 10.7.0
+	 *
+	 * @param string $action      The action performed ("fulfill_orders" or "unfulfill_orders").
+	 * @param int    $order_count The number of orders selected for the bulk action.
+	 *
+	 * @return void
+	 */
+	public static function track_fulfillment_bulk_action_used( string $action, int $order_count ): void {
+		WC_Tracks::record_event(
+			'fulfillment_bulk_action_used',
+			array(
+				'action'      => $action,
+				'order_count' => $order_count,
+			)
+		);
+	}
+
+	/**
+	 * Track when the orders list is filtered using a fulfillment-related filter.
+	 *
+	 * Tracked from: FulfillmentsRenderer::filter_orders_list_table_query().
+	 * Measures: Whether merchants use fulfillment filters and which values they filter by most.
+	 *
+	 * @since 10.7.0
+	 *
+	 * @param string $filter_by    The filter field ("fulfillment_status" or "shipping_provider").
+	 * @param string $filter_value The specific value selected (e.g., "partially_fulfilled", "usps").
+	 *
+	 * @return void
+	 */
+	public static function track_fulfillment_filter_used( string $filter_by, string $filter_value ): void {
+		WC_Tracks::record_event(
+			'fulfillment_filter_used',
+			array(
+				'filter_by'    => $filter_by,
+				'filter_value' => $filter_value,
+			)
+		);
+	}
+
+	// ──────────────────────────────────────────────
+	// 4. Customer Communication
+	// ──────────────────────────────────────────────
+
+	/**
+	 * Track when a fulfillment notification email is successfully queued to a customer.
+	 *
+	 * Tracked from: OrderFulfillmentsRestController (create, update, and delete flows).
+	 * Measures: Whether the communication loop is being closed; how often merchants notify customers.
+	 *
+	 * @since 10.7.0
+	 *
+	 * @param string $trigger_action The action that triggered the notification ("fulfillment_created", "fulfillment_updated", or "fulfillment_deleted").
+	 * @param int    $fulfillment_id The ID of the fulfillment.
+	 * @param int    $order_id       The ID of the associated order.
+	 *
+	 * @return void
+	 */
+	public static function track_fulfillment_notification_sent( string $trigger_action, int $fulfillment_id, int $order_id ): void {
+		WC_Tracks::record_event(
+			'fulfillment_notification_sent',
+			array(
+				'trigger_action' => $trigger_action,
+				'fulfillment_id' => $fulfillment_id,
+				'order_id'       => $order_id,
+			)
+		);
+	}
+
+	/**
+	 * Track when a merchant saves changes to a fulfillment email template in settings.
+	 *
+	 * Tracked from: FulfillmentsManager (hooked to woocommerce_update_options_email_{id}).
+	 * Measures: Whether merchants customize fulfillment email templates.
+	 *
+	 * @since 10.7.0
+	 *
+	 * @param string $template_name The email template ID that was customized (e.g., "customer_fulfillment_created").
+	 *
+	 * @return void
+	 */
+	public static function track_fulfillment_email_template_customized( string $template_name ): void {
+		WC_Tracks::record_event(
+			'fulfillment_email_template_customized',
+			array(
+				'template_name' => $template_name,
+			)
+		);
+	}
+
+	// ──────────────────────────────────────────────
+	// 5. Friction & Error Tracking
+	// ──────────────────────────────────────────────
+
+	/**
+	 * Track when a fulfillment action fails due to a validation error.
+	 *
+	 * Tracked from: OrderFulfillmentsRestController (create, update, and delete flows).
+	 * Measures: Where users encounter errors; helps proactively identify bugs and UX problems.
+	 *
+	 * @since 10.7.0
+	 *
+	 * @param string $action_attempted The action that was attempted ("create", "update", "delete", or "fulfill").
+	 * @param string $error_code       The error code from the exception.
+	 * @param string $source           The source of the error ("fulfillments_modal", "bulk_action", or "api").
+	 *
+	 * @return void
+	 */
+	public static function track_fulfillment_validation_error( string $action_attempted, string $error_code, string $source ): void {
+		WC_Tracks::record_event(
+			'fulfillment_validation_error',
+			array(
+				'action_attempted' => $action_attempted,
+				'error_code'       => $error_code,
+				'source'           => $source,
+			)
+		);
+	}
+
+	// ──────────────────────────────────────────────
+	// Helpers
+	// ──────────────────────────────────────────────
+
+	/**
+	 * Determine the tracking entry method from the request source and fulfillment meta data.
+	 *
+	 * Maps the shipping option meta value to the standardized entry_method values expected
+	 * by the fulfillment_tracking_added event. Whether the provider is custom or native is
+	 * tracked separately via the is_custom_provider property on the event.
+	 *
+	 *   - "ui_auto_lookup" : Tracking number was auto-detected via the lookup API.
+	 *   - "ui_manual"      : Merchant manually selected or entered a provider.
+	 *   - "api"            : Tracking was added via the REST API (not through the UI).
+	 *
+	 * @since 10.7.0
+	 *
+	 * @param string $source          The request source ("fulfillments_modal" or "api").
+	 * @param string $shipping_option The shipping option meta value ("tracking-number", "manual-entry", or "no-info").
+	 *
+	 * @return string The entry method identifier.
+	 */
+	public static function determine_tracking_entry_method( string $source, string $shipping_option ): string {
+		if ( 'fulfillments_modal' !== $source ) {
+			return 'api';
+		}
+
+		if ( 'tracking-number' === $shipping_option ) {
+			return 'ui_auto_lookup';
+		}
+
+		if ( 'manual-entry' === $shipping_option ) {
+			return 'ui_manual';
+		}
+
+		return 'api';
+	}
+}
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php
index 5773bf432bf..9f8e7b979d6 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php
@@ -251,6 +251,19 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
 	public function create_fulfillment( WP_REST_Request $request ) {
 		$order_id        = (int) $request->get_param( 'order_id' );
 		$notify_customer = (bool) $request->get_param( 'notify_customer' );
+
+		$order = wc_get_order( $order_id );
+
+		if ( ! $order ) {
+			// If the order does not exist, return an error.
+			FulfillmentsTracker::track_fulfillment_validation_error( 'create', 'woocommerce_rest_order_invalid_id', $this->check_request_source( $request ) );
+			return $this->prepare_error_response(
+				'woocommerce_rest_order_invalid_id',
+				esc_html__( 'Invalid order ID.', 'woocommerce' ),
+				WP_Http::NOT_FOUND
+			);
+		}
+
 		// Create a new fulfillment.
 		try {
 			$fulfillment = new Fulfillment();
@@ -261,6 +274,18 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {

 			$fulfillment->save();

+			FulfillmentsTracker::track_fulfillment_creation(
+				$this->check_request_source( $request ),
+				$fulfillment->get_is_fulfilled() ? 'fulfilled' : 'draft',
+				$fulfillment->get_item_count() === (int) $order->get_item_count() ? 'full' : 'partial',
+				$fulfillment->get_item_count(),
+				(int) $order->get_item_count(),
+				$notify_customer
+			);
+
+			// Track if tracking information was added with this new fulfillment.
+			$this->maybe_track_tracking_added( $fulfillment, $request );
+
 			if ( $fulfillment->get_is_fulfilled() && $notify_customer ) {
 				/**
 				 * Trigger the fulfillment created notification on creating a fulfilled fulfillment.
@@ -268,15 +293,17 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
 				 * @since 10.1.0
 				 */
 				do_action( 'woocommerce_fulfillment_created_notification', $order_id, $fulfillment, wc_get_order( $order_id ) );
+				FulfillmentsTracker::track_fulfillment_notification_sent( 'fulfillment_created', $fulfillment->get_id(), $order_id );
 			}
 		} catch ( ApiException $ex ) {
+			FulfillmentsTracker::track_fulfillment_validation_error( 'create', $ex->getErrorCode(), $this->check_request_source( $request ) );
 			return $this->prepare_error_response(
 				$ex->getErrorCode(),
 				$ex->getMessage(),
 				WP_Http::BAD_REQUEST
 			);
-
-		} catch ( \Throwable $e ) {
+		} catch ( \Exception $e ) {
+			FulfillmentsTracker::track_fulfillment_validation_error( 'create', (string) $e->getCode(), $this->check_request_source( $request ) );
 			return $this->prepare_error_response(
 				$e->getCode(),
 				$e->getMessage(),
@@ -336,10 +363,22 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
 		$fulfillment_id  = (int) $request->get_param( 'fulfillment_id' );
 		$notify_customer = (bool) $request->get_param( 'notify_customer' );

+		$order = wc_get_order( $order_id );
+		if ( ! $order ) {
+			// If the order does not exist, return an error.
+			FulfillmentsTracker::track_fulfillment_validation_error( 'update', 'woocommerce_rest_order_invalid_id', $this->check_request_source( $request ) );
+			return $this->prepare_error_response(
+				'woocommerce_rest_order_invalid_id',
+				esc_html__( 'Invalid order ID.', 'woocommerce' ),
+				WP_Http::NOT_FOUND
+			);
+		}
+
 		// Update the fulfillment for the order.
 		try {
-			$fulfillment    = new Fulfillment( $fulfillment_id );
-			$previous_state = $fulfillment->get_is_fulfilled();
+			$fulfillment     = new Fulfillment( $fulfillment_id );
+			$previous_state  = $fulfillment->get_is_fulfilled();
+			$previous_status = $fulfillment->get_status() ?? 'unfulfilled';
 			$this->validate_fulfillment( $fulfillment, $fulfillment_id, $order_id );

 			$fulfillment->set_props( $request->get_json_params() );
@@ -359,8 +398,22 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
 					}
 				}
 			}
+
+			$changed_fields = $fulfillment->get_changes();
+
 			$fulfillment->save();

+			FulfillmentsTracker::track_fulfillment_update(
+				$this->check_request_source( $request ),
+				$fulfillment->get_id(),
+				$previous_status,
+				$changed_fields,
+				$notify_customer
+			);
+
+			// Track if tracking information was added or changed in this update.
+			$this->maybe_track_tracking_added( $fulfillment, $request, $changed_fields );
+
 			if ( $notify_customer ) {
 				if ( ! $previous_state && $next_state ) {
 					/**
@@ -369,6 +422,7 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
 					 * @since 10.1.0
 					 */
 					do_action( 'woocommerce_fulfillment_created_notification', $order_id, $fulfillment, wc_get_order( $order_id ) );
+					FulfillmentsTracker::track_fulfillment_notification_sent( 'fulfillment_created', $fulfillment->get_id(), $order_id );
 				} elseif ( $next_state ) {
 					/**
 					 * Trigger the fulfillment updated notification on updating a fulfillment.
@@ -376,15 +430,18 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
 					 * @since 10.1.0
 					 */
 					do_action( 'woocommerce_fulfillment_updated_notification', $order_id, $fulfillment, wc_get_order( $order_id ) );
+					FulfillmentsTracker::track_fulfillment_notification_sent( 'fulfillment_updated', $fulfillment->get_id(), $order_id );
 				}
 			}
 		} catch ( ApiException $ex ) {
+			FulfillmentsTracker::track_fulfillment_validation_error( 'update', $ex->getErrorCode(), $this->check_request_source( $request ) );
 			return $this->prepare_error_response(
 				$ex->getErrorCode(),
 				$ex->getMessage(),
 				WP_Http::BAD_REQUEST
 			);
-		} catch ( \Throwable $e ) {
+		} catch ( \Exception $e ) {
+			FulfillmentsTracker::track_fulfillment_validation_error( 'update', (string) $e->getCode(), $this->check_request_source( $request ) );
 			return $this->prepare_error_response(
 				$e->getCode(),
 				$e->getMessage(),
@@ -414,14 +471,23 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
 		try {
 			$fulfillment = new Fulfillment( $fulfillment_id );
 			$this->validate_fulfillment( $fulfillment, $fulfillment_id, $order_id );
+			$status = $fulfillment->get_status() ?? 'unfulfilled';
 			$fulfillment->delete();
+			FulfillmentsTracker::track_fulfillment_deletion(
+				$this->check_request_source( $request ),
+				$fulfillment_id,
+				$status,
+				$notify_customer
+			);
 		} catch ( ApiException $ex ) {
+			FulfillmentsTracker::track_fulfillment_validation_error( 'delete', $ex->getErrorCode(), $this->check_request_source( $request ) );
 			return $this->prepare_error_response(
 				$ex->getErrorCode(),
 				$ex->getMessage(),
 				WP_Http::BAD_REQUEST
 			);
-		} catch ( \Throwable $e ) {
+		} catch ( \Exception $e ) {
+			FulfillmentsTracker::track_fulfillment_validation_error( 'delete', (string) $e->getCode(), $this->check_request_source( $request ) );
 			return $this->prepare_error_response(
 				$e->getCode(),
 				$e->getMessage(),
@@ -436,7 +502,9 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
 			 * @since 10.1.0
 			 */
 			do_action( 'woocommerce_fulfillment_deleted_notification', $order_id, $fulfillment, wc_get_order( $order_id ) );
+			FulfillmentsTracker::track_fulfillment_notification_sent( 'fulfillment_deleted', $fulfillment_id, $order_id );
 		}
+
 		return new WP_REST_Response(
 			array(
 				'message' => __( 'Fulfillment deleted successfully.', 'woocommerce' ),
@@ -1152,4 +1220,69 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
 			throw new \Exception( esc_html__( 'Invalid fulfillment ID.', 'woocommerce' ) );
 		}
 	}
+
+	/**
+	 * Check the request source by inspecting headers or parameters.
+	 *
+	 * @param WP_REST_Request $request The request object.
+	 *
+	 * @return string The request source identifier.
+	 *
+	 * @phpstan-ignore-next-line missingType.generics
+	 */
+	protected function check_request_source( WP_REST_Request $request ): string {
+		// Check for a custom header.
+		if ( $request->get_header( 'X-WC-Fulfillments-UI' ) ) {
+			return 'fulfillments_modal';
+		}
+
+		return 'api'; // Default to API if no specific source is identified.
+	}
+
+	/**
+	 * Track fulfillment_tracking_added if tracking information was added or changed.
+	 *
+	 * For new fulfillments ($changes is empty), fires whenever a tracking number is present.
+	 * For updates, only fires when tracking-related meta (_tracking_number, _shipment_provider,
+	 * or _tracking_url) actually changed.
+	 *
+	 * @param Fulfillment     $fulfillment The fulfillment object (after save).
+	 * @param WP_REST_Request $request     The original request.
+	 * @param array           $changes     The changes from Fulfillment::get_changes(), empty for creates.
+	 *
+	 * @phpstan-ignore-next-line missingType.generics
+	 */
+	private function maybe_track_tracking_added( Fulfillment $fulfillment, WP_REST_Request $request, array $changes = array() ): void {
+		$tracking_number = $fulfillment->get_tracking_number();
+		if ( empty( $tracking_number ) ) {
+			return;
+		}
+
+		// For updates, only track when tracking-related meta actually changed.
+		if ( ! empty( $changes ) ) {
+			$meta_changes     = $changes['meta_data'] ?? array();
+			$tracking_changed = array_key_exists( '_tracking_number', $meta_changes )
+				|| array_key_exists( '_shipment_provider', $meta_changes )
+				|| array_key_exists( '_tracking_url', $meta_changes );
+			if ( ! $tracking_changed ) {
+				return;
+			}
+		}
+
+		$source            = $this->check_request_source( $request );
+		$shipping_option   = $fulfillment->get_meta( '_shipping_option', true );
+		$shipping_option   = ! empty( $shipping_option ) ? $shipping_option : '';
+		$shipment_provider = $fulfillment->get_shipment_provider() ?? '';
+		$is_custom         = 'other' === $shipment_provider;
+
+		$entry_method      = FulfillmentsTracker::determine_tracking_entry_method( $source, $shipping_option );
+		$resolved_provider = FulfillmentUtils::resolve_provider_name( $fulfillment );
+
+		FulfillmentsTracker::track_fulfillment_tracking_added(
+			$fulfillment->get_id(),
+			$entry_method,
+			$resolved_provider,
+			$is_custom
+		);
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-emails-tests.php b/plugins/woocommerce/tests/php/includes/class-wc-emails-tests.php
index deb305d6e0c..755c314cfda 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-emails-tests.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-emails-tests.php
@@ -1,4 +1,5 @@
 <?php
+declare( strict_types = 1 );

 use Automattic\WooCommerce\Admin\Features\Fulfillments\Fulfillment;
 use Automattic\WooCommerce\Tests\Admin\Features\Fulfillments\Helpers\FulfillmentsHelper;
@@ -68,6 +69,8 @@ class WC_Emails_Tests extends \WC_Unit_Test_Case {
 	 */
 	public function test_fulfillment_meta() {
 		// Ensure the FulfillmentsController is registered, which is necessary for the translation of meta keys.
+		// Delete the DB tables flag to force recreation in case another test class left stale state.
+		delete_option( 'woocommerce_fulfillments_db_tables_created' );
 		update_option( 'woocommerce_feature_fulfillments_enabled', 'yes' );
 		$container  = wc_get_container();
 		$controller = $container->get( \Automattic\WooCommerce\Admin\Features\Fulfillments\FulfillmentsController::class );
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-install-test.php b/plugins/woocommerce/tests/php/includes/class-wc-install-test.php
index 926f446d805..c08235417ed 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-install-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-install-test.php
@@ -1,4 +1,5 @@
 <?php
+declare( strict_types = 1 );

 use Automattic\WooCommerce\Admin\Notes\Note;

@@ -127,7 +128,6 @@ class WC_Install_Test extends \WC_Unit_Test_Case {
 		WC_Install::delete_obsolete_notes();

 		$this->assertEmpty( $data_store->get_notes_with_name( $note_name ) );
-
 	}

 	/**
@@ -354,6 +354,9 @@ class WC_Install_Test extends \WC_Unit_Test_Case {
 	 * @return void
 	 */
 	public function test_order_stats_schema_does_not_include_fulfillment_status_for_new_install_without_fulfillments_feature_enabled(): void {
+		// Ensure the fulfillments feature is disabled (a prior test class may have enabled it).
+		delete_option( 'woocommerce_feature_fulfillments_enabled' );
+
 		// Mock is_new_install to return true.
 		$version = null;
 		$shop_id = null;
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStoreHookTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStoreHookTest.php
index f33718ee7f6..5ba494fade6 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStoreHookTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStoreHookTest.php
@@ -83,7 +83,8 @@ class FulfillmentsDataStoreHookTest extends WC_Unit_Test_Case {
 			}
 		);

-		$this->store->create( $this->get_test_fulfillment( $this->order->get_id() ) );
+		$fulfillment = $this->get_test_fulfillment( $this->order->get_id() );
+		$this->store->create( $fulfillment );
 		$this->assertTrue( $hook_called, 'The fulfillment before create hook was not called.' );
 		$fulfillments = $this->store->read_fulfillments( WC_Order::class, (string) $this->order->get_id() );
 		$this->assertCount( 1, $fulfillments, 'Fulfillment was not created.' );
@@ -105,7 +106,8 @@ class FulfillmentsDataStoreHookTest extends WC_Unit_Test_Case {

 		$this->expectException( \Exception::class );
 		$this->expectExceptionMessage( 'Fulfillment creation prevented by hook.' );
-		$this->store->create( $this->get_test_fulfillment( $this->order->get_id() ) );
+		$fulfillment = $this->get_test_fulfillment( $this->order->get_id() );
+		$this->store->create( $fulfillment );

 		$this->assertTrue( $hook_called, 'The fulfillment before create hook was not called.' );

@@ -131,7 +133,8 @@ class FulfillmentsDataStoreHookTest extends WC_Unit_Test_Case {
 			2
 		);

-		$this->store->create( $this->get_test_fulfillment( $this->order->get_id() ) );
+		$fulfillment = $this->get_test_fulfillment( $this->order->get_id() );
+		$this->store->create( $fulfillment );
 		$this->assertTrue( $hook_called, 'The fulfillment after create hook was not called.' );

 		// Compare the received fulfillment with the expected data.
@@ -217,17 +220,17 @@ class FulfillmentsDataStoreHookTest extends WC_Unit_Test_Case {
 		$fulfillment->save();
 		$this->assertNotNull( $fulfillment->get_id(), 'Fulfillment ID should not be null.' );

-		$hook_called          = false;
-		$received_fulfillment = null;
-		$received_changed     = null;
-		$received_old_state   = null;
+		$hook_called              = false;
+		$received_fulfillment     = null;
+		$received_changes         = null;
+		$received_previous_status = null;
 		add_action(
 			'woocommerce_fulfillment_after_update',
-			function ( $fulfillment, $changed_props, $old_state ) use ( &$hook_called, &$received_fulfillment, &$received_changed, &$received_old_state ) {
-				$received_fulfillment = $fulfillment;
-				$received_changed     = $changed_props;
-				$received_old_state   = $old_state;
-				$hook_called          = true;
+			function ( $fulfillment, $changes, $previous_status ) use ( &$hook_called, &$received_fulfillment, &$received_changes, &$received_previous_status ) {
+				$received_fulfillment     = $fulfillment;
+				$received_changes         = $changes;
+				$received_previous_status = $previous_status;
+				$hook_called              = true;
 			},
 			10,
 			3
@@ -235,7 +238,7 @@ class FulfillmentsDataStoreHookTest extends WC_Unit_Test_Case {

 		$old_status = $fulfillment->get_status();

-		// Modify a tracked property so changed_props is populated.
+		// Modify a tracked property so changes are populated.
 		$fulfillment->set_status( 'fulfilled' );
 		$fulfillment->add_meta_data( 'test_meta_update', 'test_meta_value' );

@@ -253,12 +256,11 @@ class FulfillmentsDataStoreHookTest extends WC_Unit_Test_Case {
 		$this->assertEquals( $received_fulfillment->get_meta( 'test_meta_update' ), $fulfillment->get_meta( 'test_meta_update' ) );
 		$this->assertEquals( $received_fulfillment->get_items(), $fulfillment->get_items() );

-		// Verify changed_props and old_state are passed correctly.
-		$this->assertIsArray( $received_changed );
-		$this->assertContains( 'status', $received_changed );
-		$this->assertIsArray( $received_old_state );
-		$this->assertArrayHasKey( 'status', $received_old_state );
-		$this->assertEquals( $old_status, $received_old_state['status'] );
+		// Verify changes and previous_status are passed correctly.
+		$this->assertIsArray( $received_changes );
+		$this->assertArrayHasKey( 'status', $received_changes );
+		$this->assertSame( 'fulfilled', $received_changes['status'] );
+		$this->assertEquals( $old_status, $received_previous_status );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentOrderNotesTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentOrderNotesTest.php
index 524e7d31373..0dbccb46ce3 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentOrderNotesTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentOrderNotesTest.php
@@ -219,7 +219,7 @@ class FulfillmentOrderNotesTest extends \WC_Unit_Test_Case {
 					),
 				),
 				'_tracking_number'   => 'TRACK123456',
-				'_shipping_provider' => 'fedex',
+				'_shipment_provider' => 'fedex',
 			)
 		);

@@ -305,7 +305,7 @@ class FulfillmentOrderNotesTest extends \WC_Unit_Test_Case {

 		// Update with tracking info (non-status change).
 		$fulfillment->update_meta_data( '_tracking_number', 'UPS999' );
-		$fulfillment->update_meta_data( '_shipping_provider', 'ups' );
+		$fulfillment->update_meta_data( '_shipment_provider', 'ups' );
 		$fulfillment->update_meta_data( '_tracking_url', 'https://ups.com/track/UPS999' );
 		$fulfillment->save();

diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentTest.php
index d2c6d1e8764..7cdfd260173 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentTest.php
@@ -239,6 +239,159 @@ class FulfillmentTest extends \WC_Unit_Test_Case {
 		$this->assertEquals( '', $fulfillment->get_meta( '_lock_message' ) );
 	}

+	/**
+	 * @testdox get_changes returns empty array when nothing is modified on a persisted fulfillment.
+	 */
+	public function test_get_changes_returns_empty_when_nothing_changed(): void {
+		$fulfillment = FulfillmentsHelper::create_fulfillment(
+			array(
+				'entity_id' => 123,
+			)
+		);
+
+		$reloaded = new Fulfillment( $fulfillment->get_id() );
+
+		$this->assertEmpty( $reloaded->get_changes(), 'A freshly loaded fulfillment should have no changes' );
+	}
+
+	/**
+	 * @testdox get_changes detects core data property changes via set_prop.
+	 */
+	public function test_get_changes_detects_core_data_changes(): void {
+		$fulfillment = FulfillmentsHelper::create_fulfillment(
+			array(
+				'entity_id' => 123,
+				'status'    => 'unfulfilled',
+			)
+		);
+
+		$reloaded = new Fulfillment( $fulfillment->get_id() );
+		$reloaded->set_status( 'fulfilled' );
+
+		$changes = $reloaded->get_changes();
+
+		$this->assertArrayHasKey( 'status', $changes );
+		$this->assertSame( 'fulfilled', $changes['status'] );
+		$this->assertArrayHasKey( 'is_fulfilled', $changes );
+		$this->assertTrue( $changes['is_fulfilled'] );
+	}
+
+	/**
+	 * @testdox get_changes detects meta-based field changes under the meta_data key.
+	 */
+	public function test_get_changes_detects_meta_changes(): void {
+		$fulfillment = FulfillmentsHelper::create_fulfillment(
+			array(
+				'entity_id' => 123,
+			)
+		);
+
+		$reloaded = new Fulfillment( $fulfillment->get_id() );
+		$reloaded->set_tracking_number( '1Z999AA10123456784' );
+		$reloaded->set_shipment_provider( 'ups' );
+
+		$changes = $reloaded->get_changes();
+
+		$this->assertArrayHasKey( 'meta_data', $changes );
+		$this->assertArrayHasKey( '_tracking_number', $changes['meta_data'] );
+		$this->assertSame( '1Z999AA10123456784', $changes['meta_data']['_tracking_number'] );
+		$this->assertArrayHasKey( '_shipment_provider', $changes['meta_data'] );
+		$this->assertSame( 'ups', $changes['meta_data']['_shipment_provider'] );
+	}
+
+	/**
+	 * @testdox get_changes detects both core data and meta changes together.
+	 */
+	public function test_get_changes_detects_core_and_meta_changes_together(): void {
+		$fulfillment = FulfillmentsHelper::create_fulfillment(
+			array(
+				'entity_id' => 123,
+				'status'    => 'unfulfilled',
+			)
+		);
+
+		$reloaded = new Fulfillment( $fulfillment->get_id() );
+		$reloaded->set_status( 'fulfilled' );
+		$reloaded->set_tracking_number( 'TRACK123' );
+
+		$changes = $reloaded->get_changes();
+
+		$this->assertArrayHasKey( 'status', $changes, 'Core data change should be at top level' );
+		$this->assertArrayHasKey( 'meta_data', $changes, 'Meta changes should be under meta_data key' );
+		$this->assertArrayHasKey( '_tracking_number', $changes['meta_data'] );
+	}
+
+	/**
+	 * @testdox get_changes detects custom metadata changes added via update_meta_data.
+	 */
+	public function test_get_changes_detects_custom_meta_changes(): void {
+		$fulfillment = FulfillmentsHelper::create_fulfillment(
+			array(
+				'entity_id' => 123,
+			)
+		);
+
+		$reloaded = new Fulfillment( $fulfillment->get_id() );
+		$reloaded->update_meta_data( '_custom_field', 'custom_value' );
+
+		$changes = $reloaded->get_changes();
+
+		$this->assertArrayHasKey( 'meta_data', $changes );
+		$this->assertArrayHasKey( '_custom_field', $changes['meta_data'] );
+		$this->assertSame( 'custom_value', $changes['meta_data']['_custom_field'] );
+	}
+
+	/**
+	 * @testdox get_changes detects deleted metadata.
+	 */
+	public function test_get_changes_detects_deleted_meta(): void {
+		$fulfillment = FulfillmentsHelper::create_fulfillment(
+			array(
+				'entity_id' => 123,
+			),
+			array(
+				'_custom_key' => 'some_value',
+				'_items'      => array(
+					array(
+						'item_id' => 1,
+						'qty'     => 1,
+					),
+				),
+			)
+		);
+
+		$reloaded = new Fulfillment( $fulfillment->get_id() );
+		$reloaded->delete_meta_data( '_custom_key' );
+
+		$changes = $reloaded->get_changes();
+
+		$this->assertArrayHasKey( 'meta_data', $changes );
+		$this->assertArrayHasKey( '_custom_key', $changes['meta_data'] );
+		$this->assertNull( $changes['meta_data']['_custom_key'] );
+	}
+
+	/**
+	 * @testdox apply_changes resets change tracking so get_changes returns empty.
+	 */
+	public function test_apply_changes_resets_change_tracking(): void {
+		$fulfillment = FulfillmentsHelper::create_fulfillment(
+			array(
+				'entity_id' => 123,
+				'status'    => 'unfulfilled',
+			)
+		);
+
+		$reloaded = new Fulfillment( $fulfillment->get_id() );
+		$reloaded->set_status( 'fulfilled' );
+		$reloaded->set_tracking_number( 'TRACK123' );
+
+		$this->assertNotEmpty( $reloaded->get_changes(), 'Should have changes before apply_changes' );
+
+		$reloaded->save();
+
+		$this->assertEmpty( $reloaded->get_changes(), 'Should have no changes after save' );
+	}
+
 	/**
 	 * Test that the fulfillment status is validated correctly, and the fallback doesn't change is_fulfilled flag.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentUtilsTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentUtilsTest.php
index 8795e80ccb5..9e87632abaa 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentUtilsTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentUtilsTest.php
@@ -2,7 +2,9 @@

 namespace Automattic\WooCommerce\Tests\Admin\Features\Fulfillments;

+use Automattic\WooCommerce\Admin\Features\Fulfillments\Fulfillment;
 use Automattic\WooCommerce\Admin\Features\Fulfillments\FulfillmentUtils;
+use Automattic\WooCommerce\Tests\Admin\Features\Fulfillments\Helpers\FulfillmentsHelper;

 /**
  * FulfillmentUtilsTest class.
@@ -85,4 +87,85 @@ class FulfillmentUtilsTest extends \WC_Unit_Test_Case {
 		$this->assertArrayHasKey( 'custom_status', $fulfillment_statuses );
 		$this->assertEquals( 'Custom Status', $fulfillment_statuses['custom_status']['label'] );
 	}
+
+	/**
+	 * @testdox resolve_provider_name returns slug for known providers.
+	 */
+	public function test_resolve_provider_name_returns_slug_for_known_providers(): void {
+		$fulfillment = FulfillmentsHelper::create_fulfillment(
+			array( 'entity_id' => 123 ),
+			array(
+				'_items'             => array(
+					array(
+						'item_id' => 1,
+						'qty'     => 1,
+					),
+				),
+				'_shipment_provider' => 'usps',
+				'_provider_name'     => 'USPS',
+			)
+		);
+
+		$reloaded = new Fulfillment( $fulfillment->get_id() );
+
+		$this->assertSame( 'usps', FulfillmentUtils::resolve_provider_name( $reloaded ) );
+	}
+
+	/**
+	 * @testdox resolve_provider_name returns display name for custom providers.
+	 */
+	public function test_resolve_provider_name_returns_display_name_for_custom_providers(): void {
+		$fulfillment = FulfillmentsHelper::create_fulfillment(
+			array( 'entity_id' => 123 ),
+			array(
+				'_items'             => array(
+					array(
+						'item_id' => 1,
+						'qty'     => 1,
+					),
+				),
+				'_shipment_provider' => 'other',
+				'_provider_name'     => 'My Custom Carrier',
+			)
+		);
+
+		$reloaded = new Fulfillment( $fulfillment->get_id() );
+
+		$this->assertSame( 'My Custom Carrier', FulfillmentUtils::resolve_provider_name( $reloaded ) );
+	}
+
+	/**
+	 * @testdox resolve_provider_name falls back to display name when slug is empty (auto-lookup case).
+	 */
+	public function test_resolve_provider_name_falls_back_to_display_name_when_slug_empty(): void {
+		$fulfillment = FulfillmentsHelper::create_fulfillment(
+			array( 'entity_id' => 123 ),
+			array(
+				'_items'         => array(
+					array(
+						'item_id' => 1,
+						'qty'     => 1,
+					),
+				),
+				'_provider_name' => 'UPS',
+			)
+		);
+
+		$reloaded = new Fulfillment( $fulfillment->get_id() );
+
+		$this->assertSame( 'UPS', FulfillmentUtils::resolve_provider_name( $reloaded ) );
+	}
+
+	/**
+	 * @testdox resolve_provider_name returns empty string when no provider info is set.
+	 */
+	public function test_resolve_provider_name_returns_empty_when_no_provider_info(): void {
+		$fulfillment = FulfillmentsHelper::create_fulfillment(
+			array( 'entity_id' => 123 )
+		);
+
+		$reloaded = new Fulfillment( $fulfillment->get_id() );
+
+		$this->assertSame( '', FulfillmentUtils::resolve_provider_name( $reloaded ) );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsManagerTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsManagerTest.php
index e3477aabbdd..7766666d6c2 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsManagerTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsManagerTest.php
@@ -357,8 +357,8 @@ class FulfillmentsManagerTest extends \WC_Unit_Test_Case {
 		add_filter(
 			'woocommerce_fulfillment_shipping_providers',
 			function ( $providers ) {
-				$providers = array();
-				return $providers;
+				unset( $providers );
+				return array();
 			}
 		);

@@ -366,4 +366,22 @@ class FulfillmentsManagerTest extends \WC_Unit_Test_Case {
 		$parsed_number = $this->manager->try_parse_tracking_number( $tracking_number, 'US', 'CA' );
 		$this->assertEquals( array(), $parsed_number );
 	}
+
+	/**
+	 * @testdox Email template tracking hooks are registered for all fulfillment email types.
+	 */
+	public function test_email_template_tracking_hooks_are_registered(): void {
+		$email_ids = array(
+			'customer_fulfillment_created',
+			'customer_fulfillment_updated',
+			'customer_fulfillment_deleted',
+		);
+
+		foreach ( $email_ids as $email_id ) {
+			$this->assertNotFalse(
+				has_action( 'woocommerce_update_options_email_' . $email_id ),
+				"Tracking hook should be registered for woocommerce_update_options_email_{$email_id}"
+			);
+		}
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsRendererTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsRendererTest.php
index 9806fc3e8c6..572680d41b5 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsRendererTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsRendererTest.php
@@ -88,9 +88,9 @@ class FulfillmentsRendererTest extends \WC_Unit_Test_Case {
 		$fulfillment = new Fulfillment();
 		$fulfillment->set_entity_type( WC_Order::class );
 		$fulfillment->set_entity_id( (string) $order->get_id() );
-		$fulfillment->add_meta_data( '_tracking_number', '123456789' );
-		$fulfillment->add_meta_data( '_tracking_url', 'https://example.com/track/123456789' );
-		$fulfillment->add_meta_data( '_shipment_provider', 'UPS' );
+		$fulfillment->set_tracking_number( '123456789' );
+		$fulfillment->set_tracking_url( 'https://example.com/track/123456789' );
+		$fulfillment->set_shipment_provider( 'UPS' );
 		$fulfillment->set_items(
 			array(
 				array(
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsTrackerTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsTrackerTest.php
new file mode 100644
index 00000000000..76b33b380ed
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsTrackerTest.php
@@ -0,0 +1,188 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Admin\Features\Fulfillments;
+
+use Automattic\WooCommerce\Admin\Features\Fulfillments\FulfillmentsTracker;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the FulfillmentsTracker class.
+ */
+class FulfillmentsTrackerTest extends WC_Unit_Test_Case {
+
+	/**
+	 * @testdox determine_tracking_entry_method returns api when source is api.
+	 */
+	public function test_determine_entry_method_returns_api_for_api_source(): void {
+		$result = FulfillmentsTracker::determine_tracking_entry_method( 'api', 'tracking-number' );
+
+		$this->assertSame( 'api', $result, 'API source should always return api entry method' );
+	}
+
+	/**
+	 * @testdox determine_tracking_entry_method returns ui_auto_lookup for tracking-number option from UI.
+	 */
+	public function test_determine_entry_method_returns_ui_auto_lookup(): void {
+		$result = FulfillmentsTracker::determine_tracking_entry_method( 'fulfillments_modal', 'tracking-number' );
+
+		$this->assertSame( 'ui_auto_lookup', $result, 'tracking-number option from modal should return ui_auto_lookup' );
+	}
+
+	/**
+	 * @testdox determine_tracking_entry_method returns ui_manual for manual-entry from UI.
+	 */
+	public function test_determine_entry_method_returns_ui_manual(): void {
+		$result = FulfillmentsTracker::determine_tracking_entry_method( 'fulfillments_modal', 'manual-entry' );
+
+		$this->assertSame( 'ui_manual', $result, 'manual-entry from modal should return ui_manual' );
+	}
+
+	/**
+	 * @testdox determine_tracking_entry_method returns api for unknown shipping option from UI.
+	 */
+	public function test_determine_entry_method_returns_api_for_unknown_option(): void {
+		$result = FulfillmentsTracker::determine_tracking_entry_method( 'fulfillments_modal', 'no-info' );
+
+		$this->assertSame( 'api', $result, 'Unknown shipping option from modal should fall back to api' );
+	}
+
+	/**
+	 * @testdox determine_tracking_entry_method returns api for empty shipping option from UI.
+	 */
+	public function test_determine_entry_method_returns_api_for_empty_option(): void {
+		$result = FulfillmentsTracker::determine_tracking_entry_method( 'fulfillments_modal', '' );
+
+		$this->assertSame( 'api', $result, 'Empty shipping option from modal should fall back to api' );
+	}
+
+	/**
+	 * Data provider for entry method scenarios.
+	 *
+	 * @return array<string, array{string, string, string}>
+	 */
+	public function entry_method_data_provider(): array {
+		return array(
+			'API with tracking-number'     => array( 'api', 'tracking-number', 'api' ),
+			'API with manual-entry'        => array( 'api', 'manual-entry', 'api' ),
+			'UI auto lookup'               => array( 'fulfillments_modal', 'tracking-number', 'ui_auto_lookup' ),
+			'UI manual entry'              => array( 'fulfillments_modal', 'manual-entry', 'ui_manual' ),
+			'UI no-info falls back to api' => array( 'fulfillments_modal', 'no-info', 'api' ),
+			'Unknown source falls back'    => array( 'bulk_action', 'tracking-number', 'api' ),
+		);
+	}
+
+	/**
+	 * @testdox determine_tracking_entry_method returns expected value for various input combinations.
+	 *
+	 * @dataProvider entry_method_data_provider
+	 *
+	 * @param string $source          The request source.
+	 * @param string $shipping_option The shipping option.
+	 * @param string $expected        The expected entry method.
+	 */
+	public function test_determine_entry_method_data_provider( string $source, string $shipping_option, string $expected ): void {
+		$result = FulfillmentsTracker::determine_tracking_entry_method( $source, $shipping_option );
+
+		$this->assertSame( $expected, $result );
+	}
+
+	/**
+	 * @testdox track_fulfillment_modal_opened is callable with expected parameters.
+	 */
+	public function test_track_fulfillment_modal_opened_is_callable(): void {
+		$this->assertTrue(
+			method_exists( FulfillmentsTracker::class, 'track_fulfillment_modal_opened' ),
+			'track_fulfillment_modal_opened method should exist'
+		);
+
+		$reflection = new \ReflectionMethod( FulfillmentsTracker::class, 'track_fulfillment_modal_opened' );
+		$params     = $reflection->getParameters();
+
+		$this->assertCount( 2, $params, 'track_fulfillment_modal_opened should accept 2 parameters' );
+		$this->assertSame( 'source', $params[0]->getName() );
+		$this->assertSame( 'order_id', $params[1]->getName() );
+	}
+
+	/**
+	 * @testdox track_fulfillment_creation is callable with expected parameters.
+	 */
+	public function test_track_fulfillment_creation_is_callable(): void {
+		$reflection = new \ReflectionMethod( FulfillmentsTracker::class, 'track_fulfillment_creation' );
+		$params     = $reflection->getParameters();
+
+		$this->assertCount( 6, $params, 'track_fulfillment_creation should accept 6 parameters' );
+		$this->assertSame( 'source', $params[0]->getName() );
+		$this->assertSame( 'initial_status', $params[1]->getName() );
+		$this->assertSame( 'fulfillment_type', $params[2]->getName() );
+		$this->assertSame( 'item_count', $params[3]->getName() );
+		$this->assertSame( 'total_quantity', $params[4]->getName() );
+		$this->assertSame( 'notification_sent', $params[5]->getName() );
+	}
+
+	/**
+	 * @testdox track_fulfillment_tracking_added is callable with expected parameters.
+	 */
+	public function test_track_fulfillment_tracking_added_is_callable(): void {
+		$reflection = new \ReflectionMethod( FulfillmentsTracker::class, 'track_fulfillment_tracking_added' );
+		$params     = $reflection->getParameters();
+
+		$this->assertCount( 4, $params, 'track_fulfillment_tracking_added should accept 4 parameters' );
+		$this->assertSame( 'fulfillment_id', $params[0]->getName() );
+		$this->assertSame( 'entry_method', $params[1]->getName() );
+		$this->assertSame( 'provider_name', $params[2]->getName() );
+		$this->assertSame( 'is_custom_provider', $params[3]->getName() );
+	}
+
+	/**
+	 * @testdox track_fulfillment_tracking_lookup_attempt accepts url_generated parameter.
+	 */
+	public function test_track_fulfillment_tracking_lookup_attempt_has_url_generated_param(): void {
+		$reflection = new \ReflectionMethod( FulfillmentsTracker::class, 'track_fulfillment_tracking_lookup_attempt' );
+		$params     = $reflection->getParameters();
+
+		$this->assertCount( 3, $params, 'track_fulfillment_tracking_lookup_attempt should accept 3 parameters' );
+		$this->assertSame( 'url_generated', $params[2]->getName() );
+		$this->assertTrue( $params[2]->isDefaultValueAvailable(), 'url_generated should have a default value' );
+		$this->assertFalse( $params[2]->getDefaultValue(), 'url_generated should default to false' );
+	}
+
+	/**
+	 * @testdox track_fulfillment_email_template_customized is callable with expected parameters.
+	 */
+	public function test_track_fulfillment_email_template_customized_is_callable(): void {
+		$reflection = new \ReflectionMethod( FulfillmentsTracker::class, 'track_fulfillment_email_template_customized' );
+		$params     = $reflection->getParameters();
+
+		$this->assertCount( 1, $params, 'track_fulfillment_email_template_customized should accept 1 parameter' );
+		$this->assertSame( 'template_name', $params[0]->getName() );
+	}
+
+	/**
+	 * @testdox track_fulfillment_validation_error is callable with expected parameters.
+	 */
+	public function test_track_fulfillment_validation_error_is_callable(): void {
+		$reflection = new \ReflectionMethod( FulfillmentsTracker::class, 'track_fulfillment_validation_error' );
+		$params     = $reflection->getParameters();
+
+		$this->assertCount( 3, $params, 'track_fulfillment_validation_error should accept 3 parameters' );
+		$this->assertSame( 'action_attempted', $params[0]->getName() );
+		$this->assertSame( 'error_code', $params[1]->getName() );
+		$this->assertSame( 'source', $params[2]->getName() );
+	}
+
+	/**
+	 * @testdox All tracker methods are static.
+	 */
+	public function test_all_tracker_methods_are_static(): void {
+		$reflection = new \ReflectionClass( FulfillmentsTracker::class );
+		$methods    = $reflection->getMethods( \ReflectionMethod::IS_PUBLIC );
+
+		foreach ( $methods as $method ) {
+			$this->assertTrue(
+				$method->isStatic(),
+				"Method {$method->getName()} should be static"
+			);
+		}
+	}
+}
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 06f9e6fdcc1..690cd35c2af 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/OrderFulfillmentsRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/OrderFulfillmentsRestControllerTest.php
@@ -1891,4 +1891,164 @@ class OrderFulfillmentsRestControllerTest extends WC_REST_Unit_Test_Case {

 		wp_set_current_user( $current_user->ID );
 	}
+
+	/**
+	 * @testdox maybe_track_tracking_added method exists on the controller.
+	 */
+	public function test_maybe_track_tracking_added_method_exists(): void {
+		$reflection = new \ReflectionClass( OrderFulfillmentsRestController::class );
+		$this->assertTrue(
+			$reflection->hasMethod( 'maybe_track_tracking_added' ),
+			'maybe_track_tracking_added method should exist on OrderFulfillmentsRestController'
+		);
+
+		$method = $reflection->getMethod( 'maybe_track_tracking_added' );
+		$this->assertTrue( $method->isPrivate(), 'maybe_track_tracking_added should be private' );
+	}
+
+	/**
+	 * @testdox check_request_source returns fulfillments_modal when UI header is present.
+	 */
+	public function test_check_request_source_returns_modal_for_ui_header(): void {
+		$reflection = new \ReflectionClass( OrderFulfillmentsRestController::class );
+		$method     = $reflection->getMethod( 'check_request_source' );
+		$method->setAccessible( true );
+
+		$request = new WP_REST_Request( 'POST', '/wc/v3/orders/1/fulfillments' );
+		$request->set_header( 'X-WC-Fulfillments-UI', 'true' );
+
+		$result = $method->invoke( $this->controller, $request );
+		$this->assertSame( 'fulfillments_modal', $result );
+	}
+
+	/**
+	 * @testdox check_request_source returns api when no UI header is present.
+	 */
+	public function test_check_request_source_returns_api_without_ui_header(): void {
+		$reflection = new \ReflectionClass( OrderFulfillmentsRestController::class );
+		$method     = $reflection->getMethod( 'check_request_source' );
+		$method->setAccessible( true );
+
+		$request = new WP_REST_Request( 'POST', '/wc/v3/orders/1/fulfillments' );
+
+		$result = $method->invoke( $this->controller, $request );
+		$this->assertSame( 'api', $result );
+	}
+
+	/**
+	 * @testdox Creating a fulfillment with tracking info succeeds and includes tracking metadata.
+	 */
+	public function test_create_fulfillment_with_tracking_info_succeeds(): void {
+		$order = WC_Helper_Order::create_order( get_current_user_id() );
+		$this->assertInstanceOf( WC_Order::class, $order );
+
+		wp_set_current_user( 1 );
+		$request = new WP_REST_Request( 'POST', '/wc/v3/orders/' . $order->get_id() . '/fulfillments' );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body(
+			wp_json_encode(
+				array(
+					'entity_type'  => WC_Order::class,
+					'entity_id'    => '' . $order->get_id(),
+					'status'       => 'fulfilled',
+					'is_fulfilled' => true,
+					'meta_data'    => array(
+						array(
+							'id'    => 0,
+							'key'   => '_items',
+							'value' => array(
+								array(
+									'item_id' => 1,
+									'qty'     => 1,
+								),
+							),
+						),
+						array(
+							'id'    => 0,
+							'key'   => '_tracking_number',
+							'value' => '1Z999AA10123456784',
+						),
+						array(
+							'id'    => 0,
+							'key'   => '_shipment_provider',
+							'value' => 'ups',
+						),
+						array(
+							'id'    => 0,
+							'key'   => '_tracking_url',
+							'value' => 'https://www.ups.com/track?tracknum=1Z999AA10123456784',
+						),
+						array(
+							'id'    => 0,
+							'key'   => '_shipping_option',
+							'value' => 'tracking-number',
+						),
+					),
+				)
+			)
+		);
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::CREATED, $response->get_status() );
+		$fulfillment = $response->get_data();
+		$this->assertIsArray( $fulfillment );
+		$this->assertArrayHasKey( 'id', $fulfillment );
+
+		$meta_keys = array_column( $fulfillment['meta_data'], 'key' );
+		$this->assertContains( '_tracking_number', $meta_keys, 'Fulfillment should have tracking number metadata' );
+		$this->assertContains( '_shipment_provider', $meta_keys, 'Fulfillment should have shipping provider metadata' );
+		$this->assertContains( '_tracking_url', $meta_keys, 'Fulfillment should have tracking URL metadata' );
+
+		wp_set_current_user( 0 );
+	}
+
+	/**
+	 * @testdox Creating a fulfillment without tracking info succeeds without tracking metadata.
+	 */
+	public function test_create_fulfillment_without_tracking_info_succeeds(): void {
+		$order = WC_Helper_Order::create_order( get_current_user_id() );
+		$this->assertInstanceOf( WC_Order::class, $order );
+
+		wp_set_current_user( 1 );
+		$request = new WP_REST_Request( 'POST', '/wc/v3/orders/' . $order->get_id() . '/fulfillments' );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body(
+			wp_json_encode(
+				array(
+					'entity_type'  => WC_Order::class,
+					'entity_id'    => '' . $order->get_id(),
+					'status'       => 'unfulfilled',
+					'is_fulfilled' => false,
+					'meta_data'    => array(
+						array(
+							'id'    => 0,
+							'key'   => '_items',
+							'value' => array(
+								array(
+									'item_id' => 1,
+									'qty'     => 1,
+								),
+							),
+						),
+						array(
+							'id'    => 0,
+							'key'   => '_shipping_option',
+							'value' => 'no-info',
+						),
+					),
+				)
+			)
+		);
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::CREATED, $response->get_status() );
+		$fulfillment = $response->get_data();
+		$this->assertIsArray( $fulfillment );
+		$this->assertArrayHasKey( 'id', $fulfillment );
+
+		$meta_keys = array_column( $fulfillment['meta_data'], 'key' );
+		$this->assertNotContains( '_tracking_number', $meta_keys, 'Fulfillment without tracking should not have tracking number metadata' );
+
+		wp_set_current_user( 0 );
+	}
 }