From 0eb0bacd0fede0ab13a344ba11152896119a7228 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Thu, 30 Jul 2026 18:18:10 -0700 Subject: [PATCH 1/3] feat: support starts_with and ends_with operators in local evaluation Adds local evaluation for the starts_with, not_starts_with, ends_with, and not_ends_with property filter operators (PostHog/posthog#72992). Matching is case-insensitive and mirrors icontains: both sides are stringified and lowercased, and the not_ variants are exact negations. Generated-By: PostHog Code Task-Id: ef980bb5-ff81-4191-a7df-796e932b8251 --- .changeset/starts-with-ends-with-operators.md | 5 + lib/FeatureFlag.php | 16 ++ test/FeatureFlagLocalEvaluationTest.php | 158 ++++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 .changeset/starts-with-ends-with-operators.md diff --git a/.changeset/starts-with-ends-with-operators.md b/.changeset/starts-with-ends-with-operators.md new file mode 100644 index 0000000..203f9b0 --- /dev/null +++ b/.changeset/starts-with-ends-with-operators.md @@ -0,0 +1,5 @@ +--- +"posthog-php": minor +--- + +Support the `starts_with`, `not_starts_with`, `ends_with`, and `not_ends_with` property filter operators in feature flag local evaluation. Matching is case-insensitive and mirrors `icontains`, so flags using these operators no longer fall back to remote evaluation. diff --git a/lib/FeatureFlag.php b/lib/FeatureFlag.php index b82a4fb..ae9d940 100644 --- a/lib/FeatureFlag.php +++ b/lib/FeatureFlag.php @@ -57,6 +57,22 @@ public static function matchProperty($property, $propertyValues) return strpos(strtolower(FeatureFlag::valueToString($overrideValue)), strtolower(FeatureFlag::valueToString($value))) == false; } + if ($operator == "starts_with") { + return str_starts_with(strtolower(FeatureFlag::valueToString($overrideValue)), strtolower(FeatureFlag::valueToString($value))); + } + + if ($operator == "not_starts_with") { + return !str_starts_with(strtolower(FeatureFlag::valueToString($overrideValue)), strtolower(FeatureFlag::valueToString($value))); + } + + if ($operator == "ends_with") { + return str_ends_with(strtolower(FeatureFlag::valueToString($overrideValue)), strtolower(FeatureFlag::valueToString($value))); + } + + if ($operator == "not_ends_with") { + return !str_ends_with(strtolower(FeatureFlag::valueToString($overrideValue)), strtolower(FeatureFlag::valueToString($value))); + } + if (in_array($operator, ["regex", "not_regex"])) { $regexValue = FeatureFlag::prepareValueForRegex($value); if (FeatureFlag::isRegularExpression($regexValue)) { diff --git a/test/FeatureFlagLocalEvaluationTest.php b/test/FeatureFlagLocalEvaluationTest.php index cb9cc89..cd3ebf0 100644 --- a/test/FeatureFlagLocalEvaluationTest.php +++ b/test/FeatureFlagLocalEvaluationTest.php @@ -333,6 +333,164 @@ public function testMatchPropertyContains(): void ])); } + public function testMatchPropertyStartsWith(): void + { + $prop = [ + "key" => "key", + "value" => "Val", + "operator" => "starts_with" + ]; + + self::assertTrue(FeatureFlag::matchProperty($prop, [ + "key" => "value", + ])); + + self::assertTrue(FeatureFlag::matchProperty($prop, [ + "key" => "VALUE", + ])); + + self::assertTrue(FeatureFlag::matchProperty($prop, [ + "key" => "vaLue4", + ])); + + self::assertFalse(FeatureFlag::matchProperty($prop, [ + "key" => "prevalue", + ])); + + self::assertFalse(FeatureFlag::matchProperty($prop, [ + "key" => "Alakazam", + ])); + + self::assertFalse(FeatureFlag::matchProperty($prop, [ + "key" => 123, + ])); + + $prop = [ + "key" => "key", + "value" => 3, + "operator" => "starts_with" + ]; + + self::assertTrue(FeatureFlag::matchProperty($prop, [ + "key" => "3", + ])); + + self::assertTrue(FeatureFlag::matchProperty($prop, [ + "key" => 323, + ])); + + self::assertFalse(FeatureFlag::matchProperty($prop, [ + "key" => 123, + ])); + + self::assertFalse(FeatureFlag::matchProperty($prop, [ + "key" => "val3", + ])); + + $prop = [ + "key" => "key", + "value" => "Val", + "operator" => "not_starts_with" + ]; + + self::assertFalse(FeatureFlag::matchProperty($prop, [ + "key" => "value", + ])); + + self::assertFalse(FeatureFlag::matchProperty($prop, [ + "key" => "VALUE", + ])); + + self::assertTrue(FeatureFlag::matchProperty($prop, [ + "key" => "prevalue", + ])); + + self::assertTrue(FeatureFlag::matchProperty($prop, [ + "key" => "Alakazam", + ])); + } + + public function testMatchPropertyEndsWith(): void + { + $prop = [ + "key" => "key", + "value" => "lUe", + "operator" => "ends_with" + ]; + + self::assertTrue(FeatureFlag::matchProperty($prop, [ + "key" => "value", + ])); + + self::assertTrue(FeatureFlag::matchProperty($prop, [ + "key" => "VALUE", + ])); + + self::assertTrue(FeatureFlag::matchProperty($prop, [ + "key" => "343tfvalue", + ])); + + self::assertFalse(FeatureFlag::matchProperty($prop, [ + "key" => "value2", + ])); + + self::assertFalse(FeatureFlag::matchProperty($prop, [ + "key" => "Alakazam", + ])); + + self::assertFalse(FeatureFlag::matchProperty($prop, [ + "key" => 123, + ])); + + $prop = [ + "key" => "key", + "value" => 3, + "operator" => "ends_with" + ]; + + self::assertTrue(FeatureFlag::matchProperty($prop, [ + "key" => "3", + ])); + + self::assertTrue(FeatureFlag::matchProperty($prop, [ + "key" => 323, + ])); + + self::assertTrue(FeatureFlag::matchProperty($prop, [ + "key" => 13, + ])); + + self::assertFalse(FeatureFlag::matchProperty($prop, [ + "key" => 321, + ])); + + self::assertFalse(FeatureFlag::matchProperty($prop, [ + "key" => "3val", + ])); + + $prop = [ + "key" => "key", + "value" => "lUe", + "operator" => "not_ends_with" + ]; + + self::assertFalse(FeatureFlag::matchProperty($prop, [ + "key" => "value", + ])); + + self::assertFalse(FeatureFlag::matchProperty($prop, [ + "key" => "VALUE", + ])); + + self::assertTrue(FeatureFlag::matchProperty($prop, [ + "key" => "value2", + ])); + + self::assertTrue(FeatureFlag::matchProperty($prop, [ + "key" => "Alakazam", + ])); + } + public function testMatchPropertyRegex(): void { $prop = [ From c6685436a26709c3388c7d53ba1847441f63543c Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Mon, 3 Aug 2026 13:30:24 -0700 Subject: [PATCH 2/3] Fix changeset wording and add missing-key and null-value tests The changeset previously claimed these operators fell back to remote evaluation; unrecognized operators actually evaluated to false locally. Tests now cover the missing-key inconclusive path and explicit null property values for all four operators. Generated-By: PostHog Code Task-Id: bb15888a-04c4-4dc0-916e-793a0540ece3 --- .changeset/starts-with-ends-with-operators.md | 2 +- test/FeatureFlagLocalEvaluationTest.php | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/.changeset/starts-with-ends-with-operators.md b/.changeset/starts-with-ends-with-operators.md index 203f9b0..ca4edaa 100644 --- a/.changeset/starts-with-ends-with-operators.md +++ b/.changeset/starts-with-ends-with-operators.md @@ -2,4 +2,4 @@ "posthog-php": minor --- -Support the `starts_with`, `not_starts_with`, `ends_with`, and `not_ends_with` property filter operators in feature flag local evaluation. Matching is case-insensitive and mirrors `icontains`, so flags using these operators no longer fall back to remote evaluation. +Support the `starts_with`, `not_starts_with`, `ends_with`, and `not_ends_with` property filter operators in feature flag local evaluation. Matching is case-insensitive and mirrors `icontains`. Previously, local evaluation treated these operators as unrecognized and silently evaluated their conditions to `false`; they now match correctly. diff --git a/test/FeatureFlagLocalEvaluationTest.php b/test/FeatureFlagLocalEvaluationTest.php index cd3ebf0..620268d 100644 --- a/test/FeatureFlagLocalEvaluationTest.php +++ b/test/FeatureFlagLocalEvaluationTest.php @@ -365,6 +365,10 @@ public function testMatchPropertyStartsWith(): void "key" => 123, ])); + self::assertFalse(FeatureFlag::matchProperty($prop, [ + "key" => null, + ])); + $prop = [ "key" => "key", "value" => 3, @@ -408,6 +412,10 @@ public function testMatchPropertyStartsWith(): void self::assertTrue(FeatureFlag::matchProperty($prop, [ "key" => "Alakazam", ])); + + self::assertTrue(FeatureFlag::matchProperty($prop, [ + "key" => null, + ])); } public function testMatchPropertyEndsWith(): void @@ -442,6 +450,10 @@ public function testMatchPropertyEndsWith(): void "key" => 123, ])); + self::assertFalse(FeatureFlag::matchProperty($prop, [ + "key" => null, + ])); + $prop = [ "key" => "key", "value" => 3, @@ -489,6 +501,30 @@ public function testMatchPropertyEndsWith(): void self::assertTrue(FeatureFlag::matchProperty($prop, [ "key" => "Alakazam", ])); + + self::assertTrue(FeatureFlag::matchProperty($prop, [ + "key" => null, + ])); + } + + public function testMatchPropertyStartsWithEndsWithMissingKeyIsInconclusive(): void + { + foreach (["starts_with", "not_starts_with", "ends_with", "not_ends_with"] as $operator) { + $prop = [ + "key" => "key", + "value" => "Val", + "operator" => $operator + ]; + + try { + FeatureFlag::matchProperty($prop, [ + "key2" => "value", + ]); + self::fail("Expected InconclusiveMatchException for operator {$operator}"); + } catch (InconclusiveMatchException $exception) { + self::assertInstanceOf(InconclusiveMatchException::class, $exception); + } + } } public function testMatchPropertyRegex(): void From 1c4c77491f605a6cb82f2017df47835da21ae15e Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Wed, 5 Aug 2026 08:29:36 -0700 Subject: [PATCH 3/3] Treat unknown operators as inconclusive and pin ASCII-only case folding Unknown operators now throw InconclusiveMatchException so the flag defers to the /flags endpoint instead of silently evaluating to false, matching the other SDKs. Non-ASCII tests pin the intentional ASCII-only strtolower contract, which mirrors the flags service. Generated-By: PostHog Code Task-Id: bb15888a-04c4-4dc0-916e-793a0540ece3 --- .changeset/starts-with-ends-with-operators.md | 2 + lib/FeatureFlag.php | 2 +- test/FeatureFlagLocalEvaluationTest.php | 46 +++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/.changeset/starts-with-ends-with-operators.md b/.changeset/starts-with-ends-with-operators.md index ca4edaa..29abf1d 100644 --- a/.changeset/starts-with-ends-with-operators.md +++ b/.changeset/starts-with-ends-with-operators.md @@ -3,3 +3,5 @@ --- Support the `starts_with`, `not_starts_with`, `ends_with`, and `not_ends_with` property filter operators in feature flag local evaluation. Matching is case-insensitive and mirrors `icontains`. Previously, local evaluation treated these operators as unrecognized and silently evaluated their conditions to `false`; they now match correctly. + +Operators local evaluation doesn't recognize now throw `InconclusiveMatchException`, deferring the flag to the `/flags` endpoint instead of producing a silently wrong `false` — so operators the server adds in the future degrade gracefully. diff --git a/lib/FeatureFlag.php b/lib/FeatureFlag.php index ae9d940..55f6e4c 100644 --- a/lib/FeatureFlag.php +++ b/lib/FeatureFlag.php @@ -173,7 +173,7 @@ public static function matchProperty($property, $propertyValues) && FeatureFlag::compareSemverTuples($overrideTuple, $upper) < 0; } - return false; + throw new InconclusiveMatchException("Unknown operator: " . $operator); } /** diff --git a/test/FeatureFlagLocalEvaluationTest.php b/test/FeatureFlagLocalEvaluationTest.php index 620268d..ee7064b 100644 --- a/test/FeatureFlagLocalEvaluationTest.php +++ b/test/FeatureFlagLocalEvaluationTest.php @@ -416,6 +416,22 @@ public function testMatchPropertyStartsWith(): void self::assertTrue(FeatureFlag::matchProperty($prop, [ "key" => null, ])); + + // Case folding is ASCII-only, mirroring the flags service: non-ASCII + // characters do not match across case. + $prop = [ + "key" => "key", + "value" => "ä", + "operator" => "starts_with" + ]; + + self::assertFalse(FeatureFlag::matchProperty($prop, [ + "key" => "ÄBC", + ])); + + self::assertTrue(FeatureFlag::matchProperty($prop, [ + "key" => "äbc", + ])); } public function testMatchPropertyEndsWith(): void @@ -505,6 +521,36 @@ public function testMatchPropertyEndsWith(): void self::assertTrue(FeatureFlag::matchProperty($prop, [ "key" => null, ])); + + // Case folding is ASCII-only, mirroring the flags service: non-ASCII + // characters do not match across case. + $prop = [ + "key" => "key", + "value" => "é", + "operator" => "ends_with" + ]; + + self::assertFalse(FeatureFlag::matchProperty($prop, [ + "key" => "CAFÉ", + ])); + + self::assertTrue(FeatureFlag::matchProperty($prop, [ + "key" => "café", + ])); + } + + public function testMatchPropertyUnknownOperatorIsInconclusive(): void + { + $prop = [ + "key" => "key", + "value" => "value", + "operator" => "future_operator" + ]; + + $this->expectException(InconclusiveMatchException::class); + FeatureFlag::matchProperty($prop, [ + "key" => "value", + ]); } public function testMatchPropertyStartsWithEndsWithMissingKeyIsInconclusive(): void