Index: src/wp-admin/includes/ms.php
===================================================================
--- src/wp-admin/includes/ms.php	(revision 43340)
+++ src/wp-admin/includes/ms.php	(working copy)
@@ -56,6 +56,7 @@
  * Delete a site.
  *
  * @since 3.0.0
+ * @since 5.0.0 Use wp_delete_site() internally to delete the site row from the database.
  *
  * @global wpdb $wpdb WordPress database abstraction object.
  *
@@ -142,7 +143,7 @@
 			}
 		}
 
-		$wpdb->delete( $wpdb->blogs, array( 'blog_id' => $blog_id ) );
+		wp_delete_site( $blog_id );
 
 		/**
 		 * Filters the upload base directory to delete when the site is deleted.
Index: src/wp-includes/ms-blogs.php
===================================================================
--- src/wp-includes/ms-blogs.php	(revision 43340)
+++ src/wp-includes/ms-blogs.php	(working copy)
@@ -297,132 +297,12 @@
 		$details = get_object_vars( $details );
 	}
 
-	$current_details = get_site( $blog_id );
-	if ( empty( $current_details ) ) {
-		return false;
-	}
-
-	$current_details = get_object_vars( $current_details );
-
-	$details                 = array_merge( $current_details, $details );
-	$details['last_updated'] = current_time( 'mysql', true );
-
-	$update_details = array();
-	$fields         = array( 'site_id', 'domain', 'path', 'registered', 'last_updated', 'public', 'archived', 'mature', 'spam', 'deleted', 'lang_id' );
-	foreach ( array_intersect( array_keys( $details ), $fields ) as $field ) {
-		if ( 'path' === $field ) {
-			$details[ $field ] = trailingslashit( '/' . trim( $details[ $field ], '/' ) );
-		}
-
-		$update_details[ $field ] = $details[ $field ];
-	}
+	$site = wp_update_site( $blog_id, $details );
 
-	$result = $wpdb->update( $wpdb->blogs, $update_details, array( 'blog_id' => $blog_id ) );
-
-	if ( false === $result ) {
+	if ( is_wp_error( $site ) ) {
 		return false;
 	}
 
-	// If spam status changed, issue actions.
-	if ( $details['spam'] != $current_details['spam'] ) {
-		if ( $details['spam'] == 1 ) {
-			/**
-			 * Fires when the 'spam' status is added to a blog.
-			 *
-			 * @since MU (3.0.0)
-			 *
-			 * @param int $blog_id Blog ID.
-			 */
-			do_action( 'make_spam_blog', $blog_id );
-		} else {
-			/**
-			 * Fires when the 'spam' status is removed from a blog.
-			 *
-			 * @since MU (3.0.0)
-			 *
-			 * @param int $blog_id Blog ID.
-			 */
-			do_action( 'make_ham_blog', $blog_id );
-		}
-	}
-
-	// If mature status changed, issue actions.
-	if ( $details['mature'] != $current_details['mature'] ) {
-		if ( $details['mature'] == 1 ) {
-			/**
-			 * Fires when the 'mature' status is added to a blog.
-			 *
-			 * @since 3.1.0
-			 *
-			 * @param int $blog_id Blog ID.
-			 */
-			do_action( 'mature_blog', $blog_id );
-		} else {
-			/**
-			 * Fires when the 'mature' status is removed from a blog.
-			 *
-			 * @since 3.1.0
-			 *
-			 * @param int $blog_id Blog ID.
-			 */
-			do_action( 'unmature_blog', $blog_id );
-		}
-	}
-
-	// If archived status changed, issue actions.
-	if ( $details['archived'] != $current_details['archived'] ) {
-		if ( $details['archived'] == 1 ) {
-			/**
-			 * Fires when the 'archived' status is added to a blog.
-			 *
-			 * @since MU (3.0.0)
-			 *
-			 * @param int $blog_id Blog ID.
-			 */
-			do_action( 'archive_blog', $blog_id );
-		} else {
-			/**
-			 * Fires when the 'archived' status is removed from a blog.
-			 *
-			 * @since MU (3.0.0)
-			 *
-			 * @param int $blog_id Blog ID.
-			 */
-			do_action( 'unarchive_blog', $blog_id );
-		}
-	}
-
-	// If deleted status changed, issue actions.
-	if ( $details['deleted'] != $current_details['deleted'] ) {
-		if ( $details['deleted'] == 1 ) {
-			/**
-			 * Fires when the 'deleted' status is added to a blog.
-			 *
-			 * @since 3.5.0
-			 *
-			 * @param int $blog_id Blog ID.
-			 */
-			do_action( 'make_delete_blog', $blog_id );
-		} else {
-			/**
-			 * Fires when the 'deleted' status is removed from a blog.
-			 *
-			 * @since 3.5.0
-			 *
-			 * @param int $blog_id Blog ID.
-			 */
-			do_action( 'make_undelete_blog', $blog_id );
-		}
-	}
-
-	if ( isset( $details['public'] ) ) {
-		switch_to_blog( $blog_id );
-		update_option( 'blog_public', $details['public'] );
-		restore_current_blog();
-	}
-
-	clean_blog_cache( $blog_id );
-
 	return true;
 }
 
