Magento 2 Price Rule Anomaly: When 'Category Is Not X' Fails for Uncategorized Products

Magento 2’s flexibility in setting up complex catalog and cart price rules is a cornerstone for many e-commerce strategies. However, a subtle yet significant bug has been identified that can lead to incorrect rule application, particularly when dealing with negation conditions for categories and multi-select attributes. This insight delves into a core issue where the "is not" operator in price rule conditions misbehaves for products that belong to no categories at all, or have no value for a multi-select attribute.

The Unexpected Flaw in "Is Not" Category Conditions

The core of the problem lies in how Magento 2 evaluates conditions like "Category is not X" (using the != operator) when a product is not assigned to any category. Logically, a product without any categories should certainly not be in a specific category 'X', and thus, the condition should pass. However, the system currently short-circuits this logic, causing the condition to fail. This can lead to promotions not being applied as intended, affecting sales and customer experience.

The issue is not confined to categories; it extends to other array-based product attributes, such as multi-select attributes. If you have a rule like "Product Multi-select Attribute is not Y", it will similarly fail to match products that have no value selected for that attribute, despite logically qualifying for the "is not" condition.

Demonstrating the Bug with PHPUnit Tests

The bug report provides clear PHPUnit tests that meticulously illustrate this behavior. These tests compare the problematic != ("is not") operator with the correctly behaving !() ("is not one of") operator. The key takeaway is how an uncategorized product behaves differently under these two seemingly similar negation conditions.

For PHPUnit 9:


assertTrue($this->categoryIsNot583(productCategories: ['571', '573']));
    }

    public function testProductInTheCategoryFailsCategoryIsNot(): void
    {
        $this->assertFalse($this->categoryIsNot583(productCategories: ['583']));
    }

    public function testUncategorizedProductFailsCategoryIsNot(): void
    {
        // CORE BUG: a product in no categories is certainly not in "583",
        // so this should pass — but core short-circuits to false.
        $this->assertTrue($this->categoryIsNot583(productCategories: []));
    }

    public function testUncategorizedProductPassesCategoryIsNotOneOf(): void
    {
        $this->assertTrue($this->categoryIsNot583(productCategories: [], operator: self::IS_NOT_ONE_OF));
    }

    private function categoryIsNot583(array $productCategories, string $operator = self::IS_NOT): bool
    {
        $c>createCategoryCondition($productCategories);
        $condition->setAttribute('category_ids');
        $condition->setOperator($operator);
        $condition->setValue('583');

        return $condition->validate($this->createModelOfAnyProduct());
    }

    private function createCategoryCondition(array $productCategoryIds): ProductCondition
    {
        $categoryList = $this->createMock(ProductCategoryList::class);
        $categoryList->method('getCategoryIds')->willReturn($productCategoryIds);

        // Resolve the category_ids attribute so getInputType() returns
        // 'category', the path where core rewrites != into "does not contain".
        $eavC>createMock(EavConfig::class);
        $eavConfig->method('getAttribute')
            ->willReturn(new DataObject(['attribute_code' => 'category_ids']));

        $attributeLoader = $this->getMockBuilder(AbstractEntity::class)
            ->disableOriginalConstructor()
            ->onlyMethods(['getAttributesByCode'])
            ->getMockForAbstractClass();
        $attributeLoader->method('getAttributesByCode')->willReturn([]);

        $productResource = $this->createMock(ProductResource::class);
        $productResource->method('loadAllAttributes')->willReturn($attributeLoader);

        return new ProductCondition(
            $this->createMock(Context::class),
            $this->createMock(BackendData::class),
            $eavConfig,
            $this->createMock(ProductFactory::class),
            $this->createMock(ProductRepositoryInterface::class),
            $productResource,
            $this->createMock(AttributeSetCollection::class),
            $this->createMock(FormatInterface::class),
            [],
            $categoryList
        );
    }

    private function createModelOfAnyProduct(): AbstractModel
    {
        $product = $this->getMockBuilder(Product::class)
            ->disableOriginalConstructor()
            ->onlyMethods(['getId'])
            ->getMock();
        $product->method('getId')->willReturn(42);

        $model = $this->getMockBuilder(AbstractModel::class)
            ->disableOriginalConstructor()
            ->addMethods(['getProduct'])
            ->getMockForAbstractClass();
        $model->method('getProduct')->willReturn($product);

        return $model;
    }
}

