Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
34e0710
feat: add PHP framework integration primitives
marandaneto Oct 29, 2026
3501917
Merge framework integration APIs for Laravel runtime
marandaneto Oct 29, 2026
bec01c1
feat: add Laravel runtime foundation
marandaneto Oct 29, 2026
1536956
feat: propagate safe Laravel queue context and prepare package distri…
marandaneto Oct 29, 2026
18d9ac6
feat: enrich Laravel captures with safe request metadata
marandaneto Oct 29, 2026
04967bf
feat: add opt-in Laravel server event collectors
marandaneto Oct 29, 2026
53cdbc5
fix: preserve explicit and nested Laravel scopes through lifecycle ev…
marandaneto Oct 29, 2026
460d2d7
fix: retain failed command context until Laravel reports the exception
marandaneto Oct 29, 2026
33a5dc3
Merge branch 'feat/laravel-package-runtime' into feat/laravel-autocap…
marandaneto Oct 29, 2026
caa7a73
fix: preserve collector privacy defaults and session correlation
marandaneto Oct 29, 2026
47bec03
feat: add an optional read-only Laravel Pennant driver
marandaneto Oct 29, 2026
3f4e01d
Merge branch 'feat/laravel-autocapture' into feat/laravel-pennant
marandaneto Oct 29, 2026
3b2ad5a
fix: use explicit HTTP and console execution lifecycle boundaries
marandaneto Oct 29, 2026
f375d53
Merge branch 'feat/laravel-package-runtime' into feat/laravel-autocap…
marandaneto Oct 29, 2026
8480a6f
Merge branch 'feat/laravel-autocapture' into feat/laravel-pennant
marandaneto Oct 29, 2026
5465c69
test: construct synthetic URL credentials without secret-scan false p…
marandaneto Oct 29, 2026
2f07fde
test: verify Pennant normalizes custom and model scope contracts
marandaneto Oct 29, 2026
ce94a3f
Merge branch 'feat/laravel-autocapture' into feat/laravel-pennant
marandaneto Oct 29, 2026
0377f64
Merge branch 'feat/laravel-autocapture' into feat/laravel-pennant
marandaneto Oct 29, 2026
0e3243e
fix: capture explicitly failed Laravel jobs as failures
marandaneto Oct 29, 2026
8773c43
fix: address framework integration review feedback
marandaneto Oct 29, 2026
5e93cc5
fix(laravel): discover Pennant flags for the requested scope
marandaneto Oct 29, 2026
ecbc8cc
fix(flags): retain snapshot context for lazy exposure events
marandaneto Oct 29, 2026
d9d9897
fix(laravel): bridge runtime scopes and isolate diagnostics
marandaneto Oct 29, 2026
07e50c1
Merge signed core flag exposure origin-context dependency
marandaneto Oct 29, 2026
26ced2f
fix(laravel): clear Pennant cache after restoring queue context
marandaneto Oct 29, 2026
8526b32
fix(laravel): preserve raw context precedence in manager getter
marandaneto Oct 29, 2026
9601245
fix(laravel): bound deferred model events to their owning transactions
marandaneto Oct 29, 2026
893c8c5
fix(laravel): omit outcomes for overlap-skipped schedules
marandaneto Oct 29, 2026
4387cf8
Merge commit '8526b3285e78d06b33ae1895fa72f86d353b8f8c' into feat/lar…
marandaneto Oct 29, 2026
4e953ec
Merge branch 'feat/laravel-autocapture' into feat/laravel-pennant
marandaneto Oct 29, 2026
a33384a
fix: make library metadata and release id internal integration overrides
marandaneto Oct 29, 2026
66063ab
Merge internal core integration overrides for Laravel runtime
marandaneto Oct 29, 2026
5a9958f
fix(laravel): use internal client overrides for library metadata and …
marandaneto Oct 29, 2026
b63cd37
fix(laravel): release model buffer on commit failure retry
marandaneto Oct 29, 2026
71ac3bc
Merge commit '5a9958f239dfe68744597c5df894cd6aca4fac11' into feat/lar…
marandaneto Oct 29, 2026
b2d9730
Merge branch 'feat/laravel-autocapture' into feat/laravel-pennant
marandaneto Oct 29, 2026
dc28ca9
docs: explain when to use withContext versus enterContext
marandaneto Oct 29, 2026
4848a82
Merge commit 'dc28ca9bb1e36de1c377017243391d41aac246a2' into feat/lar…
marandaneto Oct 29, 2026
bffd72a
Merge branch 'feat/laravel-autocapture' into feat/laravel-pennant
marandaneto Oct 29, 2026
8ffd0ad
Merge branch 'feat/laravel-package-runtime' into feat/laravel-autocap…
marandaneto Oct 29, 2026
e23f1fc
feat(laravel): isolate direct child command calls with opt-in trait
marandaneto Oct 29, 2026
d6848b6
Merge command isolation into Pennant integration
marandaneto Oct 29, 2026
c8cf8b0
Merge runtime command isolation into collectors
marandaneto Oct 29, 2026
3321cfb
fix(laravel): clear Pennant cache across direct command scopes
marandaneto Oct 29, 2026
858c204
fix: address framework primitive review feedback
marandaneto Oct 29, 2026
944a3bd
Merge framework review fixes into runtime
marandaneto Oct 29, 2026
c70c493
Merge framework review fixes into autocapture
marandaneto Oct 29, 2026
31abada
Merge framework review fixes into pennant
marandaneto Oct 29, 2026
4383d7f
Merge remote-tracking branch 'origin/main' into feat/laravel-pennant
marandaneto Oct 30, 2026
370eb1a
chore: drop changeset already released in posthog-php 4.20.0
marandaneto Oct 30, 2026
f79cf5f
Merge remote-tracking branch 'origin/main' into feat/laravel-pennant
marandaneto Nov 2, 2026
fbd861a
chore: remove stale Laravel runtime release intent
marandaneto Nov 2, 2026
1f4138e
fix(laravel): keep Pennant implementation classes internal
marandaneto Nov 2, 2026
0dca396
chore: defer Laravel release intents
marandaneto Nov 2, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ xdebug.log
.DS_Store
.env
node_modules/
/.laravel-changes/
75 changes: 75 additions & 0 deletions packages/laravel/PENNANT.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions packages/laravel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion packages/laravel/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}},
Expand Down
2 changes: 2 additions & 0 deletions packages/laravel/src/ConsoleLifecycle.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -86,6 +87,7 @@ private function leave(bool $flush = true): void
} catch (Throwable) {
} finally {
$this->app->make(Context::class)->leave();
Features\PennantCache::flush();
}
}
}
25 changes: 25 additions & 0 deletions packages/laravel/src/Features/PennantCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace PostHog\Laravel\Features;