@@ -518,6 +398,218 @@
 }
 
 /**
+ * Inserts a new site into the database.
+ *
+ * @since 5.0.0
+ *
+ * @global wpdb $wpdb WordPress database abstraction object.
+ *
+ * @param array $data {
+ *     Data for the new site that should be inserted.
+ *
+ *     @type string $domain       Site domain. Must always be provided.
+ *     @type string $path         Site path. Default '/'.
+ *     @type int    $network_id   The site's network ID. Default is the current network ID.
+ *     @type string $registered   When the site was registered, in SQL datetime format. Default is
+ *                                the current time.
+ *     @type string $last_updated When the site was last updated, in SQL datetime format. Default is
+ *                                the value of $registered.
+ *     @type int    $public       Whether the site is public. Default 1.
+ *     @type int    $archived     Whether the site is archived. Default 0.
+ *     @type int    $mature       Whether the site is mature. Default 0.
+ *     @type int    $spam         Whether the site is spam. Default 0.
+ *     @type int    $deleted      Whether the site is deleted. Default 0.
+ *     @type int    $lang_id      The site's language ID. Currently unused. Default 0.
+ * }
+ * @return int|WP_Error The new site's ID on success, or error object on failure.
+ */
+function wp_insert_site( array $data ) {
+	global $wpdb;
+
+	$now = current_time( 'mysql' );
+
+	$defaults = array(
+		'domain'       => '',
+		'path'         => '/',
+		'network_id'   => 0,
+		'registered'   => $now,
+		'last_updated' => $now,
+		'public'       => 1,
+		'archived'     => 0,
+		'mature'       => 0,
+		'spam'         => 0,
+		'deleted'      => 0,
+		'lang_id'      => 0,
+	);
+
+	/**
+	 * Filters passed site data in order to normalize it.
+	 *
+	 * @since 5.0.0
+	 *
+	 * @param array $data Associative array of site data passed to the respective function.
+	 *                    See {@see wp_insert_site()} for the possibly included data.
+	 */
+	$data = apply_filters( 'wp_normalize_site_data', $data );
+
+	$data = array_intersect_key( wp_parse_args( $data, $defaults ), $defaults );
+
+	// Use the current network if none is set.
+	if ( empty( $data['network_id'] ) ) {
+		$data['network_id'] = get_current_network_id();
+	}
+
+	$validity = new WP_Error();
+
+	/**
+	 * Fires when data should be validated for a site prior to inserting or updating in the database.
+	 *
+	 * Plugins should amend the `$validity` object via its `WP_Error::add()` method.
+	 *
+	 * @since 5.0.0
+	 *
+	 * @param WP_Error     $validity Error object to add validation errors to.
+	 * @param array        $data     Associative array of complete site data. See {@see wp_insert_site()}
+	 *                               for the included data.
+	 * @param WP_Site|null $old_site The old site object if the data belongs to a site being updated,
+	 *                               or null if it is a new site being inserted.
+	 */
+	do_action( 'wp_validate_site_data', $validity, $data, null );
+
+	if ( ! empty( $validity->errors ) ) {
+		return $validity;
+	}
+
+	// Prepare for database.
+	$data['site_id'] = $data['network_id'];
+	unset( $data['network_id'] );
+
+	if ( false === $wpdb->insert( $wpdb->blogs, $data ) ) {
+		return new WP_Error( 'db_insert_error', __( 'Could not insert site into the database.' ), $wpdb->last_error );
+	}
+
+	$new_site = get_site( $wpdb->insert_id );
+
+	clean_blog_cache( $new_site );
+
+	/**
+	 * Fires once a site has been inserted into the database.
+	 *
+	 * @since 5.0.0
+	 *
+	 * @param WP_Site $new_site New site object.
+	 */
+	do_action( 'wp_insert_site', $new_site );
+
+	return (int) $new_site->id;
+}
+
+/**
+ * Updates a site in the database.
+ *
+ * @since 5.0.0
+ *
+ * @global wpdb $wpdb WordPress database abstraction object.
+ *
+ * @param int   $site_id ID of the site that should be updated.
+ * @param array $data    Site data to update. See {@see wp_insert_site()} for the list of supported keys.
+ * @return int|WP_Error The updated site's ID on success, or error object on failure.
+ */
+function wp_update_site( $site_id, array $data ) {
+	global $wpdb;
+
+	$old_site = get_site( $site_id );
+	if ( ! $old_site ) {
+		return new WP_Error( 'site_not_exist', __( 'Site does not exist.' ) );
+	}
+
+	$defaults                 = $old_site->to_array();
+	$defaults['network_id']   = (int) $defaults['site_id'];
+	$defaults['last_updated'] = current_time( 'mysql' );
+	unset( $defaults['blog_id'], $defaults['site_id'] );
+
+	/** This filter is documented in wp-includes/ms-blogs.php */
+	$data = apply_filters( 'wp_normalize_site_data', $data );
+
+	$whitelist = array( 'domain', 'path', 'network_id', 'registered', 'last_updated', 'public', 'archived', 'mature', 'spam', 'deleted', 'lang_id' );
+	$data = array_intersect_key( wp_parse_args( $data, $defaults ), array_flip( $whitelist ) );
+
+	// Use the previously set network if a falsy network ID has been passed.
+	if ( empty( $data['network_id'] ) ) {
+		$data['network_id'] = $defaults['network_id'];
+	}
+
+	$validity = new WP_Error();
+
+	/** This action is documented in wp-includes/ms-blogs.php */
+	do_action( 'wp_validate_site_data', $validity, $data, $old_site );
+
+	if ( ! empty( $validity->errors ) ) {
+		return $validity;
+	}
+
+	// Prepare for database.
+	$data['site_id'] = $data['network_id'];
+	unset( $data['network_id'] );
+
+	if ( false === $wpdb->update( $wpdb->blogs, $data, array( 'blog_id' => $old_site->id ) ) ) {
+		return new WP_Error( 'db_update_error', __( 'Could not update site in the database.' ), $wpdb->last_error );
+	}
+
+	clean_blog_cache( $old_site );
+
+	$new_site = get_site( $old_site->id );
+
+	/**
+	 * Fires once a site has been updated in the database.
+	 *
+	 * @since 5.0.0
+	 *
+	 * @param WP_Site $new_site New site object.
+	 * @param WP_Site $old_site Old site object.
+	 */
+	do_action( 'wp_update_site', $new_site, $old_site );
+
+	return (int) $new_site->id;
+}
+
+/**
+ * Deletes a site from the database.
+ *
+ * @since 5.0.0
+ *
+ * @global wpdb $wpdb WordPress database abstraction object.
+ *
+ * @param int $site_id ID of the site that should be deleted.
+ * @return WP_Site|WP_Error The deleted site object on success, or error object on failure.
+ */
+function wp_delete_site( $site_id ) {
+	global $wpdb;
+
+	$old_site = get_site( $site_id );
+	if ( ! $old_site ) {
+		return new WP_Error( 'site_not_exist', __( 'Site does not exist.' ) );
+	}
+
+	if ( false === $wpdb->delete( $wpdb->blogs, array( 'blog_id' => $old_site->id ) ) ) {
+		return new WP_Error( 'db_delete_error', __( 'Could not delete site from the database.' ), $wpdb->last_error );
+	}
+
+	clean_blog_cache( $old_site );
+
+	/**
+	 * Fires once a site has been deleted from the database.
+	 *
+	 * @since 5.0.0
+	 *
+	 * @param WP_Site $old_site Deleted site object.
+	 */
+	do_action( 'wp_delete_site', $old_site );
+
+	return $old_site;
+}
+
+/**
  * Retrieves site data given a site ID or site object.
  *
  * Site data will be cached and returned after being passed through a filter.
@@ -688,6 +780,78 @@
 }
 
 /**
+ * Normalizes data for a site prior to inserting or updating in the database.
+ *
+ * @since 5.0.0
+ *
+ * @param array $data Associative array of site data passed to the respective function.
+ *                    See {@see wp_insert_site()} for the possibly included data.
+ * @return array Normalized site data.
+ */
+function wp_normalize_site_data( $data ) {
+	// Maintain backward-compatibility with `$site_id` as network ID.
+	if ( ! empty( $data['site_id'] ) && empty( $data['network_id'] ) ) {
+		$data['network_id'] = $data['site_id'];
+	}
+
+	// Sanitize domain if passed.
+	if ( array_key_exists( 'domain', $data ) ) {
+		$data['domain'] = trim( $data['domain'] );
+		$data['domain'] = preg_replace( '/\s+/', '', sanitize_user( $data['domain'], true ) );
+		if ( is_subdomain_install() ) {
+			$data['domain'] = str_replace( '@', '', $data['domain'] );
+		}
+	}
+
+	// Sanitize path if passed.
+	if ( array_key_exists( 'path', $data ) ) {
+		$data['path'] = trailingslashit( '/' . trim( $data['path'], '/' ) );
+	}
+
+	// Sanitize network ID if passed.
+	if ( array_key_exists( 'network_id', $data ) ) {
+		$data['network_id'] = (int) $data['network_id'];
+	}
+
+	return $data;
+}
+
+/**
+ * Validates data for a site prior to inserting or updating in the database.
+ *
+ * @since 5.0.0
+ *
+ * @param WP_Error     $validity Error object, passed by reference. Will contain validation errors if
+ *                               any occurred.
+ * @param array        $data     Associative array of complete site data. See {@see wp_insert_site()}
+ *                               for the included data.
+ * @param WP_Site|null $old_site The old site object if the data belongs to a site being updated,
+ *                               or null if it is a new site being inserted.
+ */
+function wp_validate_site_data( $validity, $data, $old_site = null ) {
+	// A domain must always be present.
+	if ( empty( $data['domain'] ) ) {
+		$validity->add( 'site_empty_domain', __( 'Site domain must not be empty.' ) );
+	}
+
+	// A path must always be present.
+	if ( empty( $data['path'] ) ) {
+		$validity->add( 'site_empty_path', __( 'Site path must not be empty.' ) );
+	}
+
+	// If a new site, or domain/path/network ID have changed, ensure uniqueness.
+	if ( ! $old_site
+		|| $data['domain'] !== $old_site->domain
+		|| $data['path'] !== $old_site->path
+		|| $data['network_id'] !== $old_site->network_id
+	) {
+		if ( domain_exists( $data['domain'], $data['path'], $data['network_id'] ) ) {
+			$validity->add( 'blog_taken', __( 'Sorry, that site already exists!' ) );
+		}
+	}
+}
+
+/**
  * Retrieve option value for a given blog id based on name of option.
  *
  * If the option does not exist or does not have a value, then the return value
@@ -1193,6 +1357,7 @@
  * Update a blog details field.
  *
  * @since MU (3.0.0)
+ * @since 5.0.0 Use wp_update_site() internally.
  *
  * @global wpdb $wpdb WordPress database abstraction object.
  *
@@ -1213,63 +1378,14 @@
 		return $value;
 	}
 
-	$result = $wpdb->update(
-		$wpdb->blogs, array(
-			$pref          => $value,
-			'last_updated' => current_time( 'mysql', true ),
-		), array( 'blog_id' => $blog_id )
-	);
+	$result = wp_update_site( $blog_id, array(
+		$pref => $value,
+	) );
 
-	if ( false === $result ) {
+	if ( is_wp_error( $result ) ) {
 		return false;
 	}
 
-	clean_blog_cache( $blog_id );
-
-	if ( 'spam' == $pref ) {
-		if ( $value == 1 ) {
-			/** This filter is documented in wp-includes/ms-blogs.php */
-			do_action( 'make_spam_blog', $blog_id );
-		} else {
-			/** This filter is documented in wp-includes/ms-blogs.php */
-			do_action( 'make_ham_blog', $blog_id );
-		}
-	} elseif ( 'mature' == $pref ) {
-		if ( $value == 1 ) {
-			/** This filter is documented in wp-includes/ms-blogs.php */
-			do_action( 'mature_blog', $blog_id );
-		} else {
-			/** This filter is documented in wp-includes/ms-blogs.php */
-			do_action( 'unmature_blog', $blog_id );
-		}
-	} elseif ( 'archived' == $pref ) {
-		if ( $value == 1 ) {
-			/** This filter is documented in wp-includes/ms-blogs.php */
-			do_action( 'archive_blog', $blog_id );
-		} else {
-			/** This filter is documented in wp-includes/ms-blogs.php */
-			do_action( 'unarchive_blog', $blog_id );
-		}
-	} elseif ( 'deleted' == $pref ) {
-		if ( $value == 1 ) {
-			/** This filter is documented in wp-includes/ms-blogs.php */
-			do_action( 'make_delete_blog', $blog_id );
-		} else {
-			/** This filter is documented in wp-includes/ms-blogs.php */
-			do_action( 'make_undelete_blog', $blog_id );
-		}
-	} elseif ( 'public' == $pref ) {
-		/**
-		 * Fires after the current blog's 'public' setting is updated.
-		 *
-		 * @since MU (3.0.0)
-		 *
-		 * @param int    $blog_id Blog ID.
-		 * @param string $value   The value of blog status.
-		 */
-		do_action( 'update_blog_public', $blog_id, $value ); // Moved here from update_blog_public().
-	}
-
 	return $value;
 }
 
