feat(analytics): add feature flag support (local + remote evaluation) - #86
feat(analytics): add feature flag support (local + remote evaluation)#86tylerjroach wants to merge 20 commits into
Conversation
Adds $mp->flags facade with both local in-process evaluation (FNV-1a bucketing + JSON Logic runtime rules) and remote evaluation (HTTP /flags). Mirrors the public API shape of the Python, Ruby, Go, and Java SDKs so cross-SDK behavior is consistent. Mitigates known cross-SDK bugs from the OpenFeature audit: - distinguishable failure reasons (lastFailureReason) for the future OpenFeature wrapper - backend errors propagate distinctly from "flag not found" - exposure events route through the existing event queue so evaluation never blocks on HTTP - missing-distinct_id-with-custom-bucketing-key warns via error_callback instead of silently dropping exposure - explicit shutdown(); ext checks fire only when flags are used - REASON_NOT_READY distinguishes pre-loadDefinitions calls Bumps PHP minimum to 7.2 and PHPUnit dev dep to ^7.5||^8.5||^9.5 to support modern toolchains; updates existing tests for the new PHPUnit baseline. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Aligns the PHP remote-mode $experiment_started payload with the Python, Ruby, Go, Java, Node, and Browser SDKs, which all include "Variant fetch start time" and "Variant fetch complete time" as ISO-8601 local-time strings with microsecond precision. Local mode is unchanged (only "Variant fetch latency (ms)" applies when there's no network round-trip to span). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Gives callers a grep-able, IDE-checkable alternative to bare "local"/"remote" string literals for the flags mode config. The raw strings remain valid input — these are exact aliases — so this is purely additive. PHP 7.x has no native enums; adopting these constants gets us most of the type-safety benefit without bumping the composer floor. A future major version targeting PHP 8.1+ can promote these to a backed enum. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Long-running PHP workers can now set refresh_interval_in_seconds on the flags config and call $mp->flags->refresh() inside their main loop. refresh() is a no-op until the configured interval elapses (and performs the initial fetch if loadDefinitions never ran), so it's safe to invoke on every iteration. needsRefresh() exposes the staleness check for callers who want to handle the refresh themselves (logging, scheduling, etc.). PHP can't run a background polling thread the way Python/Ruby/ Go/Java/Node do, so this synchronous refresh-from-the-loop pattern is the closest analog. Remote mode forwards both methods as no-ops (needsRefresh returns false, refresh returns true) so callers can write mode-agnostic code. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This reverts commit f42140c.
Remove two PHP-specific config options that no other Mixpanel server SDK exposes: - "report_exposure" config key (was: constructor-level default). Other SDKs handle exposure-reporting only as a per-call parameter on getVariant(); callers wanting a global default should set it themselves. The per-call $reportExposure param on getVariant() stays (default true), matching Python/Ruby/Go/ Java/Node. - "connect_timeout_in_seconds" config key. Other SDKs use a single timeout covering both connect and read phases (httpx, Net::HTTP, http.Client all behave this way). cURL now applies request_timeout_in_seconds to both CURLOPT_CONNECTTIMEOUT and CURLOPT_TIMEOUT. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the bcmath-backed FNV-1a implementation with PHP's
built-in `hash('fnv1a64', …)` (from core `ext-hash`, bundled in
every PHP install). Mod-100 normalization runs byte-by-byte on
the 8-byte raw digest — no big-int math needed, exact on every
PHP build. Canonical RFC test vectors (`""`, `"a"`, `"foobar"`)
still match.
Replace the `ext-mbstring` hard requirement with
`symfony/polyfill-mbstring` (pure-PHP implementation, no-op
when the real extension is present).
Net composer.json delta:
- `ext-bcmath`: removed (no longer used)
- `ext-mbstring`: removed (polyfilled)
- `ext-curl`, `ext-json`: removed (weren't in `require` pre-PR;
the existing producers' runtime checks gate them when needed)
- `symfony/polyfill-mbstring`: added
Tracking-only customers upgrading from 2.11.0 now see zero new
install-time requirements. Flag customers on minimal Docker
images (alpine, php:fpm) install without needing to add
`docker-php-ext-install` layers for the SDK to work.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the stateful `$mp->flags->lastFailureReason()` method with a `fallbackReason` field directly on the returned SelectedVariant. The field is null when evaluation succeeded and one of the REASON_* constants when the SDK returned the caller's fallback. This fixes three problems with the previous design: 1. State got overwritten on the next call — easy to miss the reason from the call you cared about. 2. getAllVariants() collapsed every iteration's reason into one global slot, so you couldn't tell per-flag why a particular flag fell back. 3. The pattern "call, then check a separate state-bearing method" is easy to forget and reads awkwardly. Renamed from `failureReason` to `fallbackReason` (more accurate — NO_ROLLOUT_MATCH isn't a "failure," it's the documented behavior for users not in the rollout) and made null on success (removes the REASON_OK sentinel, matches PHP's idiom for "absence", and maps 1:1 to OpenFeature's nullable errorCode field for a future OF wrapper). A new SelectedVariant::withFallbackReason() helper clones the caller's fallback so we never mutate state they own. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the hardcoded `Mixpanel::VERSION = '2.11.0'` constant with
a static lookup against `\Composer\InstalledVersions` so the
`lib_version` query param sent on flag HTTP requests stays in
sync with the released tag without any release-time bumping.
This matches the prepare-release.yml philosophy ("composer.json
intentionally has NO version field — Packagist reads from the
git tag") and keeps PHP cross-SDK aligned with Python, Ruby,
Go, and Node, all of which send their respective library
version on flag requests.
Composer 2.x bundles `\Composer\InstalledVersions` in every
install, so no new dep is required. Falls back to "unknown" on
the off chance the package is loaded outside a Composer-managed
environment.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Removes the broken Travis CI badge (travis-ci.org has been down for
OSS projects since 2021).
- Fixes a long-standing typo ("checkout out" → "check out").
- Adds a Feature Flags section covering the full public API surface:
config options, evaluation methods, SelectedVariant fields, the
fallbackReason discriminator, local-mode lifecycle, the
getInstance() singleton gotcha, and pointers to the docs site for
the deeper guide.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mistakenly added the FF API surface to the README in cebc4a7. Travis badge removal and typo fix from that commit stay; the feature-flags reference docs belong on the official docs site, not in-repo. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a tests workflow that runs `composer run-script unit-tests` on PHP 7.2 / 7.4 / 8.1 / 8.3 / 8.4 on every PR and every push to master. Pins action SHAs per the existing prepare-release.yml convention. The 7.2 / 7.4 matrix entries enforce the `>=7.2` floor declared in composer.json; the 8.x entries cover every currently-supported PHP release. Concurrency cancels in-flight PR runs on new commits but lets master-push runs always finish. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Addresses two warnings surfaced by the first CI run on PR #86: 1. Every job emitted a deprecation warning that the pinned action versions (actions/checkout@v4.2.2, actions/cache@v4.2.0, shivammathur/setup-php@v2.31.1) target the Node 20 runtime, which GitHub deprecated in September 2025. Bumped to: - actions/checkout v7.0.0 - actions/cache v6.0.0 - shivammathur/setup-php 2.37.2 2. Dependabot flagged PHPUnit < 8.5.52 (high-severity unsafe deserialization in PHPT code coverage handling). The previous constraint `^7.5 || ^8.5 || ^9.5` allowed the vulnerable range. Tightened to `^8.5.52 || ^9.5` — still covers every PHP version in the CI matrix (8.5 supports 7.2+, 9.5 supports 7.3+). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SelectedVariant now carries two source fields: `variant_source` (local | remote | fallback) and `fallback_reason` (FLAG_NOT_FOUND | MISSING_CONTEXT_KEY | NO_ROLLOUT_MATCH | BACKEND_ERROR | NOT_READY, set only when source is fallback). Three behaviorally distinct outcomes — flag-not-found, no-rollout-match, and missing-context-key — previously all returned the bare fallback. The OpenFeature wrapper collapsed them to FLAG_NOT_FOUND, sending callers chasing the flag name when the real cause was usually a rule miss or absent context. The wrapper now dispatches on fallback_reason and maps each to the spec-correct OpenFeature response. Most notably, NO_ROLLOUT_MATCH becomes `reason: DEFAULT` with no error code instead of FLAG_NOT_FOUND. Constant names align with mixpanel-php for consistency across SDKs. Linear: SDK-79 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ene) - Validate the flags 'mode' option and throw InvalidArgumentException on unknown values. A typo like 'lcoal' used to silently fall through to remote, which is a nasty debug trap. - Replace the public static _compareVariantKeys usort callback with an anonymous function so the sort helper doesn't leak into the class's public API surface. - Swap Content-Type: application/json for Accept: application/json on GET requests — the header describes what we accept, not a request body we don't send. - Wrap MixpanelFlags::__destruct in try/catch(Throwable) so a shutdown throw during fatal-error cleanup can't mask the original error. - Log via _handleError when a rollout has a runtime rule but the context lacks custom_properties. Behavior unchanged (rollout still skips), but callers debugging REASON_NO_ROLLOUT_MATCH can now see why. - Clarify (via comments + a new regression test) the lockstep invariant between lowercaseLeafNodes and lowercaseKeysAndValues — if either drifts, JSON-Logic 'var' lookups silently miss and every runtime-rule flag falls back. - Explain the intentional null vs '' asymmetry in _assignedRollout / _assignedVariant $hashSalt defaults (different salt formulas, not a cosmetic mistake). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ag surface PHP 8.1 is already end-of-life, so it's safe as a floor. Adds real PHP types (properties, params, returns, declare(strict_types=1)) throughout lib/FeatureFlags/* and typs the $flags property on the main Mixpanel class. Public API surface is unchanged — same method names, same signatures, same defaults — but every internal call site now has IDE/analyzer-visible types. - composer.json: >=7.2 -> >=8.1 - .github/workflows/tests.yml: matrix pruned to 8.1, 8.3, 8.4 - Test harness override in MixpanelRemoteFlagsTest updated to match the newly-typed base signature - Removed now-dead (bool) $reportExposure casts in local + remote providers, and the dead $_provider !== null guard in shutdown() Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…sEnabled
Three Greptile findings on the feature-flags PR:
1. P1: MixpanelRemoteFlags._fetchFlags used to pass json_encode($context)
straight into http_build_query. On non-UTF-8 strings or circular refs
json_encode returns false, http_build_query coerced it to "", and
the server received context= — silently returning nothing and giving
the caller REASON_FLAG_NOT_FOUND with no hint that serialization was
the real cause. Now we check the return value and throw so getVariant
surfaces REASON_BACKEND_ERROR via error_callback.
2. P2: MixpanelFlagsBase._httpGet called curl_init() directly.
AbstractConsumer already guards this. On minimal PHP builds without
ext-curl (some Alpine images) curl_init fatals with no hint.
Added a function_exists('curl_init') guard throwing RuntimeException.
3. P2: isEnabled uses strict === true. Documented that this is
intentional — non-boolean truthy values (1, "true", "on", ...)
signal a type mismatch on a Feature Gate flag and we fail closed,
matching Node/Ruby/Python/Go isEnabled semantics.
New test testJsonEncodeFailureSurfacesAsBackendError locks in the P1
behavior (invalid UTF-8 → BACKEND_ERROR + error_callback, no HTTP call).
All 39 flag tests pass locally.
|
Pushed P1 — P2 — P2 — All 39 flag tests pass locally. |
Summary
Adds Mixpanel feature-flag support to the PHP SDK via a new
$mp->flagsfacade — both local evaluation (one-shot fetch of definitions, then in-process FNV-1a bucketing + JSON Logic runtime rules) and remote evaluation (HTTP/flagson every call). API surface aligns with the Python / Ruby / Go / Java / Node server SDKs so cross-language behavior is consistent.PHP-specific design notes:
loadDefinitions()on whatever cadence they need./trackHTTP call.fallbackReasonon the returnedSelectedVariantdistinguishes the five ways an eval can fall back (FLAG_NOT_FOUND,MISSING_CONTEXT_KEY,NO_ROLLOUT_MATCH,BACKEND_ERROR,NOT_READY) —nullon success.Public API surface (strongly typed, PHP 8.1+)
The whole feature-flag implementation ships with real PHP types — typed properties, typed parameters, typed returns,
declare(strict_types=1)in every file. IDE autocomplete and static analyzers get accurate types; latent type-mismatch bugs surface at parse/runtime instead of silently coercing.Dependency / version changes
>=5.0to>=8.1. PHP 8.1 is already end-of-life at the time of this change, so it's the natural floor. Enables typed properties (7.4+),mixed(8.0), union types (8.0), anddeclare(strict_types=1)throughout the feature-flag implementation.jwadhams/json-logic-php ^1.5— runtime-rule evaluation in local mode.symfony/polyfill-mbstring ^1.27— Unicode case folding for runtime rules; no-op pass-through whenext-mbstringis present.hash('fnv1a64', …)from coreext-hash(bundled in every PHP install). Tracking-only customers on PHP 8.1+ upgrading from 2.11.0 see zero install-time impact — noRUN docker-php-ext-installlayers required on minimal Docker images.lib_versionquery param on flag HTTP requests resolves dynamically via\Composer\InstalledVersions— no hardcoded version constant to bump at release time. Matchesprepare-release.yml's philosophy ("composer.json intentionally has NO version field — Packagist reads from the git tag").^8.5.52 || ^9.5; dropped abandonedphpdocumentor/phpdocumentor 2.9.*(security advisories on itssymfony/validatortransitive dep).PHPUnit\Framework\TestCase,: voidsetUp/tearDown,assertString*ContainsString).MixpanelGroupsProducerTest::testRemovewas asserting$unsetwhen the SDK correctly emits$remove..github/workflows/tests.yml) runs PHPUnit against 8.1, 8.3, 8.4 — the supported PHP versions above the new floor.Test plan
composer install && composer run-script unit-tests— 83 unit tests pass, 279 assertions. Coverage includes FNV-1a canonical RFC vectors (cross-language bucketing parity), runtime-rule eval (case-folding semantics), every fallback-reason path, exposure event shape, ISO start/complete timestamps in remote-mode exposure, nested numeric preservation, custom-bucketing-key warning behavior,api_hostinheritance from top-levelhost,MODE_*constant equivalence.php -lclean on every modified file after the typing pass.What's not in this PR
prepare-release.yml.fallbackReasondiscriminator, the variant-metadata fields onSelectedVariant, and the per-call$reportExposureparameter are hooks intentionally exposed so amixpanel-openfeature-phpprovider package can bind directly without redesigning the core. Tracked as follow-up.Trying the PR before release
Customers can install directly from this branch by adding to their
composer.json:```json
{
"repositories": [
{"type": "vcs", "url": "https://github.com/mixpanel/mixpanel-php"}
],
"require": {
"mixpanel/mixpanel-php": "dev-feature-flags-support"
},
"minimum-stability": "dev",
"prefer-stable": true
}
```
Then
composer update mixpanel/mixpanel-php. Pin to a SHA (dev-feature-flags-support#<sha>) for stability while testing.🤖 Generated with Claude Code