use Illuminate\Container\Container;
use Laravel\Pennant\FeatureManager;

/** @internal Pennant caches values independently of the SDK's scoped snapshots. */
final class PennantCache
{
public static function flush(): void
{
$app = Container::getInstance();
if (!$app->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();
}
}
}
}
19 changes: 19 additions & 0 deletions packages/laravel/src/Features/PennantMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace PostHog\Laravel\Features;

use Closure;

/** @internal */
final class PennantMiddleware
{
public function handle($request, Closure $next): mixed
{
PennantCache::flush();
try {
return $next($request);
} finally {
PennantCache::flush();
}
}
}
31 changes: 31 additions & 0 deletions packages/laravel/src/Features/PennantServiceProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace PostHog\Laravel\Features;

use Illuminate\Contracts\Http\Kernel;
use Illuminate\Support\ServiceProvider;
use Laravel\Pennant\FeatureManager;

/** @internal */
final class PennantServiceProvider extends ServiceProvider
{
public function boot(): void
{
if ($this->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,
Comment thread
marandaneto marked this conversation as resolved.
Resolved
\Illuminate\Console\Events\CommandStarting::class,
\Illuminate\Console\Events\CommandFinished::class,
], fn () => PennantCache::flush());
}
}
86 changes: 86 additions & 0 deletions packages/laravel/src/Features/PostHogDriver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

namespace PostHog\Laravel\Features;

use Illuminate\Container\Container;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
use Laravel\Pennant\Contracts\DefinesFeaturesExternally;
use Laravel\Pennant\Contracts\Driver;
use LogicException;
use PostHog\Laravel\PostHog;

/** @internal */
final class PostHogDriver implements Driver, DefinesFeaturesExternally
{
public function get(string $feature, mixed $scope): mixed
{
$id = $this->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.');
}
}
4 changes: 4 additions & 0 deletions packages/laravel/src/PostHogServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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();
}
}
}
4 changes: 4 additions & 0 deletions packages/laravel/tests/ApiSurfaceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down
20 changes: 20 additions & 0 deletions packages/laravel/tests/Fixtures/PennantEndEventProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace PostHog\Laravel\Tests\Fixtures;

use Illuminate\Queue\Events\JobExceptionOccurred;
use Illuminate\Queue\Events\JobProcessed;
use Illuminate\Support\ServiceProvider;
use Laravel\Pennant\Feature;

class PennantEndEventProvider extends ServiceProvider
{
public function boot(): void
{
$this->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'));
}
});
}
}
Loading
Loading