@@ -1537,3 +1653,182 @@
 
 	update_posts_count();
 }
+
+/**
+ * Updates the count of sites for a network based on a changed site.
+ *
+ * @since 5.0.0
+ *
+ * @param WP_Site      $new_site The site object that has been inserted, updated or deleted.
+ * @param WP_Site|null $old_site Optional. If $new_site has been updated, this must be the previous
+ *                               state of that site. Default null.
+ */
+function wp_maybe_update_network_site_counts_on_update( $new_site, $old_site = null ) {
+	if ( null === $old_site ) {
+		wp_maybe_update_network_site_counts( $new_site->network_id );
+		return;
+	}
+
+	if ( $new_site->network_id != $old_site->network_id ) {
+		wp_maybe_update_network_site_counts( $new_site->network_id );
+		wp_maybe_update_network_site_counts( $old_site->network_id );
+	}
+}
+
+/**
+ * Triggers actions on site status updates.
+ *
+ * @since 5.0.0
+ *
+ * @param WP_Site      $new_site The site object after the update.
+ * @param WP_Site|null $old_site Optional. If $new_site has been updated, this must be the previous
+ *                               state of that site. Default null.
+ */
+function wp_maybe_transition_site_statuses_on_update( $new_site, $old_site ) {
+	$site_id = $new_site->id;
+
+	// Use the default values for a site if no previous state is given.
+	if ( ! $old_site ) {
+		$old_site           = new WP_Site();
+		$old_site->public   = 1;
+		$old_site->archived = 0;
+		$old_site->mature   = 0;
+		$old_site->spam     = 0;
+		$old_site->deleted  = 0;
+	}
+
+	if ( $new_site->spam != $old_site->spam ) {
+		if ( $new_site->spam == 1 ) {
+
+			/**
+			 * Fires when the 'spam' status is added to a site.
+			 *
+			 * @since MU (3.0.0)
+			 *
+			 * @param int $site_id Site ID.
+			 */
+			do_action( 'make_spam_blog', $site_id );
+		} else {
+
+			/**
+			 * Fires when the 'spam' status is removed from a site.
+			 *
+			 * @since MU (3.0.0)
+			 *
+			 * @param int $site_id Site ID.
+			 */
+			do_action( 'make_ham_blog', $site_id );
+		}
+	}
+
+	if ( $new_site->mature != $old_site->mature ) {
+		if ( $new_site->mature == 1 ) {
+
+			/**
+			 * Fires when the 'mature' status is added to a site.
+			 *
+			 * @since 3.1.0
+			 *
+			 * @param int $site_id Site ID.
+			 */
+			do_action( 'mature_blog', $site_id );
+		} else {
+
+			/**
+			 * Fires when the 'mature' status is removed from a site.
+			 *
+			 * @since 3.1.0
+			 *
+			 * @param int $site_id Site ID.
+			 */
+			do_action( 'unmature_blog', $site_id );
+		}
+	}
+
+	if ( $new_site->archived != $old_site->archived ) {
+		if ( $new_site->archived == 1 ) {
+
+			/**
+			 * Fires when the 'archived' status is added to a site.
+			 *
+			 * @since MU (3.0.0)
+			 *
+			 * @param int $site_id Site ID.
+			 */
+			do_action( 'archive_blog', $site_id );
+		} else {
+
+			/**
+			 * Fires when the 'archived' status is removed from a site.
+			 *
+			 * @since MU (3.0.0)
+			 *
+			 * @param int $site_id Site ID.
+			 */
+			do_action( 'unarchive_blog', $site_id );
+		}
+	}
+
+	if ( $new_site->deleted != $old_site->deleted ) {
+		if ( $new_site->deleted == 1 ) {
+
+			/**
+			 * Fires when the 'deleted' status is added to a site.
+			 *
+			 * @since 3.5.0
+			 *
+			 * @param int $site_id Site ID.
+			 */
+			do_action( 'make_delete_blog', $site_id );
+		} else {
+
+			/**
+			 * Fires when the 'deleted' status is removed from a site.
+			 *
+			 * @since 3.5.0
+			 *
+			 * @param int $site_id Site ID.
+			 */
+			do_action( 'make_undelete_blog', $site_id );
+		}
+	}
+
+	if ( $new_site->public != $old_site->public ) {
+
+		/**
+		 * Fires after the current blog's 'public' setting is updated.
+		 *
+		 * @since MU (3.0.0)
+		 *
+		 * @param int    $site_id Site ID.
+		 * @param string $value   The value of the site status.
+		 */
+		do_action( 'update_blog_public', $site_id, $new_site->public );
+	}
+}
+
+/**
+ * Cleans the necessary caches after specific site data has been updated.
+ *
+ * @since 5.0.0
+ *
+ * @param WP_Site $new_site The site object after the update.
+ * @param WP_Site $old_site The site obejct prior to the update.
+ */
+function wp_maybe_clean_new_site_cache_on_update( $new_site, $old_site ) {
+	if ( $old_site->domain !== $new_site->domain || $old_site->path !== $new_site->path ) {
+		clean_blog_cache( $new_site );
+	}
+}
+
+/**
+ * Updates the `blog_public` option for a given site ID.
+ *
+ * @since 5.0.0
+ *
+ * @param int    $site_id Site ID.
+ * @param string $public  The value of the site status.
+ */
+function wp_update_blog_public_option_on_site_update( $site_id, $public ) {
+	update_blog_option( $site_id, 'blog_public', $public );
+}
Index: src/wp-includes/ms-default-filters.php
===================================================================
--- src/wp-includes/ms-default-filters.php	(revision 43340)
+++ src/wp-includes/ms-default-filters.php	(working copy)
@@ -41,6 +41,15 @@
 add_action( 'wpmu_new_blog', 'newblog_notify_siteadmin', 10, 2 );
 add_action( 'wpmu_activate_blog', 'wpmu_welcome_notification', 10, 5 );
 add_action( 'after_signup_site', 'wpmu_signup_blog_notification', 10, 7 );