For PHPUnit 12:


assertTrue($this->categoryIsNot583(productCategories: ['571', '573']));
    }

    public function testProductInTheCategoryFailsCategoryIsNot(): void
    {
        $this->assertFalse($this->categoryIsNot583(productCategories: ['583']));
    }

    public function testUncategorizedProductFailsCategoryIsNot(): void
    {
        // CORE BUG: a product in no categories is certainly not in "583",
        // so this should pass — but core short-circuits to false.
        $this->assertTrue($this->categoryIsNot583(productCategories: []));
    }

    public function testUncategorizedProductPassesCategoryIsNotOneOf(): void
    {
        $this->assertTrue($this->categoryIsNot583(productCategories: [], operator: self::IS_NOT_ONE_OF));
    }

    private function categoryIsNot583(array $productCategories, string $operator = self::IS_NOT): bool
    {
        $c>createCategoryCondition($productCategories);
        $condition->setAttribute('category_ids');
        $condition->setOperator($operator);
        $condition->setValue('583');

        return $condition->validate($this->createModelOfAnyProduct());
    }

    private function createCategoryCondition(array $productCategoryIds): ProductCondition
    {
        $categoryList = $this->createStub(ProductCategoryList::class);
        $categoryList->method('getCategoryIds')->willReturn($productCategoryIds);

        // Resolve the category_ids attribute so getInputType() returns
        // 'category', the path where core rewrites != into "does not contain".
        $eavC>createStub(EavConfig::class);
        $eavConfig->method('getAttribute')
            ->willReturn(new DataObject(['attribute_code' => 'category_ids']));

        // The constructor eagerly loads product attribute options.
        $productResource = $this->createStub(ProductResource::class);
        $productResource->method('loadAllAttributes')->willReturn($productResource);
        $productResource->method('getAttributesByCode')->willReturn([]);

        return new ProductCondition(
            $this->createStub(Context::class),
            $this->createStub(BackendData::class),
            $eavConfig,
            $this->createStub(ProductFactory::class),
            $this->createStub(ProductRepositoryInterface::class),
            $productResource,
            $this->createStub(AttributeSetCollection::class),
            $this->createStub(FormatInterface::class),
            [],
            $categoryList
        );
    }

    /**
     * The condition under test pushes quote item data onto the product with
     * DataObject magic setters and expects them to chain, so both the item and
     * the product are real objects with their heavyweight constructors skipped
     * rather than mocks.
     */
    private function createModelOfAnyProduct(): AbstractModel
    {
        $product = new class extends Product {
            public function __construct()
            {
            }
        };
        $product->setData('entity_id', 42);

        $model = new class extends AbstractModel {
            public function __construct()
            {
            }
        };
        $model->setData('product', $product);

        return $model;
    }
}

Impact and Potential Workaround

This bug, confirmed on Magento 2.4.7-p10 and 2.4-develop, can have significant implications for merchants relying on precise price rule logic. Incorrect discounts or promotions can lead to customer dissatisfaction, missed sales opportunities, or unexpected financial outcomes. For instance, a rule designed to give a discount to products not in a "Clearance" category might inadvertently exclude newly added products that haven't yet been assigned to any category, thereby missing out on a potential sale.

While there's no official fix or community-provided workaround in the comments yet, the provided test cases offer a crucial hint for developers and merchants:

  • If you intend for a condition to apply to products that are not in a specific category (or don't have a specific multi-select attribute value), and you also want it to apply to products with no categories assigned at all (or no value for the attribute), then carefully consider using the "is not one of" (!()) operator. As demonstrated in the tests, this operator appears to handle uncategorized products correctly where the simple "is not" (!=) operator fails.
  • Developers should be acutely aware of this distinction when crafting custom conditions, implementing complex promotional strategies, or troubleshooting existing rules to ensure accurate rule application.

Conclusion for Magento Developers and Merchants

Understanding these intricacies is vital for maintaining robust e-commerce operations on Magento 2. For developers, this issue highlights the importance of thorough testing, especially for edge cases involving negation and empty attribute sets. For merchants, it's a reminder to meticulously test price rules, particularly those using "is not" conditions, to ensure they behave as expected. Staying informed about such core bugs helps in building more resilient and accurate Magento solutions.

Start with the tools

Explore migration tools

See options, compare methods, and pick the path that fits your store.

Explore migration tools