diff --git a/.gitignore b/.gitignore index d456527..56f1976 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ xdebug.log .DS_Store .env node_modules/ +/.laravel-changes/ diff --git a/packages/laravel/PENNANT.md b/packages/laravel/PENNANT.md new file mode 100644 index 0000000..5ddca11 --- /dev/null +++ b/packages/laravel/PENNANT.md @@ -0,0 +1,75 @@ +# Laravel Pennant + +The optional, read-only driver uses the PHP SDK's feature flag snapshots. Install +Pennant in your application, then select the store: + +```sh +composer require laravel/pennant:^1.26 +``` + +```dotenv +PENNANT_STORE=posthog +``` + +Package discovery registers a `posthog` store if the application has not already +configured one. No Pennant migration or database-backed flag store is needed. + +```php +use Laravel\Pennant\Feature; + +Feature::for('account-123')->active('new-checkout'); +$variant = Feature::for('account-123')->value('checkout-layout'); +``` + +Values retain their boolean or variant-string type. Unknown or unresolved flags +return `false`, not an accidentally active `null`. Reads use the SDK's normal +exposure events; repeated cached reads do not generate duplicate exposures. + +String and integer scopes are distinct IDs. Authenticated user/model scopes use +their auth identifier or model key, without a class-name prefix, to match ordinary +SDK capture. Models are not serialized and their properties are not sent. +Pennant normally uses the authenticated user as its default scope. When that +scope is null, the driver uses the runtime's current analytics context. Tracing +headers remain untrusted analytics input, never authorization evidence. + +For custom scope types, use Pennant's scope-identifier contract to resolve an +explicit string/integer identifier. Configure runtime group resolvers for group +flag targeting. Use the native `evaluateFlags()` API for person/group properties, +local-only evaluation, payloads, key subsets and unresolved reasons: + +```php +$payload = \PostHog\Laravel\Facades\PostHog::evaluateFlags()->getFlagPayload('checkout-layout'); +``` + +## Read-only behavior + +Define and manage flags in PostHog. `define`, `activate`, `deactivate`, `delete`, +`purge` and other write operations throw `LogicException`; the driver never pretends +to modify remote flag definitions. Use remote flag key strings, not locally defined +PHP feature classes. + +`Feature::defined()` intentionally returns an empty array: this read-only external +store has no local definitions. Pennant calls it during feature-name resolution, +before resolving the requested scope, so it never evaluates flags or performs I/O. +Use `Feature::for($scope)->all()` or `Feature::for($scope)->loadAll()` to discover +PostHog flags for a scope through Pennant's external-definition contract. Discovery +uses that scope's native snapshot without recording exposure; reading the discovered +values uses normal deduplicated exposures. Bulk and subsequent individual reads +reuse the same snapshot, preserving variants and disabled values. Unknown or +unresolved flags remain inactive. + +## Cache lifetime + +The SDK reuses snapshots for identical complete evaluation inputs. Pennant also +has its own feature/scope cache. The integration clears PostHog stores around HTTP, +queue and command boundaries, including failed jobs. Pennant's own Octane lifecycle +integration remains active, and the driver resolves the current application rather +than retaining a request manager in a singleton. Calling commands that opt into +`IsolatesCommandContext` also clear Pennant's cache on direct child-call entry and +after restoring the caller, including failures. Uninstrumented direct calls are +not intercepted; explicit context scopes alone do not clear Pennant's cache. + +If targeting properties/groups change for the **same scope within one request**, +call `Feature::flushCache()` before another Pennant read. Alternatively use native +`evaluateFlags()` directly, which keys its snapshot cache by all evaluation inputs. +Do not retain a scoped PostHog manager in an application singleton. diff --git a/packages/laravel/README.md b/packages/laravel/README.md index 2a083a2..4ccf33e 100644 --- a/packages/laravel/README.md +++ b/packages/laravel/README.md @@ -266,6 +266,11 @@ No cache is enabled implicitly, and there is no background timer. The PHP client `loadFlags()` remains available for an explicit refresh. You can instead supply a custom provider class in `sdk.flag_definition_cache_provider`. +## Laravel Pennant + +An optional read-only PostHog store supports Pennant boolean and variant reads. +See [Pennant setup, scope mapping and cache behavior](PENNANT.md). + ## Configuration callbacks and full PHP access `posthog.sdk` passes PHP SDK options through. `before_send`, `error_handler`, and diff --git a/packages/laravel/composer.json b/packages/laravel/composer.json index a5fd4d1..4491616 100644 --- a/packages/laravel/composer.json +++ b/packages/laravel/composer.json @@ -12,8 +12,10 @@ "require-dev": { "orchestra/testbench": "^10.0 || ^11.0", "phpunit/phpunit": "^11.0 || ^12.0", - "laravel/octane": "^2.0" + "laravel/octane": "^2.0", + "laravel/pennant": "^1.0" }, + "suggest": {"laravel/pennant": "Use PostHog flags through Laravel Pennant (^1.0)."}, "autoload": {"psr-4": {"PostHog\\Laravel\\": "src/"}}, "autoload-dev": {"psr-4": {"PostHog\\Laravel\\Tests\\": "tests/"}}, "extra": {"laravel": {"providers": ["PostHog\\Laravel\\PostHogServiceProvider"], "aliases": {"PostHog": "PostHog\\Laravel\\Facades\\PostHog"}}}, diff --git a/packages/laravel/src/ConsoleLifecycle.php b/packages/laravel/src/ConsoleLifecycle.php index 5905950..cd97937 100644 --- a/packages/laravel/src/ConsoleLifecycle.php +++ b/packages/laravel/src/ConsoleLifecycle.php @@ -67,6 +67,7 @@ private function enter(): void $data = $context->snapshot(); $context->enter(); $context->begin($data); + Features\PennantCache::flush(); } private function remember(Throwable $exception): void @@ -86,6 +87,7 @@ private function leave(bool $flush = true): void } catch (Throwable) { } finally { $this->app->make(Context::class)->leave(); + Features\PennantCache::flush(); } } } diff --git a/packages/laravel/src/Features/PennantCache.php b/packages/laravel/src/Features/PennantCache.php new file mode 100644 index 0000000..400df9e --- /dev/null +++ b/packages/laravel/src/Features/PennantCache.php @@ -0,0 +1,25 @@ +resolved(FeatureManager::class)) { + return; + } + $manager = $app->make(FeatureManager::class); + $manager->setContainer($app); + foreach ($app['config']->get('pennant.stores', []) as $name => $store) { + if (($store['driver'] ?? null) === 'posthog') { + $manager->store($name)->flushCache(); + } + } + } +} diff --git a/packages/laravel/src/Features/PennantMiddleware.php b/packages/laravel/src/Features/PennantMiddleware.php new file mode 100644 index 0000000..fdbac19 --- /dev/null +++ b/packages/laravel/src/Features/PennantMiddleware.php @@ -0,0 +1,19 @@ +app['config']->get('pennant.stores.posthog') === null) { + $this->app['config']->set('pennant.stores.posthog', ['driver' => 'posthog']); + } + $this->callAfterResolving(FeatureManager::class, function ($manager) { + $manager->extend('posthog', function () { + return new PostHogDriver(); + }); + }); + $this->callAfterResolving(Kernel::class, fn ($kernel) => $kernel->prependMiddleware(PennantMiddleware::class)); + $this->app['events']->listen([ + \Illuminate\Queue\Events\JobProcessing::class, + \Illuminate\Queue\Events\JobProcessed::class, + \Illuminate\Queue\Events\JobExceptionOccurred::class, + \Illuminate\Console\Events\CommandStarting::class, + \Illuminate\Console\Events\CommandFinished::class, + ], fn () => PennantCache::flush()); + } +} diff --git a/packages/laravel/src/Features/PostHogDriver.php b/packages/laravel/src/Features/PostHogDriver.php new file mode 100644 index 0000000..2ac05dc --- /dev/null +++ b/packages/laravel/src/Features/PostHogDriver.php @@ -0,0 +1,86 @@ +identity($scope); + return Container::getInstance()->make(PostHog::class)->evaluateFlags($id)->getFlag($feature) ?? false; + } + + public function getAll(array $features): array + { + $values = []; + foreach ($features as $feature => $scopes) { + $values[$feature] = []; + foreach ($scopes as $key => $scope) { + $values[$feature][$key] = $this->get($feature, $scope); + } + } + return $values; + } + + public function defined(): array + { + // Pennant calls this before resolving the scope; there are no local definitions. + return []; + } + + public function definedFeaturesForScope(mixed $scope): array + { + return Container::getInstance()->make(PostHog::class)->evaluateFlags($this->identity($scope))->getKeys(); + } + + private function identity(mixed $scope): ?string + { + if ($scope === null) { + return null; + } + if ($scope instanceof Authenticatable) { + $scope = $scope->getAuthIdentifier(); + } elseif ($scope instanceof Model) { + $scope = $scope->getKey(); + } + if (is_string($scope) || is_int($scope)) { + return (string) $scope; + } + throw new InvalidArgumentException('PostHog Pennant scopes must have a string or integer identifier.'); + } + + public function define(string $feature, callable $resolver): void + { + throw new LogicException('PostHog flags are read-only. Define them in PostHog.'); + } + + public function set(string $feature, mixed $scope, mixed $value): void + { + throw new LogicException('PostHog flags are read-only. Update them in PostHog.'); + } + + public function setForAllScopes(string $feature, mixed $value): void + { + throw new LogicException('PostHog flags are read-only. Update them in PostHog.'); + } + + public function delete(string $feature, mixed $scope): void + { + throw new LogicException('PostHog flags are read-only. Remove them in PostHog.'); + } + + public function purge(?array $features): void + { + throw new LogicException('PostHog flags are read-only. Remove them in PostHog.'); + } +} diff --git a/packages/laravel/src/PostHogServiceProvider.php b/packages/laravel/src/PostHogServiceProvider.php index 397bcfb..7b06684 100644 --- a/packages/laravel/src/PostHogServiceProvider.php +++ b/packages/laravel/src/PostHogServiceProvider.php @@ -39,6 +39,9 @@ public function register(): void $config['flag_cache']['refresh_interval'], ); }); + if (class_exists(\Laravel\Pennant\FeatureManager::class)) { + $this->app->register(Features\PennantServiceProvider::class); + } if (class_exists(AutocaptureServiceProvider::class)) { $this->app->register(AutocaptureServiceProvider::class); } @@ -151,6 +154,7 @@ private function finish(Container $app, bool $restore = false): void } finally { $context = $app->make(Context::class); $restore ? $context->leave() : $context->end(); + Features\PennantCache::flush(); } } } diff --git a/packages/laravel/tests/ApiSurfaceTest.php b/packages/laravel/tests/ApiSurfaceTest.php index 153b0c5..4decdb0 100644 --- a/packages/laravel/tests/ApiSurfaceTest.php +++ b/packages/laravel/tests/ApiSurfaceTest.php @@ -193,6 +193,10 @@ public function testPublicApiMatchesItsSnapshot(): void \PostHog\Laravel\Autocapture\ModelEvents::class, \PostHog\Laravel\Autocapture\Outcomes::class, \PostHog\Laravel\Autocapture\RequestSummary::class, + \PostHog\Laravel\Features\PennantCache::class, + \PostHog\Laravel\Features\PennantMiddleware::class, + \PostHog\Laravel\Features\PennantServiceProvider::class, + \PostHog\Laravel\Features\PostHogDriver::class, ] as $internal ) { $this->assertArrayNotHasKey($internal, $api['symbols']); diff --git a/packages/laravel/tests/Fixtures/PennantEndEventProvider.php b/packages/laravel/tests/Fixtures/PennantEndEventProvider.php new file mode 100644 index 0000000..d513ce0 --- /dev/null +++ b/packages/laravel/tests/Fixtures/PennantEndEventProvider.php @@ -0,0 +1,20 @@ +app['events']->listen([JobProcessed::class, JobExceptionOccurred::class], function (): void { + if ($this->app['config']->get('pennant-test.read-on-job-end', false)) { + $this->app['config']->set('pennant-test.job-value', Feature::value('layout')); + } + }); + } +} diff --git a/packages/laravel/tests/PennantTest.php b/packages/laravel/tests/PennantTest.php new file mode 100644 index 0000000..dae6479 --- /dev/null +++ b/packages/laravel/tests/PennantTest.php @@ -0,0 +1,363 @@ +set('posthog.api_key', 'test-key'); + $app['config']->set('pennant.default', 'posthog'); + } + + public function testValuesUseOneSnapshotPerIdentityAndUnknownFlagsAreInactive(): void + { + $events = []; + $this->sdk(2, $events); + $this->assertSame('first', Feature::for('first')->value('layout')); + $this->assertSame('first', Feature::for('first')->value('layout')); + $this->assertTrue(Feature::for('first')->active('checkout')); + $this->assertFalse(Feature::for('first')->active('unknown')); + $this->assertFalse(Feature::for('first')->active('disabled')); + $this->assertSame('second', Feature::for('second')->value('layout')); + $payload = $this->app->make(PostHog::class)->evaluateFlags('first')->getFlagPayload('layout'); + $this->assertSame(['theme' => 'dark'], $payload); + $layoutExposures = array_filter($events, fn ($event) => $event['event'] === '$feature_flag_called' + && $event['distinct_id'] === 'first' && $event['properties']['$feature_flag'] === 'layout'); + $this->assertCount(1, $layoutExposures); + } + + public function testExplicitScopeNameResolutionDoesNotEvaluateTheAmbientIdentity(): void + { + $events = []; + $this->sdk(1, $events, 'explicit-b'); + $this->app->make(Context::class)->begin(['distinctId' => 'ambient-a']); + + $this->assertSame('explicit-b', Feature::for('explicit-b')->value('layout')); + $this->assertTrue(Feature::for('explicit-b')->active('checkout')); + $this->assertFalse(Feature::for('explicit-b')->active('disabled')); + $this->assertFalse(Feature::for('explicit-b')->active('unknown')); + $this->assertSame([], Feature::defined()); + } + + public function testDefinedListsNoLocalDefinitionsAndDoesNotPerformIo(): void + { + $events = []; + $this->sdk(0, $events); + $this->app->make(Context::class)->begin(['distinctId' => 'ambient-a']); + + $this->assertSame([], Feature::defined()); + $this->assertSame([], Feature::defined()); + $this->assertSame([], $events); + } + + public function testAllDiscoversExternalFlagsForTheExplicitScope(): void + { + $events = []; + $this->sdk(1, $events, 'explicit-b', [ + 'b-only' => ['enabled' => true, 'variant' => null], + 'unresolved' => ['failed' => true], + ]); + $this->app->make(Context::class)->begin(['distinctId' => 'ambient-a']); + + $this->assertSame([ + 'layout' => 'explicit-b', + 'checkout' => true, + 'disabled' => false, + 'b-only' => true, + ], Feature::for('explicit-b')->all()); + $this->assertCount(4, $events); + $this->assertSame('explicit-b', Feature::for('explicit-b')->value('layout')); + $this->assertCount(4, $events); + $this->assertFalse(Feature::for('explicit-b')->active('unresolved')); + $this->assertFalse(Feature::for('explicit-b')->active('unknown')); + } + + public function testLoadAllNormalizesTheScopeAndReusesTheNativeSnapshot(): void + { + $events = []; + $this->sdk(1, $events, 'custom-posthog'); + $this->app->make(Context::class)->begin(['distinctId' => 'ambient-a']); + $scope = new class implements FeatureScopeable { + public function toFeatureIdentifier(string $driver): mixed + { + return 'custom-' . $driver; + } + }; + + $this->assertSame([ + 'layout' => ['custom-posthog'], + 'checkout' => [true], + 'disabled' => [false], + ], Feature::for($scope)->loadAll()); + $this->assertSame('custom-posthog', Feature::for($scope)->value('layout')); + $this->assertTrue(Feature::for($scope)->active('checkout')); + $this->assertFalse(Feature::for($scope)->active('disabled')); + $this->assertCount(3, $events); + + Feature::flushCache(); + $this->assertSame('custom-posthog', Feature::for($scope)->value('layout')); + $this->assertSame('custom-posthog', $this->app->make(PostHog::class) + ->evaluateFlags('custom-posthog')->getFlag('layout')); + $this->assertCount(3, $events); + } + + public function testScopedKeyEnumerationDoesNotRecordExposure(): void + { + $events = []; + $this->sdk(1, $events, 'explicit-b'); + $this->app->make(Context::class)->begin(['distinctId' => 'ambient-a']); + + $this->assertSame( + ['layout', 'checkout', 'disabled'], + Feature::definedFeaturesForScope('explicit-b')->all(), + ); + $this->assertSame([], $events); + $this->assertSame([], Feature::defined()); + $this->assertSame('explicit-b', Feature::for('explicit-b')->value('layout')); + $this->assertCount(1, $events); + } + + public function testBulkReadsWithoutAnIdentityAreEmptyAndDoNotPerformIo(): void + { + $events = []; + $this->sdk(0, $events); + + $this->assertSame([], Feature::all()); + $this->assertSame([], Feature::loadAll()); + $this->assertFalse(Feature::value('unknown')); + $this->assertSame([], $events); + } + + public function testDisabledBulkReadsAreEmptyAndDoNotPerformIo(): void + { + $events = []; + $this->sdk(0, $events); + $this->app['config']->set('posthog.enabled', false); + $this->app->make(Context::class)->begin(['distinctId' => 'ambient-a']); + + $this->assertSame([], Feature::for('explicit-b')->all()); + $this->assertSame([], Feature::for('explicit-b')->loadAll()); + $this->assertSame([], $events); + $this->assertFalse(Feature::for('explicit-b')->value('unknown')); + } + + public static function scopedCallers(): array + { + return [ + 'anonymous caller, success' => [null, false], + 'anonymous caller, failure' => [null, true], + 'identified caller, success' => ['parent-owner', false], + 'identified caller, failure' => ['parent-owner', true], + ]; + } + + #[DataProvider('scopedCallers')] + public function testQueueCompletionConsumersCannotPolluteTheRestoredCallersCache( + ?string $parentId, + bool $fail, + ): void { + $this->app['config']->set('queue.default', 'sync'); + $this->app['config']->set('pennant-test.read-on-job-end', true); + $events = []; + $this->sdk($parentId === null ? 1 : 2, $events); + $context = $this->app->make(Context::class); + $context->begin($parentId === null ? [] : ['distinctId' => $parentId]); + $this->assertSame($parentId ?? false, Feature::value('layout')); + $parentSnapshot = $this->app->make(PostHog::class)->evaluateFlags(); + try { + Bus::dispatchSync($fail ? new FailingCaptureJob('job-owner') : new CaptureJob('job-owner')); + $this->assertFalse($fail, 'The failing job must rethrow its original exception'); + } catch (RuntimeException $exception) { + $this->assertTrue($fail); + $this->assertSame('failed job', $exception->getMessage()); + } + $this->assertSame('job-owner', $this->app['config']->get('pennant-test.job-value')); + $this->assertSame($parentId, $context->data()['distinctId'] ?? null); + $this->assertSame($parentId ?? false, Feature::value('layout')); + if ($parentId !== null) { + $this->assertSame($parentSnapshot, $this->app->make(PostHog::class)->evaluateFlags()); + } + } + + #[DataProvider('scopedCallers')] + public function testDirectCommandCallsClearPennantCacheOnEntryAndAfterCallerRestoration( + ?string $parentId, + bool $fail, + ): void { + $events = []; + $this->sdk($parentId === null ? 1 : 2, $events); + $context = $this->app->make(Context::class); + $observed = []; + Artisan::command('posthog:child', function () use ($context, $fail, &$observed) { + $context->begin(['distinctId' => 'child-owner']); + $observed['child'] = Feature::value('layout'); + if ($fail) { + throw new RuntimeException('child failed'); + } + return 0; + }); + Artisan::registerCommand(new ScopedCommand('posthog:parent', function (Command $command) use ( + $context, + $parentId, + &$observed, + ) { + $context->begin($parentId === null ? [] : ['distinctId' => $parentId]); + $observed['before'] = Feature::value('layout'); + $observed['beforeSnapshot'] = $this->app->make(PostHog::class)->evaluateFlags(); + $observed['exception'] = null; + try { + $command->call('posthog:child'); + } catch (RuntimeException $exception) { + $observed['exception'] = $exception->getMessage(); + } + $observed['after'] = Feature::value('layout'); + $observed['afterSnapshot'] = $this->app->make(PostHog::class)->evaluateFlags(); + return 0; + })); + + $this->assertSame(0, Artisan::call('posthog:parent')); + $this->assertSame($parentId ?? false, $observed['before']); + $this->assertSame('child-owner', $observed['child']); + $this->assertSame($parentId ?? false, $observed['after']); + $this->assertSame($fail ? 'child failed' : null, $observed['exception']); + if ($parentId !== null) { + $this->assertSame($observed['beforeSnapshot'], $observed['afterSnapshot']); + } + } + + public function testAnonymousPennantScopeDoesNotCacheAnotherRequestsIdentity(): void + { + $events = []; + $this->sdk(2, $events); + Route::get('/pennant/{id}', function ($id) { + return $this->app->make(Context::class)->with(['distinctId' => $id], fn () => Feature::value('layout')); + }); + $this->get('/pennant/first')->assertSee('first'); + $this->get('/pennant/second')->assertSee('second'); + } + + public function testModelScopeUsesTheSameIdentifierAsNativeCapture(): void + { + $events = []; + $this->sdk(1, $events); + $user = new \Illuminate\Foundation\Auth\User(); + $user->setKeyType('string'); + $user->setRawAttributes(['id' => 'account-123']); + $this->assertSame('account-123', Feature::for($user)->value('layout')); + $this->assertSame('account-123', $events[0]['distinct_id']); + } + + public function testPennantScopeContractIsResolvedBeforeTheDriverReceivesIt(): void + { + $events = []; + $this->sdk(1, $events); + $scope = new class implements FeatureScopeable { + public function toFeatureIdentifier(string $driver): mixed + { + return 'custom-' . $driver; + } + }; + $this->assertSame('custom-posthog', Feature::for($scope)->value('layout')); + $this->assertSame('custom-posthog', $events[0]['distinct_id']); + } + + public function testModelScopeContractTakesPrecedenceOverItsAuthenticationIdentifier(): void + { + $events = []; + $this->sdk(1, $events); + $user = new class extends \Illuminate\Foundation\Auth\User implements FeatureScopeable { + public function toFeatureIdentifier(string $driver): mixed + { + return 'model-' . $driver; + } + }; + $user->setRawAttributes(['id' => 123]); + $this->assertSame('model-posthog', Feature::for($user)->value('layout')); + $this->assertSame('model-posthog', $events[0]['distinct_id']); + } + + public static function writes(): array + { + return [ + ['define', ['remote', null]], + ['set', ['remote', 'user', true]], + ['setForAllScopes', ['remote', true]], + ['delete', ['remote', 'user']], + ['purge', [null]], + ]; + } + + #[DataProvider('writes')] + public function testRemoteFlagsCannotBeMutated(string $method, array $arguments): void + { + if ($method === 'define') { + $arguments[1] = fn () => true; + } + $this->expectException(LogicException::class); + (new PostHogDriver())->$method(...$arguments); + } + + private function sdk( + int $requests, + array &$events, + ?string $expectedIdentity = null, + array $extraFlags = [], + ): void { + $http = $this->createMock(HttpClient::class); + $http->expects($this->exactly($requests))->method('sendRequest')->willReturnCallback(function ( + $path, + $payload, + ) use ( + $expectedIdentity, + $extraFlags, + ) { + $this->assertSame('/flags/?v=2', $path); + $id = json_decode($payload, true)['distinct_id']; + if ($expectedIdentity !== null) { + $this->assertSame($expectedIdentity, $id); + } + return new HttpResponse(json_encode(['flags' => [ + 'layout' => ['enabled' => true, 'variant' => $id, 'metadata' => ['payload' => '{"theme":"dark"}']], + 'checkout' => ['enabled' => true, 'variant' => null], + 'disabled' => ['enabled' => false, 'variant' => null], + ] + $extraFlags]), 200); + }); + $sdk = new Client('test-key', [ + 'consumer' => 'noop', + 'before_send' => function ($event) use (&$events) { + $events[] = $event; + return $event; + }, + ], $http, loadFeatureFlags: false); + (new \ReflectionProperty(PostHog::class, 'client'))->setValue($this->app->make(PostHog::class), $sdk); + } +}