+add_action( 'wp_normalize_site_data', 'wp_normalize_site_data', 10, 1 );
+add_action( 'wp_validate_site_data', 'wp_validate_site_data', 10, 3 );
+add_action( 'wp_insert_site', 'wp_maybe_update_network_site_counts_on_update', 10, 1 );
+add_action( 'wp_update_site', 'wp_maybe_update_network_site_counts_on_update', 10, 2 );
+add_action( 'wp_delete_site', 'wp_maybe_update_network_site_counts_on_update', 10, 1 );
+add_action( 'wp_insert_site', 'wp_maybe_transition_site_statuses_on_update', 10, 1 );
+add_action( 'wp_update_site', 'wp_maybe_transition_site_statuses_on_update', 10, 2 );
+add_action( 'wp_update_site', 'wp_maybe_clean_new_site_cache_on_update', 10, 2 );
+add_action( 'update_blog_public', 'wp_update_blog_public_option_on_site_update', 1, 2 );
 
 // Register Nonce
 add_action( 'signup_hidden_fields', 'signup_nonce_fields' );
Index: src/wp-includes/ms-deprecated.php
===================================================================
--- src/wp-includes/ms-deprecated.php	(revision 43340)
+++ src/wp-includes/ms-deprecated.php	(working copy)
@@ -546,3 +546,37 @@
 
 	return isset( $current_user->$local_key );
 }
