From 2eb35b6c4290b9ecf18cdcf2e8963dc8aa42d5d5 Mon Sep 17 00:00:00 2001 From: Omer Date: Sun, 13 Sep 2026 19:01:42 -0600 Subject: [PATCH 1/2] fix(flags): resolve payloads during local evaluation Local flag definitions already carry filters.payloads, but both evaluateFlags() and the single-flag path recorded payload: null for a locally computed value, so getFlagPayload() only ever worked after a remote /flags request. Read the payload from the definition, keyed by the variant or "true" for a boolean flag, mirroring the remote path's handling of pre-decoded values. --- .changeset/local-evaluation-payloads.md | 5 +++ lib/Client.php | 29 ++++++++++++- test/FeatureFlagEvaluationsTest.php | 55 +++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 .changeset/local-evaluation-payloads.md diff --git a/.changeset/local-evaluation-payloads.md b/.changeset/local-evaluation-payloads.md new file mode 100644 index 0000000..a18f85f --- /dev/null +++ b/.changeset/local-evaluation-payloads.md @@ -0,0 +1,5 @@ +--- +"posthog-php": patch +--- + +Resolve feature flag payloads during local evaluation from the definition's `filters.payloads`, so `evaluateFlags()->getFlagPayload()` and `getFeatureFlagPayload()` no longer return null for locally evaluated flags. diff --git a/lib/Client.php b/lib/Client.php index 8f496b4..7f00ab7 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -806,6 +806,9 @@ private function doGetFeatureFlagResult( } $flagWasEvaluatedLocally = !is_null($result); + if ($flagWasEvaluatedLocally) { + $payload = $this->localFlagPayload($localFlagDefinition, $result); + } $requestId = null; $evaluatedAt = null; $flagDetail = null; @@ -1137,7 +1140,7 @@ public function evaluateFlags( key: $key, enabled: $enabled, variant: $variant, - payload: null, + payload: $this->localFlagPayload($flag, $value), id: $id, version: null, reason: 'Evaluated locally', @@ -1309,6 +1312,30 @@ private static function canonicalGroupsRepr(array $groups): string return '_' . json_encode($groups, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); } + /** + * Payload for a locally computed flag value, read from the definition's `filters.payloads` + * map: keyed by the variant for a multivariate flag, or by "true" for a boolean flag that is + * on. Null when the flag is off or carries no payload. + * + * @param array $flag Local flag definition. + * @param bool|string $value Result of computeFlagLocally() for this flag. + * @return mixed + */ + private function localFlagPayload(array $flag, bool|string $value): mixed + { + if ($value === false) { + return null; + } + + $rawPayload = $flag['filters']['payloads'][is_string($value) ? $value : 'true'] ?? null; + if ($rawPayload === null) { + return null; + } + + // Payloads come down as JSON strings, but defensively handle pre-decoded values too. + return is_string($rawPayload) ? json_decode($rawPayload, true) : $rawPayload; + } + /** * Emit a non-fatal SDK warning. * diff --git a/test/FeatureFlagEvaluationsTest.php b/test/FeatureFlagEvaluationsTest.php index 0c8fa12..8d291a2 100644 --- a/test/FeatureFlagEvaluationsTest.php +++ b/test/FeatureFlagEvaluationsTest.php @@ -481,6 +481,61 @@ public function testLocalEvaluationSkipsRemoteFlagsRequestWhenAllResolved(): voi $this->assertSame(0, $this->flagsRequestCount()); } + public function testLocalEvaluationResolvesPayloads(): void + { + // Local definitions carry `filters.payloads`, keyed by "true" for boolean flags and by + // variant key for multivariate ones, so a locally evaluated flag exposes its payload + // without a /flags request. + $this->makeClient( + personalApiKey: 'test-personal-key', + localEvaluationResponse: [ + 'flags' => [ + [ + 'id' => 1, + 'key' => 'boolean-payload', + 'active' => true, + 'filters' => [ + 'groups' => [['properties' => [], 'rollout_percentage' => 100]], + 'payloads' => ['true' => '{"tiers": [33, 66, 100]}'], + ], + ], + [ + 'id' => 2, + 'key' => 'variant-payload', + 'active' => true, + 'filters' => [ + 'groups' => [['properties' => [], 'rollout_percentage' => 100]], + 'multivariate' => ['variants' => [ + ['key' => 'control', 'rollout_percentage' => 0], + ['key' => 'test', 'rollout_percentage' => 100], + ]], + 'payloads' => ['control' => '"a"', 'test' => ['already' => 'decoded']], + ], + ], + [ + 'id' => 3, + 'key' => 'off-with-payload', + 'active' => false, + 'filters' => [ + 'groups' => [['properties' => [], 'rollout_percentage' => 100]], + 'payloads' => ['true' => '1'], + ], + ], + ], + 'group_type_mapping' => [], + 'cohorts' => [], + ], + ); + + $snapshot = PostHog::evaluateFlags('user-1'); + + $this->assertSame(['tiers' => [33, 66, 100]], $snapshot->getFlagPayload('boolean-payload')); + $this->assertSame('test', $snapshot->getFlag('variant-payload')); + $this->assertSame(['already' => 'decoded'], $snapshot->getFlagPayload('variant-payload')); + $this->assertNull($snapshot->getFlagPayload('off-with-payload')); + $this->assertSame(0, $this->flagsRequestCount()); + } + public function testRemoteEvaluatedAtPropagatesToEvent(): void { $response = MockedResponses::FLAGS_V2_RESPONSE; From cf7edfb74aee7305f9544a5f64bbbe820c1c7f8a Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 14 Sep 2026 08:08:14 +0200 Subject: [PATCH 2/2] fix(flags): resolve payloads for locally false flags --- lib/Client.php | 11 +++----- test/FeatureFlagEvaluationsTest.php | 42 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/lib/Client.php b/lib/Client.php index 7f00ab7..eef10fd 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -1314,8 +1314,8 @@ private static function canonicalGroupsRepr(array $groups): string /** * Payload for a locally computed flag value, read from the definition's `filters.payloads` - * map: keyed by the variant for a multivariate flag, or by "true" for a boolean flag that is - * on. Null when the flag is off or carries no payload. + * map: keyed by the variant for a multivariate flag, or by "true" / "false" for a boolean + * flag. Null when the flag carries no payload. * * @param array $flag Local flag definition. * @param bool|string $value Result of computeFlagLocally() for this flag. @@ -1323,11 +1323,8 @@ private static function canonicalGroupsRepr(array $groups): string */ private function localFlagPayload(array $flag, bool|string $value): mixed { - if ($value === false) { - return null; - } - - $rawPayload = $flag['filters']['payloads'][is_string($value) ? $value : 'true'] ?? null; + $payloadKey = is_string($value) ? $value : ($value ? 'true' : 'false'); + $rawPayload = $flag['filters']['payloads'][$payloadKey] ?? null; if ($rawPayload === null) { return null; } diff --git a/test/FeatureFlagEvaluationsTest.php b/test/FeatureFlagEvaluationsTest.php index 8d291a2..a83f07f 100644 --- a/test/FeatureFlagEvaluationsTest.php +++ b/test/FeatureFlagEvaluationsTest.php @@ -536,6 +536,48 @@ public function testLocalEvaluationResolvesPayloads(): void $this->assertSame(0, $this->flagsRequestCount()); } + #[DataProvider('falseFlagPayloadProvider')] + public function testLocalFalseFlagResolvesPayload(mixed $rawPayload, mixed $expectedPayload): void + { + $this->makeClient( + personalApiKey: 'test-personal-key', + localEvaluationResponse: [ + 'flags' => [[ + 'id' => 1, + 'key' => 'false-payload', + 'active' => true, + 'filters' => [ + 'groups' => [['properties' => [], 'rollout_percentage' => 0]], + 'payloads' => ['true' => '123', 'false' => $rawPayload], + ], + ]], + 'group_type_mapping' => [], + 'cohorts' => [], + ], + ); + + $snapshot = $this->client->evaluateFlags('user-1'); + + $this->assertFalse($snapshot->getFlag('false-payload')); + $this->assertSame($expectedPayload, $snapshot->getFlagPayload('false-payload')); + $this->assertSame( + $expectedPayload, + $this->client->getFeatureFlagPayload('false-payload', 'user-1') + ); + $this->assertSame(0, $this->flagsRequestCount()); + } + + public static function falseFlagPayloadProvider(): array + { + return [ + 'JSON false' => ['false', false], + 'pre-decoded false' => [false, false], + 'number' => ['400', 400], + 'object' => ['{"fallback":true}', ['fallback' => true]], + 'missing payload' => [null, null], + ]; + } + public function testRemoteEvaluatedAtPropagatesToEvent(): void { $response = MockedResponses::FLAGS_V2_RESPONSE;