From dc51fdeafad6ef884518746c8624e37493817996 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 13 Nov 2026 11:31:57 +0200 Subject: [PATCH 1/5] feat: add getRemoteConfigPayload to read a remote config flag payload --- .changeset/three-pants-ask.md | 5 + api/public-api.json | 36 ++++ lib/Client.php | 70 ++++++++ lib/PostHog.php | 14 ++ test/MockedHttpClient.php | 20 ++- test/RemoteConfigPayloadTest.php | 277 +++++++++++++++++++++++++++++++ 6 files changed, 421 insertions(+), 1 deletion(-) create mode 100644 .changeset/three-pants-ask.md create mode 100644 test/RemoteConfigPayloadTest.php diff --git a/.changeset/three-pants-ask.md b/.changeset/three-pants-ask.md new file mode 100644 index 00000000..e8e80f7c --- /dev/null +++ b/.changeset/three-pants-ask.md @@ -0,0 +1,5 @@ +--- +"posthog-php": minor +--- + +The new `getRemoteConfigPayload()` reads the payload of a remote config feature flag, for example `PostHog::getRemoteConfigPayload('landing-page-config')`. It needs a secret key and sends one request without a retry. It returns the decoded payload, or `null` and a logged warning when the client is disabled, no secret key is set, the request fails, or PostHog answers with a non-2xx status. It sends no `$feature_flag_called` event. It uses the `feature_flag_request_timeout_ms` option, 3 seconds by default. diff --git a/api/public-api.json b/api/public-api.json index 689550f8..6709093f 100644 --- a/api/public-api.json +++ b/api/public-api.json @@ -824,6 +824,24 @@ "returnType": "?string", "parameters": [] }, + "getRemoteConfigPayload": { + "static": false, + "abstract": false, + "final": false, + "returnType": "mixed", + "parameters": [ + { + "name": "key", + "type": "string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, "groupIdentify": { "static": false, "abstract": false, @@ -4836,6 +4854,24 @@ } ] }, + "getRemoteConfigPayload": { + "static": true, + "abstract": false, + "final": false, + "returnType": "mixed", + "parameters": [ + { + "name": "key", + "type": "string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, "groupIdentify": { "static": true, "abstract": false, diff --git a/lib/Client.php b/lib/Client.php index 01e96314..13ae9afb 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -1225,6 +1225,76 @@ public function getFeatureFlagPayload( return $result?->getPayload(); } + /** + * Fetch the remote config payload of a feature flag from PostHog. + * + * Needs a secret key. A Personal API Key returns encrypted payloads decrypted. A Project Secret + * API Key returns them redacted. The call sends one request, with no retry, and captures no + * `$feature_flag_called` event. It logs a warning and returns null when the client is disabled, + * when no secret key is set, when the key is empty, `.` or `..`, when the request fails, or when + * PostHog answers with a non-2xx status. + * + * @param string $key Feature flag key. + * @return mixed The decoded payload, or null when no payload could be read. + */ + public function getRemoteConfigPayload(string $key): mixed + { + if (!$this->enabled) { + $this->logWarning('The client is disabled'); + return null; + } + + if ($this->secretKey === null) { + $this->logWarning('A secret key is required to fetch a remote config payload from PostHog'); + return null; + } + + if (in_array($key, ['', '.', '..'], true)) { + $this->logWarning('A feature flag key is required to fetch a remote config payload from PostHog'); + return null; + } + + $response = $this->httpClient->sendRequest( + '/api/projects/@current/feature_flags/' . rawurlencode($key) + . '/remote_config?token=' . rawurlencode($this->apiKey), + null, + [ + "User-Agent: " . $this->options['library_name'] . "/" . $this->options['library_version'], + "Authorization: Bearer " . $this->secretKey + ], + [ + 'shouldRetry' => false, + 'timeout' => $this->featureFlagsRequestTimeout + ] + ); + + $code = $response->getResponseCode(); + if ($code < 200 || $code >= 300) { + error_log( + '[PostHog][Client] Unable to fetch remote config payload: ' + . ($code === 0 ? 'connection error (curl errno ' . $response->getCurlErrno() . ')' : 'HTTP ' . $code) + ); + return null; + } + + try { + $result = json_decode((string) $response->getResponse(), true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + error_log('[PostHog][Client] Unable to read remote config payload: invalid JSON response'); + return null; + } + + if (is_string($result)) { + try { + return json_decode($result, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return $result; + } + } + + return $result; + } + /** * get the feature flag value for this distinct id. * diff --git a/lib/PostHog.php b/lib/PostHog.php index 135699ff..4f10c092 100644 --- a/lib/PostHog.php +++ b/lib/PostHog.php @@ -390,6 +390,20 @@ public static function getFeatureFlagPayload( ); } + /** + * Fetch the remote config payload of a feature flag from PostHog. + * See Client::getRemoteConfigPayload() + * + * @param string $key Feature flag key. + * @return mixed The decoded payload, or null when no payload could be read. + */ + public static function getRemoteConfigPayload(string $key): mixed + { + self::checkClient(); + + return self::$client->getRemoteConfigPayload($key); + } + /** * Evaluate every feature flag for a distinct id in a single round trip and return a snapshot. * When distinctId is omitted, the current request context distinctId is used if available. diff --git a/test/MockedHttpClient.php b/test/MockedHttpClient.php index 5fd17476..7f4323f1 100644 --- a/test/MockedHttpClient.php +++ b/test/MockedHttpClient.php @@ -30,6 +30,9 @@ class MockedHttpClient extends \PostHog\HttpClient private $batchEndpointResponse; private $batchEndpointResponseCode; private $batchEndpointCurlErrno; + private $remoteConfigEndpointResponse; + private $remoteConfigEndpointResponseCode; + private $remoteConfigEndpointCurlErrno; public function __construct( string $host, @@ -47,7 +50,10 @@ public function __construct( int $flagsEndpointCurlErrno = 0, $batchEndpointResponse = '{"status":1}', int $batchEndpointResponseCode = 200, - int $batchEndpointCurlErrno = 0 + int $batchEndpointCurlErrno = 0, + $remoteConfigEndpointResponse = '{}', + int $remoteConfigEndpointResponseCode = 200, + int $remoteConfigEndpointCurlErrno = 0 ) { parent::__construct( $host, @@ -71,6 +77,9 @@ public function __construct( $this->batchEndpointResponse = $batchEndpointResponse; $this->batchEndpointResponseCode = $batchEndpointResponseCode; $this->batchEndpointCurlErrno = $batchEndpointCurlErrno; + $this->remoteConfigEndpointResponse = $remoteConfigEndpointResponse; + $this->remoteConfigEndpointResponseCode = $remoteConfigEndpointResponseCode; + $this->remoteConfigEndpointCurlErrno = $remoteConfigEndpointCurlErrno; } /** @@ -165,6 +174,15 @@ public function sendRequest(string $path, ?string $payload, array $extraHeaders ); } + if (str_starts_with($path, "/api/projects/@current/feature_flags/")) { + return new HttpResponse( + $this->remoteConfigEndpointResponse, + $this->remoteConfigEndpointResponseCode, + null, + $this->remoteConfigEndpointCurlErrno + ); + } + throw new \LogicException('Unexpected HTTP request in test: ' . $path); } } diff --git a/test/RemoteConfigPayloadTest.php b/test/RemoteConfigPayloadTest.php new file mode 100644 index 00000000..1ac262ab --- /dev/null +++ b/test/RemoteConfigPayloadTest.php @@ -0,0 +1,277 @@ +client($this->httpClient('{"foo":["bar","baz"]}')); + + $this->assertSame(['foo' => ['bar', 'baz']], $client->getRemoteConfigPayload('test-flag')); + } + + public function testReturnsEmptyArrayForEmptyObject() + { + $client = $this->client($this->httpClient('{}')); + + $this->assertSame([], $client->getRemoteConfigPayload('test-flag')); + } + + public function testDecodesDoubleEncodedStringBody() + { + $client = $this->client($this->httpClient(json_encode('{ "foo":["bar","baz"]}'))); + + $this->assertSame(['foo' => ['bar', 'baz']], $client->getRemoteConfigPayload('test-flag')); + } + + public function testReturnsStringWhenSecondDecodeFails() + { + $client = $this->client($this->httpClient('"hello"')); + + $this->assertSame('hello', $client->getRemoteConfigPayload('test-flag')); + } + + public function testReturnsNullForJsonNullBodyWithoutLogging() + { + global $errorMessages; + $client = $this->client($this->httpClient('null')); + + $this->assertNull($client->getRemoteConfigPayload('test-flag')); + $this->assertSame([], $errorMessages); + } + + public function testReturnsNullAndLogsWhenBodyIsNotJson() + { + global $errorMessages; + $client = $this->client($this->httpClient('not json')); + + $this->assertNull($client->getRemoteConfigPayload('test-flag')); + $this->assertCount(1, $errorMessages); + $this->assertStringContainsString('invalid JSON response', $errorMessages[0]); + } + + public function testReturnsNullAndLogsWhenNoSecretKey() + { + global $errorMessages; + $http = $this->httpClient(); + $client = $this->client($http, secretKey: null); + + $this->assertNull($client->getRemoteConfigPayload('test-flag')); + $this->assertSame([], $http->calls ?? []); + $this->assertSame( + ['[PostHog][Client] A secret key is required to fetch a remote config payload from PostHog'], + $errorMessages + ); + } + + public function testReturnsNullAndLogsWhenClientDisabled() + { + global $errorMessages; + $http = $this->httpClient(); + $client = $this->client($http, projectKey: ''); + $errorMessages = []; + + $this->assertNull($client->getRemoteConfigPayload('test-flag')); + $this->assertSame([], $http->calls ?? []); + $this->assertSame(['[PostHog][Client] The client is disabled'], $errorMessages); + } + + #[DataProvider('refusedKeys')] + public function testReturnsNullAndLogsForRefusedKey(string $key) + { + global $errorMessages; + $http = $this->httpClient(); + $client = $this->client($http); + + $this->assertNull($client->getRemoteConfigPayload($key)); + $this->assertSame([], $http->calls ?? []); + $this->assertSame( + ['[PostHog][Client] A feature flag key is required to fetch a remote config payload from PostHog'], + $errorMessages + ); + } + + public static function refusedKeys(): array + { + return [ + 'empty' => [''], + 'dot' => ['.'], + 'double dot' => ['..'], + ]; + } + + #[DataProvider('failureStatuses')] + public function testReturnsNullAndLogsOnNon2xxWithoutRetry(int $code) + { + global $errorMessages; + $http = $this->httpClient('{"detail":"nope"}', $code); + $client = $this->client($http); + + $this->assertNull($client->getRemoteConfigPayload('test-flag')); + $this->assertCount(1, $http->calls); + $this->assertCount(1, $errorMessages); + $this->assertStringContainsString('HTTP ' . $code, $errorMessages[0]); + } + + public static function failureStatuses(): array + { + return [[401], [403], [404], [429], [500], [503]]; + } + + #[DataProvider('curlErrnos')] + public function testReturnsNullAndLogsOnNetworkFailure(int $errno) + { + global $errorMessages; + $http = $this->httpClient(false, 0, $errno); + $client = $this->client($http); + + $this->assertNull($client->getRemoteConfigPayload('test-flag')); + $this->assertCount(1, $http->calls); + $this->assertCount(1, $errorMessages); + $this->assertStringContainsString('curl errno ' . $errno, $errorMessages[0]); + } + + public static function curlErrnos(): array + { + return [[28], [6]]; + } + + public function testEncodesReservedCharactersInFlagKey() + { + $http = $this->httpClient(); + $client = $this->client($http); + + $client->getRemoteConfigPayload('a/b?c#d e'); + + $this->assertSame( + '/api/projects/@current/feature_flags/a%2Fb%3Fc%23d%20e/remote_config?token=random_key', + $http->calls[0]['path'] + ); + } + + public function testEncodesProjectTokenInQuery() + { + $http = $this->httpClient(); + $client = $this->client($http, projectKey: 'a b&c'); + + $client->getRemoteConfigPayload('test-flag'); + + $this->assertSame( + '/api/projects/@current/feature_flags/test-flag/remote_config?token=a%20b%26c', + $http->calls[0]['path'] + ); + } + + public function testSendsGetRequestWithBearerHeaders() + { + $http = $this->httpClient(); + $client = $this->client($http); + + $client->getRemoteConfigPayload('test-flag'); + + $this->assertCount(1, $http->calls); + $this->assertSame( + [ + 'path' => self::PATH, + 'payload' => null, + 'extraHeaders' => [ + 'User-Agent: posthog-php/' . PostHog::VERSION, + 'Authorization: Bearer phs_test', + ], + 'requestOptions' => ['shouldRetry' => false, 'timeout' => 3000], + ], + $http->calls[0] + ); + } + + public function testUsesPersonalApiKeyAliasForAuthorization() + { + $http = $this->httpClient(); + $client = $this->client($http, secretKey: null, personalApiKey: 'test'); + + $client->getRemoteConfigPayload('test-flag'); + + $this->assertContains('Authorization: Bearer test', $http->calls[0]['extraHeaders']); + } + + public function testUsesConfiguredFeatureFlagTimeout() + { + $http = $this->httpClient(); + $client = $this->client($http, options: ['feature_flag_request_timeout_ms' => 1500]); + + $client->getRemoteConfigPayload('test-flag'); + + $this->assertSame(1500, $http->calls[0]['requestOptions']['timeout']); + } + + public function testSendsNoFeatureFlagCalledEvent() + { + $http = $this->httpClient(); + $client = $this->client($http); + + $client->getRemoteConfigPayload('test-flag'); + $client->flush(); + + $this->assertCount(1, $http->calls); + foreach ($http->calls as $call) { + $this->assertStringStartsWith('/api/projects/@current/feature_flags/', $call['path']); + } + } + + public function testStaticFacadeDelegatesToClient() + { + $client = $this->client($this->httpClient('{"foo":"bar"}')); + PostHog::init(null, null, $client); + + $this->assertSame(['foo' => 'bar'], PostHog::getRemoteConfigPayload('test-flag')); + } +} From 31707b8ee566efaefbc67ad2430fe79cab8d06e2 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 13 Nov 2026 13:41:18 +0200 Subject: [PATCH 2/5] chore: list the refused key and invalid JSON cases in the changeset --- .changeset/three-pants-ask.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/three-pants-ask.md b/.changeset/three-pants-ask.md index e8e80f7c..ef77df81 100644 --- a/.changeset/three-pants-ask.md +++ b/.changeset/three-pants-ask.md @@ -2,4 +2,4 @@ "posthog-php": minor --- -The new `getRemoteConfigPayload()` reads the payload of a remote config feature flag, for example `PostHog::getRemoteConfigPayload('landing-page-config')`. It needs a secret key and sends one request without a retry. It returns the decoded payload, or `null` and a logged warning when the client is disabled, no secret key is set, the request fails, or PostHog answers with a non-2xx status. It sends no `$feature_flag_called` event. It uses the `feature_flag_request_timeout_ms` option, 3 seconds by default. +The new `getRemoteConfigPayload()` reads the payload of a remote config feature flag, for example `PostHog::getRemoteConfigPayload('landing-page-config')`. It needs a secret key and sends one request without a retry. It returns the decoded payload, or `null` and a logged warning when the client is disabled, no secret key is set, the flag key is empty or is `.` or `..`, the request fails, PostHog answers with a non-2xx status, or the response body is not valid JSON. It sends no `$feature_flag_called` event. It uses the `feature_flag_request_timeout_ms` option, 3 seconds by default. From 6114a8ea94d19362a00ce273103732a7780fe29d Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 13 Nov 2026 15:36:16 +0200 Subject: [PATCH 3/5] fix: treat an empty 2xx remote config response as no payload --- .changeset/three-pants-ask.md | 2 +- lib/Client.php | 11 +++++++++-- test/RemoteConfigPayloadTest.php | 30 ++++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/.changeset/three-pants-ask.md b/.changeset/three-pants-ask.md index ef77df81..f7a6833e 100644 --- a/.changeset/three-pants-ask.md +++ b/.changeset/three-pants-ask.md @@ -2,4 +2,4 @@ "posthog-php": minor --- -The new `getRemoteConfigPayload()` reads the payload of a remote config feature flag, for example `PostHog::getRemoteConfigPayload('landing-page-config')`. It needs a secret key and sends one request without a retry. It returns the decoded payload, or `null` and a logged warning when the client is disabled, no secret key is set, the flag key is empty or is `.` or `..`, the request fails, PostHog answers with a non-2xx status, or the response body is not valid JSON. It sends no `$feature_flag_called` event. It uses the `feature_flag_request_timeout_ms` option, 3 seconds by default. +The new `getRemoteConfigPayload()` reads the payload of a remote config feature flag, for example `PostHog::getRemoteConfigPayload('landing-page-config')`. It needs a secret key and sends one request without a retry. It returns the decoded payload, or `null` and a logged warning when the client is disabled, no secret key is set, the flag key is empty or is `.` or `..`, the request fails, PostHog answers with a non-2xx status, or the response body is not valid JSON. It returns `null` without a warning when the flag has no payload. It sends no `$feature_flag_called` event. It uses the `feature_flag_request_timeout_ms` option, 3 seconds by default. diff --git a/lib/Client.php b/lib/Client.php index 13ae9afb..93e2eb28 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -1232,7 +1232,8 @@ public function getFeatureFlagPayload( * API Key returns them redacted. The call sends one request, with no retry, and captures no * `$feature_flag_called` event. It logs a warning and returns null when the client is disabled, * when no secret key is set, when the key is empty, `.` or `..`, when the request fails, or when - * PostHog answers with a non-2xx status. + * PostHog answers with a non-2xx status. An empty response means the flag has no payload: the + * call returns null without a warning. * * @param string $key Feature flag key. * @return mixed The decoded payload, or null when no payload could be read. @@ -1277,8 +1278,14 @@ public function getRemoteConfigPayload(string $key): mixed return null; } + // PostHog answers a flag that has no payload with an empty 2xx body. + $body = (string) $response->getResponse(); + if (trim($body) === '') { + return null; + } + try { - $result = json_decode((string) $response->getResponse(), true, 512, JSON_THROW_ON_ERROR); + $result = json_decode($body, true, 512, JSON_THROW_ON_ERROR); } catch (\JsonException) { error_log('[PostHog][Client] Unable to read remote config payload: invalid JSON response'); return null; diff --git a/test/RemoteConfigPayloadTest.php b/test/RemoteConfigPayloadTest.php index 1ac262ab..4fa4f65d 100644 --- a/test/RemoteConfigPayloadTest.php +++ b/test/RemoteConfigPayloadTest.php @@ -99,6 +99,36 @@ public function testReturnsNullAndLogsWhenBodyIsNotJson() $this->assertStringContainsString('invalid JSON response', $errorMessages[0]); } + #[DataProvider('emptyBodies')] + public function testReturnsNullWithoutLoggingForEmpty2xxBody(string $body) + { + global $errorMessages; + $http = $this->httpClient($body); + $client = $this->client($http); + + $this->assertNull($client->getRemoteConfigPayload('test-flag')); + $this->assertCount(1, $http->calls); + $this->assertSame([], $errorMessages); + } + + public static function emptyBodies(): array + { + return [ + 'empty' => [''], + 'whitespace' => [" \r\n\t"], + ]; + } + + public function testReturnsNullAndLogsOn404WithEmptyBody() + { + global $errorMessages; + $client = $this->client($this->httpClient('', 404)); + + $this->assertNull($client->getRemoteConfigPayload('test-flag')); + $this->assertCount(1, $errorMessages); + $this->assertStringContainsString('HTTP 404', $errorMessages[0]); + } + public function testReturnsNullAndLogsWhenNoSecretKey() { global $errorMessages; From 82cdefec0a82a36549e3cf92e42550b55bfd78a9 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 13 Nov 2026 16:32:25 +0200 Subject: [PATCH 4/5] fix: log a warning when the remote config transfer fails after a 2xx status --- lib/Client.php | 8 ++++++-- test/RemoteConfigPayloadTest.php | 10 ++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/Client.php b/lib/Client.php index 93e2eb28..46b1a6db 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -1270,10 +1270,14 @@ public function getRemoteConfigPayload(string $key): mixed ); $code = $response->getResponseCode(); - if ($code < 200 || $code >= 300) { + // cURL keeps the 2xx status when the transfer fails after the headers arrive. + $curlErrno = $response->getCurlErrno(); + if ($curlErrno !== 0 || $code < 200 || $code >= 300) { error_log( '[PostHog][Client] Unable to fetch remote config payload: ' - . ($code === 0 ? 'connection error (curl errno ' . $response->getCurlErrno() . ')' : 'HTTP ' . $code) + . ($curlErrno !== 0 || $code === 0 + ? 'connection error (curl errno ' . $curlErrno . ')' + : 'HTTP ' . $code) ); return null; } diff --git a/test/RemoteConfigPayloadTest.php b/test/RemoteConfigPayloadTest.php index 4fa4f65d..81965a03 100644 --- a/test/RemoteConfigPayloadTest.php +++ b/test/RemoteConfigPayloadTest.php @@ -210,6 +210,16 @@ public function testReturnsNullAndLogsOnNetworkFailure(int $errno) $this->assertStringContainsString('curl errno ' . $errno, $errorMessages[0]); } + public function testReturnsNullAndLogsWhenTransferFailsAfter2xxHeaders() + { + global $errorMessages; + $client = $this->client($this->httpClient(false, 200, 28)); + + $this->assertNull($client->getRemoteConfigPayload('test-flag')); + $this->assertCount(1, $errorMessages); + $this->assertStringContainsString('curl errno 28', $errorMessages[0]); + } + public static function curlErrnos(): array { return [[28], [6]]; From 60e537691ba309d0640c460fad06d6d0bc43fa9b Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 13 Nov 2026 22:54:44 +0200 Subject: [PATCH 5/5] chore: shorten the getRemoteConfigPayload changeset --- .changeset/three-pants-ask.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/three-pants-ask.md b/.changeset/three-pants-ask.md index f7a6833e..d7026a71 100644 --- a/.changeset/three-pants-ask.md +++ b/.changeset/three-pants-ask.md @@ -2,4 +2,4 @@ "posthog-php": minor --- -The new `getRemoteConfigPayload()` reads the payload of a remote config feature flag, for example `PostHog::getRemoteConfigPayload('landing-page-config')`. It needs a secret key and sends one request without a retry. It returns the decoded payload, or `null` and a logged warning when the client is disabled, no secret key is set, the flag key is empty or is `.` or `..`, the request fails, PostHog answers with a non-2xx status, or the response body is not valid JSON. It returns `null` without a warning when the flag has no payload. It sends no `$feature_flag_called` event. It uses the `feature_flag_request_timeout_ms` option, 3 seconds by default. +The new `getRemoteConfigPayload()` reads the payload of a remote config feature flag by its key, with no distinct ID. It needs the `secretKey` the client was created with. It returns `null` and logs a warning on a failure.