+
+/**
+ * Store basic site info in the blogs table.
+ *
+ * This function creates a row in the wp_blogs table and returns
+ * the new blog's ID. It is the first step in creating a new blog.
+ *
+ * @since MU (3.0.0)
+ * @deprecated 5.0.0 Use `wp_insert_site()`
+ * @see wp_insert_site()
+ *
+ * @param string $domain  The domain of the new site.
+ * @param string $path    The path of the new site.
+ * @param int    $site_id Unless you're running a multi-network install, be sure to set this value to 1.
+ * @return int|false The ID of the new row
+ */
+function insert_blog($domain, $path, $site_id) {
+	_deprecated_function( __FUNCTION__, '5.0.0', 'wp_insert_site()' );
+
+	$data = array(
+		'domain'  => $domain,
+		'path'    => $path,
+		'site_id' => $site_id,
+	);
+
+	$site_id = wp_insert_site( $data );
+	if ( is_wp_error( $site_id ) ) {
+		return false;
+	}
+
+	clean_blog_cache( $site_id );
+
+	return $site_id;
+}
Index: src/wp-includes/ms-functions.php
===================================================================
--- src/wp-includes/ms-functions.php	(revision 43340)
+++ src/wp-includes/ms-functions.php	(working copy)
@@ -1273,30 +1273,35 @@
 	);
 	$meta     = wp_parse_args( $meta, $defaults );
 
-	$domain = preg_replace( '/\s+/', '', sanitize_user( $domain, true ) );
-
-	if ( is_subdomain_install() ) {
-		$domain = str_replace( '@', '', $domain );
-	}
-
 	$title   = strip_tags( $title );
 	$user_id = (int) $user_id;
 
-	if ( empty( $path ) ) {
-		$path = '/';
-	}
-
-	// Check if the domain has been used already. We should return an error message.
-	if ( domain_exists( $domain, $path, $network_id ) ) {
-		return new WP_Error( 'blog_taken', __( 'Sorry, that site already exists!' ) );
-	}
-
 	if ( ! wp_installing() ) {
 		wp_installing( true );
 	}
 
