Commit 97e40ba6c86 for woocommerce
commit 97e40ba6c868aebaa65283acc9e40551fd3a0832
Author: Jorge A. Torres <jorge.torres@automattic.com>
Date: Tue Jul 7 16:18:35 2026 +0100
Add `WC_Product::is_*viewable()` methods to consolidate product visibility checks (#66173)
diff --git a/plugins/woocommerce/changelog/65498-centralize-product-is-viewable b/plugins/woocommerce/changelog/65498-centralize-product-is-viewable
new file mode 100644
index 00000000000..f53a2d3b2e4
--- /dev/null
+++ b/plugins/woocommerce/changelog/65498-centralize-product-is-viewable
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Add WC_Product::is_viewable() and is_publicly_viewable() to centralize product visibility checks.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-product.php b/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
index 86dbf930778..1dccdd369ae 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
@@ -1713,6 +1713,33 @@ class WC_Product extends WC_Abstract_Legacy_Product {
return apply_filters( 'woocommerce_is_sold_individually', true === $this->get_sold_individually(), $this );
}
+ /**
+ * Whether the current user can view this product: it is published, or they can edit it.
+ * A variation additionally requires its parent to be viewable.
+ *
+ * @since 11.1.0
+ * @return bool
+ */
+ public function is_viewable() {
+ $parent_id = $this->get_parent_id();
+
+ return ( ProductStatus::PUBLISH === $this->get_status() || current_user_can( 'edit_post', $this->get_id() ) )
+ && ( ! $parent_id || ProductStatus::PUBLISH === get_post_status( $parent_id ) || current_user_can( 'edit_post', $parent_id ) );
+ }
+
+ /**
+ * Whether this product is publicly viewable: the product and its parent (if it has one) are published.
+ *
+ * @since 11.1.0
+ * @return bool
+ */
+ public function is_publicly_viewable() {
+ $parent_id = $this->get_parent_id();
+
+ return ProductStatus::PUBLISH === $this->get_status()
+ && ( ! $parent_id || ProductStatus::PUBLISH === get_post_status( $parent_id ) );
+ }
+
/**
* Returns whether or not the product is visible in the catalog.
*
@@ -1731,20 +1758,10 @@ class WC_Product extends WC_Abstract_Legacy_Product {
protected function is_visible_core() {
$visible = CatalogVisibility::VISIBLE === $this->get_catalog_visibility() || ( is_search() && CatalogVisibility::SEARCH === $this->get_catalog_visibility() ) || ( ! is_search() && CatalogVisibility::CATALOG === $this->get_catalog_visibility() );
- if ( ProductStatus::TRASH === $this->get_status() ) {
- $visible = false;
- } elseif ( ProductStatus::PUBLISH !== $this->get_status() && ! current_user_can( 'edit_post', $this->get_id() ) ) {
+ if ( ProductStatus::TRASH === $this->get_status() || ! $this->is_viewable() ) {
$visible = false;
}
- if ( $this->get_parent_id() ) {
- $parent_product = wc_get_product( $this->get_parent_id() );
-
- if ( $parent_product && ProductStatus::PUBLISH !== $parent_product->get_status() && ! current_user_can( 'edit_post', $parent_product->get_id() ) ) {
- $visible = false;
- }
- }
-
if ( 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) && ! $this->is_in_stock() ) {
$visible = false;
}
@@ -1765,7 +1782,7 @@ class WC_Product extends WC_Abstract_Legacy_Product {
* @param bool $purchasable Whether the product is purchasable.
* @param WC_Product $product Product object.
*/
- return apply_filters( 'woocommerce_is_purchasable', $this->exists() && ( ProductStatus::PUBLISH === $this->get_status() || current_user_can( 'edit_post', $this->get_id() ) ) && '' !== $this->get_price(), $this );
+ return apply_filters( 'woocommerce_is_purchasable', $this->exists() && $this->is_viewable() && '' !== $this->get_price(), $this );
}
/**
diff --git a/plugins/woocommerce/includes/class-wc-ajax.php b/plugins/woocommerce/includes/class-wc-ajax.php
index 138cb54a75d..b1bf152c1bb 100644
--- a/plugins/woocommerce/includes/class-wc-ajax.php
+++ b/plugins/woocommerce/includes/class-wc-ajax.php
@@ -618,7 +618,7 @@ class WC_AJAX {
wp_die();
}
- if ( ProductStatus::PUBLISH !== $variable_product->get_status() && ! current_user_can( 'edit_post', $variable_product->get_id() ) ) {
+ if ( ! $variable_product->is_viewable() ) {
wp_die();
}
diff --git a/plugins/woocommerce/includes/class-wc-product-variation.php b/plugins/woocommerce/includes/class-wc-product-variation.php
index 8bdd58f9aaa..dff98f8ff1d 100644
--- a/plugins/woocommerce/includes/class-wc-product-variation.php
+++ b/plugins/woocommerce/includes/class-wc-product-variation.php
@@ -559,7 +559,7 @@ class WC_Product_Variation extends WC_Product_Simple {
* @param bool $purchasable If the variation is purchasable.
* @param object $variation The variation object.
*/
- return apply_filters( 'woocommerce_variation_is_purchasable', $this->variation_is_visible() && parent::is_purchasable() && ( ProductStatus::PUBLISH === $this->parent_data['status'] || current_user_can( 'edit_post', $this->get_parent_id() ) ), $this );
+ return apply_filters( 'woocommerce_variation_is_purchasable', $this->variation_is_visible() && parent::is_purchasable(), $this );
}
/**
diff --git a/plugins/woocommerce/includes/class-wc-shortcodes.php b/plugins/woocommerce/includes/class-wc-shortcodes.php
index 39e3f9e2039..5203aedff58 100644
--- a/plugins/woocommerce/includes/class-wc-shortcodes.php
+++ b/plugins/woocommerce/includes/class-wc-shortcodes.php
@@ -575,13 +575,12 @@ class WC_Shortcodes {
$single_product = new WP_Query( $args );
- if (
- ! isset( $force_rendering ) &&
- $single_product->have_posts() &&
- ProductStatus::PUBLISH !== $single_product->post->post_status &&
- ! current_user_can( 'read_product', $single_product->post->ID )
- ) {
- return '';
+ if ( ! isset( $force_rendering ) && $single_product->have_posts() ) {
+ $queried_product = wc_get_product( $single_product->post->ID );
+
+ if ( $queried_product && ! $queried_product->is_viewable() ) {
+ return '';
+ }
}
$preselected_id = '0';
diff --git a/plugins/woocommerce/includes/wc-product-functions.php b/plugins/woocommerce/includes/wc-product-functions.php
index a63fe826bc4..aec062ee748 100644
--- a/plugins/woocommerce/includes/wc-product-functions.php
+++ b/plugins/woocommerce/includes/wc-product-functions.php
@@ -9,7 +9,6 @@
*/
use Automattic\Jetpack\Constants;
-use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\Enums\ProductStockStatus;
use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Enums\CatalogVisibility;
@@ -1740,7 +1739,7 @@ function wc_products_array_filter_visible( $product ) {
* @return bool
*/
function wc_products_array_filter_visible_grouped( $product ) {
- return $product && is_a( $product, 'WC_Product' ) && ( ProductStatus::PUBLISH === $product->get_status() || current_user_can( 'edit_product', $product->get_id() ) );
+ return $product && is_a( $product, 'WC_Product' ) && $product->is_viewable();
}
/**
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index af373fb9e2f..3b414bb09bf 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -15168,12 +15168,6 @@ parameters:
count: 1
path: includes/class-wc-shortcodes.php
- -
- message: '#^Cannot access property \$post_status on WP_Post\|null\.$#'
- identifier: property.nonObject
- count: 1
- path: includes/class-wc-shortcodes.php
-
-
message: '#^Cannot access property \$post_type on WP_Post\|null\.$#'
identifier: property.nonObject
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/FeaturedProduct.php b/plugins/woocommerce/src/Blocks/BlockTypes/FeaturedProduct.php
index 0579c194ab8..dd2a4075183 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/FeaturedProduct.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/FeaturedProduct.php
@@ -2,7 +2,6 @@
declare( strict_types = 1 );
namespace Automattic\WooCommerce\Blocks\BlockTypes;
-use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\Enums\ProductType;
/**
@@ -26,7 +25,7 @@ class FeaturedProduct extends FeaturedItem {
$id = absint( $attributes['productId'] ?? 0 );
$product = wc_get_product( $id );
- if ( ! $product || ( ProductStatus::PUBLISH !== $product->get_status() && ! current_user_can( 'read_product', $id ) ) ) {
+ if ( ! $product || ! $product->is_viewable() ) {
return null;
}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsById.php b/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsById.php
index c1793638d8a..20383b34539 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsById.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsById.php
@@ -1,7 +1,6 @@
<?php // phpcs:ignore Generic.PHP.RequireStrictTypes.MissingDeclaration
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
-use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use Automattic\WooCommerce\StoreApi\Utilities\ProductLinksTrait;
@@ -83,19 +82,10 @@ class ProductsById extends AbstractRoute {
protected function get_route_response( \WP_REST_Request $request ) {
$object = wc_get_product( (int) $request['id'] );
- if ( ! $object || 0 === $object->get_id() || ProductStatus::PUBLISH !== $object->get_status() ) {
+ if ( ! $object || 0 === $object->get_id() || ! $object->is_publicly_viewable() ) {
throw new RouteException( 'woocommerce_rest_product_invalid_id', __( 'Invalid product ID.', 'woocommerce' ), 404 );
}
- // A variation's visibility follows its parent product.
- if ( $object->get_parent_id() ) {
- $parent = wc_get_product( $object->get_parent_id() );
-
- if ( ! $parent || ProductStatus::PUBLISH !== $parent->get_status() ) {
- throw new RouteException( 'woocommerce_rest_product_invalid_id', __( 'Invalid product ID.', 'woocommerce' ), 404 ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- REST API JSON response, not HTML.
- }
- }
-
return $this->prepare_item_for_response( $object, $request );
}
}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsBySlug.php b/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsBySlug.php
index 0296263c315..39af34dbdf1 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsBySlug.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsBySlug.php
@@ -1,7 +1,6 @@
<?php // phpcs:ignore Generic.PHP.RequireStrictTypes.MissingDeclaration
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
-use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use Automattic\WooCommerce\StoreApi\Utilities\ProductLinksTrait;
@@ -88,19 +87,10 @@ class ProductsBySlug extends AbstractRoute {
$object = $this->get_product_variation_by_slug( $slug );
}
- if ( ! $object || 0 === $object->get_id() || ProductStatus::PUBLISH !== $object->get_status() ) {
+ if ( ! $object || 0 === $object->get_id() || ! $object->is_publicly_viewable() ) {
throw new RouteException( 'woocommerce_rest_product_invalid_slug', __( 'Invalid product slug.', 'woocommerce' ), 404 );
}
- // A variation's visibility follows its parent product.
- if ( $object->get_parent_id() ) {
- $parent = wc_get_product( $object->get_parent_id() );
-
- if ( ! $parent || ProductStatus::PUBLISH !== $parent->get_status() ) {
- throw new RouteException( 'woocommerce_rest_product_invalid_slug', __( 'Invalid product slug.', 'woocommerce' ), 404 ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- REST API JSON response, not HTML.
- }
- }
-
return $this->prepare_item_for_response( $object, $request );
}
diff --git a/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-product-test.php b/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-product-test.php
index 4344030499f..d95279e32b1 100644
--- a/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-product-test.php
+++ b/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-product-test.php
@@ -454,4 +454,45 @@ class WC_Abstract_Product_Test extends WC_Unit_Test_Case {
$this->assertSame( 'outofstock', $product->get_stock_status() );
}
+
+ /**
+ * @testdox A published product is viewable by anyone, with or without capability checks.
+ */
+ public function test_is_viewable_published_product() {
+ $product = WC_Helper_Product::create_simple_product();
+
+ wp_set_current_user( 0 );
+ $this->assertTrue( $product->is_viewable(), 'Published products are viewable when logged out.' );
+ $this->assertTrue( $product->is_publicly_viewable(), 'Published products are publicly viewable.' );
+
+ wp_set_current_user( $this->admin_user );
+ $this->assertTrue( $product->is_viewable(), 'Published products are viewable by admins.' );
+ }
+
+ /**
+ * @testdox A non-published product is hidden from the public but viewable by users who can edit it, unless capability checks are disabled.
+ * @testWith ["draft"]
+ * ["pending"]
+ * ["private"]
+ * @param string $status A non-published product status.
+ */
+ public function test_is_viewable_non_published_product( $status ) {
+ $customer = self::factory()->user->create( array( 'role' => 'customer' ) );
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_status( $status );
+ $product->save();
+
+ wp_set_current_user( 0 );
+ $this->assertFalse( $product->is_viewable(), "A $status product is not viewable when logged out." );
+
+ wp_set_current_user( $customer );
+ $this->assertFalse( $product->is_viewable(), "A $status product is not viewable by customers." );
+
+ wp_set_current_user( $this->shop_manager_user );
+ $this->assertTrue( $product->is_viewable(), "A $status product is viewable by shop managers." );
+
+ wp_set_current_user( $this->admin_user );
+ $this->assertTrue( $product->is_viewable(), "A $status product is viewable by admins." );
+ $this->assertFalse( $product->is_publicly_viewable(), "A $status product is never publicly viewable, even for admins." );
+ }
}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-product-variation-test.php b/plugins/woocommerce/tests/php/includes/class-wc-product-variation-test.php
index c782c177f8e..e4c5e864e2c 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-product-variation-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-product-variation-test.php
@@ -121,4 +121,24 @@ class WC_Product_Variation_Test extends WC_Unit_Test_Case {
$this->assertIsString( $url );
$this->assertNotEmpty( $url );
}
+
+ /**
+ * @testdox A variation's viewability follows its parent's status.
+ */
+ public function test_is_viewable_variation_follows_parent_status() {
+ wp_set_current_user( 0 );
+ $this->assertTrue( $this->variation->is_viewable(), 'A variation of a published parent is viewable when logged out.' );
+ $this->assertTrue( $this->variation->is_publicly_viewable(), 'A variation of a published parent is publicly viewable.' );
+
+ $this->parent_product->set_status( 'draft' );
+ $this->parent_product->save();
+
+ wp_set_current_user( 0 );
+ $this->assertFalse( $this->variation->is_viewable(), 'A variation whose parent is a draft is not viewable when logged out.' );
+
+ $admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin );
+ $this->assertTrue( $this->variation->is_viewable(), 'A variation whose parent is a draft is viewable by admins.' );
+ $this->assertFalse( $this->variation->is_publicly_viewable(), 'A variation whose parent is a draft is never publicly viewable.' );
+ }
}