Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
## Unreleased

* feat(flags): Add `evaluateFlags()` API for single-call flag evaluation. Returns a
`FeatureFlagEvaluations` snapshot you can read repeatedly without further `/flags` requests; pass
it to `capture()` via the new `flags` key to attach `$feature/<key>` and `$active_feature_flags`
on the captured event without an extra round trip.
* fix(flags): `SizeLimitedHash::contains()` and `add()` were storing entries on the outer map and
comparing values to keys, so the per-distinct_id `$feature_flag_called` dedup never matched after
the first event. Both helpers now operate on a per-key set as intended.

## 4.2.2 - 2026-04-21

* [Full Changelog](https://github.com/PostHog/posthog-php/compare/4.2.1...4.2.2)
Expand Down
220 changes: 209 additions & 11 deletions lib/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

const SIZE_LIMIT = 50_000;

class Client
class Client implements FeatureFlagEvaluationsHost
{
private const CONSUMERS = [
"socket" => Socket::class,
Expand Down Expand Up @@ -88,6 +88,11 @@ class Client
*/
private $options;

/**
* @var bool Whether to surface non-fatal warnings from feature flag helpers.
*/
private $featureFlagsLogWarnings;

/**
* Create a new posthog object with your app's API key
* key
Expand Down Expand Up @@ -123,6 +128,7 @@ public function __construct(
(int) ($options['timeout'] ?? 10000)
);
$this->featureFlagsRequestTimeout = (int) ($options['feature_flag_request_timeout_ms'] ?? 3000);
$this->featureFlagsLogWarnings = (bool) ($options['feature_flags_log_warnings'] ?? true);
$this->featureFlags = [];
$this->groupTypeMapping = [];
$this->cohorts = [];
Expand Down Expand Up @@ -155,14 +161,24 @@ public function __destruct()
*/
public function capture(array $message)
{
$flagsSnapshot = $message["flags"] ?? null;
unset($message["flags"]);

$message = $this->message($message);
$message["type"] = "capture";

if (array_key_exists('$groups', $message)) {
$message["properties"]['$groups'] = $message['$groups'];
}

if (array_key_exists("send_feature_flags", $message) && $message["send_feature_flags"]) {
if ($flagsSnapshot instanceof FeatureFlagEvaluations) {
// The snapshot already has every flag value cached. No /flags request, no override of
// properties already set on the event.
$message["properties"] = array_merge(
$flagsSnapshot->getEventProperties(),
$message["properties"]
);
} elseif (array_key_exists("send_feature_flags", $message) && $message["send_feature_flags"]) {
$extraProperties = [];
$flags = [];

Expand Down Expand Up @@ -444,7 +460,7 @@ public function getFeatureFlagResult(
}
}

if ($sendFeatureFlagEvents && !$this->distinctIdsFeatureFlagsReported->contains($key, $distinctId)) {
if ($sendFeatureFlagEvents) {
$properties = [
'$feature_flag' => $key,
'$feature_flag_response' => $result,
Expand All @@ -468,13 +484,7 @@ public function getFeatureFlagResult(
$properties['$feature_flag_error'] = $featureFlagError;
}

$this->capture([
"properties" => $properties,
"distinct_id" => $distinctId,
"event" => '$feature_flag_called',
'$groups' => $groups
]);
$this->distinctIdsFeatureFlagsReported->add($key, $distinctId);
$this->captureFlagCalledIfNeeded($distinctId, $key, $properties, $groups);
}

if (is_null($result)) {
Expand Down Expand Up @@ -581,6 +591,184 @@ public function getAllFlags(
return $response;
}

/**
* Evaluate every feature flag for a distinct id in a single round trip and return a
* FeatureFlagEvaluations snapshot. Reads on the snapshot do not trigger additional /flags
* requests; access via isEnabled() or getFlag() fires a deduped $feature_flag_called event the
* first time each key is touched.
*
* @param array<string, mixed> $groups
* @param array<string, mixed> $personProperties
* @param array<string, array<string, mixed>> $groupProperties
* @param list<string>|null $flagKeys When set, scope the underlying /flags request to these keys.
*/
public function evaluateFlags(
string $distinctId,
array $groups = [],
array $personProperties = [],
array $groupProperties = [],
bool $onlyEvaluateLocally = false,
bool $disableGeoip = false,
?array $flagKeys = null
): FeatureFlagEvaluations {
if ($distinctId === '') {
return new FeatureFlagEvaluations(
$distinctId,
[],
$groups,
$this,
null,
$this->featureFlagsLogWarnings,
);
}

[$personProperties, $groupProperties] = $this->addLocalPersonAndGroupProperties(
$distinctId,
$groups,
$personProperties,
$groupProperties
);

$records = [];

// Local pass: try to resolve any flag we can without going to the server.
foreach ($this->featureFlags as $flag) {
$key = $flag['key'] ?? null;
if (!is_string($key) || $key === '') {
continue;
}

if ($flagKeys !== null && !in_array($key, $flagKeys, true)) {
continue;
}

try {
$value = $this->computeFlagLocally(
$flag,
$distinctId,
$groups,
$personProperties,
$groupProperties
);
} catch (RequiresServerEvaluationException $e) {
continue;
} catch (InconclusiveMatchException $e) {
continue;
} catch (Exception $e) {
error_log("[PostHog][Client] Error while computing variant: " . $e->getMessage());
continue;
}

$variant = is_string($value) ? $value : null;
$enabled = is_string($value) ? true : (bool) $value;
$id = isset($flag['id']) ? (int) $flag['id'] : null;

$records[$key] = new EvaluatedFlagRecord(
key: $key,
enabled: $enabled,
variant: $variant,
payload: null,
id: $id,
version: null,
reason: 'Evaluated locally',
locallyEvaluated: true,
);
}

$requestId = null;

if (!$onlyEvaluateLocally) {
try {
$response = $this->flags(
$distinctId,
$groups,
$personProperties,
$groupProperties,
$disableGeoip,
$flagKeys
);

$requestId = $response['requestId'] ?? null;
$remoteFlags = $response['flags'] ?? [];

foreach ($remoteFlags as $key => $flagDetail) {
if (!is_string($key) || $key === '' || isset($records[$key])) {
continue;
}
if (!is_array($flagDetail) || ($flagDetail['failed'] ?? false)) {
continue;
}

$variant = $flagDetail['variant'] ?? null;
$enabled = (bool) ($flagDetail['enabled'] ?? false);
$rawPayload = $flagDetail['metadata']['payload'] ?? null;
$payload = $rawPayload !== null ? json_decode($rawPayload, true) : null;

$records[$key] = new EvaluatedFlagRecord(
key: $key,
enabled: $enabled,
variant: is_string($variant) ? $variant : null,
payload: $payload,
id: isset($flagDetail['metadata']['id'])
? (int) $flagDetail['metadata']['id']
: null,
version: isset($flagDetail['metadata']['version'])
? (int) $flagDetail['metadata']['version']
: null,
reason: $flagDetail['reason']['description'] ?? null,
locallyEvaluated: false,
);
}
} catch (Exception $e) {
error_log("[PostHog][Client] Unable to evaluate flags: " . $e->getMessage());
}
}

return new FeatureFlagEvaluations(
$distinctId,
$records,
$groups,
$this,
$requestId,
$this->featureFlagsLogWarnings,
);
}

/**
* Fire a $feature_flag_called event the first time a (flag key, distinct id) pair is seen by
* this Client, deduped via the per-distinct_id cache shared with every other flag-reading code
* path. Properties are built by the caller so each call site can shape the payload to match its
* available metadata.
*
* @param array<string, mixed> $properties
* @param array<string, mixed> $groups
*/
public function captureFlagCalledIfNeeded(
string $distinctId,
string $key,
array $properties,
array $groups = []
): void {
if ($this->distinctIdsFeatureFlagsReported->contains($key, $distinctId)) {
return;
}

$this->capture([
'properties' => $properties,
'distinct_id' => $distinctId,
'event' => '$feature_flag_called',
'$groups' => $groups,
]);
$this->distinctIdsFeatureFlagsReported->add($key, $distinctId);
}

public function logWarning(string $message): void
{
if ($this->featureFlagsLogWarnings) {
error_log("[PostHog][Client] " . $message);
}
}

private function computeFlagLocally(
array $featureFlag,
string $distinctId,
Expand Down Expand Up @@ -821,7 +1009,9 @@ public function flags(
string $distinctId,
array $groups = array(),
array $personProperties = [],
array $groupProperties = []
array $groupProperties = [],
bool $disableGeoip = false,
?array $flagKeys = null
): array {
$payload = array(
'api_key' => $this->apiKey,
Expand All @@ -840,6 +1030,14 @@ public function flags(
$payload["group_properties"] = $groupProperties;
}

if ($disableGeoip) {
$payload["geoip_disable"] = true;
}

if ($flagKeys !== null) {
$payload["flag_keys_to_evaluate"] = array_values($flagKeys);
}

$httpResponse = $this->httpClient->sendRequest(
'/flags/?v=2',
json_encode($payload),
Expand Down
29 changes: 29 additions & 0 deletions lib/EvaluatedFlagRecord.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace PostHog;

/**
* Immutable per-flag entry stored on a FeatureFlagEvaluations snapshot.
*/
final class EvaluatedFlagRecord
{
public function __construct(
public readonly string $key,
public readonly bool $enabled,
public readonly ?string $variant,
public readonly mixed $payload,
public readonly ?int $id,
public readonly ?int $version,
public readonly ?string $reason,
public readonly bool $locallyEvaluated,
) {
}

/**
* The value as $feature_flag_response would render it: variant string when set, otherwise the enabled bool.
*/
public function getValue(): bool|string
{
return $this->variant ?? $this->enabled;
}
}
Loading
Loading