-	if ( ! $blog_id = insert_blog( $domain, $path, $network_id ) ) {
-		return new WP_Error( 'insert_blog', __( 'Could not create site.' ) );
+	$site_data_whitelist = array( 'public', 'archived', 'mature', 'spam', 'deleted', 'lang_id' );
+
+	$site_data = array_merge(
+		array(
+			'domain'     => $domain,
+			'path'       => $path,
+			'network_id' => $network_id,
+		),
+		array_intersect_key(
+			$meta,
+			array_flip( $site_data_whitelist )
+		)
+	);
+
+	$meta = array_diff_key( $meta, array_flip( $site_data_whitelist ) );
+
+	remove_action( 'update_blog_public', 'wp_update_blog_public_option_on_site_update', 1 );
+	$blog_id = wp_insert_site( $site_data );
+	add_action( 'update_blog_public', 'wp_update_blog_public_option_on_site_update', 1, 2 );
+
+	if ( is_wp_error( $blog_id ) ) {
+		return $blog_id;
 	}
 
 	switch_to_blog( $blog_id );
@@ -1306,20 +1311,19 @@
 	add_user_to_blog( $blog_id, $user_id, 'administrator' );
 
 	foreach ( $meta as $key => $value ) {
-		if ( in_array( $key, array( 'public', 'archived', 'mature', 'spam', 'deleted', 'lang_id' ) ) ) {
-			update_blog_status( $blog_id, $key, $value );
-		} else {
-			update_option( $key, $value );
-		}
+		update_option( $key, $value );
 	}
 
-	update_option( 'blog_public', (int) $meta['public'] );
+	update_option( 'blog_public', (int) $site_data['public'] );
 
 	if ( ! is_super_admin( $user_id ) && ! get_user_meta( $user_id, 'primary_blog', true ) ) {
 		update_user_meta( $user_id, 'primary_blog', $blog_id );
 	}
 
 	restore_current_blog();
+
+	$site = get_site( $blog_id );
+
 	/**
 	 * Fires immediately after a new site is created.
 	 *
@@ -1332,7 +1336,7 @@
 	 * @param int    $network_id Network ID. Only relevant on multi-network installations.
 	 * @param array  $meta       Meta data. Used to set initial site options.
 	 */
-	do_action( 'wpmu_new_blog', $blog_id, $user_id, $domain, $path, $network_id, $meta );
+	do_action( 'wpmu_new_blog', $blog_id, $user_id, $site->domain, $site->path, $site->network_id, $meta );
 
 	wp_cache_set( 'last_changed', microtime(), 'sites' );
 
@@ -1486,47 +1490,6 @@
 }
 
 /**
- * Store basic site info in the blogs table.
- *
- * This function creates a row in the wp_blogs table and returns
- * the new blog's ID. It is the first step in creating a new blog.
- *
- * @since MU (3.0.0)
- *
- * @global wpdb $wpdb WordPress database abstraction object.
- *
- * @param string $domain     The domain of the new site.
- * @param string $path       The path of the new site.
- * @param int    $network_id Unless you're running a multi-network installation, be sure to set this value to 1.
- * @return int|false The ID of the new row
- */
-function insert_blog( $domain, $path, $network_id ) {
-	global $wpdb;
-
-	$path       = trailingslashit( $path );
-	$network_id = (int) $network_id;
-
-	$result = $wpdb->insert(
-		$wpdb->blogs, array(
-			'site_id'    => $network_id,
-			'domain'     => $domain,
-			'path'       => $path,
-			'registered' => current_time( 'mysql' ),
-		)
-	);
-	if ( ! $result ) {
-		return false;
-	}
-
-	$blog_id = $wpdb->insert_id;
-	clean_blog_cache( $blog_id );
-
-	wp_maybe_update_network_site_counts( $network_id );
-
-	return $blog_id;
-}
-
-/**
  * Install an empty blog.
  *
  * Creates the new blog tables and options. If calling this function
@@ -1538,7 +1501,7 @@
  * @global wpdb     $wpdb
  * @global WP_Roles $wp_roles
  *
- * @param int    $blog_id    The value returned by insert_blog().
+ * @param int    $blog_id    The value returned by wp_insert_site().
  * @param string $blog_title The title of the new site.
  */
 function install_blog( $blog_id, $blog_title = '' ) {
Index: tests/phpunit/tests/multisite/site.php
===================================================================
--- tests/phpunit/tests/multisite/site.php	(revision 43340)
+++ tests/phpunit/tests/multisite/site.php	(working copy)
@@ -446,12 +446,12 @@
 			$this->assertEquals( '0', $blog->spam );
 			$this->assertEquals( 1, $test_action_counter );
 
-			// The action should fire if the status of 'spam' stays the same.
+			// The action should not fire if the status of 'spam' stays the same.
 			update_blog_status( $blog_id, 'spam', 0 );
 			$blog = get_site( $blog_id );
 
 			$this->assertEquals( '0', $blog->spam );
-			$this->assertEquals( 2, $test_action_counter );
+			$this->assertEquals( 1, $test_action_counter );
 
 			remove_action( 'make_ham_blog', array( $this, '_action_counter_cb' ), 10 );
 		}
@@ -469,12 +469,12 @@
 			$this->assertEquals( '1', $blog->spam );
 			$this->assertEquals( 1, $test_action_counter );
 
-			// The action should fire if the status of 'spam' stays the same.
+			// The action should not fire if the status of 'spam' stays the same.
 			update_blog_status( $blog_id, 'spam', 1 );
 			$blog = get_site( $blog_id );
 
 			$this->assertEquals( '1', $blog->spam );
-			$this->assertEquals( 2, $test_action_counter );
+			$this->assertEquals( 1, $test_action_counter );
 
 			remove_action( 'make_spam_blog', array( $this, '_action_counter_cb' ), 10 );
 		}
@@ -492,12 +492,12 @@
 			$this->assertEquals( '1', $blog->archived );
 			$this->assertEquals( 1, $test_action_counter );
 
-			// The action should fire if the status of 'archived' stays the same.
+			// The action should not fire if the status of 'archived' stays the same.
 			update_blog_status( $blog_id, 'archived', 1 );
 			$blog = get_site( $blog_id );
 
 			$this->assertEquals( '1', $blog->archived );
-			$this->assertEquals( 2, $test_action_counter );
+			$this->assertEquals( 1, $test_action_counter );
 
 			remove_action( 'archive_blog', array( $this, '_action_counter_cb' ), 10 );
 		}
@@ -516,11 +516,11 @@
 			$this->assertEquals( '0', $blog->archived );
 			$this->assertEquals( 1, $test_action_counter );
 
-			// The action should fire if the status of 'archived' stays the same.
+			// The action should not fire if the status of 'archived' stays the same.
 			update_blog_status( $blog_id, 'archived', 0 );
 			$blog = get_site( $blog_id );
 			$this->assertEquals( '0', $blog->archived );
-			$this->assertEquals( 2, $test_action_counter );
+			$this->assertEquals( 1, $test_action_counter );
 
 			remove_action( 'unarchive_blog', array( $this, '_action_counter_cb' ), 10 );
 		}
@@ -538,12 +538,12 @@
 			$this->assertEquals( '1', $blog->deleted );
 			$this->assertEquals( 1, $test_action_counter );
 
-			// The action should fire if the status of 'deleted' stays the same.
+			// The action should not fire if the status of 'deleted' stays the same.
 			update_blog_status( $blog_id, 'deleted', 1 );
 			$blog = get_site( $blog_id );
 
 			$this->assertEquals( '1', $blog->deleted );
-			$this->assertEquals( 2, $test_action_counter );
+			$this->assertEquals( 1, $test_action_counter );
 
 			remove_action( 'make_delete_blog', array( $this, '_action_counter_cb' ), 10 );
 		}
@@ -562,12 +562,12 @@
 			$this->assertEquals( '0', $blog->deleted );
 			$this->assertEquals( 1, $test_action_counter );
 
-			// The action should fire if the status of 'deleted' stays the same.
+			// The action should not fire if the status of 'deleted' stays the same.
 			update_blog_status( $blog_id, 'deleted', 0 );
 			$blog = get_site( $blog_id );
 
 			$this->assertEquals( '0', $blog->deleted );
-			$this->assertEquals( 2, $test_action_counter );
+			$this->assertEquals( 1, $test_action_counter );
 
 			remove_action( 'make_undelete_blog', array( $this, '_action_counter_cb' ), 10 );
 		}
@@ -585,12 +585,12 @@
 			$this->assertEquals( '1', $blog->mature );
 			$this->assertEquals( 1, $test_action_counter );
 
-			// The action should fire if the status of 'mature' stays the same.
+			// The action should not fire if the status of 'mature' stays the same.
 			update_blog_status( $blog_id, 'mature', 1 );
 			$blog = get_site( $blog_id );
 
 			$this->assertEquals( '1', $blog->mature );
-			$this->assertEquals( 2, $test_action_counter );
+			$this->assertEquals( 1, $test_action_counter );
 
 			remove_action( 'mature_blog', array( $this, '_action_counter_cb' ), 10 );
 		}
@@ -609,12 +609,12 @@
 			$this->assertEquals( '0', $blog->mature );
 			$this->assertEquals( 1, $test_action_counter );
 
-			// The action should fire if the status of 'mature' stays the same.
+			// The action should not fire if the status of 'mature' stays the same.
 			update_blog_status( $blog_id, 'mature', 0 );
 			$blog = get_site( $blog_id );
 
 			$this->assertEquals( '0', $blog->mature );
-			$this->assertEquals( 2, $test_action_counter );
+			$this->assertEquals( 1, $test_action_counter );
 
 			remove_action( 'unmature_blog', array( $this, '_action_counter_cb' ), 10 );
 		}
@@ -632,12 +632,12 @@
 			$this->assertEquals( '0', $blog->public );
 			$this->assertEquals( 1, $test_action_counter );
 
-			// The action should fire if the status of 'mature' stays the same.
+			// The action should not fire if the status of 'mature' stays the same.
 			update_blog_status( $blog_id, 'public', 0 );
 			$blog = get_site( $blog_id );
 
 			$this->assertEquals( '0', $blog->public );
-			$this->assertEquals( 2, $test_action_counter );
+			$this->assertEquals( 1, $test_action_counter );
 
 			remove_action( 'update_blog_public', array( $this, '_action_counter_cb' ), 10 );
 		}
@@ -1259,6 +1259,330 @@
 				array( 'current_blog_%domain%%path%', 'site-options' ),
 			);
 		}
+
+	/**
+	 * @ticket 40364
+	 * @dataProvider data_wp_insert_site
+	 */
+	function test_wp_insert_site( $site_data, $expected_data ) {
+		$site_id = wp_insert_site( $site_data );
+
+		$this->assertInternalType( 'integer', $site_id );
+
+		$site = get_site( $site_id );
+		foreach ( $expected_data as $key => $value ) {
+			$this->assertEquals( $value, $site->$key );
+		}
+	}
+
+	function data_wp_insert_site() {
+		return array(
+			array(
+				array(
+					'domain' => 'example.com',
+				),
+				array(
+					'domain'     => 'example.com',
+					'path'       => '/',
+					'network_id' => 1,
+					'public'     => 1,
+					'archived'   => 0,
+					'mature'     => 0,
+					'spam'       => 0,
+					'deleted'    => 0,
+					'lang_id'    => 0,
+				),
+			),
+			array(
+				array(
+					'domain'     => 'example.com',
+					'path'       => '/foo',
+					'network_id' => 2,
+				), array(
+					'domain'     => 'example.com',
+					'path'       => '/foo/',
+					'network_id' => 2,
+				),
+			),
+			array(
+				array(
+					'domain'  => 'example.com',
+					'path'    => '/bar/',
+					'site_id' => 2,
+					'public'  => 0,
+				), array(
+					'domain'     => 'example.com',
+					'path'       => '/bar/',
+					'network_id' => 2,
+					'public'     => 0,
+				),
+			),
+			array(
+				array(
+					'domain'   => 'example.com',
+					'path'     => 'foobar',
+					'public'   => 0,
+					'archived' => 1,
+					'mature'   => 1,
+					'spam'     => 1,
+					'deleted'  => 1,
+					'lang_id'  => 1,
+				), array(
+					'domain'   => 'example.com',
+					'path'     => '/foobar/',
+					'public'   => 0,
+					'archived' => 1,
+					'mature'   => 1,
+					'spam'     => 1,
+					'deleted'  => 1,
+					'lang_id'  => 1,
+				),
+			),
+		);
+	}
+
+	/**
+	 * @ticket 40364
+	 */
+	function test_wp_insert_site_empty_domain() {
+		$site_id = wp_insert_site( array( 'public' => 0 ) );
+
+		$this->assertWPError( $site_id );
+		$this->assertSame( 'site_empty_domain', $site_id->get_error_code() );
+	}
+
+	/**
+	 * @ticket 40364
+	 * @dataProvider data_wp_update_site
+	 */
+	function test_wp_update_site( $site_data, $expected_data ) {
+		$site_id = self::factory()->blog->create();
+
+		$old_site = get_site( $site_id );
+
+		$result = wp_update_site( $site_id, $site_data );
+
+		$this->assertSame( $site_id, $result );
+
+		$new_site = get_site( $site_id );
+		foreach ( $new_site->to_array() as $key => $value ) {
+			if ( isset( $expected_data[ $key ] ) ) {
+				$this->assertEquals( $expected_data[ $key ], $value );
+			} elseif ( 'last_updated' === $key ) {
+				$this->assertTrue( $old_site->last_updated <= $value );
+			} else {
+				$this->assertEquals( $old_site->$key, $value );
+			}
+		}
+	}
+
+	function data_wp_update_site() {
+		return array(
+			array(
+				array(
+					'domain'     => 'example.com',
+					'network_id' => 2,
+				),
+				array(
+					'domain'  => 'example.com',
+					'site_id' => 2,
+				),
+			),
+			array(
+				array(
+					'path' => 'foo',
+				),
+				array(
+					'path' => '/foo/'
+				),
+			),
+			array(
+				array(
+					'public'   => 0,
+					'archived' => 1,
+					'mature'   => 1,
+					'spam'     => 1,
+					'deleted'  => 1,
+					'lang_id'  => 1,
+				),
+				array(
+					'public'   => 0,
+					'archived' => 1,
+					'mature'   => 1,
+					'spam'     => 1,
+					'deleted'  => 1,
+					'lang_id'  => 1,
+				),
+			),
+		);
+	}
+
+	/**
+	 * @ticket 40364
+	 */
+	function test_wp_update_site_empty_domain() {
+		$site_id = self::factory()->blog->create();
+
+		$result = wp_update_site( $site_id, array( 'domain' => '' ) );
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'site_empty_domain', $result->get_error_code() );
+	}
+
+	/**
+	 * @ticket 40364
+	 */
+	function test_wp_update_site_invalid_id() {
+		$result = wp_update_site( 444444, array( 'domain' => 'example.com' ) );
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'site_not_exist', $result->get_error_code() );
+	}
+
+	/**
+	 * @ticket 40364
+	 */
+	function test_wp_update_site_cleans_cache() {
+		$site_id = self::factory()->blog->create();
+		$site1 = get_site( $site_id );
+
+		$result = wp_update_site( $site_id, array( 'public' => 0 ) );
+		$site2 = get_site( $site_id );
+
+		$result = wp_update_site( $site_id, array( 'public' => 1 ) );
+		$site3 = get_site( $site_id );
+
+		$this->assertEquals( 1, $site1->public );
+		$this->assertEquals( 0, $site2->public );
+		$this->assertEquals( 1, $site3->public );
+	}
+
+	/**
+	 * @ticket 40364
+	 */
+	function test_wp_delete_site() {
+		$site_id = self::factory()->blog->create();
+
+		$site = get_site( $site_id );
+
+		$result = wp_delete_site( $site_id );
+
+		$this->assertInstanceOf( 'WP_Site', $result );
+		$this->assertEquals( $result->to_array(), $site->to_array() );
+	}
+
+	/**
+	 * @ticket 40364
+	 */
+	function test_wp_delete_site_invalid_id() {
+		$result = wp_delete_site( 444444 );
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'site_not_exist', $result->get_error_code() );
+	}
+
+	/**
+	 * @ticket 40364
+	 */
+	function test_wp_delete_site_cleans_cache() {
+		$site_id = self::factory()->blog->create();
+
+		get_site( $site_id );
+
+		wp_delete_site( $site_id );
+
+		$this->assertNull( get_site( $site_id ) );
+	}
+
+	/**
+	 * @ticket 40364
+	 */
+	function test_wp_update_site_cleans_old_cache_on_domain_change() {
+		$old_domain = 'old.wordpress.org';
+		$new_domain = 'new.wordpress.org';
+
+		$site = self::factory()->blog->create_and_get( array(
+			'domain' => $old_domain,
+			'path'   => '/',
+		) );
+
+		// Populate the caches.
+		get_blog_details( array(
+			'domain' => $old_domain,
+			'path'   => '/',
+		) );
+		get_blog_id_from_url( $old_domain, '/' );
+		get_blog_details( array(
+			'domain' => $new_domain,
+			'path'   => '/',
+		) );
+		get_blog_id_from_url( $new_domain, '/' );
+
+		wp_update_site( $site->id, array(
+			'domain' => $new_domain,
+		) );
+
+		$domain_path_key_old = md5( $old_domain . '/' );
+		$domain_path_key_new = md5( $new_domain . '/' );
+
+		// Ensure all respective cache values are empty.
+		$result = array(
+			wp_cache_get( $domain_path_key_old, 'blog-lookup' ),
+			wp_cache_get( $domain_path_key_old, 'blog-id-cache' ),
+			wp_cache_get( 'current_blog_' . $old_domain, 'site-options' ),
+			wp_cache_get( 'current_blog_' . $old_domain . '/', 'site-options' ),
+			wp_cache_get( $domain_path_key_new, 'blog-lookup' ),
+			wp_cache_get( $domain_path_key_new, 'blog-id-cache' ),
+			wp_cache_get( 'current_blog_' . $new_domain, 'site-options' ),
+			wp_cache_get( 'current_blog_' . $new_domain . '/', 'site-options' ),
+		);
+
+		$this->assertEmpty( array_filter( $result ) );
+	}
+
+	/**
+	 * @ticket 40364
+	 */
+	function test_wp_update_site_cleans_old_cache_on_path_change() {
+		$old_path = '/foo/';
+		$new_path = '/bar/';
+
+		$site = self::factory()->blog->create_and_get( array(
+			'domain' => 'test.wordpress.org',
+			'path'   => $old_path,
+		) );
+
+		// Populate the caches.
+		get_blog_details( array(
+			'domain' => 'test.wordpress.org',
+			'path'   => $old_path,
+		) );
+		get_blog_id_from_url( 'test.wordpress.org', $old_path );
+		get_blog_details( array(
+			'domain' => 'test.wordpress.org',
+			'path'   => $new_path,
+		) );
+		get_blog_id_from_url( 'test.wordpress.org', $new_path );
+
+		wp_update_site( $site->id, array(
+			'path' => $new_path,
+		) );
+
+		$domain_path_key_old = md5( 'test.wordpress.org' . $old_path );
+		$domain_path_key_new = md5( 'test.wordpress.org' . $new_path );
+
+		// Ensure all respective cache values are empty.
+		$result = array(
+			wp_cache_get( $domain_path_key_old, 'blog-lookup' ),
+			wp_cache_get( $domain_path_key_old, 'blog-id-cache' ),
+			wp_cache_get( 'current_blog_test.wordpress.org' . $old_path, 'site-options' ),
+			wp_cache_get( $domain_path_key_new, 'blog-lookup' ),
+			wp_cache_get( $domain_path_key_new, 'blog-id-cache' ),
+			wp_cache_get( 'current_blog_test.wordpress.org' . $new_path, 'site-options' ),
+		);
+
+		$this->assertEmpty( array_filter( $result ) );
 	}
+}
 
 endif;
