From 34e07100d9ce06f2c56312a9d1072a59574ffed8 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 09:25:09 +0100 Subject: [PATCH 01/37] feat: add PHP framework integration primitives --- .changeset/young-onions-hide.md | 5 + api/public-api.json | 176 ++++++++++++++++++++++++++++ lib/Client.php | 90 +++++++++++++-- lib/ContextScope.php | 27 +++++ lib/PostHog.php | 57 ++++----- lib/RequestContext.php | 27 +++-- test/FrameworkIntegrationTest.php | 184 ++++++++++++++++++++++++++++++ 7 files changed, 510 insertions(+), 56 deletions(-) create mode 100644 .changeset/young-onions-hide.md create mode 100644 lib/ContextScope.php create mode 100644 test/FrameworkIntegrationTest.php diff --git a/.changeset/young-onions-hide.md b/.changeset/young-onions-hide.md new file mode 100644 index 00000000..9a218d4e --- /dev/null +++ b/.changeset/young-onions-hide.md @@ -0,0 +1,5 @@ +--- +"posthog-php": minor +--- + +Add instance group identify, lifecycle context scopes, framework exception metadata, and explicit library and release configuration. diff --git a/api/public-api.json b/api/public-api.json index 81b148bb..2196769f 100644 --- a/api/public-api.json +++ b/api/public-api.json @@ -203,6 +203,16 @@ "default": [], "defaultConstant": null, "hasDefault": true + }, + { + "name": "options", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true } ] }, @@ -292,6 +302,34 @@ } ] }, + "enterContext": { + "static": false, + "abstract": false, + "final": false, + "returnType": "PostHog\\ContextScope", + "parameters": [ + { + "name": "data", + "type": "array", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "options", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + } + ] + }, "evaluateFlags": { "static": false, "abstract": false, @@ -779,6 +817,24 @@ "returnType": "?string", "parameters": [] }, + "groupIdentify": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [ + { + "name": "message", + "type": "array", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, "identify": { "static": false, "abstract": false, @@ -1570,6 +1626,50 @@ } } }, + "PostHog\\ContextScope": { + "type": "class", + "abstract": false, + "final": true, + "readonly": false, + "extends": null, + "implements": [], + "constants": [], + "properties": [], + "methods": { + "__construct": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [ + { + "name": "onClose", + "type": "?Closure", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, + "__destruct": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [] + }, + "close": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [] + } + } + }, "PostHog\\EvaluatedFlagRecord": { "type": "class", "abstract": false, @@ -3750,6 +3850,16 @@ "default": [], "defaultConstant": null, "hasDefault": true + }, + { + "name": "options", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true } ] }, @@ -3771,6 +3881,34 @@ } ] }, + "enterContext": { + "static": true, + "abstract": false, + "final": false, + "returnType": "PostHog\\ContextScope", + "parameters": [ + { + "name": "data", + "type": "array", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "options", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + } + ] + }, "evaluateFlags": { "static": true, "abstract": false, @@ -4589,6 +4727,44 @@ } ] }, + "enterContext": { + "static": true, + "abstract": false, + "final": false, + "returnType": "PostHog\\ContextScope", + "parameters": [ + { + "name": "data", + "type": "array", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "options", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "contextKey", + "type": "?int", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + } + ] + }, "getContext": { "static": true, "abstract": false, diff --git a/lib/Client.php b/lib/Client.php index a857ca36..88ba9a57 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -273,6 +273,9 @@ class Client implements FeatureFlagEvaluationsHost * * @param array{ * host?: string, + * library_name?: string, + * library_version?: string, + * release_id?: string|null, * ssl?: bool, * timeout?: int|float, * verify_batch_events_request?: bool, @@ -330,7 +333,14 @@ public function __construct( $this->flagDefinitionCacheProviderShutdown = false; $this->shutdownComplete = false; $envReleaseId = getenv(self::ENV_RELEASE_ID); - $this->releaseId = $envReleaseId === false ? null : StringNormalizer::normalizeOptional($envReleaseId); + $releaseId = array_key_exists('release_id', $options) + ? $options['release_id'] + : ($envReleaseId === false ? null : $envReleaseId); + $this->releaseId = StringNormalizer::normalizeOptional($releaseId); + $this->options['library_name'] = StringNormalizer::normalizeOptional($options['library_name'] ?? null) + ?? PostHog::LIBRARY; + $this->options['library_version'] = StringNormalizer::normalizeOptional($options['library_version'] ?? null) + ?? PostHog::VERSION; $this->options['host'] = StringNormalizer::normalizeHost($options['host'] ?? null); if (!$this->enabled) { if (($this->options['consumer'] ?? null) !== 'noop') { @@ -629,12 +639,15 @@ private function shouldSendMinimalFlagCalledEvent(array $message): bool * @param \Throwable|string $exception The exception to capture or a plain string message * @param string|null $distinctId User ID; a random UUID is used when omitted (no person profile created) * @param array $additionalProperties Extra properties merged into the event + * @param array{handled?: bool, mechanism?: string} $options Framework boundary metadata. + * Manual capture defaults to handled; integrations may explicitly mark a failed boundary. * @return bool whether the capture call succeeded */ public function captureException( \Throwable|string $exception, ?string $distinctId = null, - array $additionalProperties = [] + array $additionalProperties = [], + array $options = [] ): bool { $errorTrackingConfig = $this->options['error_tracking'] ?? []; $maxFrames = max(0, (int) ($errorTrackingConfig['max_frames'] ?? 20)); @@ -644,6 +657,11 @@ public function captureException( return false; } + $exceptionList = ExceptionPayloadBuilder::overridePrimaryMechanism($exceptionList, [ + 'type' => $options['mechanism'] ?? 'generic', + 'handled' => $options['handled'] ?? true, + ]); + $traceProperties = array_diff_key( ExceptionPayloadBuilder::getTraceProperties(), $this->getContext()['properties'] ?? [] @@ -669,6 +687,41 @@ public function captureException( return $this->capture($message); } + /** + * Update a group using explicit group identity, without inheriting request context. + * + * @param array{groupType: string, groupKey: string, properties?: array, + * distinctId?: string, distinct_id?: string, disableGeoip?: bool} $message + */ + public function groupIdentify(array $message): bool + { + if (empty($message['groupType'])) { + throw new Exception('PostHog::groupIdentify() expects a groupType'); + } + if (empty($message['groupKey'])) { + throw new Exception('PostHog::groupIdentify() expects a groupKey'); + } + + $distinctId = "\${$message['groupType']}_{$message['groupKey']}"; + foreach (['distinctId', 'distinct_id'] as $key) { + if (isset($message[$key]) && is_scalar($message[$key]) && (string) $message[$key] !== '') { + $distinctId = (string) $message[$key]; + break; + } + } + + return $this->capture([ + 'event' => '$groupidentify', + 'distinctId' => $distinctId, + 'disableGeoip' => $message['disableGeoip'] ?? null, + 'properties' => [ + '$group_type' => $message['groupType'], + '$group_key' => $message['groupKey'], + '$group_set' => $message['properties'] ?? [], + ], + ]); + } + /** * Tags properties about the user. * @@ -1977,7 +2030,7 @@ public function localFlags(): HttpResponse $headers = [ // Send user agent in the form of {library_name}/{library_version} as per RFC 7231. - "User-Agent: " . PostHog::LIBRARY . "/" . PostHog::VERSION, + "User-Agent: " . $this->options['library_name'] . "/" . $this->options['library_version'], "Authorization: Bearer " . $this->secretKey ]; @@ -2184,7 +2237,7 @@ private function sendFeatureFlagsRequest(array $payload): HttpResponse $requestPayload, [ // Send user agent in the form of {library_name}/{library_version} as per RFC 7231. - "User-Agent: " . PostHog::LIBRARY . "/" . PostHog::VERSION, + "User-Agent: " . $this->options['library_name'] . "/" . $this->options['library_version'], ], [ "shouldRetry" => false, @@ -2383,6 +2436,22 @@ public function withContext(array $data, callable $fn, array $options = []): mix }, $options, $this->contextKey()); } + /** + * Enter context across framework lifecycle callbacks. Close the returned scope in a finally block. + * + * @param array $data + * @param array{fresh?: bool} $options + */ + public function enterContext(array $data, array $options = []): ContextScope + { + $scope = RequestContext::enterContext($data, $options, $this->contextKey()); + Internal\SpanContext::enrichCurrent($this); + // The bound closure keeps this client alive so its context key cannot be reused before close. + return new ContextScope(function () use ($scope): void { + $scope->close(); + }); + } + /** * Get the currently active request context for this client, if any. * @@ -2543,19 +2612,22 @@ private function message($msg) unset($msg["type"], $msg["library"], $msg["library_version"], $msg["library_consumer"]); if (!array_key_exists('$lib', $msg["properties"])) { - $msg["properties"]['$lib'] = is_scalar($legacyLibrary) && (string) $legacyLibrary !== '' + $msg["properties"]['$lib'] = is_scalar($legacyLibrary) + && (string) $legacyLibrary !== '' ? (string) $legacyLibrary - : PostHog::LIBRARY; + : $this->options['library_name']; } if (!array_key_exists('$lib_version', $msg["properties"])) { - $msg["properties"]['$lib_version'] = is_scalar($legacyLibraryVersion) && (string) $legacyLibraryVersion !== '' + $msg["properties"]['$lib_version'] = is_scalar($legacyLibraryVersion) + && (string) $legacyLibraryVersion !== '' ? (string) $legacyLibraryVersion - : PostHog::VERSION; + : $this->options['library_version']; } if (!array_key_exists('$lib_consumer', $msg["properties"])) { - $msg["properties"]['$lib_consumer'] = is_scalar($legacyLibraryConsumer) && (string) $legacyLibraryConsumer !== '' + $msg["properties"]['$lib_consumer'] = is_scalar($legacyLibraryConsumer) + && (string) $legacyLibraryConsumer !== '' ? (string) $legacyLibraryConsumer : $this->consumer->getConsumer(); } diff --git a/lib/ContextScope.php b/lib/ContextScope.php new file mode 100644 index 00000000..c6e41901 --- /dev/null +++ b/lib/ContextScope.php @@ -0,0 +1,27 @@ +onClose; + $this->onClose = null; + if ($close !== null) { + $close(); + } + } + + public function __destruct() + { + $this->close(); + } +} diff --git a/lib/PostHog.php b/lib/PostHog.php index c4d4058b..843706fc 100644 --- a/lib/PostHog.php +++ b/lib/PostHog.php @@ -41,6 +41,9 @@ class PostHog * * @param array{ * host?: string, + * library_name?: string, + * library_version?: string, + * release_id?: string|null, * ssl?: bool, * timeout?: int|float, * verify_batch_events_request?: bool, @@ -124,16 +127,18 @@ public static function init( * @param \Throwable|string $exception The exception to capture or a plain string message. * @param string|null $distinctId User ID; a random UUID is used when omitted (no person profile created). * @param array $additionalProperties Extra properties merged into the event. + * @param array{handled?: bool, mechanism?: string} $options Framework boundary metadata. * @return bool Whether the capture call succeeded. * @throws Exception */ public static function captureException( \Throwable|string $exception, ?string $distinctId = null, - array $additionalProperties = [] + array $additionalProperties = [], + array $options = [] ): bool { self::checkClient(); - return self::$client->captureException($exception, $distinctId, $additionalProperties); + return self::$client->captureException($exception, $distinctId, $additionalProperties, $options); } /** @@ -214,40 +219,8 @@ public static function identify(array $message) */ public static function groupIdentify(array $message) { - self::assert(!empty($message["groupType"]), "PostHog::groupIdentify() expects a groupType"); - self::assert(!empty($message["groupKey"]), "PostHog::groupIdentify() expects a groupKey"); - - if (!isset($message["properties"])) { - $message["properties"] = array(); - } - - $distinctId = "\${$message['groupType']}_{$message['groupKey']}"; - if ( - array_key_exists("distinctId", $message) - && is_scalar($message["distinctId"]) - && (string) $message["distinctId"] !== "" - ) { - $distinctId = (string) $message["distinctId"]; - } elseif ( - array_key_exists("distinct_id", $message) - && is_scalar($message["distinct_id"]) - && (string) $message["distinct_id"] !== "" - ) { - $distinctId = (string) $message["distinct_id"]; - } - - $msg = array( - "event" => "\$groupidentify", - "distinctId" => $distinctId, - "disableGeoip" => $message["disableGeoip"] ?? null, - "properties" => array( - "\$group_type" => $message["groupType"], - "\$group_key" => $message["groupKey"], - "\$group_set" => $message["properties"], - ) - ); - - return self::capture($msg); + self::checkClient(); + return self::$client->groupIdentify($message); } /** @@ -519,6 +492,18 @@ public static function withContext(array $data, callable $fn, array $options = [ return self::$client->withContext($data, $fn, $options); } + /** + * Enter framework context until the returned scope is closed. + * + * @param array $data + * @param array{fresh?: bool} $options + */ + public static function enterContext(array $data, array $options = []): ContextScope + { + self::checkClient(); + return self::$client->enterContext($data, $options); + } + /** * Get the currently active request context for this client, if any. * diff --git a/lib/RequestContext.php b/lib/RequestContext.php index edd9e3cf..62ae3aa9 100644 --- a/lib/RequestContext.php +++ b/lib/RequestContext.php @@ -40,16 +40,26 @@ public static function withContext( array $options = [], ?int $contextKey = null ): mixed { - $contextKey = self::contextKey($contextKey); - $scopeId = self::push($data, (bool) ($options['fresh'] ?? false), $contextKey); - + $scope = self::enterContext($data, $options, $contextKey); try { return $fn(); } finally { - self::pop($scopeId, $contextKey); + $scope->close(); } } + /** @internal Use Client::enterContext() to keep the owning client alive. */ + public static function enterContext(array $data, array $options = [], ?int $contextKey = null): ContextScope + { + $contextKey = self::contextKey($contextKey); + $fiber = \Fiber::getCurrent(); + $scopeId = self::push($data, (bool) ($options['fresh'] ?? false), $contextKey); + + return new ContextScope(static function () use ($scopeId, $contextKey, $fiber): void { + self::pop($scopeId, $contextKey, $fiber === null ? 0 : spl_object_id($fiber)); + }); + } + /** * Get the current context for a context scope. * @@ -169,9 +179,8 @@ private static function push(array $data, bool $fresh, int $contextKey): int return $scopeId; } - private static function pop(int $scopeId, int $contextKey): void + private static function pop(int $scopeId, int $contextKey, int $fiberKey): void { - $fiberKey = self::fiberKey(); if (empty(self::$stacks[$contextKey][$fiberKey])) { return; } @@ -185,11 +194,7 @@ private static function pop(int $scopeId, int $contextKey): void foreach (self::$stacks[$contextKey][$fiberKey] as $index => $frame) { if ($frame['scopeId'] === $scopeId) { - self::$stacks[$contextKey][$fiberKey] = array_slice( - self::$stacks[$contextKey][$fiberKey], - 0, - $index - ); + array_splice(self::$stacks[$contextKey][$fiberKey], $index, 1); self::unsetStackIfEmpty($contextKey, $fiberKey); return; } diff --git a/test/FrameworkIntegrationTest.php b/test/FrameworkIntegrationTest.php new file mode 100644 index 00000000..2dfd323e --- /dev/null +++ b/test/FrameworkIntegrationTest.php @@ -0,0 +1,184 @@ +http = new MockedHttpClient('localhost'); + return new Client('test-key', $options, $this->http, loadFeatureFlags: false); + } + + private function events(Client $client): array + { + $client->flush(); + $events = []; + foreach ($this->http->calls ?? [] as $call) { + if ($call['path'] === '/batch/') { + $events = array_merge($events, json_decode($call['payload'], true)['batch']); + } + } + return $events; + } + + public function testInstanceGroupIdentifyDoesNotInheritRequestIdentity(): void + { + $client = $this->client(); + $client->withContext( + ['distinctId' => 'request-user', 'properties' => ['secret' => 'private']], + function () use ($client) { + $client->groupIdentify([ + 'groupType' => 'company', 'groupKey' => 'acme', 'properties' => ['plan' => 'pro'], + ]); + } + ); + $event = $this->events($client)[0]; + self::assertSame('$groupidentify', $event['event']); + self::assertSame('$company_acme', $event['distinct_id']); + self::assertSame('acme', $event['properties']['$group_key']); + self::assertSame(['plan' => 'pro'], $event['properties']['$group_set']); + self::assertArrayNotHasKey('secret', $event['properties']); + } + + public function testDetachedScopesRestoreOuterContextAndCloseOnlyOnce(): void + { + $client = $this->client(); + $outer = $client->enterContext(['distinctId' => 'outer']); + $inner = $client->enterContext(['distinctId' => 'inner'], ['fresh' => true]); + $client->capture(['event' => 'inside']); + $inner->close(); + $inner->close(); + $client->capture(['event' => 'outside']); + $outer->close(); + self::assertNull($client->getContext()); + self::assertSame(['inner', 'outer'], array_column($this->events($client), 'distinct_id')); + } + + public function testClosingOuterScopeDoesNotRemoveAYoungerScope(): void + { + $client = $this->client(); + $outer = $client->enterContext(['distinctId' => 'outer']); + $inner = $client->enterContext(['distinctId' => 'inner'], ['fresh' => true]); + $outer->close(); + self::assertSame('inner', $client->getContext()['distinctId']); + $inner->close(); + self::assertNull($client->getContext()); + } + + public function testClosingScopeFromAnotherFiberDoesNotClearItsContext(): void + { + $client = $this->client(); + $fiber = new Fiber(function () use ($client) { + $scope = $client->enterContext(['distinctId' => 'fiber-user']); + Fiber::suspend($scope); + self::assertNull($client->getContext()); + }); + $fiberScope = $fiber->start(); + $mainScope = $client->enterContext(['distinctId' => 'main-user']); + $fiberScope->close(); + self::assertSame('main-user', $client->getContext()['distinctId']); + $fiber->resume(); + $mainScope->close(); + self::assertNull($client->getContext()); + } + + public function testScopeDestructionClosesItsContext(): void + { + $client = $this->client(); + $scope = $client->enterContext(['distinctId' => 'temporary']); + unset($scope); + self::assertNull($client->getContext()); + } + + public function testFrameworkExceptionOptionsPreserveChainAndStack(): void + { + $client = $this->client(); + $exception = new RuntimeException('outer', previous: new RuntimeException('root')); + $client->captureException($exception, 'user', ['$exception_list' => [], '$exception_handled' => true], [ + 'handled' => false, + 'mechanism' => 'auto.framework', + ]); + $event = $this->events($client)[0]; + self::assertFalse($event['properties']['$exception_handled']); + $list = $event['properties']['$exception_list']; + self::assertCount(2, $list); + self::assertSame('outer', $list[0]['value']); + self::assertSame('root', $list[1]['value']); + self::assertSame(['type' => 'auto.framework', 'handled' => false], $list[0]['mechanism']); + self::assertTrue($list[1]['mechanism']['handled']); + self::assertNotEmpty($list[0]['stacktrace']['frames']); + } + + public function testManualExceptionsStayHandledByDefault(): void + { + $client = $this->client(); + $client->captureException(new RuntimeException('handled')); + $event = $this->events($client)[0]; + self::assertTrue($event['properties']['$exception_handled']); + self::assertSame('generic', $event['properties']['$exception_list'][0]['mechanism']['type']); + } + + public function testConfiguredLibraryAndReleaseApplyAcrossEventTypes(): void + { + $client = $this->client([ + 'library_name' => 'posthog-laravel', 'library_version' => '0.1.0', 'release_id' => 'release', + ]); + $client->capture(['event' => 'purchase']); + $client->identify(['distinctId' => 'user', 'properties' => ['plan' => 'pro']]); + $client->alias(['distinctId' => 'user', 'alias' => 'old-user']); + $client->groupIdentify(['groupType' => 'company', 'groupKey' => 'acme']); + $client->captureException('error'); + $events = $this->events($client); + self::assertCount(5, $events); + foreach ($events as $event) { + self::assertSame('posthog-laravel', $event['properties']['$lib']); + self::assertSame('0.1.0', $event['properties']['$lib_version']); + self::assertSame('release', $event['properties']['$release_id']); + } + } + + public function testConfiguredLibraryIsUsedForFlagHttpRequests(): void + { + $client = $this->client(['library_name' => 'posthog-laravel', 'library_version' => '0.1.0']); + $client->evaluateFlags('user'); + self::assertContains('User-Agent: posthog-laravel/0.1.0', $this->http->calls[0]['extraHeaders']); + } + + public function testReleaseOptionOverridesEnvironmentAndNullDisablesIt(): void + { + $previous = getenv('POSTHOG_RELEASE_ID'); + putenv('POSTHOG_RELEASE_ID=environment-release'); + try { + $client = $this->client(['release_id' => 'configured-release']); + $client->capture(['event' => 'configured']); + self::assertSame('configured-release', $this->events($client)[0]['properties']['$release_id']); + $client = $this->client(['release_id' => null]); + $client->capture(['event' => 'disabled']); + self::assertArrayNotHasKey('$release_id', $this->events($client)[0]['properties']); + } finally { + putenv($previous === false ? 'POSTHOG_RELEASE_ID' : 'POSTHOG_RELEASE_ID=' . $previous); + } + } + + public function testExplicitEventMetadataWinsOverClientOptions(): void + { + $client = $this->client([ + 'library_name' => 'integration', 'library_version' => '0.1.0', 'release_id' => 'default', + ]); + $client->capture(['event' => 'custom', 'properties' => [ + '$lib' => 'custom', '$lib_version' => '2.0.0', '$release_id' => 'explicit', + ]]); + $properties = $this->events($client)[0]['properties']; + self::assertSame('custom', $properties['$lib']); + self::assertSame('2.0.0', $properties['$lib_version']); + self::assertSame('explicit', $properties['$release_id']); + } +} From bec01c1c8eba95f59db4753cbc9211e361815e33 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 09:39:22 +0100 Subject: [PATCH 02/37] feat: add Laravel runtime foundation --- packages/laravel/.gitignore | 3 + packages/laravel/LICENSE.md | 23 + packages/laravel/README.md | 222 ++++++++ packages/laravel/composer.json | 24 + packages/laravel/config/posthog.php | 25 + packages/laravel/phpunit.xml | 4 + .../laravel/src/Commands/DoctorCommand.php | 30 ++ .../laravel/src/Commands/InstallCommand.php | 22 + packages/laravel/src/Commands/TestCommand.php | 29 ++ packages/laravel/src/Context.php | 114 +++++ packages/laravel/src/ExceptionReporter.php | 71 +++ packages/laravel/src/Facades/PostHog.php | 23 + .../src/FlagDefinitionCacheProvider.php | 37 ++ .../laravel/src/Middleware/RequestContext.php | 35 ++ packages/laravel/src/PostHog.php | 226 ++++++++ .../laravel/src/PostHogServiceProvider.php | 122 +++++ packages/laravel/src/Testing/PostHogFake.php | 156 ++++++ .../tests/Fixtures/CaptureCallback.php | 14 + .../laravel/tests/Fixtures/CaptureJob.php | 24 + .../tests/Fixtures/FailingCaptureJob.php | 16 + .../laravel/tests/Fixtures/FlushClient.php | 26 + .../laravel/tests/Fixtures/GroupsResolver.php | 11 + .../tests/Fixtures/IdentityResolver.php | 11 + .../laravel/tests/Fixtures/IgnoredFailure.php | 9 + .../tests/Fixtures/IgnoredOctaneFailure.php | 9 + .../tests/Fixtures/ScopedFailingJob.php | 17 + packages/laravel/tests/OctaneTest.php | 86 ++++ packages/laravel/tests/RuntimeTest.php | 482 ++++++++++++++++++ 28 files changed, 1871 insertions(+) create mode 100644 packages/laravel/.gitignore create mode 100644 packages/laravel/LICENSE.md create mode 100644 packages/laravel/README.md create mode 100644 packages/laravel/composer.json create mode 100644 packages/laravel/config/posthog.php create mode 100644 packages/laravel/phpunit.xml create mode 100644 packages/laravel/src/Commands/DoctorCommand.php create mode 100644 packages/laravel/src/Commands/InstallCommand.php create mode 100644 packages/laravel/src/Commands/TestCommand.php create mode 100644 packages/laravel/src/Context.php create mode 100644 packages/laravel/src/ExceptionReporter.php create mode 100644 packages/laravel/src/Facades/PostHog.php create mode 100644 packages/laravel/src/FlagDefinitionCacheProvider.php create mode 100644 packages/laravel/src/Middleware/RequestContext.php create mode 100644 packages/laravel/src/PostHog.php create mode 100644 packages/laravel/src/PostHogServiceProvider.php create mode 100644 packages/laravel/src/Testing/PostHogFake.php create mode 100644 packages/laravel/tests/Fixtures/CaptureCallback.php create mode 100644 packages/laravel/tests/Fixtures/CaptureJob.php create mode 100644 packages/laravel/tests/Fixtures/FailingCaptureJob.php create mode 100644 packages/laravel/tests/Fixtures/FlushClient.php create mode 100644 packages/laravel/tests/Fixtures/GroupsResolver.php create mode 100644 packages/laravel/tests/Fixtures/IdentityResolver.php create mode 100644 packages/laravel/tests/Fixtures/IgnoredFailure.php create mode 100644 packages/laravel/tests/Fixtures/IgnoredOctaneFailure.php create mode 100644 packages/laravel/tests/Fixtures/ScopedFailingJob.php create mode 100644 packages/laravel/tests/OctaneTest.php create mode 100644 packages/laravel/tests/RuntimeTest.php diff --git a/packages/laravel/.gitignore b/packages/laravel/.gitignore new file mode 100644 index 00000000..559dc605 --- /dev/null +++ b/packages/laravel/.gitignore @@ -0,0 +1,3 @@ +/vendor/ +/composer.lock +/.phpunit.cache/ diff --git a/packages/laravel/LICENSE.md b/packages/laravel/LICENSE.md new file mode 100644 index 00000000..2c58e5d9 --- /dev/null +++ b/packages/laravel/LICENSE.md @@ -0,0 +1,23 @@ +MIT License + +Copyright (c) 2020 PostHog (part of Hiberly Inc) + +Copyright (c) 2014 Segment Inc. friends@segment.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/laravel/README.md b/packages/laravel/README.md new file mode 100644 index 00000000..ebf74451 --- /dev/null +++ b/packages/laravel/README.md @@ -0,0 +1,222 @@ +# PostHog for Laravel + +Server-side analytics, feature flags and exception reporting for Laravel 12 and 13. +Requires PHP 8.2+ (Laravel 13 requires PHP 8.3+). + +## Install + +```sh +composer require posthog/posthog-laravel +php artisan posthog:install +``` + +Discovery registers the provider, facade and scoped container bindings automatically. +For applications without discovery, register `PostHog\Laravel\PostHogServiceProvider`. + +Configure your **server environment**: + +```dotenv +POSTHOG_API_KEY=phc_your_project_key +POSTHOG_HOST=https://us.i.posthog.com +POSTHOG_ENABLED=true +``` + +Use your project's ingestion host. `POSTHOG_SECRET_KEY` is optional and enables local +flag evaluation. Never publish secret keys in browser configuration, logs, or event +properties. No browser SDK or browser configuration endpoint is installed. + +A missing/blank project key or `POSTHOG_ENABLED=false` disables all SDK network paths, +including flag requests and the `client()` escape hatch. Setting `sdk.consumer=noop` +only changes event transport: a valid-key client can still evaluate flags remotely. + +## Capture and identity + +```php +use PostHog\Laravel\Facades\PostHog; + +PostHog::capture('checkout completed', ['items' => 2]); +PostHog::capture('import completed', ['rows' => 42], 'account-123', ['team' => 'team-1']); +PostHog::identify(['distinctId' => 'account-123', 'properties' => ['plan' => 'paid']]); +PostHog::alias(['distinctId' => 'account-123', 'alias' => 'old-account']); +PostHog::groupIdentify(['groupType' => 'team', 'groupKey' => 'team-1', 'properties' => ['plan' => 'paid']]); +``` + +Inject `PostHog\Laravel\PostHog` to use the same API without a facade. `posthog` is a +container alias. `PostHog\Client` is also injectable, but raw client calls require +explicit identity or an explicit core context. + +Identity precedence: an explicit API `distinctId` wins, then the authenticated +request user's auth identifier, then `X-PostHog-Distinct-Id` as an anonymous analytics +fallback. `X-PostHog-Session-Id` provides session correlation. Headers are untrusted +analytics input, **never authentication or authorization**. Set `header_context=false` +to ignore both headers. No anonymous ID is generated for requests without identity. + +By default there is no collection of raw URLs, query strings, request bodies, +cookies, email, IP addresses, or user profile fields. GeoIP is disabled by default +(`sdk.disableGeoip=true`). Application-supplied properties and exception messages may +still contain sensitive information; use `sdk.before_send` to redact or drop events. +Exception stacks are serialized by the PHP SDK. + +For a custom identity resolver, set `distinct_id` to an invokable container class. +It receives the application and returns a string identifier or null. A non-null +custom result replaces the request auth identifier. Otherwise auth/header fallback +applies. Resolver errors do not break the application. Set `groups` to an invokable +container class receiving the application and returning group identifiers. Explicit +context groups override matching resolver groups; exception snapshots inherit them. + +## Scoped context and workers + +```php +use PostHog\Laravel\Context; + +app(Context::class)->with([ + 'distinctId' => 'job-owner-123', + 'groups' => ['team' => 'team-1'], + 'properties' => ['component' => 'import'], +], function () { + PostHog::capture('import started'); +}); +``` + +`Context::begin()`, `data()`, `snapshot()` and `end()` support explicit integrations. +`with()` restores the previous data and flag snapshots even when the callback throws. +Auth is resolved lazily only while handling a request; no user model is retained by +the analytics context after cleanup. Queue jobs start fresh; identity is not copied +into job payloads. Set it explicitly inside jobs. Nested synchronous jobs restore the +caller's context when they finish or fail. + +Global request middleware, queue events, Artisan command events, and Octane +request/task/tick events flush and clean context automatically. SDK creation is lazy, +per Laravel scope. Shared flag definitions live in Laravel Cache, not a global SDK. +Do not retain scoped services in application singletons or prewarm them in Octane. + +Supported execution models are ordinary HTTP, Laravel queue workers (including sync +jobs), console commands, and sequential Octane sandbox lifecycles. The package does +not claim isolation for overlapping Swoole coroutines sharing one Laravel container. +Core Fiber context support alone does not make Laravel scopes coroutine-local. +Automatic flush failures do not replace an HTTP response or worker failure. Explicit +`flush()`/`shutdown()` expose the SDK results; flush is not a delivery receipt. + +## Exceptions + +Laravel's reportable exception path respects `dontReport`, exception-specific report +methods, and preceding report callbacks that stop reporting. The package does not +install global PHP exception/error handlers, even if `sdk.error_tracking.enabled` is +set. Disable automatic reporting with `capture_exceptions=false`. + +```php +try { + // Application work... +} catch (Throwable $exception) { + PostHog::captureException($exception, properties: ['component' => 'import']); +} +``` + +Manual captures and ordinary `report()` calls are handled. Failures reported from +Laravel's routing pipeline or HTTP/console kernel failure paths, escaping request +middleware or queue execution, or reported by Octane's worker error listener are +unhandled. Boundary detection inspects only framework call names, never arguments. +Weakly held exception snapshots preserve the originating identity after cleanup; +automatic reports of the same exception object are deduplicated. A report already +emitted by an earlier framework listener cannot be retroactively relabeled by a +later boundary event. Explicit manual capture is intentionally independent of +automatic-report deduplication. + +## Feature flags + +```php +$flags = PostHog::evaluateFlags(); // Current request identity, evaluated on demand. +if ($flags->isEnabled('new-checkout')) { + $variant = $flags->getFlag('checkout-layout'); + $payload = $flags->getFlagPayload('checkout-layout'); +} +``` + +The same complete evaluation inputs reuse one core snapshot within the current +context. New identities, groups, properties, key scopes, or lifecycle scopes do not +reuse another user's result. Capture and boot do not evaluate flags. Value reads +emit the core's deduplicated exposure events; payload reads are silent. An explicit +empty `flagKeys: []` returns an empty snapshot without evaluation. + +The full core signature is supported: `distinctId`, `groups`, `personProperties`, +`groupProperties`, `onlyEvaluateLocally`, `disableGeoip`, and `flagKeys`. +All other public PHP client methods are forwarded with current context: + +```php +$result = PostHog::getFeatureFlagResult('flag-key', 'account-123'); +$client = PostHog::client(); // Advanced PHP SDK access; pass identity explicitly. +$scope = $client->enterContext(['distinctId' => 'account-123'], ['fresh' => true]); +try { + $client->capture(['event' => 'advanced', 'properties' => ['component' => 'worker']]); +} finally { + $scope->close(); +} +``` + +Array-form `capture()` forwards the PHP message unchanged, including its advanced +options. Prefer `evaluateFlags()` over the legacy getters for shared flag decisions. +Forwarded legacy flag getters also lazily load configured local definitions on first +identified use, preserving local-only options. With the raw `client()` escape hatch, +call `loadFlags()` explicitly before local evaluation if definitions have not loaded. + +### Shared local definitions + +Enable `flag_cache.enabled` to use `PostHog\Laravel\FlagDefinitionCacheProvider`. +`flag_cache.store` selects a Laravel cache store, `ttl` controls definition retention, +and `refresh_interval` controls refresh eligibility (seconds). The key is isolated +by ingestion host and project-key hash. Complete definition documents, including +`property_matching_version`, round-trip unchanged. Cache backend failures are handled +by the core's defensive provider contract. Use a shared production cache store whose +`add()` operation is atomic for best-effort fetch coordination; the array store is +process-local, not distributed coordination. + +With a secret key, a worker can publish definitions; without it, workers only read +the cache. Definitions load lazily during the first nonempty flag evaluation and +refresh lazily after the interval. There is no background timer. The PHP client's +`loadFlags()` remains available for an explicit refresh. You can instead supply a +custom provider class in `sdk.flag_definition_cache_provider`. + +## Configuration callbacks and full PHP access + +`posthog.sdk` passes PHP SDK options through. `before_send`, `error_handler`, and +`flag_definition_cache_provider` accept container class names, so published config +can be cached without serializing closures. Callback classes must implement the +corresponding SDK callable/interface contract. The Laravel library metadata and +framework handler ownership are enforced by the wrapper. `release_id` sets release +metadata; advanced SDK options remain accessible under `sdk`. + +## Diagnostics + +- `php artisan posthog:doctor`: local configuration validation, no network or keys in output. +- `php artisan posthog:install`: publish config without overwriting an existing file. +- `php artisan posthog:test --send`: explicitly opt in to a diagnostic event with a random test ID. + +No collectors are enabled by this foundation package. Optional collectors can be +registered by a separate `AutocaptureServiceProvider` when installed. + +## Testing + +```php +$fake = PostHog::fake(); +PostHog::capture('checkout completed', ['items' => 2], 'account-123'); +$fake->assertCaptured('checkout completed', fn ($event) => $event['properties']['items'] === 2); +$fake->assertCapturedCount(1); +$fake->assertNotCaptured('checkout failed'); +``` + +Also available: `assertNothingCaptured()`, `assertIdentified()`, `assertAliased()`, +`assertGroupIdentified()`, `assertExceptionCaptured()`, and `recorded()`. Message API +assertions accept an optional predicate. `flags($coreSnapshot)` configures fake flag +results; the default is an empty snapshot. The fake's raw client is always disabled, +so escape-hatch calls cannot send traffic (they are not recorded). + +Package development uses a local Composer path repository pointing at the core SDK: + +```sh +cd packages/laravel +composer install +composer test +``` + +Tests use Testbench, mocked transports and real Laravel lifecycle events; no live +PostHog service or Octane server process is required. diff --git a/packages/laravel/composer.json b/packages/laravel/composer.json new file mode 100644 index 00000000..23675644 --- /dev/null +++ b/packages/laravel/composer.json @@ -0,0 +1,24 @@ +{ + "name": "posthog/posthog-laravel", + "description": "PostHog for Laravel", + "version": "0.1.0", + "license": "MIT", + "require": { + "php": ">=8.2", + "posthog/posthog-php": "^4.20", + "illuminate/support": "^12.0 || ^13.0", + "illuminate/contracts": "^12.0 || ^13.0" + }, + "require-dev": { + "orchestra/testbench": "^10.0 || ^11.0", + "phpunit/phpunit": "^11.0 || ^12.0", + "laravel/octane": "^2.0" + }, + "repositories": [{"type": "path", "url": "../..", "options": {"versions": {"posthog/posthog-php": "4.20.x-dev"}}}], + "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"}}}, + "scripts": {"test": "phpunit"}, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/packages/laravel/config/posthog.php b/packages/laravel/config/posthog.php new file mode 100644 index 00000000..6ce0cded --- /dev/null +++ b/packages/laravel/config/posthog.php @@ -0,0 +1,25 @@ + env('POSTHOG_ENABLED', true), + 'api_key' => env('POSTHOG_API_KEY'), + 'host' => env('POSTHOG_HOST', 'https://us.i.posthog.com'), + 'secret_key' => env('POSTHOG_SECRET_KEY'), + 'release_id' => env('POSTHOG_RELEASE_ID'), + 'capture_exceptions' => true, + 'header_context' => true, + // Invokable container class; receives the application and returns a distinct ID or null. + 'distinct_id' => null, + // Invokable container class; receives the application and returns group identifiers. + 'groups' => null, + 'sdk' => [ + 'disableGeoip' => true, + // before_send, error_handler and flag_definition_cache_provider accept container class names. + ], + 'flag_cache' => [ + 'enabled' => false, + 'store' => null, + 'ttl' => 300, + 'refresh_interval' => 30, + ], +]; diff --git a/packages/laravel/phpunit.xml b/packages/laravel/phpunit.xml new file mode 100644 index 00000000..4c162c25 --- /dev/null +++ b/packages/laravel/phpunit.xml @@ -0,0 +1,4 @@ + + + tests + diff --git a/packages/laravel/src/Commands/DoctorCommand.php b/packages/laravel/src/Commands/DoctorCommand.php new file mode 100644 index 00000000..f4981626 --- /dev/null +++ b/packages/laravel/src/Commands/DoctorCommand.php @@ -0,0 +1,30 @@ +enabled()) { + $this->warn('PostHog is disabled or has no project API key. No traffic will be sent.'); + return self::FAILURE; + } + $host = config('posthog.host'); + if ( + !is_string($host) || !filter_var($host, FILTER_VALIDATE_URL) + || !in_array(parse_url($host, PHP_URL_SCHEME), ['http', 'https'], true) + ) { + $this->error('PostHog host must be an HTTP or HTTPS URL.'); + return self::FAILURE; + } + $this->info('PostHog is configured. Credentials are server-side. No network check was performed.'); + return self::SUCCESS; + } +} diff --git a/packages/laravel/src/Commands/InstallCommand.php b/packages/laravel/src/Commands/InstallCommand.php new file mode 100644 index 00000000..134b465e --- /dev/null +++ b/packages/laravel/src/Commands/InstallCommand.php @@ -0,0 +1,22 @@ +call('vendor:publish', ['--tag' => 'posthog-config']); + if ($result !== self::SUCCESS) { + return $result; + } + $this->info('Set POSTHOG_API_KEY and POSTHOG_HOST in your server environment.'); + $this->info('Never expose POSTHOG_SECRET_KEY to a browser.'); + return self::SUCCESS; + } +} diff --git a/packages/laravel/src/Commands/TestCommand.php b/packages/laravel/src/Commands/TestCommand.php new file mode 100644 index 00000000..63a0a912 --- /dev/null +++ b/packages/laravel/src/Commands/TestCommand.php @@ -0,0 +1,29 @@ +option('send') || !$posthog->enabled()) { + $this->warn('No event sent. Configure PostHog and pass --send to opt in.'); + return self::FAILURE; + } + $captured = $posthog->capture('posthog test event', [], 'posthog-test-' . Str::uuid()); + $flushed = $posthog->flush(); + if (!$captured || !$flushed) { + $this->error('The diagnostic event was not accepted or the transport did not flush.'); + return self::FAILURE; + } + $this->info('Diagnostic event queued and flushed. Check your PostHog project for delivery.'); + return self::SUCCESS; + } +} diff --git a/packages/laravel/src/Context.php b/packages/laravel/src/Context.php new file mode 100644 index 00000000..d867c1de --- /dev/null +++ b/packages/laravel/src/Context.php @@ -0,0 +1,114 @@ +context = $data; + $this->flags = []; + $this->request = null; + } + + public function end(): void + { + $this->begin(); + } + + public function data(): array + { + $data = $this->context; + try { + $resolver = $this->app['config']->get('posthog.distinct_id'); + $id = $resolver ? ($this->app->make($resolver))($this->app) : null; + if ($id === null && $this->request !== null) { + $id = $this->request->user()?->getAuthIdentifier(); + } + } catch (Throwable) { + return $data; + } + if (is_scalar($id) && (string) $id !== '') { + $data['distinctId'] = (string) $id; + } + $groupsResolver = $this->app['config']->get('posthog.groups'); + if ($groupsResolver !== null) { + try { + $groups = ($this->app->make($groupsResolver))($this->app); + if (is_array($groups)) { + $data['groups'] = array_replace($groups, $data['groups'] ?? []); + } + } catch (Throwable) { + } + } + return $data; + } + + public function snapshot(): array + { + return $this->data(); + } + + public function with(array $data, callable $callback): mixed + { + $previous = $this->context; + $flags = $this->flags; + $request = $this->request; + $this->begin(array_replace_recursive($previous, $data)); + $this->request = $request; + try { + return $callback(); + } catch (Throwable $exception) { + $this->app->make(ExceptionReporter::class)->rememberContext($exception); + throw $exception; + } finally { + $this->context = $previous; + $this->flags = $flags; + $this->request = $request; + } + } + + public function fromRequest(Request $request): void + { + $this->begin($this->app['config']->get('posthog.header_context', true) + ? CorePostHog::contextFromHeaders($request->headers->all()) : []); + $this->request = $request; + } + + /** @internal Isolate nested queue executions without discarding the caller's context. */ + public function enter(): void + { + $this->scopes[] = [$this->context, $this->flags, $this->request]; + $this->begin(); + } + + /** @internal Restore the scope active before queue execution. */ + public function leave(): void + { + if ($this->scopes === []) { + $this->end(); + return; + } + [$this->context, $this->flags, $this->request] = array_pop($this->scopes); + } + + public function flags(string $key, callable $evaluate): FeatureFlagEvaluations + { + return $this->flags[$key] ??= $evaluate(); + } +} diff --git a/packages/laravel/src/ExceptionReporter.php b/packages/laravel/src/ExceptionReporter.php new file mode 100644 index 00000000..117c104d --- /dev/null +++ b/packages/laravel/src/ExceptionReporter.php @@ -0,0 +1,71 @@ +snapshots = new WeakMap(); + $this->reported = new WeakMap(); + } + + public function rememberContext(Throwable $exception): void + { + $this->snapshots[$exception] ??= ['data' => $this->app->make(Context::class)->snapshot()]; + } + + public function remember(Throwable $exception, string $mechanism): void + { + $this->rememberContext($exception); + $snapshot = $this->snapshots[$exception]; + $snapshot['options'] ??= ['handled' => false, 'mechanism' => $mechanism]; + $this->snapshots[$exception] = $snapshot; + } + + private function reportOptions(): array + { + foreach (debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS) as $frame) { + $class = $frame['class'] ?? ''; + $method = $frame['function'] ?? ''; + // Laravel's routing pipeline consumes failures before outer middleware can catch them. + if ( + ($method === 'handleException' && is_a($class, \Illuminate\Routing\Pipeline::class, true)) + || ($method === 'reportException' && is_a($class, \Illuminate\Foundation\Http\Kernel::class, true)) + ) { + return ['handled' => false, 'mechanism' => 'middleware']; + } + if ( + ($method === 'reportException' && is_a($class, \Illuminate\Foundation\Console\Kernel::class, true)) + || ($method === 'handle' && is_a($class, 'Laravel\\Octane\\Listeners\\ReportException', true)) + ) { + return ['handled' => false, 'mechanism' => 'runtime']; + } + } + return ['handled' => true, 'mechanism' => 'middleware']; + } + + public function report(Throwable $exception): void + { + if (isset($this->reported[$exception]) || !$this->app['config']->get('posthog.capture_exceptions', true)) { + return; + } + $this->reported[$exception] = true; + $snapshot = $this->snapshots[$exception] ?? ['data' => $this->app->make(Context::class)->snapshot()]; + $options = $snapshot['options'] ?? $this->reportOptions(); + $manager = $this->app->make(PostHog::class); + try { + $manager->reportException($exception, $snapshot['data'], $options); + $manager->flush(); + } catch (Throwable) { + // Reporting must never replace the application's original failure. + } + } +} diff --git a/packages/laravel/src/Facades/PostHog.php b/packages/laravel/src/Facades/PostHog.php new file mode 100644 index 00000000..b8d38178 --- /dev/null +++ b/packages/laravel/src/Facades/PostHog.php @@ -0,0 +1,23 @@ +instance(\PostHog\Client::class, $fake->client()); + return $fake; + } +} diff --git a/packages/laravel/src/FlagDefinitionCacheProvider.php b/packages/laravel/src/FlagDefinitionCacheProvider.php new file mode 100644 index 00000000..f02f07e5 --- /dev/null +++ b/packages/laravel/src/FlagDefinitionCacheProvider.php @@ -0,0 +1,37 @@ +cache->get($this->key); + return is_array($data) ? $data : null; + } + + public function shouldFetchFlagDefinitions(): bool + { + return $this->cache->add($this->key . ':refresh', true, $this->refreshInterval); + } + + public function onFlagDefinitionsReceived(array $data): void + { + $this->cache->put($this->key, $data, $this->ttl); + } + + public function shutdown(): void + { + } +} diff --git a/packages/laravel/src/Middleware/RequestContext.php b/packages/laravel/src/Middleware/RequestContext.php new file mode 100644 index 00000000..d0383438 --- /dev/null +++ b/packages/laravel/src/Middleware/RequestContext.php @@ -0,0 +1,35 @@ +context->fromRequest($request); + try { + return $next($request); + } catch (Throwable $exception) { + $this->reporter->remember($exception, 'middleware'); + throw $exception; + } finally { + try { + $this->posthog->flush(); + } catch (Throwable) { + // A transport failure must not change the HTTP response. + } finally { + $this->context->end(); + } + } + } +} diff --git a/packages/laravel/src/PostHog.php b/packages/laravel/src/PostHog.php new file mode 100644 index 00000000..9e1b538f --- /dev/null +++ b/packages/laravel/src/PostHog.php @@ -0,0 +1,226 @@ +app['config']->get('posthog.enabled', true) + && trim((string) $this->app['config']->get('posthog.api_key')) !== ''; + } + + public function client(): Client + { + if ($this->sdk !== null) { + return $this->sdk; + } + if (!$this->enabled()) { + return $this->sdk = new Client(null, ['consumer' => 'noop'], loadFeatureFlags: false); + } + $config = $this->app['config']->get('posthog', []); + $options = $config['sdk'] ?? []; + $options += ['disableGeoip' => true]; + $options['host'] = $config['host'] ?? 'https://us.i.posthog.com'; + $options['library_name'] = 'posthog-laravel'; + $options['library_version'] = self::VERSION; + if (($config['release_id'] ?? null) !== null) { + $options['release_id'] = $config['release_id']; + } + // Laravel owns reporting and process-wide PHP handlers. + $options['error_tracking'] = array_replace($options['error_tracking'] ?? [], [ + 'enabled' => false, + ]); + foreach (['before_send', 'error_handler', 'flag_definition_cache_provider'] as $key) { + if (isset($options[$key]) && is_string($options[$key])) { + $options[$key] = $this->app->make($options[$key]); + } + } + if (!isset($options['flag_definition_cache_provider']) && ($config['flag_cache']['enabled'] ?? false)) { + $options['flag_definition_cache_provider'] = $this->app->make(FlagDefinitionCacheProvider::class); + } + return $this->sdk = new Client( + $config['api_key'], + $options, + loadFeatureFlags: false, + secretKey: $config['secret_key'] ?? null, + ); + } + + public function capture( + string|array $event, + array $properties = [], + ?string $distinctId = null, + array $groups = [], + ): bool { + $message = is_array($event) ? $event : ['event' => $event, 'properties' => $properties]; + if ($distinctId !== null) { + $message['distinctId'] = $distinctId; + } + if ($groups !== []) { + $message['groups'] = $groups; + } + return $this->message('capture', $message); + } + + public function identify(array $message): bool + { + return $this->message('identify', $message); + } + + public function alias(array $message): bool + { + return $this->message('alias', $message); + } + + public function groupIdentify(array $message): bool + { + return $this->message('groupIdentify', $message); + } + + protected function message(string $method, array $message): bool + { + if (!$this->enabled()) { + return false; + } + $data = $this->app->make(Context::class)->data(); + if (isset($data['groups']) && !array_key_exists('groups', $message)) { + $message['groups'] = $data['groups']; + } + return $this->client()->withContext( + $data, + fn () => (bool) $this->client()->$method($message), + ['fresh' => true], + ); + } + + public function captureException( + Throwable|string $exception, + ?string $distinctId = null, + array $properties = [], + array $options = [], + ): bool { + if (!$this->enabled()) { + return false; + } + $data = $this->app->make(Context::class)->data(); + $properties += isset($data['groups']) ? ['$groups' => $data['groups']] : []; + return $this->client()->withContext( + $data, + fn () => $this->client()->captureException($exception, $distinctId, $properties, $options), + ['fresh' => true], + ); + } + + public function evaluateFlags( + ?string $distinctId = null, + array $groups = [], + array $personProperties = [], + array $groupProperties = [], + bool $onlyEvaluateLocally = false, + ?bool $disableGeoip = null, + ?array $flagKeys = null, + ): FeatureFlagEvaluations { + $context = $this->app->make(Context::class); + $data = $context->data(); + $distinctId ??= $data['distinctId'] ?? null; + $groups = $groups ?: ($data['groups'] ?? []); + $disableGeoip ??= $this->app['config']->get('posthog.sdk.disableGeoip', true); + $inputs = [ + $distinctId, $groups, $personProperties, $groupProperties, $onlyEvaluateLocally, $disableGeoip, $flagKeys, + ]; + if (!$this->enabled() || $distinctId === null || $distinctId === '') { + return new FeatureFlagEvaluations( + $distinctId ?? '', + [], + $groups, + $this->client(), + disableGeoip: $disableGeoip, + ); + } + return $context->flags(hash('sha256', serialize($inputs)), function () use ($inputs, $flagKeys) { + if ($flagKeys !== []) { + $this->loadDefinitionsIfNeeded(); + } + return $this->client()->evaluateFlags(...$inputs); + }); + } + + private function loadDefinitionsIfNeeded(): void + { + $config = $this->app['config']->get('posthog'); + $hasDefinitions = !empty($config['secret_key']) + || !empty($config['sdk']['flag_definition_cache_provider']) + || ($config['flag_cache']['enabled'] ?? false); + $interval = max(1, (int) ($config['flag_cache']['refresh_interval'] ?? 30)); + $refreshDue = $this->definitionsLoadedAt === null || time() - $this->definitionsLoadedAt >= $interval; + if ($hasDefinitions && $refreshDue) { + $this->client()->loadFlags(); + $this->definitionsLoadedAt = time(); + } + } + + /** @internal Report a Laravel failure using its originating boundary snapshot. */ + public function reportException(Throwable $exception, array $snapshot, array $options): bool + { + if (!$this->enabled()) { + return false; + } + $properties = isset($snapshot['groups']) ? ['$groups' => $snapshot['groups']] : []; + return $this->client()->withContext( + $snapshot, + fn () => $this->client()->captureException($exception, null, $properties, $options), + ['fresh' => true], + ); + } + + public function flush(): bool + { + return $this->sdk !== null ? (bool) $this->sdk->flush() : false; + } + + public function shutdown(): bool + { + return $this->sdk !== null ? $this->sdk->shutdown() : false; + } + + public function __call(string $method, array $arguments): mixed + { + $data = $this->app->make(Context::class)->data(); + $legacyFlags = [ + 'getAllFlags' => 0, + 'getFeatureFlagResult' => 1, + 'getFeatureFlag' => 1, + 'getFeatureFlagPayload' => 1, + 'isFeatureEnabled' => 1, + ]; + if ($this->enabled() && isset($legacyFlags[$method])) { + $distinctId = $arguments['distinctId'] ?? $arguments[$legacyFlags[$method]] ?? null; + if ($distinctId === null || $distinctId === '') { + $distinctId = $data['distinctId'] ?? null; + } + if (is_string($distinctId) && $distinctId !== '') { + $this->loadDefinitionsIfNeeded(); + } + } + return $this->client()->withContext( + $data, + fn () => $this->client()->$method(...$arguments), + ['fresh' => true], + ); + } +} diff --git a/packages/laravel/src/PostHogServiceProvider.php b/packages/laravel/src/PostHogServiceProvider.php new file mode 100644 index 00000000..3743ebbe --- /dev/null +++ b/packages/laravel/src/PostHogServiceProvider.php @@ -0,0 +1,122 @@ +mergeConfigFrom(__DIR__ . '/../config/posthog.php', 'posthog'); + $this->app->scoped(Context::class); + $this->app->scoped(PostHog::class); + $this->app->alias(PostHog::class, 'posthog'); + $this->app->scoped(ExceptionReporter::class); + $this->app->scoped(\PostHog\Client::class, fn ($app) => $app->make(PostHog::class)->client()); + $this->app->scoped(FlagDefinitionCacheProvider::class, function ($app) { + $config = $app['config']->get('posthog'); + $key = 'posthog:definitions:' . hash('sha256', ($config['host'] ?? '') . ':' . ($config['api_key'] ?? '')); + return new FlagDefinitionCacheProvider( + $app['cache']->store($config['flag_cache']['store']), + $key, + $config['flag_cache']['ttl'], + $config['flag_cache']['refresh_interval'], + ); + }); + if (class_exists(AutocaptureServiceProvider::class)) { + $this->app->register(AutocaptureServiceProvider::class); + } + } + + public function boot(): void + { + $this->publishes([__DIR__ . '/../config/posthog.php' => config_path('posthog.php')], 'posthog-config'); + if ($this->app->runningInConsole()) { + $this->commands([ + Commands\DoctorCommand::class, + Commands\InstallCommand::class, + Commands\TestCommand::class, + ]); + } + $this->callAfterResolving(Kernel::class, function ($kernel) { + $kernel->prependMiddleware(RequestContextMiddleware::class); + }); + $this->callAfterResolving(ExceptionHandler::class, function ($handler) { + if (method_exists($handler, 'reportable')) { + $handler->reportable(function (Throwable $exception) { + Container::getInstance()->make(ExceptionReporter::class)->report($exception); + }); + } + }); + $events = $this->app['events']; + $events->listen(JobProcessing::class, function () { + Container::getInstance()->make(Context::class)->enter(); + }); + $events->listen(JobExceptionOccurred::class, function ($event) { + $app = Container::getInstance(); + $app->make(ExceptionReporter::class)->remember($event->exception, 'task'); + $this->finish($app, true); + }); + $events->listen(JobProcessed::class, fn () => $this->finish(Container::getInstance(), true)); + foreach ( + [ + CommandStarting::class, + 'Laravel\\Octane\\Events\\RequestReceived', + 'Laravel\\Octane\\Events\\TaskReceived', + 'Laravel\\Octane\\Events\\TickReceived', + ] as $event + ) { + $events->listen($event, function ($event) { + $context = ($event->sandbox ?? $event->app ?? Container::getInstance())->make(Context::class); + if (isset($event->request)) { + $context->fromRequest($event->request); + } else { + $context->begin(); + } + }); + } + foreach ( + [ + Looping::class, + CommandFinished::class, + 'Laravel\\Octane\\Events\\RequestTerminated', + 'Laravel\\Octane\\Events\\TaskTerminated', + 'Laravel\\Octane\\Events\\TickTerminated', + ] as $event + ) { + $events->listen($event, function ($event) { + $this->finish($event->sandbox ?? $event->app ?? Container::getInstance()); + }); + } + $events->listen('Laravel\\Octane\\Events\\WorkerErrorOccurred', function ($event) { + $app = $event->sandbox; + $app->make(ExceptionReporter::class)->remember($event->exception, 'runtime'); + $this->finish($app); + }); + $this->app->terminating(fn () => $this->finish(Container::getInstance())); + } + + private function finish(Container $app, bool $queue = false): void + { + try { + $app->make(PostHog::class)->flush(); + } catch (Throwable) { + // Lifecycle cleanup must continue when a transport fails. + } finally { + $context = $app->make(Context::class); + $queue ? $context->leave() : $context->end(); + } + } +} diff --git a/packages/laravel/src/Testing/PostHogFake.php b/packages/laravel/src/Testing/PostHogFake.php new file mode 100644 index 00000000..34d6b3a4 --- /dev/null +++ b/packages/laravel/src/Testing/PostHogFake.php @@ -0,0 +1,156 @@ +disabledClient ??= new Client(null, ['consumer' => 'noop'], loadFeatureFlags: false); + } + + protected function message(string $method, array $message): bool + { + $data = $this->app->make(Context::class)->data(); + $message['distinctId'] ??= $data['distinctId'] ?? null; + $message['groups'] ??= $data['groups'] ?? []; + $message['properties'] = array_replace($data['properties'] ?? [], $message['properties'] ?? []); + $this->recorded[] = ['method' => $method, 'message' => $message]; + return true; + } + + public function captureException( + Throwable|string $exception, + ?string $distinctId = null, + array $properties = [], + array $options = [], + ): bool { + $data = $this->app->make(Context::class)->data(); + $properties += isset($data['groups']) ? ['$groups' => $data['groups']] : []; + return $this->message('captureException', [ + 'exception' => $exception, 'distinctId' => $distinctId, 'properties' => $properties, 'options' => $options, + ]); + } + + public function evaluateFlags( + ?string $distinctId = null, + array $groups = [], + array $personProperties = [], + array $groupProperties = [], + bool $onlyEvaluateLocally = false, + ?bool $disableGeoip = null, + ?array $flagKeys = null, + ): FeatureFlagEvaluations { + return $this->flagSnapshot ?? new FeatureFlagEvaluations($distinctId ?? '', [], $groups, $this->client()); + } + + public function flags(FeatureFlagEvaluations $snapshot): static + { + $this->flagSnapshot = $snapshot; + return $this; + } + + public function recorded(?string $method = null): array + { + return array_values(array_filter( + $this->recorded, + fn ($item) => $method === null || $item['method'] === $method, + )); + } + + public function assertCaptured(string $event, ?callable $callback = null): void + { + $matches = array_filter( + $this->recorded('capture'), + fn ($item) => ($item['message']['event'] ?? null) === $event + && ($callback === null || $callback($item['message'])), + ); + Assert::assertNotEmpty($matches, 'Expected PostHog event was not captured: ' . $event); + } + + public function assertNotCaptured(string $event): void + { + Assert::assertEmpty(array_filter( + $this->recorded('capture'), + fn ($item) => ($item['message']['event'] ?? null) === $event, + )); + } + + public function assertCapturedCount(int $count): void + { + Assert::assertCount($count, $this->recorded('capture')); + } + + public function assertNothingCaptured(): void + { + Assert::assertSame([], $this->recorded); + } + + public function assertIdentified(?callable $callback = null): void + { + $this->assertRecorded('identify', $callback); + } + + public function assertAliased(?callable $callback = null): void + { + $this->assertRecorded('alias', $callback); + } + + public function assertGroupIdentified(?callable $callback = null): void + { + $this->assertRecorded('groupIdentify', $callback); + } + + public function assertExceptionCaptured(?callable $callback = null): void + { + $this->assertRecorded('captureException', $callback); + } + + private function assertRecorded(string $method, ?callable $callback): void + { + Assert::assertNotEmpty(array_filter( + $this->recorded($method), + fn ($item) => $callback === null || $callback($item['message']), + )); + } + + public function reportException(Throwable $exception, array $snapshot, array $options): bool + { + $this->recorded[] = ['method' => 'captureException', 'message' => [ + 'exception' => $exception, + 'distinctId' => $snapshot['distinctId'] ?? null, + 'properties' => array_replace( + $snapshot['properties'] ?? [], + isset($snapshot['groups']) ? ['$groups' => $snapshot['groups']] : [], + ), + 'options' => $options, + ]]; + return true; + } + + public function flush(): bool + { + return true; + } + + public function shutdown(): bool + { + return true; + } +} diff --git a/packages/laravel/tests/Fixtures/CaptureCallback.php b/packages/laravel/tests/Fixtures/CaptureCallback.php new file mode 100644 index 00000000..f8b03f66 --- /dev/null +++ b/packages/laravel/tests/Fixtures/CaptureCallback.php @@ -0,0 +1,14 @@ +events[] = $event; + return null; + } +} diff --git a/packages/laravel/tests/Fixtures/CaptureJob.php b/packages/laravel/tests/Fixtures/CaptureJob.php new file mode 100644 index 00000000..610eca4e --- /dev/null +++ b/packages/laravel/tests/Fixtures/CaptureJob.php @@ -0,0 +1,24 @@ +begin(['distinctId' => $this->id]); + $posthog->capture('job'); + } +} diff --git a/packages/laravel/tests/Fixtures/FailingCaptureJob.php b/packages/laravel/tests/Fixtures/FailingCaptureJob.php new file mode 100644 index 00000000..5259aae6 --- /dev/null +++ b/packages/laravel/tests/Fixtures/FailingCaptureJob.php @@ -0,0 +1,16 @@ + 'noop'], loadFeatureFlags: false); + } + + public function flush(): bool + { + $this->flushes++; + if ($this->fail) { + throw new RuntimeException('transport failure'); + } + return true; + } +} diff --git a/packages/laravel/tests/Fixtures/GroupsResolver.php b/packages/laravel/tests/Fixtures/GroupsResolver.php new file mode 100644 index 00000000..ab863bd8 --- /dev/null +++ b/packages/laravel/tests/Fixtures/GroupsResolver.php @@ -0,0 +1,11 @@ + 'resolved-team']; + } +} diff --git a/packages/laravel/tests/Fixtures/IdentityResolver.php b/packages/laravel/tests/Fixtures/IdentityResolver.php new file mode 100644 index 00000000..3e32bb9d --- /dev/null +++ b/packages/laravel/tests/Fixtures/IdentityResolver.php @@ -0,0 +1,11 @@ +with(['distinctId' => $this->id, 'groups' => ['team' => 'job-team']], function () { + throw new RuntimeException('scoped failure'); + }); + } +} diff --git a/packages/laravel/tests/OctaneTest.php b/packages/laravel/tests/OctaneTest.php new file mode 100644 index 00000000..7b49a717 --- /dev/null +++ b/packages/laravel/tests/OctaneTest.php @@ -0,0 +1,86 @@ +app; + $root['config']->set('posthog.api_key', 'project-key'); + $root['config']->set('posthog.sdk.consumer', 'noop'); + $clients = []; + try { + foreach (['first', 'second'] as $id) { + $sandbox = clone $root; + CurrentApplication::set($sandbox); + $request = Request::create('/', server: ['HTTP_X_POSTHOG_DISTINCT_ID' => $id]); + $sandbox['events']->dispatch(new RequestReceived($root, $sandbox, $request)); + $context = $sandbox->make(Context::class); + $this->assertSame($id, $context->data()['distinctId']); + $clients[] = $sandbox->make(PostHog::class)->client(); + $this->assertSame($clients[array_key_last($clients)], $sandbox->make(\PostHog\Client::class)); + $sandbox['events']->dispatch(new RequestTerminated($root, $sandbox, $request, new Response())); + $this->assertSame([], $context->data()); + CurrentApplication::set($root); + } + $this->assertNotSame($clients[0], $clients[1]); + } finally { + CurrentApplication::set($root); + } + } + + public function testWorkerFailureReportsThroughDontReportPolicyAndCleansContext(): void + { + $sandbox = $this->app; + $fake = Facade::fake(); + $sandbox['events']->dispatch(new TaskReceived($sandbox, $sandbox, null)); + $sandbox->make(Context::class)->begin(['distinctId' => 'failing-task']); + $exception = new RuntimeException('failure'); + $sandbox['events']->dispatch(new WorkerErrorOccurred($exception, $sandbox)); + $sandbox->make(ExceptionHandler::class)->report($exception); + $fake->assertExceptionCaptured( + fn ($message) => $message['distinctId'] === 'failing-task' + && !$message['options']['handled'] + ); + $this->assertSame([], $sandbox->make(Context::class)->data()); + $sandbox->make(ExceptionHandler::class)->report($exception); + $this->assertCount(1, $fake->recorded('captureException')); + $sandbox->make(ExceptionHandler::class)->dontReport([IgnoredOctaneFailure::class]); + $ignored = new IgnoredOctaneFailure(); + $sandbox['events']->dispatch(new WorkerErrorOccurred($ignored, $sandbox)); + $sandbox->make(ExceptionHandler::class)->report($ignored); + $this->assertCount(1, $fake->recorded('captureException')); + $sandbox->instance('env', 'production'); + $sandbox->make(Context::class)->begin(['distinctId' => 'early-report']); + $earlyEvent = new WorkerErrorOccurred(new RuntimeException('early failure'), $sandbox); + (new \Laravel\Octane\Listeners\ReportException())->handle($earlyEvent); + $sandbox['events']->dispatch($earlyEvent); + $fake->assertExceptionCaptured( + fn ($message) => $message['distinctId'] === 'early-report' && !$message['options']['handled'], + ); + $this->assertCount(2, $fake->recorded('captureException')); + } +} diff --git a/packages/laravel/tests/RuntimeTest.php b/packages/laravel/tests/RuntimeTest.php new file mode 100644 index 00000000..6bc1a191 --- /dev/null +++ b/packages/laravel/tests/RuntimeTest.php @@ -0,0 +1,482 @@ +set('posthog.api_key', 'project-key'); + $app['config']->set('posthog.sdk.consumer', 'noop'); + $app['config']->set('queue.default', 'sync'); + } + + public function testDiscoveryBindingsAndLazyDisabledClient(): void + { + $manager = $this->app->make(PostHog::class); + $this->assertSame($manager, $this->app->make('posthog')); + $sdk = new \ReflectionProperty(PostHog::class, 'sdk'); + $this->assertNull($sdk->getValue($manager)); + $this->app['config']->set('posthog.api_key', ' '); + $this->assertFalse($manager->capture('no traffic')); + $this->assertNull($sdk->getValue($manager)); + $this->assertSame([], $manager->evaluateFlags('user')->getKeys()); + $this->assertFalse($manager->captureException(new RuntimeException('disabled'))); + $this->assertSame([], $manager->client()->evaluateFlags('user')->getKeys()); + $this->assertFalse($manager->client()->capture(['event' => 'disabled', 'distinctId' => 'user'])); + } + + public function testDisabledWithKeyAndSecretDoesNotFetch(): void + { + $this->app['config']->set('posthog.enabled', false); + $this->app['config']->set('posthog.secret_key', 'server-secret'); + $manager = $this->app->make(PostHog::class); + $this->assertSame([], $manager->evaluateFlags('user')->getKeys()); + $this->assertFalse($manager->client()->captureException('disabled')); + $this->assertFalse($manager->enabled()); + } + + public function testActualHttpRequestsResolveAuthAfterMiddlewareAndNeverLeak(): void + { + $fake = Facade::fake(); + Route::get('/runtime/{id}', function ($id) { + $this->app['auth']->setUser(new GenericUser(['id' => $id, 'email' => 'private@example.test'])); + Facade::capture('request'); + Facade::capture('explicit', [], 'override'); + return 'ok'; + }); + foreach (['first', 'second'] as $id) { + $this->get('/runtime/' . $id . '?secret=private', [ + 'X-PostHog-Distinct-Id' => 'untrusted', + 'X-PostHog-Session-Id' => 'session-' . $id, + ])->assertOk(); + $this->app['auth']->forgetGuards(); + } + $messages = array_column($fake->recorded('capture'), 'message'); + $this->assertSame(['first', 'override', 'second', 'override'], array_column($messages, 'distinctId')); + $this->assertSame(['$session_id' => 'session-first'], $messages[0]['properties']); + $this->assertSame(['$session_id' => 'session-second'], $messages[2]['properties']); + $this->assertSame([], $this->app->make(Context::class)->snapshot()); + $this->assertStringNotContainsString('private', json_encode($messages)); + } + + public function testHeaderFallbackAndNestedContextRestoreOnFailure(): void + { + $context = $this->app->make(Context::class); + $context->fromRequest(Request::create('/', server: ['HTTP_X_POSTHOG_DISTINCT_ID' => 'anonymous'])); + $this->assertSame('anonymous', $context->data()['distinctId']); + try { + $context->with(['groups' => ['team' => 'one']], function () use ($context) { + $this->assertSame(['team' => 'one'], $context->data()['groups']); + throw new RuntimeException('restore'); + }); + } catch (RuntimeException) { + } + $this->assertArrayNotHasKey('groups', $context->data()); + $context->end(); + $this->assertSame([], $context->data()); + } + + public function testRequestFailureSnapshotsSurviveCleanupAndDontReportIsRespected(): void + { + $fake = Facade::fake(); + $middleware = $this->app->make(RequestContext::class); + $exception = new RuntimeException('failure'); + try { + $middleware->handle( + Request::create('/', server: ['HTTP_X_POSTHOG_DISTINCT_ID' => 'origin']), + fn () => throw $exception, + ); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + $this->app->make(Context::class)->begin(['distinctId' => 'next-request']); + $handler = $this->app->make(ExceptionHandler::class); + $handler->report($exception); + $handler->report($exception); + $this->assertCount(1, $fake->recorded('captureException')); + $fake->assertExceptionCaptured( + fn ($message) => $message['distinctId'] === 'origin' + && $message['options'] === ['handled' => false, 'mechanism' => 'middleware'] + ); + $handler->dontReport([IgnoredFailure::class]); + $handler->report(new IgnoredFailure('ignored')); + $this->assertCount(1, $fake->recorded('captureException')); + $handler->report(new RuntimeException('manual report')); + $fake->assertExceptionCaptured(fn ($message) => $message['options']['handled'] === true); + } + + public function testRealRouteFailuresAreUnhandledButManualReportRemainsHandled(): void + { + $fake = Facade::fake(); + Route::get('/failure', fn () => throw new RuntimeException('route failure')); + Route::get('/manual', function () { + report(new RuntimeException('manual report')); + return 'ok'; + }); + $this->get('/failure', ['X-PostHog-Distinct-Id' => 'failed-user'])->assertStatus(500); + $this->get('/manual', ['X-PostHog-Distinct-Id' => 'manual-user'])->assertOk(); + $fake->assertExceptionCaptured( + fn ($message) => $message['distinctId'] === 'failed-user' + && $message['options']['handled'] === false + ); + $fake->assertExceptionCaptured( + fn ($message) => $message['distinctId'] === 'manual-user' + && $message['options']['handled'] === true + ); + } + + public function testQueueBoundariesCleanTwoJobsAndPreserveFailedJobIdentity(): void + { + $fake = Facade::fake(); + Bus::dispatchSync(new CaptureJob('first')); + Bus::dispatchSync(new CaptureJob('second')); + $fake->assertCaptured('job', fn ($message) => $message['distinctId'] === 'first'); + $fake->assertCaptured('job', fn ($message) => $message['distinctId'] === 'second'); + $this->assertSame([], $this->app->make(Context::class)->data()); + $job = $this->createStub(\Illuminate\Contracts\Queue\Job::class); + $this->app['events']->dispatch(new JobProcessing('sync', $job)); + $this->app->make(Context::class)->begin(['distinctId' => 'failed-job']); + $exception = new RuntimeException('job failure'); + $this->app['events']->dispatch(new JobExceptionOccurred('sync', $job, $exception)); + $this->app->make(Context::class)->begin(['distinctId' => 'later-job']); + $this->app->make(ExceptionHandler::class)->report($exception); + $fake->assertExceptionCaptured( + fn ($message) => $message['distinctId'] === 'failed-job' + && $message['options'] === ['handled' => false, 'mechanism' => 'task'] + ); + $this->app['events']->dispatch(new JobProcessed('sync', $job)); + $this->assertSame([], $this->app->make(Context::class)->data()); + } + + public function testNestedSyncJobRestoresOuterContextAndActualFailureCleansUp(): void + { + $fake = Facade::fake(); + $context = $this->app->make(Context::class); + $context->begin(['distinctId' => 'outer', 'groups' => ['team' => 'one']]); + Bus::dispatchSync(new CaptureJob('inner')); + $this->assertSame('outer', $context->data()['distinctId']); + $this->assertSame(['team' => 'one'], $context->data()['groups']); + try { + Bus::dispatchSync(new FailingCaptureJob('failed-inner')); + $this->fail('The job must fail.'); + } catch (RuntimeException $exception) { + $this->assertSame('outer', $context->data()['distinctId']); + $this->app->make(ExceptionHandler::class)->report($exception); + } + $fake->assertExceptionCaptured(fn ($message) => $message['distinctId'] === 'failed-inner'); + } + + public function testScopedExceptionDataSurvivesUnwindWithoutInventingAnUnhandledBoundary(): void + { + $fake = Facade::fake(); + $context = $this->app->make(Context::class); + $context->begin(['distinctId' => 'outer']); + try { + $context->with(['distinctId' => 'caught-origin'], fn () => throw new RuntimeException('caught')); + } catch (RuntimeException $exception) { + $this->assertSame('outer', $context->data()['distinctId']); + $this->app->make(ExceptionHandler::class)->report($exception); + } + $fake->assertExceptionCaptured( + fn ($message) => $message['distinctId'] === 'caught-origin' && $message['options']['handled'], + ); + try { + Bus::dispatchSync(new ScopedFailingJob('job-origin')); + } catch (RuntimeException $exception) { + $this->assertSame('outer', $context->data()['distinctId']); + $this->app->make(ExceptionHandler::class)->report($exception); + } + $fake->assertExceptionCaptured( + fn ($message) => $message['distinctId'] === 'job-origin' && !$message['options']['handled'] + && $message['properties']['$groups'] === ['team' => 'job-team'], + ); + } + + public function testConsoleAndOctaneBoundariesFlushAndReset(): void + { + $client = new FlushClient(); + $manager = $this->app->make(PostHog::class); + (new \ReflectionProperty(PostHog::class, 'sdk'))->setValue($manager, $client); + $context = $this->app->make(Context::class); + $input = new ArrayInput([]); + $output = new NullOutput(); + $this->app['events']->dispatch(new \Illuminate\Console\Events\CommandStarting('test', $input, $output)); + $context->begin(['distinctId' => 'console']); + $this->app['events']->dispatch(new \Illuminate\Console\Events\CommandFinished('test', $input, $output, 1)); + $this->assertSame([], $context->data()); + foreach (['first', 'second'] as $id) { + $this->app['events']->dispatch('Laravel\\Octane\\Events\\RequestReceived', [(object) [ + 'request' => Request::create('/', server: ['HTTP_X_POSTHOG_DISTINCT_ID' => $id]), + ]]); + $this->assertSame($id, $context->data()['distinctId']); + $this->app['events']->dispatch('Laravel\\Octane\\Events\\RequestTerminated', [(object) []]); + $this->assertSame([], $context->data()); + } + $this->assertSame(3, $client->flushes); + } + + public function testLifecycleCleanupSurvivesFlushFailure(): void + { + $manager = $this->app->make(PostHog::class); + $client = new FlushClient(); + $client->fail = true; + (new \ReflectionProperty(PostHog::class, 'sdk'))->setValue($manager, $client); + $context = $this->app->make(Context::class); + $context->begin(['distinctId' => 'stale']); + $this->app['events']->dispatch('Laravel\\Octane\\Events\\TaskTerminated', [(object) []]); + $this->assertSame([], $context->data()); + $response = $this->app->make(RequestContext::class)->handle(Request::create('/'), fn () => 'ok'); + $this->assertSame('ok', $response); + $this->assertSame([], $context->data()); + $client->fail = false; + } + + public function testFlagsAreLazyScopedAndValidNoopStillEvaluates(): void + { + $http = $this->createMock(HttpClient::class); + $http->expects($this->exactly(2))->method('sendRequest')->willReturnCallback(function ($path, $payload) { + $body = json_decode($payload, true); + $this->assertContains($body['distinct_id'], ['first', 'second']); + $this->assertTrue($body['geoip_disable']); + return new HttpResponse( + json_encode(['flags' => ['checkout' => ['enabled' => true, 'variant' => null]]]), + 200, + ); + }); + $sdk = new Client( + 'project-key', + ['consumer' => 'noop', 'disableGeoip' => true], + $http, + loadFeatureFlags: false, + ); + $manager = $this->app->make(PostHog::class); + (new \ReflectionProperty(PostHog::class, 'sdk'))->setValue($manager, $sdk); + $manager->capture('does not fetch', [], 'first'); + $context = $this->app->make(Context::class); + foreach (['first', 'second'] as $id) { + $context->begin(['distinctId' => $id]); + $this->assertSame([], $manager->evaluateFlags(flagKeys: [])->getKeys()); + $flags = $manager->evaluateFlags(); + $this->assertSame($flags, $manager->evaluateFlags()); + $this->assertTrue($flags->isEnabled('checkout')); + $this->assertTrue($flags->isEnabled('checkout')); + $context->end(); + } + } + + public function testNoIdentityNeverLoadsPrivilegedDefinitionsAndResolversUseContainer(): void + { + $this->app['config']->set('posthog.secret_key', 'server-secret'); + $http = $this->createMock(HttpClient::class); + $http->expects($this->never())->method('sendRequest'); + $sdk = new Client( + 'project-key', + ['consumer' => 'noop'], + $http, + loadFeatureFlags: false, + secretKey: 'server-secret', + ); + $manager = $this->app->make(PostHog::class); + (new \ReflectionProperty(PostHog::class, 'sdk'))->setValue($manager, $sdk); + $this->assertSame([], $manager->evaluateFlags()->getKeys()); + $this->app['config']->set('posthog.distinct_id', IdentityResolver::class); + $this->app['config']->set('posthog.groups', GroupsResolver::class); + $context = $this->app->make(Context::class); + $this->assertSame('resolved-user', $context->data()['distinctId']); + $this->assertSame(['team' => 'resolved-team'], $context->data()['groups']); + $context->begin(['groups' => ['team' => 'explicit-team']]); + $this->assertSame(['team' => 'explicit-team'], $context->data()['groups']); + $fake = Facade::fake(); + $this->app->make(ExceptionHandler::class)->report(new RuntimeException('grouped error')); + $fake->assertExceptionCaptured( + fn ($message) => $message['properties']['$groups'] === ['team' => 'explicit-team'] + ); + } + + public function testLegacyLocalOnlyFlagsLoadDefinitionsLazilyOnFirstIdentifiedUse(): void + { + $provider = $this->createMock(\PostHog\FlagDefinitionCacheProvider::class); + $provider->expects($this->once())->method('getFlagDefinitions')->willReturn([ + 'flags' => [[ + 'id' => 1, + 'key' => 'local-flag', + 'active' => true, + 'filters' => ['groups' => [['properties' => [], 'rollout_percentage' => 100]]], + ]], + 'group_type_mapping' => [], + 'cohorts' => [], + ]); + $provider->expects($this->never())->method('shouldFetchFlagDefinitions'); + $http = $this->createMock(HttpClient::class); + $http->expects($this->never())->method('sendRequest'); + $this->app['config']->set('posthog.sdk.flag_definition_cache_provider', $provider); + $sdk = new Client('project-key', [ + 'consumer' => 'noop', + 'flag_definition_cache_provider' => $provider, + ], $http, loadFeatureFlags: false); + $manager = $this->app->make(PostHog::class); + (new \ReflectionProperty(PostHog::class, 'sdk'))->setValue($manager, $sdk); + $this->assertSame([], $manager->getAllFlags(onlyEvaluateLocally: true)); + $this->app->make(Context::class)->begin(['distinctId' => '0']); + $this->assertSame(['local-flag' => true], $manager->getAllFlags(onlyEvaluateLocally: true)); + $this->assertSame(['local-flag' => true], $manager->getAllFlags(onlyEvaluateLocally: false)); + } + + public function testCacheProviderRoundTripsCompleteSnapshotAndCoordinatesFetch(): void + { + $provider = new FlagDefinitionCacheProvider($this->app['cache']->store('array'), 'isolated', 300, 30); + $data = [ + 'flags' => [], + 'group_type_mapping' => ['0' => 'team'], + 'cohorts' => ['1' => []], + 'property_matching_version' => 2, + 'minimal_flag_called_events' => true, + ]; + $this->assertNull($provider->getFlagDefinitions()); + $this->assertTrue($provider->shouldFetchFlagDefinitions()); + $this->assertFalse($provider->shouldFetchFlagDefinitions()); + $provider->onFlagDefinitionsReceived($data); + $this->assertSame($data, $provider->getFlagDefinitions()); + $provider->shutdown(); + } + + public function testContainerCallbackAndFrameworkHandlerOwnership(): void + { + $this->app->singleton(CaptureCallback::class); + $this->app['config']->set('posthog.sdk.before_send', CaptureCallback::class); + $this->app['config']->set('posthog.sdk.error_tracking.enabled', true); + $before = set_error_handler(static fn () => false); + restore_error_handler(); + $manager = $this->app->make(PostHog::class); + $this->app->make(Context::class)->begin(['distinctId' => 'user']); + $manager->capture('safe'); + $events = $this->app->make(CaptureCallback::class)->events; + $this->assertSame('safe', $events[0]['event']); + $this->assertTrue($events[0]['properties']['$geoip_disable']); + $after = set_error_handler(static fn () => false); + restore_error_handler(); + $this->assertSame($before, $after); + } + + public function testRealClientMetadataGroupsAndCanonicalExceptionAreForwarded(): void + { + $this->app->singleton(CaptureCallback::class); + $this->app['config']->set('posthog.sdk.before_send', CaptureCallback::class); + $this->app['config']->set('posthog.release_id', 'release-123'); + $manager = $this->app->make(PostHog::class); + $context = $this->app->make(Context::class); + $context->begin(['distinctId' => 'user', 'groups' => ['team' => 'one']]); + $manager->capture(['event' => 'advanced', 'distinctId' => 'override', 'properties' => ['custom' => true]]); + $manager->captureException(new RuntimeException('canonical'), properties: ['$exception_list' => ['forged']]); + $events = $this->app->make(CaptureCallback::class)->events; + $this->assertSame('override', $events[0]['distinct_id']); + $this->assertSame('posthog-laravel', $events[0]['properties']['$lib']); + $this->assertSame(PostHog::VERSION, $events[0]['properties']['$lib_version']); + $this->assertSame('release-123', $events[0]['properties']['$release_id']); + $this->assertSame(['team' => 'one'], $events[0]['properties']['$groups']); + $this->assertSame(['team' => 'one'], $events[1]['properties']['$groups']); + $this->assertSame('RuntimeException', $events[1]['properties']['$exception_list'][0]['type']); + $this->assertTrue($events[1]['properties']['$exception_list'][0]['mechanism']['handled']); + $this->assertNull($manager->client()->getContext()); + $exception = new RuntimeException('originating boundary'); + $this->app->make(ExceptionReporter::class)->remember($exception, 'task'); + $context->begin(['distinctId' => 'next-user', 'groups' => ['team' => 'two']]); + $this->app->make(ExceptionHandler::class)->report($exception); + $events = $this->app->make(CaptureCallback::class)->events; + $this->assertSame('user', $events[2]['distinct_id']); + $this->assertSame(['team' => 'one'], $events[2]['properties']['$groups']); + $this->assertFalse($events[2]['properties']['$exception_list'][0]['mechanism']['handled']); + } + + public function testInstallPublishesWithoutOverwritingApplicationConfig(): void + { + $path = $this->app->configPath('posthog.php'); + $original = is_file($path) ? file_get_contents($path) : null; + @unlink($path); + try { + $this->artisan('posthog:install')->assertSuccessful(); + $this->assertFileExists($path); + file_put_contents($path, ' true];'); + $this->artisan('posthog:install')->assertSuccessful(); + $this->assertSame(' true];', file_get_contents($path)); + } finally { + if ($original === null) { + @unlink($path); + } else { + file_put_contents($path, $original); + } + } + } + + public function testFakeForwardsAllMessageApisAndAssertions(): void + { + $previousClient = $this->app->make(Client::class); + $fake = Facade::fake(); + $this->assertNotSame($previousClient, $this->app->make(Client::class)); + $this->assertSame($fake->client(), $this->app->make(Client::class)); + $fake->assertNothingCaptured(); + Facade::capture('event', ['count' => 1], 'user'); + Facade::identify(['distinctId' => 'user', 'properties' => ['plan' => 'paid']]); + Facade::alias(['distinctId' => 'user', 'alias' => 'old']); + Facade::groupIdentify(['groupType' => 'team', 'groupKey' => 'one']); + Facade::captureException('manual'); + $fake->assertCaptured('event', fn ($message) => $message['properties']['count'] === 1); + $fake->assertCapturedCount(1); + $fake->assertNotCaptured('missing'); + $fake->assertIdentified(); + $fake->assertAliased(); + $fake->assertGroupIdentified(); + $fake->assertExceptionCaptured(); + $this->assertFalse($fake->client()->capture(['event' => 'escape', 'distinctId' => 'user'])); + } + + public function testCommandsNeverPrintKeysAndRequireExplicitSending(): void + { + $this->artisan('posthog:doctor')->expectsOutput( + 'PostHog is configured. Credentials are server-side. No network check was performed.', + )->assertSuccessful(); + $this->artisan('posthog:test')->assertFailed(); + Facade::fake(); + $this->artisan('posthog:test', ['--send' => true])->assertSuccessful(); + $this->app['config']->set('posthog.api_key', null); + $this->app->instance(PostHog::class, new PostHog($this->app)); + $this->artisan('posthog:doctor')->assertFailed(); + } +} From 1536956a260c3b8f9568b15a0a17cbc77ed2a263 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 09:47:38 +0100 Subject: [PATCH 03/37] feat: propagate safe Laravel queue context and prepare package distribution --- .../413684c92c1a43b38f4ea69024be861c.json | 5 ++ packages/laravel/README.md | 21 ++++---- packages/laravel/composer.json | 1 - packages/laravel/config/posthog.php | 1 + .../laravel/src/PostHogServiceProvider.php | 11 +++- packages/laravel/src/QueueContext.php | 46 ++++++++++++++++ packages/laravel/src/Testing/PostHogFake.php | 3 +- .../tests/Fixtures/AmbientCaptureJob.php | 17 ++++++ packages/laravel/tests/QueueContextTest.php | 54 +++++++++++++++++++ packages/laravel/tests/RuntimeTest.php | 17 ++++++ 10 files changed, 162 insertions(+), 14 deletions(-) create mode 100644 .laravel-changes/413684c92c1a43b38f4ea69024be861c.json create mode 100644 packages/laravel/src/QueueContext.php create mode 100644 packages/laravel/tests/Fixtures/AmbientCaptureJob.php create mode 100644 packages/laravel/tests/QueueContextTest.php diff --git a/.laravel-changes/413684c92c1a43b38f4ea69024be861c.json b/.laravel-changes/413684c92c1a43b38f4ea69024be861c.json new file mode 100644 index 00000000..c17b3645 --- /dev/null +++ b/.laravel-changes/413684c92c1a43b38f4ea69024be861c.json @@ -0,0 +1,5 @@ +{ + "package": "posthog-laravel", + "bump": "initial", + "summary": "Add the Laravel package with request and worker context, exception reporting, flags, and test fakes." +} diff --git a/packages/laravel/README.md b/packages/laravel/README.md index ebf74451..0fb38816 100644 --- a/packages/laravel/README.md +++ b/packages/laravel/README.md @@ -81,9 +81,12 @@ app(Context::class)->with([ `Context::begin()`, `data()`, `snapshot()` and `end()` support explicit integrations. `with()` restores the previous data and flag snapshots even when the callback throws. Auth is resolved lazily only while handling a request; no user model is retained by -the analytics context after cleanup. Queue jobs start fresh; identity is not copied -into job payloads. Set it explicitly inside jobs. Nested synchronous jobs restore the -caller's context when they finish or fail. +the analytics context after cleanup. Queue jobs start fresh and receive only bounded +identity, session and scalar group context in `_posthog` payload metadata. Arbitrary +properties, models and request data are not propagated. Set `queue_context=false` to +disable propagation. This metadata is analytics context, never authorization evidence. +Jobs may override it explicitly. Nested synchronous jobs restore the caller's context +when they finish or fail. Global request middleware, queue events, Artisan command events, and Octane request/task/tick events flush and clean context automatically. SDK creation is lazy, @@ -210,13 +213,11 @@ assertions accept an optional predicate. `flags($coreSnapshot)` configures fake results; the default is an empty snapshot. The fake's raw client is always disabled, so escape-hatch calls cannot send traffic (they are not recorded). -Package development uses a local Composer path repository pointing at the core SDK: - -```sh -cd packages/laravel -composer install -composer test -``` +The production manifest deliberately has no development repository overrides. For +local development before the required PHP SDK release is published, use the root +`scripts/test-laravel.sh 12` or `scripts/test-laravel.sh 13` helper once the tooling +PR in this stack is merged. It installs local dependencies in a disposable source +copy and runs the matching framework tests without changing package metadata. Tests use Testbench, mocked transports and real Laravel lifecycle events; no live PostHog service or Octane server process is required. diff --git a/packages/laravel/composer.json b/packages/laravel/composer.json index 23675644..a5fd4d10 100644 --- a/packages/laravel/composer.json +++ b/packages/laravel/composer.json @@ -14,7 +14,6 @@ "phpunit/phpunit": "^11.0 || ^12.0", "laravel/octane": "^2.0" }, - "repositories": [{"type": "path", "url": "../..", "options": {"versions": {"posthog/posthog-php": "4.20.x-dev"}}}], "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/config/posthog.php b/packages/laravel/config/posthog.php index 6ce0cded..133a1826 100644 --- a/packages/laravel/config/posthog.php +++ b/packages/laravel/config/posthog.php @@ -8,6 +8,7 @@ 'release_id' => env('POSTHOG_RELEASE_ID'), 'capture_exceptions' => true, 'header_context' => true, + 'queue_context' => true, // Invokable container class; receives the application and returns a distinct ID or null. 'distinct_id' => null, // Invokable container class; receives the application and returns group identifiers. diff --git a/packages/laravel/src/PostHogServiceProvider.php b/packages/laravel/src/PostHogServiceProvider.php index 3743ebbe..6143c0b6 100644 --- a/packages/laravel/src/PostHogServiceProvider.php +++ b/packages/laravel/src/PostHogServiceProvider.php @@ -61,8 +61,15 @@ public function boot(): void } }); $events = $this->app['events']; - $events->listen(JobProcessing::class, function () { - Container::getInstance()->make(Context::class)->enter(); + QueueContext::register(); + $events->listen(JobProcessing::class, function ($event) { + $app = Container::getInstance(); + $context = $app->make(Context::class); + $context->enter(); + $data = $event->job->payload()['_posthog'] ?? null; + if ($app['config']->get('posthog.queue_context', true) && is_array($data)) { + $context->begin(QueueContext::normalize($data)); + } }); $events->listen(JobExceptionOccurred::class, function ($event) { $app = Container::getInstance(); diff --git a/packages/laravel/src/QueueContext.php b/packages/laravel/src/QueueContext.php new file mode 100644 index 00000000..5f6d1643 --- /dev/null +++ b/packages/laravel/src/QueueContext.php @@ -0,0 +1,46 @@ +get('posthog.queue_context', true) || !$app->make(PostHog::class)->enabled()) { + return []; + } + $data = self::normalize($app->make(Context::class)->data()); + return $data === [] ? [] : ['_posthog' => $data]; + } + + public static function normalize(array $data): array + { + $headers = []; + foreach (['distinctId' => 'X-PostHog-Distinct-Id', 'sessionId' => 'X-PostHog-Session-Id'] as $key => $header) { + if (isset($data[$key]) && is_scalar($data[$key])) { + $headers[$header] = (string) $data[$key]; + } + } + $context = CorePostHog::contextFromHeaders($headers); + $context = array_filter($context, static fn ($value) => $value !== null && $value !== []); + if (isset($data['groups']) && is_array($data['groups'])) { + foreach (array_slice($data['groups'], 0, 32, true) as $type => $value) { + if (is_string($type) && strlen($type) <= 100 && is_scalar($value) && strlen((string) $value) <= 1000) { + $context['groups'][$type] = (string) $value; + } + } + } + return $context; + } +} diff --git a/packages/laravel/src/Testing/PostHogFake.php b/packages/laravel/src/Testing/PostHogFake.php index 34d6b3a4..61fd6aa9 100644 --- a/packages/laravel/src/Testing/PostHogFake.php +++ b/packages/laravel/src/Testing/PostHogFake.php @@ -27,7 +27,8 @@ public function client(): Client protected function message(string $method, array $message): bool { - $data = $this->app->make(Context::class)->data(); + $data = in_array($method, ['capture', 'captureException'], true) + ? $this->app->make(Context::class)->data() : []; $message['distinctId'] ??= $data['distinctId'] ?? null; $message['groups'] ??= $data['groups'] ?? []; $message['properties'] = array_replace($data['properties'] ?? [], $message['properties'] ?? []); diff --git a/packages/laravel/tests/Fixtures/AmbientCaptureJob.php b/packages/laravel/tests/Fixtures/AmbientCaptureJob.php new file mode 100644 index 00000000..ff5dbb74 --- /dev/null +++ b/packages/laravel/tests/Fixtures/AmbientCaptureJob.php @@ -0,0 +1,17 @@ +set('queue.default', 'sync'); + } + + public function testJobPayloadCarriesOnlyAllowlistedContextAndRestoresParent(): void + { + $fake = PostHog::fake(); + $context = $this->app->make(Context::class); + foreach (['first', 'second'] as $id) { + $context->begin([ + 'distinctId' => $id, + 'sessionId' => 'session-' . $id, + 'groups' => ['team' => $id], + 'properties' => ['secret' => 'never-propagate'], + ]); + Bus::dispatchSync(new AmbientCaptureJob()); + $fake->assertCaptured('ambient job', fn ($event) => $event['distinctId'] === $id + && $event['groups'] === ['team' => $id] + && $event['properties'] === ['$session_id' => 'session-' . $id]); + $this->assertSame($id, $context->data()['distinctId']); + $this->assertSame('never-propagate', $context->data()['properties']['secret']); + } + $this->assertCount(2, $fake->recorded('capture')); + } + + public function testJobPropagationCanBeDisabled(): void + { + $this->app['config']->set('posthog.queue_context', false); + $fake = PostHog::fake(); + $this->app->make(Context::class)->begin(['distinctId' => 'parent']); + Bus::dispatchSync(new AmbientCaptureJob()); + $fake->assertCaptured('ambient job', fn ($event) => $event['distinctId'] === null + && $event['groups'] === [] && $event['properties'] === []); + } +} diff --git a/packages/laravel/tests/RuntimeTest.php b/packages/laravel/tests/RuntimeTest.php index 6bc1a191..7be2b6f4 100644 --- a/packages/laravel/tests/RuntimeTest.php +++ b/packages/laravel/tests/RuntimeTest.php @@ -445,6 +445,23 @@ public function testInstallPublishesWithoutOverwritingApplicationConfig(): void } } + public function testFakeDoesNotApplyCaptureContextToIdentityMutations(): void + { + $fake = Facade::fake(); + $this->app->make(Context::class)->begin([ + 'distinctId' => 'ambient-user', 'properties' => ['private' => 'capture-only'], + ]); + Facade::identify(['distinctId' => 'target', 'properties' => ['plan' => 'pro']]); + Facade::alias(['distinctId' => 'target', 'alias' => 'old']); + Facade::groupIdentify(['groupType' => 'team', 'groupKey' => 'one']); + $messages = array_column($fake->recorded(), 'message'); + $this->assertSame('target', $messages[0]['distinctId']); + $this->assertSame(['plan' => 'pro'], $messages[0]['properties']); + $this->assertSame([], $messages[1]['properties']); + $this->assertNull($messages[2]['distinctId']); + $this->assertSame([], $messages[2]['properties']); + } + public function testFakeForwardsAllMessageApisAndAssertions(): void { $previousClient = $this->app->make(Client::class); From 18d9ac6845cecda568626278ad364e39e7de545b Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 09:51:01 +0100 Subject: [PATCH 04/37] feat: enrich Laravel captures with safe request metadata --- packages/laravel/README.md | 2 ++ packages/laravel/src/Context.php | 8 ++++++++ packages/laravel/tests/RuntimeTest.php | 8 ++++++-- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/laravel/README.md b/packages/laravel/README.md index 0fb38816..2caaed6e 100644 --- a/packages/laravel/README.md +++ b/packages/laravel/README.md @@ -51,6 +51,8 @@ fallback. `X-PostHog-Session-Id` provides session correlation. Headers are untru analytics input, **never authentication or authorization**. Set `header_context=false` to ignore both headers. No anonymous ID is generated for requests without identity. +Captures include the request method and matched route template (for example +`/orders/{order}`), never concrete route parameters. Explicit event properties win. By default there is no collection of raw URLs, query strings, request bodies, cookies, email, IP addresses, or user profile fields. GeoIP is disabled by default (`sdk.disableGeoip=true`). Application-supplied properties and exception messages may diff --git a/packages/laravel/src/Context.php b/packages/laravel/src/Context.php index d867c1de..8867d1d6 100644 --- a/packages/laravel/src/Context.php +++ b/packages/laravel/src/Context.php @@ -4,6 +4,7 @@ use Illuminate\Contracts\Container\Container; use Illuminate\Http\Request; +use Illuminate\Routing\Route; use PostHog\FeatureFlagEvaluations; use PostHog\PostHog as CorePostHog; use Throwable; @@ -34,6 +35,13 @@ public function end(): void public function data(): array { $data = $this->context; + if ($this->request !== null) { + $data['properties']['$request_method'] ??= $this->request->method(); + $route = $this->request->route(); + if ($route instanceof Route) { + $data['properties']['$request_path'] ??= '/' . ltrim($route->uri(), '/'); + } + } try { $resolver = $this->app['config']->get('posthog.distinct_id'); $id = $resolver ? ($this->app->make($resolver))($this->app) : null; diff --git a/packages/laravel/tests/RuntimeTest.php b/packages/laravel/tests/RuntimeTest.php index 7be2b6f4..f170d2c8 100644 --- a/packages/laravel/tests/RuntimeTest.php +++ b/packages/laravel/tests/RuntimeTest.php @@ -90,8 +90,12 @@ public function testActualHttpRequestsResolveAuthAfterMiddlewareAndNeverLeak(): } $messages = array_column($fake->recorded('capture'), 'message'); $this->assertSame(['first', 'override', 'second', 'override'], array_column($messages, 'distinctId')); - $this->assertSame(['$session_id' => 'session-first'], $messages[0]['properties']); - $this->assertSame(['$session_id' => 'session-second'], $messages[2]['properties']); + $this->assertSame([ + '$session_id' => 'session-first', '$request_method' => 'GET', '$request_path' => '/runtime/{id}', + ], $messages[0]['properties']); + $this->assertSame([ + '$session_id' => 'session-second', '$request_method' => 'GET', '$request_path' => '/runtime/{id}', + ], $messages[2]['properties']); $this->assertSame([], $this->app->make(Context::class)->snapshot()); $this->assertStringNotContainsString('private', json_encode($messages)); } From 04967bfa01bda70ca96ce7ec4d17d982f0e5613a Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 09:54:22 +0100 Subject: [PATCH 05/37] feat: add opt-in Laravel server event collectors --- packages/laravel/AUTOCAPTURE.md | 186 +++++++ packages/laravel/README.md | 9 +- packages/laravel/config/autocapture.php | 23 + .../laravel/src/Autocapture/AuthEvents.php | 33 ++ .../laravel/src/Autocapture/Collector.php | 63 +++ .../laravel/src/Autocapture/DomainEvents.php | 37 ++ .../laravel/src/Autocapture/ModelEvents.php | 40 ++ packages/laravel/src/Autocapture/Outcomes.php | 56 +++ .../src/Autocapture/RequestSummary.php | 78 +++ .../src/AutocaptureServiceProvider.php | 113 +++++ packages/laravel/tests/AutocaptureTest.php | 452 ++++++++++++++++++ .../tests/Fixtures/CollectedRecord.php | 12 + .../laravel/tests/Fixtures/OrderCompleted.php | 14 + 13 files changed, 1114 insertions(+), 2 deletions(-) create mode 100644 packages/laravel/AUTOCAPTURE.md create mode 100644 packages/laravel/config/autocapture.php create mode 100644 packages/laravel/src/Autocapture/AuthEvents.php create mode 100644 packages/laravel/src/Autocapture/Collector.php create mode 100644 packages/laravel/src/Autocapture/DomainEvents.php create mode 100644 packages/laravel/src/Autocapture/ModelEvents.php create mode 100644 packages/laravel/src/Autocapture/Outcomes.php create mode 100644 packages/laravel/src/Autocapture/RequestSummary.php create mode 100644 packages/laravel/src/AutocaptureServiceProvider.php create mode 100644 packages/laravel/tests/AutocaptureTest.php create mode 100644 packages/laravel/tests/Fixtures/CollectedRecord.php create mode 100644 packages/laravel/tests/Fixtures/OrderCompleted.php diff --git a/packages/laravel/AUTOCAPTURE.md b/packages/laravel/AUTOCAPTURE.md new file mode 100644 index 00000000..720aabd4 --- /dev/null +++ b/packages/laravel/AUTOCAPTURE.md @@ -0,0 +1,186 @@ +# Optional server event collectors + +These are **server events**, not UI interaction autocapture. The package does not +observe clicks or emit `$autocapture`. All collectors and Blade flag conditionals +are off by default. The main `PostHogServiceProvider` discovers the separate +`PostHog\Laravel\AutocaptureServiceProvider` automatically. Register only the main +provider; registering the collector provider again does not add duplicate listeners. + +## Configuration + +Publish the optional configuration: + +```sh +php artisan vendor:publish --tag=posthog-autocapture-config +``` + +Edit `config/posthog-autocapture.php`. The complete defaults are in +[`config/autocapture.php`](config/autocapture.php). These settings are merged into +`posthog.autocapture`; values explicitly set under `autocapture` in +`config/posthog.php` take precedence **per top-level section**. When overriding a +section there, include its complete settings. Both approaches support config caching. +Use class names, not closures, in configuration. + +```php +return [ + 'requests' => [ + 'enabled' => true, + 'exclude' => ['up', 'health', 'health/*', 'assets/*', 'build/*', '*.css', '*.js'], + 'sample_rate' => 1.0, + ], + 'pageviews' => ['enabled' => false], + 'auth' => [ + 'enabled' => true, + 'events' => ['registered', 'login', 'logout'], + ], + 'models' => [ + 'enabled' => true, + 'allow' => [App\Models\Order::class => ['state']], + ], + 'domain_events' => [ + App\Events\OrderCompleted::class => [ + 'event' => 'order_completed', + 'properties' => ['amount' => 'amount', 'currency' => 'details.currency'], + ], + ], + 'jobs' => ['enabled' => true, 'exclude' => [App\Jobs\InternalMaintenance::class]], + 'commands' => ['enabled' => false, 'exclude' => ['posthog:*']], + 'scheduler' => ['enabled' => false, 'exclude' => []], + 'blade' => ['enabled' => false], +]; +``` + +Every `enabled` default is `false`. `domain_events` and `models.allow` default to +empty arrays. Auth defaults include only registered/login/logout; add `failed` and +`lockout` explicitly to capture those outcomes. `exclude` patterns use Laravel's +case-sensitive `Str::is` wildcard matching. Request exclusions match the route +and concrete path **only for filtering**, without capturing that concrete path. +Defaults exclude `up`, `health`, `health/*`, `build/*`, `assets/*`, `vendor/*`, +`favicon.ico`, `robots.txt`, and common asset extensions; add your application's +other health/static routes. Unmatched routes are never captured. Sampling clamps +`requests.sample_rate` to 0–1 and makes one decision per request for both summary +and pageview events. Zero drops both; the default 1 captures every eligible request. + +### Privacy and identity + +Collectors emit only the properties listed below, plus normal SDK metadata and +explicit identity/groups. Arbitrary context properties are not copied. Collection +never reads request bodies, query values, cookies, credentials, email, IP addresses, +job arguments or full model/event payloads. Auth uses `getAuthIdentifier()`; configure +an opaque identifier rather than email if your application customizes that method. +Application-selected attributes/property mappings may contain PII: allowlist only +fields you intend to send and use `sdk.before_send` for additional redaction. + +Events require an existing analytics identity; collectors do not invent anonymous +IDs. HTTP uses the runtime's lazy authenticated identity/header fallback. Queue jobs +use the runtime's bounded queue context propagation or explicit job context. Console +and scheduler events need explicit context or a configured identity resolver. Failed +login/lockout events may use existing anonymous context but **never** the attempted +user identifier. Missing credentials or disabled SDK configuration cannot send traffic. +Failures in analytics do not change application responses or replace original errors. + +## Exact event schemas + +The following properties are collector-owned. `duration_ms` is a nonnegative float +measured by a monotonic clock, not a trace/span. No request IDs or parameters are +added implicitly. Each event passes through the ordinary PHP capture pipeline. + +| Event | Properties | +| --- | --- | +| `laravel_request` | `route: string`, `method: string`, `status_code: int`, `duration_ms: float` | +| `$pageview` | Request properties plus `$current_url: string`, `$pathname: string` | +| `laravel_auth_registered` | Empty properties | +| `laravel_auth_login` | `guard: string` when supplied | +| `laravel_auth_logout` | `guard: string` when supplied | +| `laravel_auth_failed` | `guard: string` when supplied; no credentials or attempted ID | +| `laravel_auth_lockout` | Empty properties; no request/credentials/attempted ID | +| `laravel_model_created` | `model: string` (exact class), `attributes: array` (only allowlisted JSON-safe values) | +| `laravel_model_updated` | Same as created; selected values at update time, not a before/after diff | +| `laravel_model_deleted` | Same as created; selected values at deletion time | +| Configured domain event name | Only explicitly mapped output names and JSON-safe values | +| `laravel_job` | `name: string` (queued job class, not display name), `outcome: 'success'\|'failed'`, `attempts: int`, `duration_ms: float` | +| `laravel_command` | `name: string` (first command token only), `outcome: 'success'\|'failed'`, `exit_code: int`, `duration_ms: float` | +| `laravel_scheduled_task` | `name: string` (task PHP class only), `outcome: 'success'\|'failed'\|'background_started'`, `exit_code: int\|null`, `duration_ms: float` | + +### Requests and server pageviews + +`route` and `$pathname` are normalized matched route templates with a leading `/`, +for example `/orders/{order}`, never `/orders/123`. Request summaries include all +methods and status codes for eligible matched routes. Escaping middleware errors +are summarized as 500 and rethrown unchanged; framework-rendered errors use the +response status. Streaming duration measures response creation, not body delivery. + +Pageviews are a **separate opt-in** and require a matched route, HTML Content-Type, +GET method, and 2xx status. They can be enabled without request summaries. The +`$current_url` is the sanitized configured `app.url` origin (HTTP/HTTPS scheme, +host and optional port only) followed by the route template. Credentials, paths +and queries in `app.url` are discarded; the request Host header is not trusted. +Invalid configured origins suppress pageviews. + +**Browser duplication warning:** disable server pageviews if a browser SDK already +captures the same pageviews, or disable equivalent browser pageviews. No automatic +cross-SDK deduplication is provided. Server pageviews do not track client-side +navigation, browser lifecycle or actual browser rendering. + +### Models and transactions + +Only exact allowlisted Eloquent classes are collected for created/updated/deleted +lifecycle events. An empty attribute list emits class metadata with an empty +`attributes` array—no implicit primary key, relation, dirty attributes or `toArray()`. +Only existing attributes are read, with their normal casts/accessors; JSON-safe +scalar/null/array values are accepted, objects/resources/invalid JSON values are +omitted. Do not allowlist a sensitive accessor or JSON column. + +Values, identity and groups are snapshotted at the change, then emitted **after the +model connection's outer transaction commits**. Rollback (including a nested +savepoint rollback) discards the corresponding pending events. No model object is +retained by the commit callback. Outside a transaction, collection is immediate. +This is not a durable outbox; process crashes or transport failures can lose events. +Each actual model lifecycle event generates one event: multiple saves are not +coalesced. Bulk query-builder updates/deletes do not produce model events. Soft +deletes produce the normal deleted event; restores are not collected. + +### Domain events + +Mappings select exact event classes and named public property/array-key paths. +No wildcard subscriptions, getter calls, magic model attribute access, model +serialization or automatic inheritance matching are performed. Missing paths and +non-JSON-safe values are omitted. Domain collection is immediate, including inside +a transaction; use Laravel's own after-commit event dispatch when the domain event +must reflect committed state. Mapping is read at provider boot, so rebuild cached +config/restart long-running workers after changing it. + +### Worker outcomes and duplication + +Jobs emit once per started attempt; a retry is another attempt, not deduplicated +against the previous failure. Repeated completion events for an already completed +attempt are ignored. Command metadata excludes all arguments/options. Scheduler +names deliberately use task PHP classes, not descriptions or command lines. A +foreground task emits one outcome even when Laravel emits Finished then Failed for +a nonzero exit. Background launch emits `background_started`, **not completion**; +there is no cross-process completion/heartbeat guarantee. Skipped scheduled tasks +and hard-killed jobs/commands without a terminal framework event are not captured. +Weak object keys retain neither worker payloads nor completed execution objects. + +Enabling several collectors intentionally produces different events for different +boundaries: a request that logs in a user and saves a model can produce request, +auth, and model events. Explicit application captures remain independent. The +package does not deduplicate these against your manual instrumentation. + +## Optional Blade flag conditionals + +Enable `blade.enabled`, then use: + +```blade +@posthogFeature('new-checkout') + +@else + +@endposthogFeature +``` + +This reads `evaluateFlags()->isEnabled()` using the runtime's existing context +snapshot. Repeated checks share evaluation and core exposure deduplication; a new +identity/context evaluates separately. Disabled/unavailable flags fail closed. +There is no boot-time evaluation, payload read, independent flag cache, or custom +exposure event. This conditional is a presentation helper, **not authorization**. diff --git a/packages/laravel/README.md b/packages/laravel/README.md index 0fb38816..4fed8082 100644 --- a/packages/laravel/README.md +++ b/packages/laravel/README.md @@ -194,8 +194,13 @@ metadata; advanced SDK options remain accessible under `sdk`. - `php artisan posthog:install`: publish config without overwriting an existing file. - `php artisan posthog:test --send`: explicitly opt in to a diagnostic event with a random test ID. -No collectors are enabled by this foundation package. Optional collectors can be -registered by a separate `AutocaptureServiceProvider` when installed. +Optional server event collectors are included but **all are off by default**. +The main provider discovers the separate `AutocaptureServiceProvider` automatically. +See [collector configuration and exact event schemas](AUTOCAPTURE.md) for request +summaries, separately enabled server HTML pageviews, auth outcomes, explicit domain +mappings, after-commit model events, worker outcomes, and Blade flag conditionals. +Server pageviews can duplicate browser SDK pageviews: enable only one equivalent +source. These collectors do not observe UI interactions or emit `$autocapture`. ## Testing diff --git a/packages/laravel/config/autocapture.php b/packages/laravel/config/autocapture.php new file mode 100644 index 00000000..17d36369 --- /dev/null +++ b/packages/laravel/config/autocapture.php @@ -0,0 +1,23 @@ + [ + 'enabled' => false, + 'exclude' => [ + 'up', 'health', 'health/*', 'build/*', 'assets/*', 'vendor/*', 'favicon.ico', 'robots.txt', + '*.css', '*.js', '*.map', '*.png', '*.jpg', '*.svg', '*.ico', '*.woff*', + ], + 'sample_rate' => 1.0, + ], + // Server HTML pageviews can duplicate browser SDK pageviews. Enable only one source. + 'pageviews' => ['enabled' => false], + 'auth' => ['enabled' => false, 'events' => ['registered', 'login', 'logout']], + // Exact model class => approved attribute names. Empty lists capture class metadata only. + 'models' => ['enabled' => false, 'allow' => []], + // Exact event class => ['event' => 'name', 'properties' => ['output' => 'public.property.path']]. + 'domain_events' => [], + 'jobs' => ['enabled' => false, 'exclude' => []], + 'commands' => ['enabled' => false, 'exclude' => []], + 'scheduler' => ['enabled' => false, 'exclude' => []], + 'blade' => ['enabled' => false], +]; diff --git a/packages/laravel/src/Autocapture/AuthEvents.php b/packages/laravel/src/Autocapture/AuthEvents.php new file mode 100644 index 00000000..0ba5300a --- /dev/null +++ b/packages/laravel/src/Autocapture/AuthEvents.php @@ -0,0 +1,33 @@ +get( + 'posthog.autocapture.auth.events', + [], + ), true) + ) { + return; + } + try { + $snapshot = Collector::snapshot(); + if (!in_array($name, ['failed', 'lockout'], true)) { + $id = $event->user?->getAuthIdentifier(); + if (is_scalar($id) && (string) $id !== '') { + $snapshot['distinctId'] = (string) $id; + } + } + $properties = isset($event->guard) && is_string($event->guard) ? ['guard' => $event->guard] : []; + Collector::capture('laravel_auth_' . $name, $properties, $snapshot); + } catch (Throwable) { + } + } +} diff --git a/packages/laravel/src/Autocapture/Collector.php b/packages/laravel/src/Autocapture/Collector.php new file mode 100644 index 00000000..cd75eb8e --- /dev/null +++ b/packages/laravel/src/Autocapture/Collector.php @@ -0,0 +1,63 @@ +get('posthog.autocapture.' . $section . '.enabled', false); + } + + public static function snapshot(): array + { + return array_intersect_key(Container::getInstance()->make(Context::class)->snapshot(), [ + 'distinctId' => true, 'groups' => true, + ]); + } + + public static function capture(string $event, array $properties, ?array $snapshot = null): void + { + $context = null; + try { + $app = Container::getInstance(); + $snapshot ??= self::snapshot(); + $id = $snapshot['distinctId'] ?? null; + if (!is_scalar($id) || (string) $id === '') { + return; + } + $context = $app->make(Context::class); + $context->enter(); + $context->begin($snapshot); + $app->make(PostHog::class)->capture([ + 'event' => $event, 'properties' => $properties, + 'distinctId' => (string) $id, 'groups' => $snapshot['groups'] ?? [], + ]); + } catch (Throwable) { + // Analytics must never replace an application result or its original exception. + } finally { + $context?->leave(); + } + } + + public static function jsonSafe(mixed $value, int $depth = 0): bool + { + if ($depth > 16 || is_object($value) || is_resource($value)) { + return false; + } + if (is_array($value)) { + foreach ($value as $item) { + if (!self::jsonSafe($item, $depth + 1)) { + return false; + } + } + } + return json_encode($value) !== false; + } +} diff --git a/packages/laravel/src/Autocapture/DomainEvents.php b/packages/laravel/src/Autocapture/DomainEvents.php new file mode 100644 index 00000000..e75405df --- /dev/null +++ b/packages/laravel/src/Autocapture/DomainEvents.php @@ -0,0 +1,37 @@ + $path) { + if (!is_string($name) || !is_string($path)) { + continue; + } + $value = $event; + foreach (explode('.', $path) as $part) { + // Public properties and array keys only: never invoke model magic or getters. + $values = is_object($value) ? get_object_vars($value) : $value; + if (!is_array($values) || !array_key_exists($part, $values)) { + continue 2; + } + $value = $values[$part]; + } + if (Collector::jsonSafe($value)) { + $properties[$name] = $value; + } + } + Collector::capture($mapping['event'], $properties); + } catch (Throwable) { + } + } +} diff --git a/packages/laravel/src/Autocapture/ModelEvents.php b/packages/laravel/src/Autocapture/ModelEvents.php new file mode 100644 index 00000000..e924610b --- /dev/null +++ b/packages/laravel/src/Autocapture/ModelEvents.php @@ -0,0 +1,40 @@ +get('posthog.autocapture.models.allow', []); + if (!array_key_exists($model::class, $models) || !is_array($models[$model::class])) { + return; + } + $attributes = []; + $stored = $model->getAttributes(); + foreach ($models[$model::class] as $key) { + if (is_string($key) && array_key_exists($key, $stored)) { + $value = $model->getAttribute($key); + if (Collector::jsonSafe($value)) { + $attributes[$key] = $value; + } + } + } + $properties = ['model' => $model::class, 'attributes' => $attributes]; + $snapshot = Collector::snapshot(); + // Register with this model's connection; no model or payload survives until commit. + $model->getConnection()->afterCommit(static function () use ($operation, $properties, $snapshot) { + Collector::capture('laravel_model_' . $operation, $properties, $snapshot); + }); + } catch (Throwable) { + } + } +} diff --git a/packages/laravel/src/Autocapture/Outcomes.php b/packages/laravel/src/Autocapture/Outcomes.php new file mode 100644 index 00000000..7bd36f0b --- /dev/null +++ b/packages/laravel/src/Autocapture/Outcomes.php @@ -0,0 +1,56 @@ +starts = new WeakMap(); + } + + public function start(string $section, object $key): void + { + if (Collector::enabled($section)) { + $this->starts[$key] = hrtime(true); + } + } + + public function finish(string $section, object $key, callable $metadata): void + { + if (!isset($this->starts[$key])) { + return; + } + $started = $this->starts[$key]; + unset($this->starts[$key]); + try { + if (!Collector::enabled($section)) { + return; + } + $properties = $metadata(); + if (!is_string($properties['name'] ?? null) || $properties['name'] === '') { + return; + } + $excluded = Container::getInstance()['config']->get('posthog.autocapture.' . $section . '.exclude', []); + foreach ($excluded as $pattern) { + if (Str::is($pattern, $properties['name'])) { + return; + } + } + $properties['duration_ms'] = (hrtime(true) - $started) / 1e6; + $event = [ + 'jobs' => 'laravel_job', 'commands' => 'laravel_command', 'scheduler' => 'laravel_scheduled_task', + ][$section]; + Collector::capture($event, $properties); + } catch (Throwable) { + } + } +} diff --git a/packages/laravel/src/Autocapture/RequestSummary.php b/packages/laravel/src/Autocapture/RequestSummary.php new file mode 100644 index 00000000..d8fe8ca7 --- /dev/null +++ b/packages/laravel/src/Autocapture/RequestSummary.php @@ -0,0 +1,78 @@ +collect($request, $response, $started); + } catch (Throwable) { + // Never mask an application failure, including a failing collector callback. + } + } + } + + private function collect(Request $request, mixed $response, int $started): void + { + if (!Collector::enabled('requests') && !Collector::enabled('pageviews')) { + return; + } + $route = $request->route(); + if (!$route instanceof Route) { + return; + } + $template = '/' . ltrim($route->uri(), '/'); + $config = Container::getInstance()['config']; + foreach ($config->get('posthog.autocapture.requests.exclude', []) as $pattern) { + $pattern = ltrim($pattern, '/'); + if (Str::is($pattern, ltrim($template, '/')) || Str::is($pattern, $request->path())) { + return; + } + } + $rate = max(0.0, min(1.0, (float) $config->get('posthog.autocapture.requests.sample_rate', 1.0))); + if ($rate === 0.0 || ($rate < 1.0 && random_int(1, 1000000) / 1000000 > $rate)) { + return; + } + $properties = [ + 'route' => $template, + 'method' => $request->method(), + 'status_code' => $response?->getStatusCode() ?? 500, + 'duration_ms' => (hrtime(true) - $started) / 1e6, + ]; + if (Collector::enabled('requests')) { + Collector::capture('laravel_request', $properties); + } + if ( + Collector::enabled('pageviews') && $request->isMethod('GET') + && $properties['status_code'] >= 200 && $properties['status_code'] < 300 + && str_starts_with(strtolower($response?->headers->get('Content-Type', '') ?? ''), 'text/html') + ) { + // A configured origin avoids trusting a request Host header or exposing URL credentials. + $url = parse_url((string) $config->get('app.url')); + if ( + !is_array($url) || !isset($url['scheme'], $url['host']) + || !in_array($url['scheme'], ['http', 'https'], true) + ) { + return; + } + $origin = $url['scheme'] . '://' . $url['host'] . (isset($url['port']) ? ':' . $url['port'] : ''); + Collector::capture('$pageview', $properties + [ + '$current_url' => $origin . $template, '$pathname' => $template, + ]); + } + } +} diff --git a/packages/laravel/src/AutocaptureServiceProvider.php b/packages/laravel/src/AutocaptureServiceProvider.php new file mode 100644 index 00000000..2c1b4c29 --- /dev/null +++ b/packages/laravel/src/AutocaptureServiceProvider.php @@ -0,0 +1,113 @@ +mergeConfigFrom(__DIR__ . '/../config/autocapture.php', 'posthog-autocapture'); + $config = $this->app['config']; + $config->set('posthog.autocapture', array_replace( + $config->get('posthog-autocapture', []), + $config->get('posthog.autocapture', []), + )); + } + + public function boot(): void + { + $this->publishes([ + __DIR__ . '/../config/autocapture.php' => config_path('posthog-autocapture.php'), + ], 'posthog-autocapture-config'); + $this->callAfterResolving(Kernel::class, fn ($kernel) => $kernel->prependMiddleware(RequestSummary::class)); + $events = $this->app['events']; + foreach (['Registered', 'Login', 'Logout', 'Failed', 'Lockout'] as $name) { + $events->listen('Illuminate\\Auth\\Events\\' . $name, static function ($event) use ($name) { + (new AuthEvents())->collect(strtolower($name), $event); + }); + } + foreach (['created', 'updated', 'deleted'] as $operation) { + $events->listen('eloquent.' . $operation . ': *', static function ($name, $payload) use ($operation) { + (new ModelEvents())->collect($operation, $payload[0]); + }); + } + foreach ($this->app['config']->get('posthog.autocapture.domain_events', []) as $class => $mapping) { + if (is_string($class) && is_array($mapping)) { + $events->listen($class, static function ($event) use ($class, $mapping) { + if (is_object($event) && $event::class === $class) { + (new DomainEvents())->collect($event, $mapping); + } + }); + } + } + $this->registerOutcomes($events); + $this->callAfterResolving('blade.compiler', static function ($blade) { + $blade->if('posthogFeature', static function (string $key): bool { + if (!Collector::enabled('blade')) { + return false; + } + try { + return Container::getInstance()->make(PostHog::class)->evaluateFlags()->isEnabled($key); + } catch (Throwable) { + return false; + } + }); + }); + } + + private function registerOutcomes(Dispatcher $events): void + { + $outcomes = new Outcomes(); + $events->listen(\Illuminate\Queue\Events\JobProcessing::class, static function ($event) use ($outcomes) { + $outcomes->start('jobs', $event->job); + }); + foreach (['JobProcessed' => 'success', 'JobExceptionOccurred' => 'failed'] as $event => $outcome) { + $events->listen('Illuminate\\Queue\\Events\\' . $event, static function ($event) use ($outcomes, $outcome) { + $outcomes->finish('jobs', $event->job, static fn () => [ + 'name' => $event->job->resolveQueuedJobClass(), + 'outcome' => $outcome, 'attempts' => $event->job->attempts(), + ]); + }); + } + $events->listen(\Illuminate\Console\Events\CommandStarting::class, static function ($event) use ($outcomes) { + $outcomes->start('commands', $event->input); + }); + $events->listen(\Illuminate\Console\Events\CommandFinished::class, static function ($event) use ($outcomes) { + $outcomes->finish('commands', $event->input, static fn () => [ + 'name' => preg_split('/\s+/', trim($event->command ?? ''))[0], + 'outcome' => $event->exitCode === 0 ? 'success' : 'failed', 'exit_code' => $event->exitCode, + ]); + }); + $events->listen( + \Illuminate\Console\Events\ScheduledTaskStarting::class, + static function ($event) use ($outcomes) { + $outcomes->start('scheduler', $event->task); + }, + ); + foreach (['ScheduledTaskFinished' => false, 'ScheduledTaskFailed' => true] as $event => $failed) { + $events->listen( + 'Illuminate\\Console\\Events\\' . $event, + static function ($event) use ($outcomes, $failed) { + $outcomes->finish('scheduler', $event->task, static fn () => [ + 'name' => $event->task::class, + 'outcome' => $failed ? 'failed' : ($event->task->runInBackground ? 'background_started' + : ($event->task->exitCode === 0 ? 'success' : 'failed')), + 'exit_code' => $event->task->exitCode, + ]); + }, + ); + } + } +} diff --git a/packages/laravel/tests/AutocaptureTest.php b/packages/laravel/tests/AutocaptureTest.php new file mode 100644 index 00000000..0ef10fdb --- /dev/null +++ b/packages/laravel/tests/AutocaptureTest.php @@ -0,0 +1,452 @@ +set('posthog.api_key', 'project-key'); + $app['config']->set('posthog.sdk.consumer', 'noop'); + $app['config']->set('queue.default', 'sync'); + $app['config']->set('database.default', 'testing'); + $app['config']->set('database.connections.testing', ['driver' => 'sqlite', 'database' => ':memory:']); + $app['config']->set('posthog.autocapture.domain_events', [OrderCompleted::class => [ + 'event' => 'order_completed', + 'properties' => [ + 'amount' => 'amount', 'currency' => 'details.currency', 'unsafe' => 'model', 'absent' => 'absent', + ], + ]]); + } + + private function identity(string $id = 'actor'): void + { + $this->app->make(Context::class)->begin(['distinctId' => $id, 'groups' => ['team' => $id]]); + } + + private function enable(string $section): void + { + $this->app['config']->set('posthog.autocapture.' . $section . '.enabled', true); + } + + private function events(): array + { + return array_column(PostHog::getFacadeRoot()->recorded('capture'), 'message'); + } + + private function modelTable(): void + { + Schema::create('collected_records', function (Blueprint $table) { + $table->id(); + $table->string('state'); + $table->string('email'); + }); + } + + public function testDefaultCollectorsAreOffAndProviderRegistrationIsIdempotent(): void + { + $fake = PostHog::fake(); + $this->app->register(AutocaptureServiceProvider::class); + $this->identity(); + Route::get('/off', fn () => response('ok')->header('Content-Type', 'text/html')); + $this->get('/off', ['X-PostHog-Distinct-Id' => 'actor'])->assertOk(); + $this->app['events']->dispatch(new \Illuminate\Auth\Events\Login( + 'web', + new GenericUser(['id' => 'actor']), + false, + )); + $this->modelTable(); + CollectedRecord::create(['state' => 'new', 'email' => 'secret@example.test']); + $input = new ArrayInput([]); + $this->app['events']->dispatch(new CommandStarting('safe', $input, new NullOutput())); + $this->app['events']->dispatch(new CommandFinished('safe', $input, new NullOutput(), 0)); + $fake->assertNothingCaptured(); + $this->assertSame('off', trim(Blade::render("@posthogFeature('flag')\n on\n@else\n off\n@endposthogFeature"))); + } + + public function testPublishedCollectorConfigAndNestedOverridesAreHonored(): void + { + $path = $this->app->configPath('posthog-autocapture.php'); + $original = is_file($path) ? file_get_contents($path) : null; + @unlink($path); + try { + $this->artisan('vendor:publish', ['--tag' => 'posthog-autocapture-config'])->assertSuccessful(); + $this->assertFileExists($path); + $defaults = require $path; + $this->assertFalse($defaults['requests']['enabled']); + $this->assertSame([], $defaults['domain_events']); + $config = $this->app['config']; + $config->set('posthog.autocapture', []); + $config->set('posthog-autocapture.requests.enabled', true); + (new AutocaptureServiceProvider($this->app))->register(); + $this->assertTrue($config->get('posthog.autocapture.requests.enabled')); + $config->set('posthog.autocapture.requests', ['enabled' => false]); + (new AutocaptureServiceProvider($this->app))->register(); + $this->assertFalse($config->get('posthog.autocapture.requests.enabled')); + } finally { + if ($original === null) { + @unlink($path); + } else { + file_put_contents($path, $original); + } + } + } + + public function testRequestSummaryAndPageviewUseTemplatesAndConfiguredOriginOnly(): void + { + $fake = PostHog::fake(); + $this->enable('requests'); + $this->enable('pageviews'); + $this->app->register(AutocaptureServiceProvider::class); + $this->app['config']->set('app.url', 'https://user:secret@app.example:8443/base?secret=hidden'); + Route::get('/orders/{order}', fn () => response('

secret

')->header('Content-Type', 'text/html')); + foreach (['first-private', 'second-private'] as $id) { + $this->get('/orders/' . $id . '?token=secret', ['X-PostHog-Distinct-Id' => $id])->assertOk(); + } + $fake->assertCapturedCount(4); + $messages = $this->events(); + $this->assertSame( + ['first-private', 'first-private', 'second-private', 'second-private'], + array_column($messages, 'distinctId'), + ); + $this->assertSame(['route', 'method', 'status_code', 'duration_ms'], array_keys($messages[0]['properties'])); + $this->assertSame('/orders/{order}', $messages[0]['properties']['route']); + $this->assertSame('https://app.example:8443/orders/{order}', $messages[1]['properties']['$current_url']); + $this->assertSame('/orders/{order}', $messages[1]['properties']['$pathname']); + $this->assertGreaterThanOrEqual(0, $messages[0]['properties']['duration_ms']); + $this->assertStringNotContainsString('secret', json_encode(array_column($messages, 'properties'))); + $this->assertStringNotContainsString('private', json_encode(array_column($messages, 'properties'))); + $this->assertSame([], $this->app->make(Context::class)->snapshot()); + } + + public function testHealthAssetsUnmatchedAndSamplingDoNotCollectAndPageviewIsSeparate(): void + { + $fake = PostHog::fake(); + $this->enable('requests'); + $this->enable('pageviews'); + foreach (['/up', '/health/check', '/assets/{file}', '/bundle.js'] as $uri) { + Route::get($uri, fn () => response('ok')->header('Content-Type', 'text/html')); + } + foreach (['/up', '/health/check', '/assets/private', '/bundle.js', '/unmatched'] as $uri) { + $this->get($uri, ['X-PostHog-Distinct-Id' => 'actor']); + } + $fake->assertNothingCaptured(); + Route::get('/json', fn () => response()->json(['ok' => true])); + Route::post('/html', fn () => response('ok')->header('Content-Type', 'text/html')); + Route::get('/redirect', fn () => redirect('/json')); + Route::get('/error', fn () => response('error', 503)->header('Content-Type', 'text/html')); + $this->get('/json', ['X-PostHog-Distinct-Id' => 'actor']); + $this->post('/html', [], ['X-PostHog-Distinct-Id' => 'actor']); + $this->get('/redirect', ['X-PostHog-Distinct-Id' => 'actor']); + $this->get('/error', ['X-PostHog-Distinct-Id' => 'actor']); + $fake->assertCapturedCount(4); + $fake->assertNotCaptured('$pageview'); + $this->app['config']->set('posthog.autocapture.requests.sample_rate', 0); + $this->get('/json', ['X-PostHog-Distinct-Id' => 'actor']); + $fake->assertCapturedCount(4); + } + + public function testPageviewCanBeEnabledWithoutRequestSummary(): void + { + $fake = PostHog::fake(); + $this->enable('pageviews'); + Route::get('/page', fn () => response('ok')->header('Content-Type', 'text/html')); + $this->get('/page', ['X-PostHog-Distinct-Id' => 'actor']); + $fake->assertCapturedCount(1); + $fake->assertCaptured('$pageview'); + $fake->assertNotCaptured('laravel_request'); + } + + public function testAuthUsesOnlyIdentifierAndGuardAndFailedLockoutNeedExplicitOptIn(): void + { + $fake = PostHog::fake(); + $this->enable('auth'); + $this->identity('anonymous'); + $user = new GenericUser(['id' => 123, 'email' => 'secret@example.test', 'password' => 'secret']); + $events = $this->app['events']; + $events->dispatch(new \Illuminate\Auth\Events\Registered($user)); + $events->dispatch(new \Illuminate\Auth\Events\Login('web', $user, true)); + $events->dispatch(new \Illuminate\Auth\Events\Logout('web', $user)); + $failed = new \Illuminate\Auth\Events\Failed( + 'web', + $user, + ['email' => 'secret@example.test', 'password' => 'secret'], + ); + $lockout = new \Illuminate\Auth\Events\Lockout(Request::create('/?password=secret')); + $events->dispatch($failed); + $events->dispatch($lockout); + $fake->assertCapturedCount(3); + $this->app['config']->set('posthog.autocapture.auth.events', ['failed', 'lockout']); + $events->dispatch($failed); + $events->dispatch($lockout); + $this->assertSame(['123', '123', '123', 'anonymous', 'anonymous'], array_column($this->events(), 'distinctId')); + $this->assertSame( + [[], ['guard' => 'web'], ['guard' => 'web'], ['guard' => 'web'], []], + array_column($this->events(), 'properties'), + ); + $this->assertStringNotContainsString('secret', json_encode($this->events())); + } + + public function testDomainMapsOnlyExactClassAndPublicJsonSafeProperties(): void + { + $fake = PostHog::fake(); + $this->identity(); + $this->app['events']->dispatch(new OrderCompleted(model: new GenericUser(['email' => 'secret']))); + $this->assertSame(['amount' => 42, 'currency' => 'USD'], $this->events()[0]['properties']); + $this->app['events']->dispatch(new class extends OrderCompleted { + }); + $fake->assertCapturedCount(1); + } + + public function testModelsCaptureAfterCommitWithOriginalIdentityNoPayloadAndRollbackDrops(): void + { + $fake = PostHog::fake(); + $this->modelTable(); + $this->enable('models'); + $this->app['config']->set('posthog.autocapture.models.allow', [CollectedRecord::class => ['state']]); + $this->identity('original'); + DB::beginTransaction(); + $record = CollectedRecord::create(['state' => 'created', 'email' => 'secret@example.test']); + $record->update(['state' => 'updated']); + $record->delete(); + $fake->assertNothingCaptured(); + $this->identity('next'); + DB::commit(); + $fake->assertCapturedCount(3); + $this->assertSame(['original', 'original', 'original'], array_column($this->events(), 'distinctId')); + $this->assertSame( + ['created', 'updated', 'updated'], + array_map(fn ($event) => $event['properties']['attributes']['state'], $this->events()), + ); + $this->assertSame(['team' => 'original'], $this->events()[0]['groups']); + $this->assertStringNotContainsString('secret', json_encode($this->events())); + $this->assertSame('next', $this->app->make(Context::class)->data()['distinctId']); + $exception = new RuntimeException('original failure'); + try { + DB::transaction(function () use ($exception) { + CollectedRecord::create(['state' => 'rolled back', 'email' => 'secret']); + throw $exception; + }); + $this->fail('Transaction must throw'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + $fake->assertCapturedCount(3); + $this->assertSame(0, CollectedRecord::count()); + $this->app['config']->set('posthog.autocapture.models.allow', [CollectedRecord::class => []]); + CollectedRecord::create(['state' => 'safe', 'email' => 'secret']); + $this->assertSame(['model' => CollectedRecord::class, 'attributes' => []], $this->events()[3]['properties']); + } + + public function testNestedRollbackDropsOnlyRolledBackModelEventsAndUnallowlistedModelsDoNotCollect(): void + { + $fake = PostHog::fake(); + $this->modelTable(); + $this->enable('models'); + $this->identity(); + CollectedRecord::create(['state' => 'not allowed', 'email' => 'secret']); + $fake->assertNothingCaptured(); + $this->app['config']->set('posthog.autocapture.models.allow', [CollectedRecord::class => []]); + DB::beginTransaction(); + CollectedRecord::create(['state' => 'outer', 'email' => 'secret']); + DB::beginTransaction(); + CollectedRecord::create(['state' => 'inner', 'email' => 'secret']); + DB::rollBack(); + $fake->assertNothingCaptured(); + DB::commit(); + $fake->assertCapturedCount(1); + } + + public function testQueueOutcomesAreOncePerAttemptAndNeverContainJobPayloadOrException(): void + { + $fake = PostHog::fake(); + $this->enable('jobs'); + $job = $this->createStub(\Illuminate\Contracts\Queue\Job::class); + $job->method('resolveQueuedJobClass')->willReturn('App\\Jobs\\Import'); + $job->method('resolveName')->willReturn('secret display name'); + $job->method('attempts')->willReturn(2); + $events = $this->app['events']; + foreach (['first', 'second'] as $id) { + $events->dispatch(new JobProcessing('sync', $job)); + $this->identity($id); + $event = $id === 'first' ? new JobProcessed('sync', $job) + : new JobExceptionOccurred('sync', $job, new RuntimeException('secret failure')); + $events->dispatch($event); + $events->dispatch($event); + } + $fake->assertCapturedCount(2); + $this->assertSame(['first', 'second'], array_column($this->events(), 'distinctId')); + $this->assertSame( + ['success', 'failed'], + array_map(fn ($event) => $event['properties']['outcome'], $this->events()), + ); + $this->assertSame(['name', 'outcome', 'attempts', 'duration_ms'], array_keys($this->events()[0]['properties'])); + $this->assertStringNotContainsString('secret', json_encode($this->events())); + $this->assertSame([], $this->app->make(Context::class)->data()); + } + + public function testRealSyncJobSuccessAndFailureKeepOriginalExceptionAndOuterWorkerContext(): void + { + $fake = PostHog::fake(); + $this->enable('jobs'); + $this->identity('outer'); + \Illuminate\Support\Facades\Bus::dispatchSync(new Fixtures\CaptureJob('job-user')); + try { + \Illuminate\Support\Facades\Bus::dispatchSync(new Fixtures\FailingCaptureJob('failed-user')); + $this->fail('Job must fail'); + } catch (RuntimeException $exception) { + $this->assertSame('failed job', $exception->getMessage()); + } + $fake->assertCaptured('laravel_job', fn ($event) => $event['distinctId'] === 'job-user' + && $event['properties']['name'] === Fixtures\CaptureJob::class + && $event['properties']['outcome'] === 'success'); + $fake->assertCaptured('laravel_job', fn ($event) => $event['distinctId'] === 'failed-user' + && $event['properties']['name'] === Fixtures\FailingCaptureJob::class + && $event['properties']['outcome'] === 'failed'); + $this->assertSame('outer', $this->app->make(Context::class)->data()['distinctId']); + } + + public function testCommandAndSchedulerMetadataExcludeArgumentsAndDeduplicateFailure(): void + { + $fake = PostHog::fake(); + $this->enable('commands'); + $this->enable('scheduler'); + $input = new ArrayInput(['secret' => 'password']); + $output = new NullOutput(); + $events = $this->app['events']; + $events->dispatch(new CommandStarting('import --secret=password', $input, $output)); + $this->identity(); + $events->dispatch(new CommandFinished('import --secret=password', $input, $output, 7)); + $this->identity(); + $task = new Event($this->createStub(EventMutex::class), 'php artisan import --secret=password'); + $task->exitCode = 1; + $events->dispatch(new ScheduledTaskStarting($task)); + $events->dispatch(new ScheduledTaskFinished($task, 0.1)); + $events->dispatch(new ScheduledTaskFailed($task, new RuntimeException('secret'), 0.1)); + $task->runInBackground = true; + $task->exitCode = null; + $events->dispatch(new ScheduledTaskStarting($task)); + $events->dispatch(new ScheduledTaskFinished($task, 0.1)); + $fake->assertCapturedCount(3); + $this->assertSame('import', $this->events()[0]['properties']['name']); + $this->assertSame('failed', $this->events()[1]['properties']['outcome']); + $this->assertSame('background_started', $this->events()[2]['properties']['outcome']); + $this->assertStringNotContainsString('password', json_encode($this->events())); + $this->assertStringNotContainsString('secret', json_encode($this->events())); + } + + public function testBladeUsesSharedFlagSnapshotsAndCoreExposureDeduplicationAcrossScopes(): void + { + $this->enable('blade'); + $http = $this->createMock(\PostHog\HttpClient::class); + $http->expects($this->exactly(2))->method('sendRequest')->willReturn(new \PostHog\HttpResponse( + json_encode(['flags' => ['checkout' => ['enabled' => true, 'variant' => null]]]), + 200, + )); + $captures = []; + $sdk = new \PostHog\Client('project-key', [ + 'consumer' => 'noop', + 'before_send' => static function ($event) use (&$captures) { + $captures[] = $event; + return $event; + }, + ], $http, loadFeatureFlags: false); + $manager = $this->app->make(\PostHog\Laravel\PostHog::class); + (new \ReflectionProperty(\PostHog\Laravel\PostHog::class, 'sdk'))->setValue($manager, $sdk); + foreach (['first', 'second'] as $id) { + $this->identity($id); + $snapshot = $manager->evaluateFlags(); + \PostHog\Laravel\Autocapture\Collector::capture('between reads', []); + $this->assertSame($snapshot, $manager->evaluateFlags()); + for ($i = 0; $i < 2; $i++) { + $template = "@posthogFeature('checkout')\n on\n@else\n off\n@endposthogFeature"; + $this->assertSame('on', trim(Blade::render($template))); + } + } + $exposures = array_values(array_filter($captures, fn ($event) => $event['event'] === '$feature_flag_called')); + $this->assertCount(2, $exposures); + $this->assertSame(['first', 'second'], array_column($exposures, 'distinct_id')); + } + + public function testOutcomeExclusionsAndNoIdentityDoNotInventAnonymousEvents(): void + { + $fake = PostHog::fake(); + $this->enable('jobs'); + $this->app['config']->set('posthog.autocapture.jobs.exclude', ['App\\Jobs\\*']); + $job = $this->createStub(\Illuminate\Contracts\Queue\Job::class); + $job->method('resolveQueuedJobClass')->willReturn('App\\Jobs\\Import'); + $job->method('attempts')->willReturn(1); + $this->app['events']->dispatch(new JobProcessing('sync', $job)); + $this->identity(); + $this->app['events']->dispatch(new JobProcessed('sync', $job)); + $this->app['events']->dispatch(new OrderCompleted()); + $fake->assertNothingCaptured(); + } + + public function testCollectorFailureNeverMasksOriginalExceptionOrLeaksContextProperties(): void + { + $this->enable('requests'); + $this->app->instance(\PostHog\Laravel\PostHog::class, new class ($this->app) extends \PostHog\Laravel\PostHog { + public function capture( + string|array $event, + array $properties = [], + ?string $distinctId = null, + array $groups = [], + ): bool { + throw new RuntimeException('collector failed'); + } + }); + $this->identity(); + $request = Request::create('/test?secret=password'); + $route = new \Illuminate\Routing\Route('GET', 'test', fn () => null); + $request->setRouteResolver(fn () => $route); + $exception = new RuntimeException('original'); + try { + (new RequestSummary())->handle($request, fn () => throw $exception); + $this->fail('Original exception must be thrown'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + $fake = PostHog::fake(); + $this->app->make(Context::class)->begin(['distinctId' => 'actor', 'properties' => ['password' => 'secret']]); + $this->app['events']->dispatch(new OrderCompleted()); + $this->assertStringNotContainsString('secret', json_encode($this->events())); + $this->assertSame(['password' => 'secret'], $this->app->make(Context::class)->data()['properties']); + $fake->assertCapturedCount(1); + } +} diff --git a/packages/laravel/tests/Fixtures/CollectedRecord.php b/packages/laravel/tests/Fixtures/CollectedRecord.php new file mode 100644 index 00000000..4d40bd46 --- /dev/null +++ b/packages/laravel/tests/Fixtures/CollectedRecord.php @@ -0,0 +1,12 @@ + 'USD'], + public mixed $model = null, + ) { + } +} From 53cdbc56f11457a461dfe23987d2105abd317459 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 10:00:48 +0100 Subject: [PATCH 06/37] fix: preserve explicit and nested Laravel scopes through lifecycle events --- packages/laravel/README.md | 10 ++-- packages/laravel/src/Context.php | 13 +++-- .../laravel/src/PostHogServiceProvider.php | 54 +++++++++++-------- packages/laravel/tests/RuntimeTest.php | 31 +++++++++++ 4 files changed, 77 insertions(+), 31 deletions(-) diff --git a/packages/laravel/README.md b/packages/laravel/README.md index 2caaed6e..9ccd055c 100644 --- a/packages/laravel/README.md +++ b/packages/laravel/README.md @@ -45,9 +45,9 @@ Inject `PostHog\Laravel\PostHog` to use the same API without a facade. `posthog` container alias. `PostHog\Client` is also injectable, but raw client calls require explicit identity or an explicit core context. -Identity precedence: an explicit API `distinctId` wins, then the authenticated -request user's auth identifier, then `X-PostHog-Distinct-Id` as an anonymous analytics -fallback. `X-PostHog-Session-Id` provides session correlation. Headers are untrusted +Identity precedence: an explicit API `distinctId` wins, then an explicitly scoped +`Context` identity, then the custom identity resolver or authenticated request user's +auth identifier, then `X-PostHog-Distinct-Id` as an anonymous analytics fallback. `X-PostHog-Session-Id` provides session correlation. Headers are untrusted analytics input, **never authentication or authorization**. Set `header_context=false` to ignore both headers. No anonymous ID is generated for requests without identity. @@ -87,8 +87,8 @@ the analytics context after cleanup. Queue jobs start fresh and receive only bou identity, session and scalar group context in `_posthog` payload metadata. Arbitrary properties, models and request data are not propagated. Set `queue_context=false` to disable propagation. This metadata is analytics context, never authorization evidence. -Jobs may override it explicitly. Nested synchronous jobs restore the caller's context -when they finish or fail. +Jobs may override it explicitly. Nested synchronous jobs and Artisan commands restore +the caller's context and flag snapshots when they finish or fail. Global request middleware, queue events, Artisan command events, and Octane request/task/tick events flush and clean context automatically. SDK creation is lazy, diff --git a/packages/laravel/src/Context.php b/packages/laravel/src/Context.php index 8867d1d6..ac851334 100644 --- a/packages/laravel/src/Context.php +++ b/packages/laravel/src/Context.php @@ -15,6 +15,7 @@ class Context private array $flags = []; private array $scopes = []; private ?Request $request = null; + private bool $headerIdentity = false; public function __construct(private Container $app) { @@ -25,6 +26,7 @@ public function begin(array $data = []): void $this->context = $data; $this->flags = []; $this->request = null; + $this->headerIdentity = false; } public function end(): void @@ -44,7 +46,8 @@ public function data(): array } try { $resolver = $this->app['config']->get('posthog.distinct_id'); - $id = $resolver ? ($this->app->make($resolver))($this->app) : null; + $explicitId = !$this->headerIdentity && isset($data['distinctId']); + $id = $explicitId ? $data['distinctId'] : ($resolver ? ($this->app->make($resolver))($this->app) : null); if ($id === null && $this->request !== null) { $id = $this->request->user()?->getAuthIdentifier(); } @@ -77,8 +80,10 @@ public function with(array $data, callable $callback): mixed $previous = $this->context; $flags = $this->flags; $request = $this->request; + $headerIdentity = $this->headerIdentity; $this->begin(array_replace_recursive($previous, $data)); $this->request = $request; + $this->headerIdentity = !array_key_exists('distinctId', $data) && $headerIdentity; try { return $callback(); } catch (Throwable $exception) { @@ -88,6 +93,7 @@ public function with(array $data, callable $callback): mixed $this->context = $previous; $this->flags = $flags; $this->request = $request; + $this->headerIdentity = $headerIdentity; } } @@ -96,12 +102,13 @@ public function fromRequest(Request $request): void $this->begin($this->app['config']->get('posthog.header_context', true) ? CorePostHog::contextFromHeaders($request->headers->all()) : []); $this->request = $request; + $this->headerIdentity = true; } /** @internal Isolate nested queue executions without discarding the caller's context. */ public function enter(): void { - $this->scopes[] = [$this->context, $this->flags, $this->request]; + $this->scopes[] = [$this->context, $this->flags, $this->request, $this->headerIdentity]; $this->begin(); } @@ -112,7 +119,7 @@ public function leave(): void $this->end(); return; } - [$this->context, $this->flags, $this->request] = array_pop($this->scopes); + [$this->context, $this->flags, $this->request, $this->headerIdentity] = array_pop($this->scopes); } public function flags(string $key, callable $evaluate): FeatureFlagEvaluations diff --git a/packages/laravel/src/PostHogServiceProvider.php b/packages/laravel/src/PostHogServiceProvider.php index 6143c0b6..3c745b2b 100644 --- a/packages/laravel/src/PostHogServiceProvider.php +++ b/packages/laravel/src/PostHogServiceProvider.php @@ -72,14 +72,16 @@ public function boot(): void } }); $events->listen(JobExceptionOccurred::class, function ($event) { - $app = Container::getInstance(); - $app->make(ExceptionReporter::class)->remember($event->exception, 'task'); - $this->finish($app, true); + Container::getInstance()->make(ExceptionReporter::class)->remember($event->exception, 'task'); + }); + $events->listen(CommandStarting::class, function () { + $context = Container::getInstance()->make(Context::class); + $data = $context->snapshot(); + $context->enter(); + $context->begin($data); }); - $events->listen(JobProcessed::class, fn () => $this->finish(Container::getInstance(), true)); foreach ( [ - CommandStarting::class, 'Laravel\\Octane\\Events\\RequestReceived', 'Laravel\\Octane\\Events\\TaskReceived', 'Laravel\\Octane\\Events\\TickReceived', @@ -94,28 +96,34 @@ public function boot(): void } }); } - foreach ( - [ - Looping::class, - CommandFinished::class, - 'Laravel\\Octane\\Events\\RequestTerminated', - 'Laravel\\Octane\\Events\\TaskTerminated', - 'Laravel\\Octane\\Events\\TickTerminated', - ] as $event - ) { - $events->listen($event, function ($event) { - $this->finish($event->sandbox ?? $event->app ?? Container::getInstance()); - }); - } $events->listen('Laravel\\Octane\\Events\\WorkerErrorOccurred', function ($event) { - $app = $event->sandbox; - $app->make(ExceptionReporter::class)->remember($event->exception, 'runtime'); - $this->finish($app); + $event->sandbox->make(ExceptionReporter::class)->remember($event->exception, 'runtime'); + }); + // End-event consumers must observe the completed scope before cleanup restores its caller. + $this->app->booted(function () use ($events) { + foreach ([JobProcessed::class, JobExceptionOccurred::class, CommandFinished::class] as $event) { + $events->listen($event, fn () => $this->finish(Container::getInstance(), true)); + } + foreach ( + [ + Looping::class, + 'Laravel\\Octane\\Events\\RequestTerminated', + 'Laravel\\Octane\\Events\\TaskTerminated', + 'Laravel\\Octane\\Events\\TickTerminated', + ] as $event + ) { + $events->listen($event, function ($event) { + $this->finish($event->sandbox ?? $event->app ?? Container::getInstance()); + }); + } + $events->listen('Laravel\\Octane\\Events\\WorkerErrorOccurred', function ($event) { + $this->finish($event->sandbox); + }); }); $this->app->terminating(fn () => $this->finish(Container::getInstance())); } - private function finish(Container $app, bool $queue = false): void + private function finish(Container $app, bool $restore = false): void { try { $app->make(PostHog::class)->flush(); @@ -123,7 +131,7 @@ private function finish(Container $app, bool $queue = false): void // Lifecycle cleanup must continue when a transport fails. } finally { $context = $app->make(Context::class); - $queue ? $context->leave() : $context->end(); + $restore ? $context->leave() : $context->end(); } } } diff --git a/packages/laravel/tests/RuntimeTest.php b/packages/laravel/tests/RuntimeTest.php index f170d2c8..1532bc84 100644 --- a/packages/laravel/tests/RuntimeTest.php +++ b/packages/laravel/tests/RuntimeTest.php @@ -100,6 +100,37 @@ public function testActualHttpRequestsResolveAuthAfterMiddlewareAndNeverLeak(): $this->assertStringNotContainsString('private', json_encode($messages)); } + public function testExplicitScopedIdentityWinsOverAuthenticationAndCustomResolvers(): void + { + $fake = Facade::fake(); + $context = $this->app->make(Context::class); + $request = Request::create('/', server: ['HTTP_X_POSTHOG_DISTINCT_ID' => 'header-user']); + $request->setUserResolver(fn () => new GenericUser(['id' => 'authenticated-user'])); + $context->fromRequest($request); + $context->with(['distinctId' => 'scoped-user'], fn () => Facade::capture('scoped')); + $fake->assertCaptured('scoped', fn ($event) => $event['distinctId'] === 'scoped-user'); + $this->assertSame('authenticated-user', $context->data()['distinctId']); + $this->app['config']->set('posthog.distinct_id', IdentityResolver::class); + $context->begin(['distinctId' => 'explicit-job-user']); + $this->assertSame('explicit-job-user', $context->data()['distinctId']); + } + + public function testNestedArtisanCommandRestoresCallerContextAndFlagSnapshot(): void + { + $context = $this->app->make(Context::class); + $context->begin(['distinctId' => 'caller', 'sessionId' => 'session', 'groups' => ['team' => 'one']]); + $before = $context->snapshot(); + $flags = new \PostHog\FeatureFlagEvaluations('caller', [], [], Facade::fake()->client()); + $context->flags('caller-snapshot', fn () => $flags); + $this->app->make(\Illuminate\Contracts\Console\Kernel::class)->rerouteSymfonyCommandEvents(); + \Illuminate\Support\Facades\Artisan::call('list', ['--raw' => true]); + $this->assertSame($before, $context->snapshot()); + $this->assertSame($flags, $context->flags( + 'caller-snapshot', + fn () => throw new RuntimeException('lost flags'), + )); + } + public function testHeaderFallbackAndNestedContextRestoreOnFailure(): void { $context = $this->app->make(Context::class); From 4a9e95b1bb6636562839de44fffe4d8872defcf3 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 10:01:51 +0100 Subject: [PATCH 07/37] feat: add runnable Laravel SDK dashboard example --- examples/laravel/.env.example | 33 + examples/laravel/.gitignore | 6 + examples/laravel/README.md | 196 + .../app/Console/Commands/DemoCommand.php | 30 + .../laravel/app/Events/OrderCompleted.php | 10 + .../app/Http/Controllers/DemoController.php | 67 + examples/laravel/app/Jobs/DemoJob.php | 28 + examples/laravel/app/Models/DemoOrder.php | 15 + examples/laravel/app/Models/DemoUser.php | 10 + .../app/Providers/DemoServiceProvider.php | 25 + examples/laravel/app/Support/DemoActions.php | 110 + .../app/Support/RedactDemoProperties.php | 13 + examples/laravel/artisan | 7 + examples/laravel/bootstrap/app.php | 19 + examples/laravel/bootstrap/cache/.gitignore | 2 + examples/laravel/bootstrap/providers.php | 3 + examples/laravel/composer.json | 64 + examples/laravel/composer.lock | 8030 +++++++++++++++++ examples/laravel/config/app.php | 14 + examples/laravel/config/auth.php | 7 + examples/laravel/config/cache.php | 14 + examples/laravel/config/database.php | 14 + examples/laravel/config/demo.php | 3 + examples/laravel/config/logging.php | 9 + examples/laravel/config/posthog.php | 51 + examples/laravel/config/queue.php | 13 + examples/laravel/config/session.php | 19 + .../2026_01_01_000000_create_demo_tables.php | 48 + examples/laravel/phpunit.xml | 27 + examples/laravel/public/index.php | 6 + .../resources/views/dashboard.blade.php | 82 + examples/laravel/routes/console.php | 16 + examples/laravel/routes/web.php | 12 + examples/laravel/storage/app/.gitignore | 2 + .../storage/framework/cache/data/.gitignore | 2 + .../storage/framework/sessions/.gitignore | 2 + .../storage/framework/views/.gitignore | 2 + examples/laravel/storage/logs/.gitignore | 2 + .../laravel/tests/Feature/CollectorsTest.php | 107 + .../laravel/tests/Feature/DashboardTest.php | 210 + .../laravel/tests/Feature/TransportTest.php | 96 + examples/laravel/tests/TestCase.php | 17 + 42 files changed, 9443 insertions(+) create mode 100644 examples/laravel/.env.example create mode 100644 examples/laravel/.gitignore create mode 100644 examples/laravel/README.md create mode 100644 examples/laravel/app/Console/Commands/DemoCommand.php create mode 100644 examples/laravel/app/Events/OrderCompleted.php create mode 100644 examples/laravel/app/Http/Controllers/DemoController.php create mode 100644 examples/laravel/app/Jobs/DemoJob.php create mode 100644 examples/laravel/app/Models/DemoOrder.php create mode 100644 examples/laravel/app/Models/DemoUser.php create mode 100644 examples/laravel/app/Providers/DemoServiceProvider.php create mode 100644 examples/laravel/app/Support/DemoActions.php create mode 100644 examples/laravel/app/Support/RedactDemoProperties.php create mode 100755 examples/laravel/artisan create mode 100644 examples/laravel/bootstrap/app.php create mode 100644 examples/laravel/bootstrap/cache/.gitignore create mode 100644 examples/laravel/bootstrap/providers.php create mode 100644 examples/laravel/composer.json create mode 100644 examples/laravel/composer.lock create mode 100644 examples/laravel/config/app.php create mode 100644 examples/laravel/config/auth.php create mode 100644 examples/laravel/config/cache.php create mode 100644 examples/laravel/config/database.php create mode 100644 examples/laravel/config/demo.php create mode 100644 examples/laravel/config/logging.php create mode 100644 examples/laravel/config/posthog.php create mode 100644 examples/laravel/config/queue.php create mode 100644 examples/laravel/config/session.php create mode 100644 examples/laravel/database/migrations/2026_01_01_000000_create_demo_tables.php create mode 100644 examples/laravel/phpunit.xml create mode 100644 examples/laravel/public/index.php create mode 100644 examples/laravel/resources/views/dashboard.blade.php create mode 100644 examples/laravel/routes/console.php create mode 100644 examples/laravel/routes/web.php create mode 100644 examples/laravel/storage/app/.gitignore create mode 100644 examples/laravel/storage/framework/cache/data/.gitignore create mode 100644 examples/laravel/storage/framework/sessions/.gitignore create mode 100644 examples/laravel/storage/framework/views/.gitignore create mode 100644 examples/laravel/storage/logs/.gitignore create mode 100644 examples/laravel/tests/Feature/CollectorsTest.php create mode 100644 examples/laravel/tests/Feature/DashboardTest.php create mode 100644 examples/laravel/tests/Feature/TransportTest.php create mode 100644 examples/laravel/tests/TestCase.php diff --git a/examples/laravel/.env.example b/examples/laravel/.env.example new file mode 100644 index 00000000..8881fc48 --- /dev/null +++ b/examples/laravel/.env.example @@ -0,0 +1,33 @@ +APP_NAME="PostHog Laravel Demo" +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=http://localhost:8000 +LOG_CHANNEL=single +DB_CONNECTION=sqlite +# Leave DB_DATABASE unset to use database/database.sqlite. +SESSION_DRIVER=file +CACHE_STORE=file +QUEUE_CONNECTION=sync + +# Blank key = no SDK network, including feature flags. Synthetic demo identities only. +POSTHOG_ENABLED=true +POSTHOG_API_KEY= +POSTHOG_HOST=https://us.i.posthog.com +# Optional SERVER-ONLY secret for local flag definitions. Never render in HTML. +POSTHOG_SECRET_KEY= +POSTHOG_RELEASE_ID=laravel-example-local +POSTHOG_CONSUMER=lib_curl +POSTHOG_FLAG_CACHE=false +POSTHOG_LOCAL_ONLY=false +POSTHOG_QUEUE_CONTEXT=true + +# Independent opt-ins; restart workers after changing configuration. +POSTHOG_COLLECT_REQUESTS=false +POSTHOG_COLLECT_PAGEVIEWS=false +POSTHOG_COLLECT_AUTH=false +POSTHOG_COLLECT_MODELS=false +POSTHOG_COLLECT_DOMAIN=false +POSTHOG_COLLECT_JOBS=false +POSTHOG_COLLECT_COMMANDS=false +POSTHOG_COLLECT_SCHEDULER=false diff --git a/examples/laravel/.gitignore b/examples/laravel/.gitignore new file mode 100644 index 00000000..1402000c --- /dev/null +++ b/examples/laravel/.gitignore @@ -0,0 +1,6 @@ +/.env +/vendor/ +/.phpunit.cache/ +/bootstrap/cache/*.php +/database/*.sqlite* +/storage/*.key diff --git a/examples/laravel/README.md b/examples/laravel/README.md new file mode 100644 index 00000000..364de044 --- /dev/null +++ b/examples/laravel/README.md @@ -0,0 +1,196 @@ +# PostHog Laravel example + +An original, minimal Laravel 12/13 application using `posthog/posthog-laravel` and +the PHP SDK from this checkout. No JavaScript build, npm, external database, or +PostHog credentials are needed to run it. **Local development only:** synthetic +login deliberately has no password; never expose this application publicly. + +## Run + +Requirements: PHP 8.2+ with Laravel's extensions (including DOM/XML, mbstring, +PDO SQLite) and cURL for live SDK transport; Composer 2. Dependency installation +requires access to Composer's package repositories. No vendor files are committed. + +From this directory (`examples/laravel`): + +```sh +composer install +cp .env.example .env +php artisan key:generate +php artisan migrate --force +php artisan serve +``` + +Open . Migration creates the SQLite file automatically. +The checked-in lock targets Laravel 12 / PHP 8.2. The Composer constraint also +supports Laravel 13 (PHP 8.3+); to try it on a compatible interpreter: + +```sh +composer config platform.php 8.3.0 +composer update --with-all-dependencies +composer test +``` + +The example's **development-only path repositories** point to `../..` (PHP SDK, +version override `4.20.x-dev`) and `../../packages/laravel` (integration, override +`0.1.0`). Run inside this repository, not as an isolated copy. They do not belong +in a published package manifest. The lock does not replace the local source trees. + +## Safe defaults and configuration + +With a blank `POSTHOG_API_KEY` or `POSTHOG_ENABLED=false`, **all SDK network paths +are disabled**, including flags and raw client access. Forms still run and show +false/empty SDK results. SQLite operations and synthetic login still work. + +To send synthetic events to a development project, set `POSTHOG_API_KEY` and the +project's ingestion `POSTHOG_HOST` in `.env`. Keep `.env` private. Do not use +production credentials/data for this demo. Never put `POSTHOG_SECRET_KEY` in HTML, +browser code, event properties, logs, or a browser configuration endpoint. + +`config/posthog.php` demonstrates SDK options, release metadata, GeoIP disabled, +and a container-resolved `before_send` callback. `RedactDemoProperties` strips +email/password top-level properties as defense in depth; it is **not** a complete +PII scrubber. Application properties and exception messages/stacks may be sensitive. +Request collection does not include raw URLs, query strings, bodies, cookies, +credentials, email, or IP. Synthetic model fields are explicitly allowlisted. + +After changing configuration, use `php artisan config:clear` and restart workers. +`php artisan config:cache` also works (no closures in config). + +## Dashboard and source map + +| Form / command | What it demonstrates | Source | +| --- | --- | --- | +| Capture two events | explicit identity/groups and ambient scoped capture | `app/Support/DemoActions.php` | +| Identify, alias, group identify | synthetic member/visitor relationship, team profile | `DemoActions::identity()` | +| Evaluate flags | boolean, variant, decoded payload, one shared snapshot, `onlyAccessed()` attachment, unresolved reasons | `DemoActions::flags()` | +| Manual exception | explicit handled capture and safe properties | `DemoActions::exception()` | +| Laravel report() | handled framework reporting (no global PHP handlers) | `DemoActions::exception()` | +| Throw an unhandled 500 | escaping failure reporting and context cleanup | `DemoController::failure()` | +| Create order | SQLite transaction, model collector, explicitly mapped domain event | `DemoActions::order()`, `app/Events/OrderCompleted.php` | +| Login / logout | real Laravel session auth events using a synthetic user | `app/Http/Controllers/DemoController.php` | +| Dispatch job | default sync queue; bounded analytics propagation plus explicit job scope override | `app/Jobs/DemoJob.php` | +| Flush | explicit transport result; lifecycle flush is automatic | `DemoController::run()` | +| `php artisan demo:posthog --flags` | the same APIs from Artisan (`--flags` is optional) | `app/Console/Commands/DemoCommand.php` | +| `php artisan schedule:run` | named once-per-minute scheduled command | `routes/console.php` | + +The example-only `DemoServiceProvider` assigns `demo-console` to the known +`demo:posthog`, `demo:scheduled`, and `schedule:run` commands for their complete +lifetime, so command/task outcomes have an actor. This is a synthetic diagnostic +identity, not real-user attribution, and does not change HTTP identity. + +Captures use `demo_*` event names. These are custom business events, **not** +automatic duplicate `$pageview` events. `flush()` is not a delivery receipt. +Transport defaults to `lib_curl`; `POSTHOG_CONSUMER=noop` drops event delivery but +**does not disable flag network requests** when a project key is present. Use +`POSTHOG_ENABLED=false` or blank credentials for a genuinely offline app. + +### Context and tracing headers + +`Context::with()` restores prior context on success/failure. Request auth identity +wins over the `X-PostHog-Distinct-Id` anonymous analytics fallback; explicit capture +identity wins over both. `X-PostHog-Session-Id` adds `$session_id`. This application's +explicit synthetic fallback is `demo-visitor`, not an SDK-generated anonymous ID. +**Headers are untrusted analytics context, never authentication/authorization.** + +The runtime propagates only bounded identity, session and scalar groups to queue +payload `_posthog` metadata. It does not serialize arbitrary context properties, +models or request data. Set `POSTHOG_QUEUE_CONTEXT=false` to opt out. The job first +captures with the propagated identity, then overrides it with `demo-job-owner`. +For a persistent worker instead of sync: + +```sh +# Set QUEUE_CONNECTION=database in .env, then click Dispatch job. +php artisan queue:work --once +``` + +### Feature flags and local evaluation + +Create `demo-checkout` (boolean) and `demo-layout` (multivariate, optionally with a +JSON payload) in your development project. Flag evaluation is explicit: dashboard +GET and ordinary capture do not evaluate flags. Value reads emit deduplicated core +exposure events; payload reads are silent. Snapshot attachment adds +`$feature/` and `$active_feature_flags` without reevaluating. Missing or +disabled keys are distinct from local unresolved reasons. + +For local evaluation set the **server-only** `POSTHOG_SECRET_KEY` and optionally +`POSTHOG_FLAG_CACHE=true`. Definitions load/refresh lazily and use Laravel Cache; +the default file store is suitable for a local demo, not a distributed production +cache. `POSTHOG_LOCAL_ONLY=true` prevents remote flag fallback, but a secret-key +worker may still fetch definitions. Unresolved entries can report missing context, +unsupported definitions, unresolved dependencies or experience continuity. Without +local definitions, local-only evaluation is empty; it is not proof a flag is off. + +Advanced PHP SDK methods remain accessible without another global SDK instance: + +```php +$client = \PostHog\Laravel\Facades\PostHog::client(); +$scope = $client->enterContext(['distinctId' => 'demo-advanced'], ['fresh' => true]); +try { + $client->capture(['event' => 'demo_advanced', 'properties' => ['source' => 'raw-client']]); +} finally { + $scope->close(); +} +``` + +### Collector opt-ins + +All collector flags in `.env.example` default to false. The dashboard displays +configuration, not a remote configuration editor. The separate collector provider +must be present for automatic events; explicit runtime examples do not depend on it. + +| Toggle | Events / limits | +| --- | --- | +| `POSTHOG_COLLECT_REQUESTS` | `laravel_request`: matched route template, method, status, duration; excludes health routes | +| `POSTHOG_COLLECT_PAGEVIEWS` | canonical `$pageview`, successful matched HTML GET only, sanitized template URL; don't also enable browser pageviews | +| `POSTHOG_COLLECT_AUTH` | `laravel_auth_registered/login/logout/failed/lockout`; no attempted credentials/identity for failure events | +| `POSTHOG_COLLECT_MODELS` | `laravel_model_created/updated/deleted`, after commit; `DemoOrder` amount/status only, rollback discarded | +| `POSTHOG_COLLECT_DOMAIN` | explicit `OrderCompleted` → `order_completed`, amount only; no whole-event serialization | +| `POSTHOG_COLLECT_JOBS` | `laravel_job`, safe name/outcome/duration, no payload | +| `POSTHOG_COLLECT_COMMANDS` | `laravel_command`, command name/outcome/duration, no arguments | +| `POSTHOG_COLLECT_SCHEDULER` | `laravel_scheduled_task`, safe task name/outcome/duration | + +### Optional browser JavaScript + +No browser SDK, script tags, or key-bearing HTML are installed. If you add browser +analytics, use only its public project token and ingestion host. Configure tracing +headers for your application's host only and keep secret keys server-side. +Choose **either** browser pageviews **or** the server pageview collector to avoid +double-counting. Browser identity headers do not authenticate the synthetic login. + +## Repeatable offline tests + +```sh +composer test +``` + +Tests use a fresh in-memory SQLite database, `PostHog::fake()`/mocked transport, +no real keys, no live PostHog, and no queue server. They exercise dashboard HTML, +POST forms, auth/header precedence, flag variant/payload/snapshot/unresolved output, +handled and unhandled exceptions, queue payloads/opt-out, transport failure results, +Artisan, and each collector family (including commit/rollback). The collector provider is required for those integration tests; no collector +coverage is silently skipped. Tests explicitly enable Symfony command event +rerouting because Laravel normally omits it under `runningUnitTests()`. + +Example fake assertions (see `tests/Feature/DashboardTest.php`): + +```php +$fake = \PostHog\Laravel\Facades\PostHog::fake(); +$this->post('/demo/capture')->assertRedirect('/'); +$fake->assertCaptured('demo_explicit', fn ($event) => $event['distinctId'] === 'demo-explicit'); +$fake->assertCapturedCount(2); +$fake->assertNotCaptured('$pageview'); +``` + +The fake supports identify/alias/group/exception assertions and fixture flag +snapshots. Raw `client()` escape-hatch calls on the fake are disabled and **not +recorded**. Tests assert message payloads; they do not claim server delivery. + +`php artisan posthog:doctor` checks configuration without network (nonzero exit is +expected with blank credentials). `posthog:test --send` is a deliberate live-send +opt-in and is not used by this application's tests. + +The examples follow the SDK specs for capture, identify, alias, group identify, +exception capture, flags, tracing headers, flush and autocapture. No changes to +core SDK semantics or public APIs are made here; server collector names are the +Laravel integration contract, not new canonical reserved SDK events. diff --git a/examples/laravel/app/Console/Commands/DemoCommand.php b/examples/laravel/app/Console/Commands/DemoCommand.php new file mode 100644 index 00000000..15699b61 --- /dev/null +++ b/examples/laravel/app/Console/Commands/DemoCommand.php @@ -0,0 +1,30 @@ + $demo->capture(), + 'identity' => $demo->identity(), + 'exception' => $demo->exception(), + 'order' => $demo->order(), + 'queue' => $demo->queue(), + ]; + if ($this->option('flags')) { + $result['flags'] = $demo->flags(); + } + $result['flushed'] = PostHog::flush(); + $this->line(json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)); + return self::SUCCESS; + } +} diff --git a/examples/laravel/app/Events/OrderCompleted.php b/examples/laravel/app/Events/OrderCompleted.php new file mode 100644 index 00000000..3f573c71 --- /dev/null +++ b/examples/laravel/app/Events/OrderCompleted.php @@ -0,0 +1,10 @@ + PostHog::enabled(), + 'collectorsAvailable' => class_exists(\PostHog\Laravel\AutocaptureServiceProvider::class), + 'collectors' => config('posthog.autocapture'), + ]); + } + + public function run(string $action, DemoActions $demo): RedirectResponse + { + $result = match ($action) { + 'capture' => $demo->capture(), + 'identity' => $demo->identity(), + 'flags' => $demo->flags(), + 'exception' => $demo->exception(), + 'report' => $demo->exception(report: true), + 'order' => $demo->order(), + 'queue' => $demo->queue(), + 'flush' => ['flushed' => PostHog::flush(), 'note' => 'Not a delivery receipt.'], + default => abort(404), + }; + return redirect()->route('dashboard')->with('result', $result); + } + + public function login(Request $request): RedirectResponse + { + // LOCAL DEMO ONLY: no password. Never deploy this synthetic login in production. + $user = DemoUser::firstOrCreate(['name' => 'Synthetic demo member']); + if ($user->wasRecentlyCreated) { + event(new Registered($user)); + } + Auth::login($user); + $request->session()->regenerate(); + PostHog::capture('demo_authenticated'); + return redirect()->route('dashboard')->with('result', ['synthetic_login' => true]); + } + + public function logout(Request $request): RedirectResponse + { + Auth::logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + return redirect()->route('dashboard')->with('result', ['logout' => true]); + } + + public function failure(DemoActions $demo): never + { + $demo->ambient(fn () => throw new RuntimeException('Synthetic unhandled demo failure')); + } +} diff --git a/examples/laravel/app/Jobs/DemoJob.php b/examples/laravel/app/Jobs/DemoJob.php new file mode 100644 index 00000000..9d2a92f0 --- /dev/null +++ b/examples/laravel/app/Jobs/DemoJob.php @@ -0,0 +1,28 @@ + 3]); + // Explicit job scope may override the safely propagated analytics identity. + $context->with([ + 'distinctId' => $this->analyticsId, + 'groups' => ['team' => 'demo-team'], + 'properties' => ['component' => 'demo-job'], + ], fn () => PostHog::capture('demo_job_completed', ['rows' => 3])); + } +} diff --git a/examples/laravel/app/Models/DemoOrder.php b/examples/laravel/app/Models/DemoOrder.php new file mode 100644 index 00000000..11635d65 --- /dev/null +++ b/examples/laravel/app/Models/DemoOrder.php @@ -0,0 +1,15 @@ + 'integer']; + } +} diff --git a/examples/laravel/app/Models/DemoUser.php b/examples/laravel/app/Models/DemoUser.php new file mode 100644 index 00000000..c6007221 --- /dev/null +++ b/examples/laravel/app/Models/DemoUser.php @@ -0,0 +1,10 @@ +app['events']->listen(CommandStarting::class, function (CommandStarting $event): void { + if (in_array($event->command, ['demo:posthog', 'demo:scheduled', 'schedule:run'], true)) { + // Synthetic diagnostic identity, not attribution to an authenticated person. + // Runtime command cleanup closes this scope after outcome collectors finish. + app(Context::class)->begin([ + 'distinctId' => 'demo-console', + 'groups' => ['team' => 'demo-team'], + 'properties' => ['component' => 'demo-console'], + ]); + } + }); + } +} diff --git a/examples/laravel/app/Support/DemoActions.php b/examples/laravel/app/Support/DemoActions.php new file mode 100644 index 00000000..f49f117f --- /dev/null +++ b/examples/laravel/app/Support/DemoActions.php @@ -0,0 +1,110 @@ +with([ + 'distinctId' => $context->data()['distinctId'] ?? 'demo-visitor', + 'groups' => ['team' => 'demo-team'], + 'properties' => ['component' => 'laravel-demo'], + ], $callback); + } + + public function capture(): array + { + return $this->ambient(function (): array { + $explicit = PostHog::capture('demo_explicit', ['source' => 'form'], 'demo-explicit', ['team' => 'demo-team']); + $ambient = PostHog::capture('demo_ambient', ['source' => 'context']); + return ['explicit_accepted' => $explicit, 'ambient_accepted' => $ambient]; + }); + } + + public function identity(): array + { + return [ + 'identified' => PostHog::identify(['distinctId' => 'demo-member', 'properties' => ['plan' => 'demo']]), + 'aliased' => PostHog::alias(['distinctId' => 'demo-member', 'alias' => 'demo-visitor']), + 'group_identified' => PostHog::groupIdentify([ + 'groupType' => 'team', 'groupKey' => 'demo-team', 'properties' => ['plan' => 'demo'], + ]), + ]; + } + + public function flags(): array + { + return $this->ambient(function (): array { + $flags = PostHog::evaluateFlags( + personProperties: ['plan' => 'demo'], + groupProperties: ['team' => ['plan' => 'demo']], + onlyEvaluateLocally: (bool) config('demo.local_only'), + ); + $enabled = $flags->isEnabled('demo-checkout'); + $variant = $flags->getFlag('demo-layout'); + $payload = $flags->getFlagPayload('demo-layout'); // Silent: no exposure event. + PostHog::capture([ + 'event' => 'demo_flag_decision', + 'properties' => ['checkout_enabled' => $enabled], + 'flags' => $flags->onlyAccessed(), + ]); + return [ + 'enabled' => $enabled, + 'variant' => $variant, + 'payload' => $payload, + 'keys' => $flags->getKeys(), + // An unresolved flag is not the same as a disabled flag or a missing definition. + 'unresolved' => $flags->getUnresolvedFlags(), + 'attached' => $flags->onlyAccessed()->getEventProperties(), + ]; + }); + } + + public function exception(bool $report = false): array + { + return $this->ambient(function () use ($report): array { + $exception = new RuntimeException('Synthetic Laravel demo failure'); + if ($report) { + report($exception); // Handled report; Laravel's reportable pipeline owns this capture. + return ['reported' => true]; + } + return ['captured' => PostHog::captureException( + $exception, + properties: ['component' => 'manual-demo'], + options: ['handled' => true, 'mechanism' => 'generic'], + )]; + }); + } + + public function order(): array + { + return $this->ambient(function (): array { + $order = DB::transaction(function (): DemoOrder { + $order = DemoOrder::create(['amount' => 4200, 'status' => 'completed']); + // Explicit domain mapping collects amount only; no model/event serialization. + event(new OrderCompleted($order->amount)); + return $order; + }); + return ['order_id' => $order->id, 'amount' => $order->amount]; + }); + } + + public function queue(): array + { + return $this->ambient(function (): array { + // Runtime propagates only bounded identity/session/scalar groups, never arbitrary properties. + DemoJob::dispatch('demo-job-owner'); + return ['dispatched' => true, 'connection' => config('queue.default')]; + }); + } +} diff --git a/examples/laravel/app/Support/RedactDemoProperties.php b/examples/laravel/app/Support/RedactDemoProperties.php new file mode 100644 index 00000000..6962bdc8 --- /dev/null +++ b/examples/laravel/app/Support/RedactDemoProperties.php @@ -0,0 +1,13 @@ +handleCommand(new Symfony\Component\Console\Input\ArgvInput())); diff --git a/examples/laravel/bootstrap/app.php b/examples/laravel/bootstrap/app.php new file mode 100644 index 00000000..f0eb28ae --- /dev/null +++ b/examples/laravel/bootstrap/app.php @@ -0,0 +1,19 @@ +withRouting( + web: __DIR__ . '/../routes/web.php', + commands: __DIR__ . '/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware): void { + // PostHog's discovered provider installs scoped request context automatically. + }) + ->withExceptions(function (Exceptions $exceptions): void { + // Laravel owns reporting. The discovered PostHog provider adds a report callback. + }) + ->create(); diff --git a/examples/laravel/bootstrap/cache/.gitignore b/examples/laravel/bootstrap/cache/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/examples/laravel/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/examples/laravel/bootstrap/providers.php b/examples/laravel/bootstrap/providers.php new file mode 100644 index 00000000..489ff343 --- /dev/null +++ b/examples/laravel/bootstrap/providers.php @@ -0,0 +1,3 @@ +=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.1" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2026-09-06T14:11:38+00:00" + }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "46351b6812b7b6d52b82c3efa5dfb786a198ec88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/46351b6812b7b6d52b82c3efa5dfb786a198ec88", + "reference": "46351b6812b7b6d52b82c3efa5dfb786a198ec88", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^14.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2026-10-26T08:14:14+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "71d305eaa6480d3d3460c7d053d1da7749fa9cca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/71d305eaa6480d3d3460c7d053d1da7749fa9cca", + "reference": "71d305eaa6480d3d3460c7d053d1da7749fa9cca", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10.5.58 || ^12.5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.3" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2026-10-01T06:59:29+00:00" + }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "4189372ffeff09bf14f731f67a9de90c7b413ebe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/4189372ffeff09bf14f731f67a9de90c7b413ebe", + "reference": "4189372ffeff09bf14f731f67a9de90c7b413ebe", + "shasum": "" + }, + "require": { + "php": "^8.2|^8.3|^8.4|^8.5" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.31", + "phpunit/phpunit": "^11.5.43 || ^12.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.1" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2026-10-19T17:53:57+00:00" + }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" + }, + { + "name": "fruitcake/php-cors", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Fruitcake\\Cors\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fruitcake", + "homepage": "https://fruitcake.nl" + }, + { + "name": "Barryvdh", + "email": "barryvdh@gmail.com" + } + ], + "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", + "homepage": "https://github.com/fruitcake/php-cors", + "keywords": [ + "cors", + "laravel", + "symfony" + ], + "support": { + "issues": "https://github.com/fruitcake/php-cors/issues", + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-12-03T09:33:47+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "adccca3324eece92ca35463648c12b9e6293c05b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/adccca3324eece92ca35463648c12b9e6293c05b", + "reference": "adccca3324eece92ca35463648c12b9e6293c05b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.10" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.52 || ^9.6.34 || ^10.5.63 || ^11.5.55 || ^12.5.14" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:06:52+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.16.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "81755d989b3007ca7f88a069857553cd08e7dc2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/81755d989b3007ca7f88a069857553cd08e7dc2e", + "reference": "81755d989b3007ca7f88a069857553cd08e7dc2e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.6", + "guzzlehttp/psr7": "^2.13.2", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.3", + "guzzlehttp/test-server": "^0.7", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.16.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-10-25T21:35:21+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.6.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "3d88c829bd5f13d13847b39067dbf09a60af2154" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/3d88c829bd5f13d13847b39067dbf09a60af2154", + "reference": "3d88c829bd5f13d13847b39067dbf09a60af2154", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.6.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-10-18T12:46:59+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.13.2", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "adee536f95101929f262b8c59e8a61472f347d58" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/adee536f95101929f262b8c59e8a61472f347d58", + "reference": "adee536f95101929f262b8c59e8a61472f347d58", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.13.2" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-10-17T23:49:46+00:00" + }, + { + "name": "guzzlehttp/uri-template", + "version": "v1.0.11", + "source": { + "type": "git", + "url": "https://github.com/guzzle/uri-template.git", + "reference": "d0058dccf4299d70c3d9da3378b8908b32780368" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/d0058dccf4299d70c3d9da3378b8908b32780368", + "reference": "d0058dccf4299d70c3d9da3378b8908b32780368", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-php80": "^1.25" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "uri-template/tests": "1.0.0" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\UriTemplate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "A polyfill class for uri_template of PHP", + "keywords": [ + "guzzlehttp", + "uri-template" + ], + "support": { + "issues": "https://github.com/guzzle/uri-template/issues", + "source": "https://github.com/guzzle/uri-template/tree/v1.0.11" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:15:32+00:00" + }, + { + "name": "laravel/framework", + "version": "v12.70.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "4dc928b47c05a2a96ba51c39d2bd7749b5ea332e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/4dc928b47c05a2a96ba51c39d2bd7749b5ea332e", + "reference": "4dc928b47c05a2a96ba51c39d2bd7749b5ea332e", + "shasum": "" + }, + "require": { + "brick/math": "^0.11|^0.12|^0.13|^0.14", + "composer-runtime-api": "^2.2", + "doctrine/inflector": "^2.0.5", + "dragonmantank/cron-expression": "^3.4", + "egulias/email-validator": "^3.2.1|^4.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-session": "*", + "ext-tokenizer": "*", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2", + "guzzlehttp/uri-template": "^1.0", + "laravel/prompts": "^0.3.0", + "laravel/serializable-closure": "^1.3|^2.0", + "league/commonmark": "^2.8.1", + "league/flysystem": "^3.25.1", + "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", + "monolog/monolog": "^3.0", + "nesbot/carbon": "^3.8.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.2", + "psr/container": "^1.1.1|^2.0.1", + "psr/log": "^1.0|^2.0|^3.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "ramsey/uuid": "^4.7", + "symfony/console": "^7.2.0", + "symfony/error-handler": "^7.2.0", + "symfony/finder": "^7.2.0", + "symfony/http-foundation": "^7.2.0", + "symfony/http-kernel": "^7.2.0", + "symfony/mailer": "^7.2.0", + "symfony/mime": "^7.2.0", + "symfony/polyfill-php83": "^1.33", + "symfony/polyfill-php84": "^1.34", + "symfony/polyfill-php85": "^1.34", + "symfony/process": "^7.2.0", + "symfony/routing": "^7.2.0", + "symfony/uid": "^7.2.0", + "symfony/var-dumper": "^7.2.0", + "tijsverkoyen/css-to-inline-styles": "^2.2.5", + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "psr/log-implementation": "1.0|2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0" + }, + "replace": { + "illuminate/auth": "self.version", + "illuminate/broadcasting": "self.version", + "illuminate/bus": "self.version", + "illuminate/cache": "self.version", + "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", + "illuminate/conditionable": "self.version", + "illuminate/config": "self.version", + "illuminate/console": "self.version", + "illuminate/container": "self.version", + "illuminate/contracts": "self.version", + "illuminate/cookie": "self.version", + "illuminate/database": "self.version", + "illuminate/encryption": "self.version", + "illuminate/events": "self.version", + "illuminate/filesystem": "self.version", + "illuminate/hashing": "self.version", + "illuminate/http": "self.version", + "illuminate/json-schema": "self.version", + "illuminate/log": "self.version", + "illuminate/macroable": "self.version", + "illuminate/mail": "self.version", + "illuminate/notifications": "self.version", + "illuminate/pagination": "self.version", + "illuminate/pipeline": "self.version", + "illuminate/process": "self.version", + "illuminate/queue": "self.version", + "illuminate/redis": "self.version", + "illuminate/reflection": "self.version", + "illuminate/routing": "self.version", + "illuminate/session": "self.version", + "illuminate/support": "self.version", + "illuminate/testing": "self.version", + "illuminate/translation": "self.version", + "illuminate/validation": "self.version", + "illuminate/view": "self.version", + "spatie/once": "*" + }, + "require-dev": { + "ably/ably-php": "^1.0", + "aws/aws-sdk-php": "^3.322.9", + "ext-gmp": "*", + "fakerphp/faker": "^1.24", + "guzzlehttp/promises": "^2.0.3", + "guzzlehttp/psr7": "^2.4", + "laravel/pint": "^1.18", + "league/flysystem-aws-s3-v3": "^3.25.1", + "league/flysystem-ftp": "^3.25.1", + "league/flysystem-path-prefixing": "^3.25.1", + "league/flysystem-read-only": "^3.25.1", + "league/flysystem-sftp-v3": "^3.25.1", + "mockery/mockery": "^1.6.10", + "opis/json-schema": "^2.4.1", + "orchestra/testbench-core": "^10.15.0", + "pda/pheanstalk": "^5.0.6|^7.0.0", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "2.2.13", + "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", + "predis/predis": "^2.3|^3.0", + "resend/resend-php": "^0.10.0|^1.0", + "symfony/cache": "^7.2.0", + "symfony/http-client": "^7.2.0", + "symfony/psr-http-message-bridge": "^7.2.0", + "symfony/translation": "^7.2.0" + }, + "suggest": { + "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0|^8.0).", + "ext-apcu": "Required to use the APC cache driver.", + "ext-fileinfo": "Required to use the Filesystem class.", + "ext-ftp": "Required to use the Flysystem FTP driver.", + "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", + "ext-memcached": "Required to use the memcache cache driver.", + "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", + "ext-pdo": "Required to use all database features.", + "ext-posix": "Required to use all features of the queue worker.", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).", + "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", + "filp/whoops": "Required for friendly error pages in development (^2.14.3).", + "laravel/tinker": "Required to use the tinker console command (^2.0).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).", + "predis/predis": "Required to use the predis connector (^2.3|^3.0).", + "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0|^1.0).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.2).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.2).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.2).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.2)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "12.x-dev" + } + }, + "autoload": { + "files": [ + "src/Illuminate/Collections/functions.php", + "src/Illuminate/Collections/helpers.php", + "src/Illuminate/Events/functions.php", + "src/Illuminate/Filesystem/functions.php", + "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Reflection/helpers.php", + "src/Illuminate/Support/functions.php", + "src/Illuminate/Support/helpers.php" + ], + "psr-4": { + "Illuminate\\": "src/Illuminate/", + "Illuminate\\Support\\": [ + "src/Illuminate/Macroable/", + "src/Illuminate/Collections/", + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Laravel Framework.", + "homepage": "https://laravel.com", + "keywords": [ + "framework", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-10-27T14:07:51+00:00" + }, + { + "name": "laravel/prompts", + "version": "v0.3.26", + "source": { + "type": "git", + "url": "https://github.com/laravel/prompts.git", + "reference": "2d1e9bf6bab8d3f383c254db7f2b4ea8816d55e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/prompts/zipball/2d1e9bf6bab8d3f383c254db7f2b4ea8816d55e0", + "reference": "2d1e9bf6bab8d3f383c254db7f2b4ea8816d55e0", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "ext-mbstring": "*", + "php": "^8.1", + "symfony/console": "^6.2|^7.0|^8.0" + }, + "conflict": { + "illuminate/console": ">=10.17.0 <10.25.0", + "laravel/framework": ">=10.17.0 <10.25.0" + }, + "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.3|^3.4|^4.0", + "phpstan/phpstan": "^1.12.28", + "phpstan/phpstan-mockery": "^1.1.3" + }, + "suggest": { + "ext-pcntl": "Required for the spinner to be animated." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Laravel\\Prompts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Add beautiful and user-friendly forms to your command-line applications.", + "support": { + "source": "https://github.com/laravel/prompts/tree/v0.3.26" + }, + "time": "2026-10-09T14:45:13+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2d5869a838bbcf37e0d8b0568fc41914e81374b5", + "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2026-09-22T14:32:34+00:00" + }, + { + "name": "league/commonmark", + "version": "2.10.4", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "40821b7606d8d946bac57dbd2290076c722e3a65" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/40821b7606d8d946bac57dbd2290076c722e3a65", + "reference": "40821b7606d8d946bac57dbd2290076c722e3a65", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^2.0.0", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.11-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2026-10-17T19:59:50+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, + { + "name": "league/flysystem", + "version": "3.36.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25", + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.36.0" + }, + "time": "2026-09-02T08:00:27+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.35.3", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/a099b24dce160f3b2239043d13d47c4a1a214ea4", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-local/tree/3.35.3" + }, + "time": "2026-08-12T13:29:21+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.17.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0 || ^11.0 || ^12.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.17.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2026-07-09T11:49:27+00:00" + }, + { + "name": "league/uri", + "version": "7.9.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "40403f5c3141457a61f7f1aba6706e8fa4a0ece5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/40403f5c3141457a61f7f1aba6706e8fa4a0ece5", + "reference": "40403f5c3141457a61f7f1aba6706e8fa4a0ece5", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/uri-interfaces": "^7.9.1", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.9.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-10-27T07:14:23+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.9.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "994300a79e17f179112dc259f8df0c4de982f736" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/994300a79e17f179112dc259f8df0c4de982f736", + "reference": "994300a79e17f179112dc259f8df0c4de982f736", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.9.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-10-27T07:14:23+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.12.1", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "d7059e4c691d05ea1222b5124bed0f961be46e15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/d7059e4c691d05ea1222b5124bed0f961be46e15", + "reference": "d7059e4c691d05ea1222b5124bed0f961be46e15", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "psr/clock": "^1.0", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "psr/clock": "Required to pass a clock to the Logger and control the timestamp of log records", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.12.1" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-09-29T19:21:12+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.15.1", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "ce0e5636fae7d1e99856eb70cae4c719a284aa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/ce0e5636fae7d1e99856eb70cae4c719a284aa08", + "reference": "ce0e5636fae7d1e99856eb70cae4c719a284aa08", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-time": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^3.0@dev", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbonphp.github.io/carbon/", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2026-10-25T14:32:48+00:00" + }, + { + "name": "nette/schema", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "af9fa8a86bfca9b02b10346220db26e62ef942ec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/af9fa8a86bfca9b02b10346220db26e62ef942ec", + "reference": "af9fa8a86bfca9b02b10346220db26e62ef942ec", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.6" + }, + "require-dev": { + "nette/phpstan-rules": "^1.0.7", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.3@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.4-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + } + ], + "description": "📐 Nette Schema: validates and normalizes data such as configuration or API input against a schema described in a few readable lines of PHP, with precise error messages.", + "homepage": "https://doc.nette.org/schema", + "keywords": [ + "configuration", + "data validation", + "nette", + "normalization", + "schema", + "validation" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.4.0" + }, + "time": "2026-10-22T02:12:50+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.6", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "6bf24518accf32cf845bca305e0b92078700fd01" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/6bf24518accf32cf845bca305e0b92078700fd01", + "reference": "6bf24518accf32cf845bca305e0b92078700fd01", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.6" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::chr(), ord() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.6" + }, + "time": "2026-10-20T17:07:56+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2", + "symfony/console": "^7.4.4 || ^8.0.4" + }, + "require-dev": { + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "It's like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", + "php", + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2026-02-16T23:10:27+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.10.0", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/67b192b6a42ec03944b972d6e633ddec78ad2c6d", + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.54 || ^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.33" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.10.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2026-08-24T00:54:40+00:00" + }, + { + "name": "posthog/posthog-laravel", + "version": "0.1.0", + "dist": { + "type": "path", + "url": "../../packages/laravel", + "reference": "f5941a165259399a769b62dd45af78eaed8c7db4" + }, + "require": { + "illuminate/contracts": "^12.0 || ^13.0", + "illuminate/support": "^12.0 || ^13.0", + "php": ">=8.2", + "posthog/posthog-php": "^4.20" + }, + "require-dev": { + "laravel/octane": "^2.0", + "orchestra/testbench": "^10.0 || ^11.0", + "phpunit/phpunit": "^11.0 || ^12.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "PostHog\\Laravel\\PostHogServiceProvider" + ], + "aliases": { + "PostHog": "PostHog\\Laravel\\Facades\\PostHog" + } + } + }, + "autoload": { + "psr-4": { + "PostHog\\Laravel\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "PostHog\\Laravel\\Tests\\": "tests/" + } + }, + "scripts": { + "test": [ + "phpunit" + ] + }, + "license": [ + "MIT" + ], + "description": "PostHog for Laravel", + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "posthog/posthog-php", + "version": "4.20.x-dev", + "dist": { + "type": "path", + "url": "../..", + "reference": "4fdbc7bedc7cc4031da860373c591e5799cc09fb" + }, + "require": { + "ext-json": "*", + "php": ">=8.2", + "symfony/clock": "^6.2|^7.0|^8.0", + "symfony/polyfill-iconv": "^1.31", + "symfony/polyfill-mbstring": "^1.31" + }, + "require-dev": { + "ext-zlib": "*", + "open-telemetry/api": "^1.0", + "open-telemetry/sdk": "^1.0", + "phpunit/phpunit": "^11.0", + "squizlabs/php_codesniffer": "^4.0" + }, + "bin": [ + "bin/posthog" + ], + "type": "library", + "autoload": { + "psr-4": { + "PostHog\\": "lib/" + } + }, + "autoload-dev": { + "psr-4": { + "PostHog\\Test\\": "test/", + "PostHog\\Test\\Assets\\": "test/assests" + } + }, + "scripts": { + "api:check": [ + "@php scripts/check-public-api.php" + ], + "api:update": [ + "@php scripts/check-public-api.php --update" + ] + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PostHog ", + "homepage": "https://posthog.com/" + } + ], + "description": "PostHog PHP Library", + "homepage": "https://github.com/PostHog/posthog-php", + "keywords": [ + "posthog" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.4", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.4" + }, + "time": "2026-09-16T11:39:30+00:00" + }, + { + "name": "symfony/clock", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "07437f612dca89c1894c1db04491002b1e798f03" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/07437f612dca89c1894c1db04491002b1e798f03", + "reference": "07437f612dca89c1894c1db04491002b1e798f03", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-12T14:31:36+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.22", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "8012f842110b414fd1cc641adb0ed8423353b0bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/8012f842110b414fd1cc641adb0ed8423353b0bb", + "reference": "8012f842110b414fd1cc641adb0ed8423353b0bb", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.22" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-28T11:08:27+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v7.4.22", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "4cdbb4a575111d771791339a9d5231155918f699" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/4cdbb4a575111d771791339a9d5231155918f699", + "reference": "4cdbb4a575111d771791339a9d5231155918f699", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v7.4.22" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-18T14:39:27+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v7.4.22", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "545852cfe9c4979c82389155b4d8d05f8864e4f1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/545852cfe9c4979c82389155b4d8d05f8864e4f1", + "reference": "545852cfe9c4979c82389155b4d8d05f8864e4f1", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v7.4.22" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-25T14:40:16+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "d269974ee93c61d03620ffee358355bfdb471d66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d269974ee93c61d03620ffee358355bfdb471d66", + "reference": "d269974ee93c61d03620ffee358355bfdb471d66", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:40:08+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.22", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "e13420a8e0a76bdde1e7be09f923e62ad13739f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/e13420a8e0a76bdde1e7be09f923e62ad13739f2", + "reference": "e13420a8e0a76bdde1e7be09f923e62ad13739f2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.4.22" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-19T16:56:54+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v7.4.22", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "63eda1d0c4de229d6b3e825709e49dd5c149699a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/63eda1d0c4de229d6b3e825709e49dd5c149699a", + "reference": "63eda1d0c4de229d6b3e825709e49dd5c149699a", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v7.4.22" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-27T13:26:03+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.4.22", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "87e727b33983238d3f2ea50f9f62265499ca1a6d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/87e727b33983238d3f2ea50f9f62265499ca1a6d", + "reference": "87e727b33983238d3f2ea50f9f62265499ca1a6d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v7.4.22" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-28T18:18:28+00:00" + }, + { + "name": "symfony/mailer", + "version": "v7.4.22", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "1aa1e2ee7515826bb17b0ad0d2f16962fcaa7859" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/1aa1e2ee7515826bb17b0ad0d2f16962fcaa7859", + "reference": "1aa1e2ee7515826bb17b0ad0d2f16962fcaa7859", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.2", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/mime": "^7.2|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v7.4.22" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-23T16:51:52+00:00" + }, + { + "name": "symfony/mime", + "version": "v7.4.22", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "94a4c017435bc515f5ac80421a2c555fb9ca3676" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/94a4c017435bc515f5ac80421a2c555fb9ca3676", + "reference": "94a4c017435bc515f5ac80421a2c555fb9ca3676", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/mailer": "<6.4", + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.44|^7.4.17|^8.1.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v7.4.22" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-28T17:57:58+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-iconv", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-iconv.git", + "reference": "254724d95fe01b679cd97d88786e9623ee00bfb7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/254724d95fe01b679cd97d88786e9623ee00bfb7", + "reference": "254724d95fe01b679cd97d88786e9623ee00bfb7", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-iconv": "*" + }, + "suggest": { + "ext-iconv": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Iconv\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Iconv extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "iconv", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-iconv/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-29T06:25:14+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.43.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "5a8f33c87aeecffce24e721b5064fab05f4becf8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/5a8f33c87aeecffce24e721b5064fab05f4becf8", + "reference": "5a8f33c87aeecffce24e721b5064fab05f4becf8", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.43.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-15T09:18:53+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "533d68cf99c8f8ee2e8893c59ed59192e92cfd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/533d68cf99c8f8ee2e8893c59ed59192e92cfd74", + "reference": "533d68cf99c8f8ee2e8893c59ed59192e92cfd74", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-23T14:40:04+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-23T22:02:09+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.43.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "122889ed24dc000165bcbfd77ec412f15cbdfc6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/122889ed24dc000165bcbfd77ec412f15cbdfc6a", + "reference": "122889ed24dc000165bcbfd77ec412f15cbdfc6a", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-28T09:35:49+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:50:05+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/80ccff923a8d61f73ebe9da0aa94e25232f004ce", + "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:50:05+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "22c6548499cbe5c5bb072fc0c42312dff062a015" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/22c6548499cbe5c5bb072fc0c42312dff062a015", + "reference": "22c6548499cbe5c5bb072fc0c42312dff062a015", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-23T22:01:09+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "86a42cbcc07adc331481a5ab310a4024bfc227f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/86a42cbcc07adc331481a5ab310a4024bfc227f6", + "reference": "86a42cbcc07adc331481a5ab310a4024bfc227f6", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:46:47+00:00" + }, + { + "name": "symfony/polyfill-time", + "version": "v1.42.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-time.git", + "reference": "ac9f1ed802dd58f37e720388130c088434aa09fc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-time/zipball/ac9f1ed802dd58f37e720388130c088434aa09fc", + "reference": "ac9f1ed802dd58f37e720388130c088434aa09fc", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Time\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Philippe Nyamagana Butera", + "email": "nyamsprod@gmail.com" + }, + { + "name": "Tim Düsterhus", + "email": "tim@tideways-gmbh.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Time API added to PHP 8.6", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "duration", + "polyfill", + "portable", + "shim", + "time" + ], + "support": { + "source": "https://github.com/symfony/polyfill-time/tree/v1.42.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-07T13:08:13+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "b23ff8245cc5c4479ea2be3d0132d2e6dd433993" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/b23ff8245cc5c4479ea2be3d0132d2e6dd433993", + "reference": "b23ff8245cc5c4479ea2be3d0132d2e6dd433993", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-24T05:20:52+00:00" + }, + { + "name": "symfony/process", + "version": "v7.4.22", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "a5a6912edbbfc4c98cd080bc4bbe37c8e7eeb20a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/a5a6912edbbfc4c98cd080bc4bbe37c8e7eeb20a", + "reference": "a5a6912edbbfc4c98cd080bc4bbe37c8e7eeb20a", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.22" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-25T14:40:16+00:00" + }, + { + "name": "symfony/routing", + "version": "v7.4.22", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "f79ed6fbf60d9e32d358054f1915295f3b8a6f7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/f79ed6fbf60d9e32d358054f1915295f3b8a6f7a", + "reference": "f79ed6fbf60d9e32d358054f1915295f3b8a6f7a", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v7.4.22" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-26T21:52:50+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-27T15:39:01+00:00" + }, + { + "name": "symfony/string", + "version": "v7.4.22", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "8dff7da3a56638f1d189c5368183294102de4ced" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/8dff7da3a56638f1d189c5368183294102de4ced", + "reference": "8dff7da3a56638f1d189c5368183294102de4ced", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.4.22" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-23T16:51:52+00:00" + }, + { + "name": "symfony/translation", + "version": "v7.4.22", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "a892cd1807bfaf0a10690359c47fdf95b162ef22" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/a892cd1807bfaf0a10690359c47fdf95b162ef22", + "reference": "a892cd1807bfaf0a10690359c47fdf95b162ef22", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2.5.3|^3.3" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/service-contracts": "<2.5", + "symfony/twig-bundle": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v7.4.22" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-25T14:40:16+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/uid", + "version": "v7.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "3e706a245b5343ea93be9cafe84525bfb54e60fc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/3e706a245b5343ea93be9cafe84525bfb54e60fc", + "reference": "3e706a245b5343ea93be9cafe84525bfb54e60fc", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-12T14:31:36+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v7.4.22", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "6e6ff33ea8cf5df27f00a5561ebd5ee9bd7f1618" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/6e6ff33ea8cf5df27f00a5561ebd5ee9bd7f1618", + "reference": "6e6ff33ea8cf5df27f00a5561ebd5ee9bd7f1618", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v7.4.22" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-10-25T21:04:04+00:00" + }, + { + "name": "tijsverkoyen/css-to-inline-styles", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "php": "^7.4 || ^8.0", + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^8.5.21 || ^9.5.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "TijsVerkoyen\\CssToInlineStyles\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Tijs Verkoyen", + "email": "css_to_inline_styles@verkoyen.eu", + "role": "Developer" + } + ], + "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", + "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", + "support": { + "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" + }, + "time": "2025-12-02T11:56:42+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/phpdotenv/phpdotenv.git", + "reference": "96149103e62ac67e915f6d2ef59b72dee2e011e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpdotenv/phpdotenv/zipball/96149103e62ac67e915f6d2ef59b72dee2e011e0", + "reference": "96149103e62ac67e915f6d2ef59b72dee2e011e0", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.2", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.10", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.8-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `$_ENV` and `$_SERVER` automagically, and optionally to `getenv()`.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/phpdotenv/phpdotenv/issues", + "source": "https://github.com/phpdotenv/phpdotenv/tree/v5.8.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2026-10-25T16:26:19+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2026-04-26T05:33:54+00:00" + } + ], + "packages-dev": [ + { + "name": "hamcrest/hamcrest-php", + "version": "v3.0.0", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/b61cd040da1a4925bc90a51c074f5297e7c0fa52", + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-dom": "*", + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v3.0.0" + }, + "time": "2026-03-17T11:56:53+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.15", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/967a801bd188989a5669bd280f252d51c0fdc9ee", + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0 || ^3.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6.36", + "symplify/easy-coding-standard": "^13.2.17" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2026-08-19T19:37:52+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.9.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" + }, + "time": "2026-09-13T18:51:52+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.46" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-12-24T07:01:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T13:52:54+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.57", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "36b75bf56a8535623f5b3e24b27ea1823759da1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/36b75bf56a8535623f5b3e24b27ea1823759da1a", + "reference": "36b75bf56a8535623f5b3e24b27ea1823759da1a", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.14.0", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.57" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-10-05T05:47:54+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:26:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "dev", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": "^8.2" + }, + "platform-dev": {}, + "platform-overrides": { + "php": "8.2.0" + }, + "plugin-api-version": "2.9.0" +} diff --git a/examples/laravel/config/app.php b/examples/laravel/config/app.php new file mode 100644 index 00000000..ff7e1771 --- /dev/null +++ b/examples/laravel/config/app.php @@ -0,0 +1,14 @@ + env('APP_NAME', 'PostHog Laravel Demo'), + 'env' => env('APP_ENV', 'production'), + 'debug' => (bool) env('APP_DEBUG', false), + 'url' => env('APP_URL', 'http://localhost:8000'), + 'timezone' => 'UTC', + 'locale' => 'en', + 'fallback_locale' => 'en', + 'faker_locale' => 'en_US', + 'key' => env('APP_KEY'), + 'cipher' => 'AES-256-CBC', +]; diff --git a/examples/laravel/config/auth.php b/examples/laravel/config/auth.php new file mode 100644 index 00000000..9a1ac583 --- /dev/null +++ b/examples/laravel/config/auth.php @@ -0,0 +1,7 @@ + ['guard' => 'web', 'passwords' => 'users'], + 'guards' => ['web' => ['driver' => 'session', 'provider' => 'users']], + 'providers' => ['users' => ['driver' => 'eloquent', 'model' => App\Models\DemoUser::class]], +]; diff --git a/examples/laravel/config/cache.php b/examples/laravel/config/cache.php new file mode 100644 index 00000000..97a93413 --- /dev/null +++ b/examples/laravel/config/cache.php @@ -0,0 +1,14 @@ + env('CACHE_STORE', 'file'), + 'stores' => [ + 'array' => ['driver' => 'array', 'serialize' => false], + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), + 'lock_path' => storage_path('framework/cache/data'), + ], + ], + 'prefix' => 'posthog_laravel_example_', +]; diff --git a/examples/laravel/config/database.php b/examples/laravel/config/database.php new file mode 100644 index 00000000..5a771b95 --- /dev/null +++ b/examples/laravel/config/database.php @@ -0,0 +1,14 @@ + env('DB_CONNECTION', 'sqlite'), + 'connections' => [ + 'sqlite' => [ + 'driver' => 'sqlite', + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => true, + ], + ], + 'migrations' => ['table' => 'migrations', 'update_date_on_publish' => true], +]; diff --git a/examples/laravel/config/demo.php b/examples/laravel/config/demo.php new file mode 100644 index 00000000..15ebc4ca --- /dev/null +++ b/examples/laravel/config/demo.php @@ -0,0 +1,3 @@ + env('POSTHOG_LOCAL_ONLY', false)]; diff --git a/examples/laravel/config/logging.php b/examples/laravel/config/logging.php new file mode 100644 index 00000000..6470aad2 --- /dev/null +++ b/examples/laravel/config/logging.php @@ -0,0 +1,9 @@ + env('LOG_CHANNEL', 'single'), + 'channels' => [ + 'single' => ['driver' => 'single', 'path' => storage_path('logs/laravel.log'), 'level' => 'debug'], + 'null' => ['driver' => 'monolog', 'handler' => Monolog\Handler\NullHandler::class], + ], +]; diff --git a/examples/laravel/config/posthog.php b/examples/laravel/config/posthog.php new file mode 100644 index 00000000..ecb5454b --- /dev/null +++ b/examples/laravel/config/posthog.php @@ -0,0 +1,51 @@ + env('POSTHOG_ENABLED', true), + 'api_key' => env('POSTHOG_API_KEY'), + 'host' => env('POSTHOG_HOST', 'https://us.i.posthog.com'), + 'secret_key' => env('POSTHOG_SECRET_KEY'), + 'release_id' => env('POSTHOG_RELEASE_ID', 'laravel-example-local'), + 'capture_exceptions' => true, + 'header_context' => true, + 'queue_context' => env('POSTHOG_QUEUE_CONTEXT', true), + 'distinct_id' => null, + 'groups' => null, + 'sdk' => [ + 'disableGeoip' => true, + 'consumer' => env('POSTHOG_CONSUMER', 'lib_curl'), + 'before_send' => App\Support\RedactDemoProperties::class, + ], + 'flag_cache' => [ + 'enabled' => env('POSTHOG_FLAG_CACHE', false), + 'store' => env('CACHE_STORE', 'file'), + 'ttl' => 300, + 'refresh_interval' => 30, + ], + 'autocapture' => [ + 'requests' => [ + 'enabled' => env('POSTHOG_COLLECT_REQUESTS', false), + 'exclude' => ['up', 'health', 'health/*'], + 'sample_rate' => 1.0, + ], + // Do not enable equivalent browser pageviews at the same time. + 'pageviews' => ['enabled' => env('POSTHOG_COLLECT_PAGEVIEWS', false)], + 'auth' => [ + 'enabled' => env('POSTHOG_COLLECT_AUTH', false), + 'events' => ['registered', 'login', 'logout', 'failed', 'lockout'], + ], + 'models' => [ + 'enabled' => env('POSTHOG_COLLECT_MODELS', false), + 'allow' => [DemoOrder::class => ['amount', 'status']], + ], + 'domain_events' => env('POSTHOG_COLLECT_DOMAIN', false) ? [ + OrderCompleted::class => ['event' => 'order_completed', 'properties' => ['amount' => 'amount']], + ] : [], + 'jobs' => ['enabled' => env('POSTHOG_COLLECT_JOBS', false), 'exclude' => []], + 'commands' => ['enabled' => env('POSTHOG_COLLECT_COMMANDS', false), 'exclude' => []], + 'scheduler' => ['enabled' => env('POSTHOG_COLLECT_SCHEDULER', false), 'exclude' => []], + ], +]; diff --git a/examples/laravel/config/queue.php b/examples/laravel/config/queue.php new file mode 100644 index 00000000..95fdb027 --- /dev/null +++ b/examples/laravel/config/queue.php @@ -0,0 +1,13 @@ + env('QUEUE_CONNECTION', 'sync'), + 'connections' => [ + 'sync' => ['driver' => 'sync'], + 'database' => [ + 'driver' => 'database', 'connection' => null, 'table' => 'jobs', + 'queue' => 'default', 'retry_after' => 90, 'after_commit' => true, + ], + ], + 'failed' => ['driver' => 'database-uuids', 'database' => env('DB_CONNECTION', 'sqlite'), 'table' => 'failed_jobs'], +]; diff --git a/examples/laravel/config/session.php b/examples/laravel/config/session.php new file mode 100644 index 00000000..c692f8d5 --- /dev/null +++ b/examples/laravel/config/session.php @@ -0,0 +1,19 @@ + env('SESSION_DRIVER', 'file'), + 'lifetime' => 120, + 'expire_on_close' => false, + 'encrypt' => false, + 'files' => storage_path('framework/sessions'), + 'connection' => null, + 'table' => 'sessions', + 'store' => null, + 'lottery' => [2, 100], + 'cookie' => 'posthog_laravel_demo_session', + 'path' => '/', + 'domain' => null, + 'secure' => env('SESSION_SECURE_COOKIE', false), + 'http_only' => true, + 'same_site' => 'lax', +]; diff --git a/examples/laravel/database/migrations/2026_01_01_000000_create_demo_tables.php b/examples/laravel/database/migrations/2026_01_01_000000_create_demo_tables.php new file mode 100644 index 00000000..c73333a4 --- /dev/null +++ b/examples/laravel/database/migrations/2026_01_01_000000_create_demo_tables.php @@ -0,0 +1,48 @@ +id(); + $table->string('name'); + $table->rememberToken(); + $table->timestamps(); + }); + Schema::create('demo_orders', function (Blueprint $table): void { + $table->id(); + $table->unsignedInteger('amount'); + $table->string('status'); + $table->timestamps(); + }); + Schema::create('jobs', function (Blueprint $table): void { + $table->id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + Schema::create('failed_jobs', function (Blueprint $table): void { + $table->id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + public function down(): void + { + foreach (['failed_jobs', 'jobs', 'demo_orders', 'demo_users'] as $table) { + Schema::dropIfExists($table); + } + } +}; diff --git a/examples/laravel/phpunit.xml b/examples/laravel/phpunit.xml new file mode 100644 index 00000000..0d15e90e --- /dev/null +++ b/examples/laravel/phpunit.xml @@ -0,0 +1,27 @@ + + + tests/Feature + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/laravel/public/index.php b/examples/laravel/public/index.php new file mode 100644 index 00000000..e9cf149a --- /dev/null +++ b/examples/laravel/public/index.php @@ -0,0 +1,6 @@ +handleRequest(Illuminate\Http\Request::capture()); diff --git a/examples/laravel/resources/views/dashboard.blade.php b/examples/laravel/resources/views/dashboard.blade.php new file mode 100644 index 00000000..f1464ff8 --- /dev/null +++ b/examples/laravel/resources/views/dashboard.blade.php @@ -0,0 +1,82 @@ + + + + + + PostHog Laravel demo + + + +

PostHog Laravel demo

+

Server-side analytics, flags, exceptions, and opt-in collectors. No npm or browser SDK required.

+

Local development only. Synthetic login has no password. Do not expose this app publicly.

+

SDK: {{ $enabled ? 'configured' : 'disabled (blank key or POSTHOG_ENABLED=false)' }}. + Authentication: {{ auth()->check() ? 'synthetic member' : 'guest' }}. + Missing credentials are safe: SDK network paths are disabled.

+@if (session('result')) +

Last result

{{ json_encode(session('result'), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}
+@endif +
+

Explicit and ambient capture

+

Explicit ID demo-explicit wins. Ambient capture uses auth, then untrusted analytics headers, + then this application's synthetic demo-visitor fallback. Both use team demo-team.

+
@csrf
+
@csrf
+ Identity operations use fixed demo IDs; don't alias unrelated real users. +
+
+

Feature flags

+

Create boolean demo-checkout and multivariate demo-layout flags in your project. + One snapshot supplies enabled state, variant, decoded payload, and an accessed-only event attachment.

+
@csrf
+

POSTHOG_SECRET_KEY (server only) + POSTHOG_FLAG_CACHE=true enables shared local definitions. + POSTHOG_LOCAL_ONLY=true disables remote fallback. Unresolved reasons are shown separately + (missing context, unsupported definition, dependency, or experience continuity); an empty result with no key is expected.

+
+
+

Exceptions and transport

+
@csrf
+
@csrf
+
@csrf
+
@csrf
+

Laravel owns reporting; no global PHP error handlers. The failing route intentionally returns 500. + Automatic lifecycle flushes clean context even on failure. A flush result is not a delivery receipt.

+
+
+

Application and worker collectors

+
@csrf
+
@csrf
+
@csrf
+
@csrf
+

Queue defaults to sync. For a real worker set QUEUE_CONNECTION=database, then run + php artisan queue:work --once. Jobs inherit only bounded analytics identity/session/scalar groups, then demonstrate an explicit scoped override. + Set POSTHOG_QUEUE_CONTEXT=false to disable propagation; request bodies and arbitrary properties are never copied. + Run php artisan demo:posthog --flags and php artisan schedule:run for command/schedule demos.

+

Collector provider: {{ $collectorsAvailable ? 'available' : 'not installed on this checkout; explicit demos still work' }}. + Edit the following environment toggles, then clear config/restart workers. All are opt-in, not form-controlled.

+ + @foreach (['requests', 'pageviews', 'auth', 'models', 'domain', 'jobs', 'commands', 'scheduler'] as $collector) + + + @endforeach +
Collector / environment toggleEnabled
POSTHOG_COLLECT_{{ strtoupper($collector) }}{{ $collector === 'domain' ? (!empty($collectors['domain_events']) ? 'yes' : 'no') : (!empty($collectors[$collector]['enabled']) ? 'yes' : 'no') }}
+

Requests use matched route templates, never raw URLs or query strings. Model attributes are explicitly + allowlisted to amount/status; domain mapping sends amount only. No credentials, request bodies, email, cookies, or IP collection.

+
+
+

Optional browser integration

+

No JavaScript SDK is loaded here. If you add one, use only its public project token and ingestion host; + never expose POSTHOG_SECRET_KEY. Configure tracing headers for your application host only: + X-PostHog-Distinct-Id and X-PostHog-Session-Id. They correlate analytics, not authentication.

+

Choose either browser pageviews or POSTHOG_COLLECT_PAGEVIEWS, not both: avoid duplicate + $pageview events. Server pageviews use sanitized route-template URLs.

+
+

See README.md for fake assertions, offline tests, privacy, and setup.

+ + diff --git a/examples/laravel/routes/console.php b/examples/laravel/routes/console.php new file mode 100644 index 00000000..16fcaed0 --- /dev/null +++ b/examples/laravel/routes/console.php @@ -0,0 +1,16 @@ +with([ + 'distinctId' => 'demo-scheduler', + 'properties' => ['component' => 'scheduler'], + ], fn () => PostHog::capture('demo_scheduled_tick')); + $this->info('Synthetic scheduled tick captured (or disabled without a key).'); +})->purpose('Run a synthetic scheduled task'); + +Schedule::command('demo:scheduled')->everyMinute()->name('demo-scheduled-tick'); diff --git a/examples/laravel/routes/web.php b/examples/laravel/routes/web.php new file mode 100644 index 00000000..bea7896c --- /dev/null +++ b/examples/laravel/routes/web.php @@ -0,0 +1,12 @@ +name('dashboard'); +Route::post('/demo/login', [DemoController::class, 'login'])->name('demo.login'); +Route::post('/demo/logout', [DemoController::class, 'logout'])->name('demo.logout'); +Route::post('/demo/failure', [DemoController::class, 'failure'])->name('demo.failure'); +Route::post('/demo/{action}', [DemoController::class, 'run']) + ->whereIn('action', ['capture', 'identity', 'flags', 'exception', 'report', 'order', 'queue', 'flush']) + ->name('demo.run'); diff --git a/examples/laravel/storage/app/.gitignore b/examples/laravel/storage/app/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/examples/laravel/storage/app/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/examples/laravel/storage/framework/cache/data/.gitignore b/examples/laravel/storage/framework/cache/data/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/examples/laravel/storage/framework/cache/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/examples/laravel/storage/framework/sessions/.gitignore b/examples/laravel/storage/framework/sessions/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/examples/laravel/storage/framework/sessions/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/examples/laravel/storage/framework/views/.gitignore b/examples/laravel/storage/framework/views/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/examples/laravel/storage/framework/views/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/examples/laravel/storage/logs/.gitignore b/examples/laravel/storage/logs/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/examples/laravel/storage/logs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/examples/laravel/tests/Feature/CollectorsTest.php b/examples/laravel/tests/Feature/CollectorsTest.php new file mode 100644 index 00000000..a3665c05 --- /dev/null +++ b/examples/laravel/tests/Feature/CollectorsTest.php @@ -0,0 +1,107 @@ +afterBootstrapping(LoadConfiguration::class, function ($app): void { + foreach (['requests', 'pageviews', 'auth', 'models', 'jobs', 'commands', 'scheduler'] as $collector) { + $app['config']->set("posthog.autocapture.$collector.enabled", true); + } + $app['config']->set('posthog.autocapture.domain_events', [ + OrderCompleted::class => ['event' => 'order_completed', 'properties' => ['amount' => 'amount']], + ]); + }); + $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + return $app; + } + + public function test_request_and_pageview_collectors_exclude_raw_query_body_and_health(): void + { + $fake = PostHog::fake(); + $this->withHeader('X-PostHog-Distinct-Id', 'collector-visitor')->get('/?private=do-not-collect') + ->assertOk(); + $fake->assertCaptured('laravel_request', fn ($event) => $event['properties']['route'] === '/' + && $event['properties']['method'] === 'GET' && $event['properties']['status_code'] === 200); + $fake->assertCaptured('$pageview', fn ($event) => !str_contains($event['properties']['$current_url'], 'private')); + $before = count($fake->recorded()); + $this->get('/up')->assertOk(); + self::assertCount($before, $fake->recorded()); + $this->post('/demo/capture', ['password' => 'never-collect-this'])->assertRedirect('/'); + self::assertStringNotContainsString('never-collect-this', json_encode($fake->recorded(), JSON_THROW_ON_ERROR)); + self::assertStringNotContainsString('do-not-collect', json_encode($fake->recorded(), JSON_THROW_ON_ERROR)); + } + + public function test_auth_collectors_use_selected_user_not_credentials(): void + { + $fake = PostHog::fake(); + $this->post('/demo/login')->assertRedirect('/'); + $fake->assertCaptured('laravel_auth_registered'); + $fake->assertCaptured('laravel_auth_login'); + $this->post('/demo/logout')->assertRedirect('/'); + $fake->assertCaptured('laravel_auth_logout'); + } + + public function test_model_and_domain_collectors_emit_only_allowlisted_data(): void + { + $fake = PostHog::fake(); + $this->withHeader('X-PostHog-Distinct-Id', 'order-owner')->post('/demo/order')->assertRedirect('/'); + $fake->assertCaptured('laravel_model_created'); + $fake->assertCaptured('order_completed', fn ($event) => $event['properties']['amount'] === 4200 + && !isset($event['properties']['order_id'])); + app(Context::class)->with(['distinctId' => 'order-owner'], function (): void { + $order = DemoOrder::firstOrFail(); + $order->update(['status' => 'refunded']); + $order->delete(); + }); + $fake->assertCaptured('laravel_model_updated'); + $fake->assertCaptured('laravel_model_deleted'); + } + + public function test_model_collector_drops_rolled_back_changes(): void + { + $fake = PostHog::fake(); + app(Context::class)->begin(['distinctId' => 'rollback-owner']); + DB::beginTransaction(); + DemoOrder::create(['amount' => 42, 'status' => 'draft']); + DB::rollBack(); + $fake->assertNotCaptured('laravel_model_created'); + } + + public function test_job_command_and_scheduler_collectors_are_executable(): void + { + $fake = PostHog::fake(); + $this->post('/demo/queue')->assertRedirect('/'); + $fake->assertCaptured('laravel_job'); + // Laravel omits Symfony command-event rerouting under runningUnitTests(). + app(\Illuminate\Contracts\Console\Kernel::class)->rerouteSymfonyCommandEvents(); + $this->artisan('demo:posthog')->assertSuccessful(); + $fake->assertCaptured('laravel_command'); + // Use a callback task to keep the fake in-process; production demo schedules a command. + foreach (Schedule::events() as $task) { + $task->skip(fn () => true); + } + Schedule::call(fn () => PostHog::capture('test_scheduled_callback', [], 'demo-scheduler')) + ->everyMinute()->name('test-scheduled-callback'); + $this->artisan('schedule:run')->assertSuccessful(); + $fake->assertCaptured('laravel_scheduled_task'); + $fake->assertCaptured('test_scheduled_callback'); + } +} diff --git a/examples/laravel/tests/Feature/DashboardTest.php b/examples/laravel/tests/Feature/DashboardTest.php new file mode 100644 index 00000000..5ce0cf73 --- /dev/null +++ b/examples/laravel/tests/Feature/DashboardTest.php @@ -0,0 +1,210 @@ + 'project-must-not-render', 'posthog.secret_key' => 'secret-must-not-render']); + $this->get('/')->assertOk() + ->assertSee('PostHog Laravel demo') + ->assertSee('POSTHOG_COLLECT_SCHEDULER') + ->assertSee('avoid duplicate') + ->assertDontSee('project-must-not-render') + ->assertDontSee('secret-must-not-render') + ->assertDontSee('assertNothingCaptured(); + } + + public function test_capture_uses_explicit_and_header_ambient_identity_and_groups(): void + { + $fake = PostHog::fake(); + $this->withHeaders([ + 'X-PostHog-Distinct-Id' => 'header-visitor', + 'X-PostHog-Session-Id' => 'demo-session', + ])->post('/demo/capture')->assertRedirect('/'); + $fake->assertCaptured('demo_explicit', fn ($event) => $event['distinctId'] === 'demo-explicit' + && $event['groups'] === ['team' => 'demo-team']); + $fake->assertCaptured('demo_ambient', fn ($event) => $event['distinctId'] === 'header-visitor' + && $event['properties']['$session_id'] === 'demo-session' + && $event['properties']['component'] === 'laravel-demo'); + $fake->assertCapturedCount(2); + $fake->assertNotCaptured('$pageview'); + self::assertSame([], app(Context::class)->data()); + } + + public function test_auth_identity_beats_analytics_headers_but_not_explicit_api_identity(): void + { + $fake = PostHog::fake(); + $user = DemoUser::create(['name' => 'Synthetic member']); + $this->actingAs($user)->withHeader('X-PostHog-Distinct-Id', 'untrusted') + ->post('/demo/capture')->assertRedirect('/'); + $fake->assertCaptured('demo_ambient', fn ($event) => $event['distinctId'] === (string) $user->id); + $fake->assertCaptured('demo_explicit', fn ($event) => $event['distinctId'] === 'demo-explicit'); + } + + public function test_identity_form_records_identify_alias_and_group_messages(): void + { + $fake = PostHog::fake(); + $this->post('/demo/identity')->assertRedirect('/'); + $fake->assertIdentified(fn ($message) => $message['distinctId'] === 'demo-member' + && $message['properties']['plan'] === 'demo'); + $fake->assertAliased(fn ($message) => $message['alias'] === 'demo-visitor'); + $fake->assertGroupIdentified(fn ($message) => $message['groupKey'] === 'demo-team'); + } + + public function test_flag_form_uses_variant_payload_snapshot_and_unresolved_reasons(): void + { + $fake = PostHog::fake(); + $snapshot = new FeatureFlagEvaluations('demo-visitor', [ + 'demo-checkout' => new EvaluatedFlagRecord('demo-checkout', true, null, null, null, null, null, true), + 'demo-layout' => new EvaluatedFlagRecord('demo-layout', true, 'compact', ['color' => 'blue'], null, null, null, true), + ], [], $fake->client(), unresolvedFlags: ['needs-plan' => UnresolvedFlagReason::MISSING_CONTEXT]); + $fake->flags($snapshot); + $this->post('/demo/flags')->assertRedirect('/')->assertSessionHas('result', fn ($result) => + $result['enabled'] === true && $result['variant'] === 'compact' + && $result['payload'] === ['color' => 'blue'] + && $result['unresolved'] === ['needs-plan' => UnresolvedFlagReason::MISSING_CONTEXT]); + $fake->assertCaptured('demo_flag_decision', fn ($event) => + $event['flags']->getEventProperties()['$feature/demo-layout'] === 'compact' + && $event['flags']->getEventProperties()['$active_feature_flags'] === ['demo-checkout', 'demo-layout']); + } + + public function test_manual_and_report_endpoints_capture_handled_exceptions_without_network(): void + { + $fake = PostHog::fake(); + $this->post('/demo/exception')->assertRedirect('/'); + $fake->assertExceptionCaptured(fn ($message) => $message['options']['handled'] === true + && $message['properties']['component'] === 'manual-demo'); + $this->post('/demo/report')->assertRedirect('/'); + self::assertCount(2, $fake->recorded('captureException')); + $fake->assertExceptionCaptured(fn ($message) => $message['distinctId'] === 'demo-visitor' + && $message['options']['handled'] === true); + } + + public function test_failing_endpoint_reports_unhandled_and_cleans_context(): void + { + $fake = PostHog::fake(); + $this->post('/demo/failure')->assertStatus(500); + $fake->assertExceptionCaptured(fn ($message) => $message['options']['handled'] === false + && $message['distinctId'] === 'demo-visitor'); + self::assertSame([], app(Context::class)->data()); + } + + public function test_order_form_persists_synthetic_data_and_dispatches_allowlisted_event(): void + { + PostHog::fake(); + \Illuminate\Support\Facades\Event::fake([\App\Events\OrderCompleted::class]); + $this->post('/demo/order')->assertRedirect('/'); + $this->assertDatabaseHas('demo_orders', ['amount' => 4200, 'status' => 'completed']); + \Illuminate\Support\Facades\Event::assertDispatched(\App\Events\OrderCompleted::class, fn ($event) => $event->amount === 4200); + } + + public function test_queue_form_dispatches_job_and_sync_execution_records_scoped_payload(): void + { + $fake = PostHog::fake(); + $this->withHeader('X-PostHog-Distinct-Id', 'must-not-leak')->post('/demo/queue')->assertRedirect('/'); + $fake->assertCaptured('demo_job_completed', fn ($message) => $message['distinctId'] === 'demo-job-owner' + && $message['properties']['rows'] === 3 && $message['groups'] === ['team' => 'demo-team']); + $fake->assertCaptured('demo_job_ambient', fn ($message) => $message['distinctId'] === 'must-not-leak' + && !isset($message['properties']['component'])); + self::assertSame([], app(Context::class)->data()); + } + + public function test_queue_job_object_does_not_copy_request_context(): void + { + PostHog::fake(); + Queue::fake(); + $this->withHeader('X-PostHog-Distinct-Id', 'private-request-identity')->post('/demo/queue')->assertRedirect('/'); + Queue::assertPushed(DemoJob::class, fn (DemoJob $job) => $job->analyticsId === 'demo-job-owner' + && !str_contains(serialize($job), 'private-request-identity')); + } + + public function test_database_queue_metadata_contains_only_bounded_analytics_context(): void + { + PostHog::fake(); + config(['queue.default' => 'database']); + $this->withHeaders([ + 'X-PostHog-Distinct-Id' => 'queued-visitor', 'X-PostHog-Session-Id' => 'queued-session', + ])->post('/demo/queue')->assertRedirect('/'); + $payload = json_decode(\Illuminate\Support\Facades\DB::table('jobs')->first()->payload, true, flags: JSON_THROW_ON_ERROR); + self::assertSame('queued-visitor', $payload['_posthog']['distinctId']); + self::assertSame('queued-session', $payload['_posthog']['sessionId']); + self::assertSame(['team' => 'demo-team'], $payload['_posthog']['groups']); + self::assertArrayNotHasKey('component', $payload['_posthog']['properties']); + self::assertSame(['$session_id' => 'queued-session'], $payload['_posthog']['properties']); + } + + public function test_queue_context_propagation_can_be_disabled(): void + { + PostHog::fake(); + config(['queue.default' => 'database', 'posthog.queue_context' => false]); + $this->withHeader('X-PostHog-Distinct-Id', 'must-not-copy')->post('/demo/queue')->assertRedirect('/'); + $payload = json_decode(\Illuminate\Support\Facades\DB::table('jobs')->first()->payload, true, flags: JSON_THROW_ON_ERROR); + self::assertArrayNotHasKey('_posthog', $payload); + } + + public function test_synthetic_login_and_logout_are_executable(): void + { + $fake = PostHog::fake(); + $this->post('/demo/login')->assertRedirect('/'); + $this->assertAuthenticated(); + $fake->assertCaptured('demo_authenticated', fn ($event) => $event['distinctId'] === (string) DemoUser::first()->id); + $this->post('/demo/logout')->assertRedirect('/'); + $this->assertGuest(); + } + + public function test_flush_failure_is_visible_without_breaking_response(): void + { + $fake = \Mockery::mock(PostHogFake::class, [$this->app])->makePartial(); + $fake->shouldReceive('flush')->andReturn(false); + PostHog::swap($fake); + $this->post('/demo/flush')->assertRedirect('/')->assertSessionHas('result.flushed', false); + } + + public function test_disabled_sdk_allows_demo_and_flag_routes_without_live_network(): void + { + self::assertFalse(PostHog::enabled()); + $this->post('/demo/capture')->assertRedirect('/')->assertSessionHas('result.explicit_accepted', false); + $this->post('/demo/flags')->assertRedirect('/')->assertSessionHas('result.keys', []); + $this->post('/demo/report')->assertRedirect('/'); + self::assertInstanceOf(Client::class, PostHog::client()); + } + + public function test_artisan_demo_and_scheduled_command_are_repeatable(): void + { + $fake = PostHog::fake(); + app(\Illuminate\Contracts\Console\Kernel::class)->rerouteSymfonyCommandEvents(); + $this->artisan('demo:posthog', ['--flags' => true])->assertSuccessful(); + $this->artisan('demo:posthog')->assertSuccessful(); + $this->artisan('demo:scheduled')->assertSuccessful(); + $fake->assertCaptured('demo_scheduled_tick', fn ($event) => $event['distinctId'] === 'demo-scheduler'); + $fake->assertCaptured('demo_job_completed'); + self::assertSame(2, DemoOrder::count()); + } + + public function test_unknown_demo_action_is_not_executable(): void + { + $fake = PostHog::fake(); + $this->post('/demo/not-a-demo')->assertNotFound(); + $fake->assertNothingCaptured(); + } +} diff --git a/examples/laravel/tests/Feature/TransportTest.php b/examples/laravel/tests/Feature/TransportTest.php new file mode 100644 index 00000000..3bd9f785 --- /dev/null +++ b/examples/laravel/tests/Feature/TransportTest.php @@ -0,0 +1,96 @@ +createStub(HttpClient::class); + $http->method('sendRequest')->willReturnCallback(function ($path, $payload) use (&$requests): HttpResponse { + $requests[] = ['path' => $path, 'body' => json_decode($payload, true, flags: JSON_THROW_ON_ERROR)]; + return new HttpResponse($path === '/flags/?v=2' ? json_encode([ + 'flags' => [ + 'demo-checkout' => ['enabled' => true, 'variant' => null], + 'demo-layout' => ['enabled' => true, 'variant' => 'compact'], + ], + 'featureFlagPayloads' => ['demo-layout' => '{"color":"blue"}'], + ]) : '{}', 200); + }); + $this->useClient($http); + $this->post('/demo/flags')->assertRedirect('/')->assertSessionHas('result.variant', 'compact'); + $flagRequests = array_values(array_filter($requests, fn ($request) => $request['path'] === '/flags/?v=2')); + self::assertCount(1, $flagRequests); + self::assertTrue($flagRequests[0]['body']['geoip_disable']); + $events = array_merge(...array_map( + fn ($request) => $request['body']['batch'] ?? [], + $requests, + )); + $decision = array_values(array_filter($events, fn ($event) => $event['event'] === 'demo_flag_decision'))[0]; + self::assertSame('demo-visitor', $decision['distinct_id']); + self::assertSame('compact', $decision['properties']['$feature/demo-layout']); + self::assertSame(['demo-checkout', 'demo-layout'], $decision['properties']['$active_feature_flags']); + self::assertCount(2, array_filter($events, fn ($event) => $event['event'] === '$feature_flag_called')); + PostHog::shutdown(); + } + + public function test_flags_and_transport_failure_do_not_break_demo_response(): void + { + $paths = []; + $outage = true; + $http = $this->createStub(HttpClient::class); + $http->method('sendRequest')->willReturnCallback(function ($path) use (&$paths, &$outage): HttpResponse { + $paths[] = $path; + return new HttpResponse('{"error":"synthetic outage"}', $outage ? 503 : 200); + }); + $this->useClient($http); + $this->post('/demo/flags')->assertRedirect('/')->assertSessionHas('result.keys', []); + $this->post('/demo/flush')->assertRedirect('/')->assertSessionHas('result.flushed', false); + self::assertContains('/flags/?v=2', $paths); + self::assertContains('/batch/', $paths); + $outage = false; + self::assertTrue(PostHog::flush()); + PostHog::shutdown(); + } + + public function test_before_send_callback_drops_explicit_sensitive_properties(): void + { + $http = $this->createMock(HttpClient::class); + $http->expects($this->once())->method('sendRequest')->willReturnCallback(function ($path, $payload): HttpResponse { + self::assertSame('/batch/', $path); + $properties = json_decode($payload, true, flags: JSON_THROW_ON_ERROR)['batch'][0]['properties']; + self::assertArrayNotHasKey('email', $properties); + self::assertArrayNotHasKey('password', $properties); + self::assertSame('demo', $properties['plan']); + return new HttpResponse('{}', 200); + }); + $this->useClient($http); + PostHog::capture('test_redaction', ['email' => 'synthetic', 'password' => 'synthetic', 'plan' => 'demo'], 'demo'); + self::assertTrue(PostHog::flush()); + PostHog::shutdown(); + } + + private function useClient(HttpClient $http): void + { + config(['posthog.enabled' => true, 'posthog.api_key' => 'synthetic-test-key', 'posthog.secret_key' => null]); + $client = new Client('synthetic-test-key', [ + 'consumer' => 'lib_curl', + 'disableGeoip' => true, + 'feature_flag_request_max_retries' => 0, + 'before_send' => new RedactDemoProperties(), + ], $http, loadFeatureFlags: false); + $manager = app(Manager::class); + // Match the runtime's transport-injection tests without exposing an application API for test wiring. + (new \ReflectionProperty(Manager::class, 'sdk'))->setValue($manager, $client); + PostHog::swap($manager); + } +} diff --git a/examples/laravel/tests/TestCase.php b/examples/laravel/tests/TestCase.php new file mode 100644 index 00000000..6442aa4c --- /dev/null +++ b/examples/laravel/tests/TestCase.php @@ -0,0 +1,17 @@ +make(Kernel::class)->bootstrap(); + return $app; + } +} From 460d2d7095ecd2b2b3144c395f9ad02dff6067f5 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 10:10:39 +0100 Subject: [PATCH 08/37] fix: retain failed command context until Laravel reports the exception --- packages/laravel/src/ExceptionReporter.php | 24 +++++++++++++++++++ .../laravel/src/PostHogServiceProvider.php | 11 +++++++-- .../tests/Fixtures/ReportingConsoleKernel.php | 13 ++++++++++ packages/laravel/tests/RuntimeTest.php | 21 ++++++++++++++++ 4 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 packages/laravel/tests/Fixtures/ReportingConsoleKernel.php diff --git a/packages/laravel/src/ExceptionReporter.php b/packages/laravel/src/ExceptionReporter.php index 117c104d..82c46090 100644 --- a/packages/laravel/src/ExceptionReporter.php +++ b/packages/laravel/src/ExceptionReporter.php @@ -10,6 +10,7 @@ class ExceptionReporter { private WeakMap $snapshots; private WeakMap $reported; + private ?array $failedCommand = null; public function __construct(private Container $app) { @@ -17,6 +18,20 @@ public function __construct(private Container $app) $this->reported = new WeakMap(); } + /** @internal Symfony terminates commands before Laravel reports their exceptions. */ + public function commandFinished(int $exitCode): void + { + if ($exitCode !== 0) { + $this->failedCommand ??= $this->app->make(Context::class)->snapshot(); + } + } + + /** @internal A previous completed command must not identify a later failure. */ + public function commandStarting(): void + { + $this->failedCommand = null; + } + public function rememberContext(Throwable $exception): void { $this->snapshots[$exception] ??= ['data' => $this->app->make(Context::class)->snapshot()]; @@ -60,6 +75,15 @@ public function report(Throwable $exception): void $this->reported[$exception] = true; $snapshot = $this->snapshots[$exception] ?? ['data' => $this->app->make(Context::class)->snapshot()]; $options = $snapshot['options'] ?? $this->reportOptions(); + if (!isset($this->snapshots[$exception]) && !$options['handled'] && $this->failedCommand !== null) { + foreach ($exception->getTrace() as $frame) { + if (is_a($frame['class'] ?? '', \Illuminate\Console\Command::class, true)) { + $snapshot['data'] = $this->failedCommand; + $this->failedCommand = null; + break; + } + } + } $manager = $this->app->make(PostHog::class); try { $manager->reportException($exception, $snapshot['data'], $options); diff --git a/packages/laravel/src/PostHogServiceProvider.php b/packages/laravel/src/PostHogServiceProvider.php index 3c745b2b..d8a512e1 100644 --- a/packages/laravel/src/PostHogServiceProvider.php +++ b/packages/laravel/src/PostHogServiceProvider.php @@ -75,7 +75,9 @@ public function boot(): void Container::getInstance()->make(ExceptionReporter::class)->remember($event->exception, 'task'); }); $events->listen(CommandStarting::class, function () { - $context = Container::getInstance()->make(Context::class); + $app = Container::getInstance(); + $app->make(ExceptionReporter::class)->commandStarting(); + $context = $app->make(Context::class); $data = $context->snapshot(); $context->enter(); $context->begin($data); @@ -101,7 +103,12 @@ public function boot(): void }); // End-event consumers must observe the completed scope before cleanup restores its caller. $this->app->booted(function () use ($events) { - foreach ([JobProcessed::class, JobExceptionOccurred::class, CommandFinished::class] as $event) { + $events->listen(CommandFinished::class, function ($event) { + $app = Container::getInstance(); + $app->make(ExceptionReporter::class)->commandFinished($event->exitCode); + $this->finish($app, true); + }); + foreach ([JobProcessed::class, JobExceptionOccurred::class] as $event) { $events->listen($event, fn () => $this->finish(Container::getInstance(), true)); } foreach ( diff --git a/packages/laravel/tests/Fixtures/ReportingConsoleKernel.php b/packages/laravel/tests/Fixtures/ReportingConsoleKernel.php new file mode 100644 index 00000000..98d43cfc --- /dev/null +++ b/packages/laravel/tests/Fixtures/ReportingConsoleKernel.php @@ -0,0 +1,13 @@ +app->make(Context::class); + $context->begin(['distinctId' => 'caller']); + $kernel = new \PostHog\Laravel\Tests\Fixtures\ReportingConsoleKernel($this->app, $this->app['events']); + $this->app->instance(\Illuminate\Contracts\Console\Kernel::class, $kernel); + \Illuminate\Support\Facades\Artisan::clearResolvedInstance(\Illuminate\Contracts\Console\Kernel::class); + $kernel->rerouteSymfonyCommandEvents(); + \Illuminate\Support\Facades\Artisan::command('posthog:failing', function () { + app(Context::class)->begin(['distinctId' => 'failed-command']); + throw new RuntimeException('command failure'); + }); + $this->assertSame(1, $kernel->handle(new ArrayInput(['command' => 'posthog:failing']), new NullOutput())); + $this->assertSame('caller', $context->data()['distinctId']); + $this->assertCount(1, $fake->recorded('captureException')); + $this->assertSame('failed-command', $fake->recorded('captureException')[0]['message']['distinctId']); + $fake->assertExceptionCaptured(fn ($event) => $event['distinctId'] === 'failed-command' + && $event['options']['handled'] === false); + } + public function testHeaderFallbackAndNestedContextRestoreOnFailure(): void { $context = $this->app->make(Context::class); From caa7a73e3012a4cc2bf59e8bde6def63a5f4d6d5 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 10:13:14 +0100 Subject: [PATCH 09/37] fix: preserve collector privacy defaults and session correlation --- .../eb4dfed4373d440294ff58970818d3e7.json | 5 +++++ packages/laravel/AUTOCAPTURE.md | 4 ++-- packages/laravel/src/Autocapture/Collector.php | 4 ++-- .../laravel/src/AutocaptureServiceProvider.php | 10 ++++++---- packages/laravel/src/Testing/PostHogFake.php | 3 +++ packages/laravel/tests/AutocaptureTest.php | 15 +++++++++++++++ 6 files changed, 33 insertions(+), 8 deletions(-) create mode 100644 .laravel-changes/eb4dfed4373d440294ff58970818d3e7.json diff --git a/.laravel-changes/eb4dfed4373d440294ff58970818d3e7.json b/.laravel-changes/eb4dfed4373d440294ff58970818d3e7.json new file mode 100644 index 00000000..0721ba99 --- /dev/null +++ b/.laravel-changes/eb4dfed4373d440294ff58970818d3e7.json @@ -0,0 +1,5 @@ +{ + "package": "posthog-laravel", + "bump": "initial", + "summary": "Add opt-in Laravel request, auth, domain, model and worker event collectors." +} diff --git a/packages/laravel/AUTOCAPTURE.md b/packages/laravel/AUTOCAPTURE.md index 720aabd4..962611e1 100644 --- a/packages/laravel/AUTOCAPTURE.md +++ b/packages/laravel/AUTOCAPTURE.md @@ -64,7 +64,7 @@ and pageview events. Zero drops both; the default 1 captures every eligible requ ### Privacy and identity Collectors emit only the properties listed below, plus normal SDK metadata and -explicit identity/groups. Arbitrary context properties are not copied. Collection +explicit identity, session correlation and groups. Arbitrary context properties are not copied. Collection never reads request bodies, query values, cookies, credentials, email, IP addresses, job arguments or full model/event payloads. Auth uses `getAuthIdentifier()`; configure an opaque identifier rather than email if your application customizes that method. @@ -131,7 +131,7 @@ Only existing attributes are read, with their normal casts/accessors; JSON-safe scalar/null/array values are accepted, objects/resources/invalid JSON values are omitted. Do not allowlist a sensitive accessor or JSON column. -Values, identity and groups are snapshotted at the change, then emitted **after the +Values, identity, session and groups are snapshotted at the change, then emitted **after the model connection's outer transaction commits**. Rollback (including a nested savepoint rollback) discards the corresponding pending events. No model object is retained by the commit callback. Outside a transaction, collection is immediate. diff --git a/packages/laravel/src/Autocapture/Collector.php b/packages/laravel/src/Autocapture/Collector.php index cd75eb8e..4f237f28 100644 --- a/packages/laravel/src/Autocapture/Collector.php +++ b/packages/laravel/src/Autocapture/Collector.php @@ -7,7 +7,7 @@ use PostHog\Laravel\PostHog; use Throwable; -/** @internal Collectors retain only identity and groups, never arbitrary context payloads. */ +/** @internal Collectors retain correlation IDs and groups, never arbitrary context payloads. */ class Collector { public static function enabled(string $section): bool @@ -18,7 +18,7 @@ public static function enabled(string $section): bool public static function snapshot(): array { return array_intersect_key(Container::getInstance()->make(Context::class)->snapshot(), [ - 'distinctId' => true, 'groups' => true, + 'distinctId' => true, 'sessionId' => true, 'groups' => true, ]); } diff --git a/packages/laravel/src/AutocaptureServiceProvider.php b/packages/laravel/src/AutocaptureServiceProvider.php index 2c1b4c29..8f2104b0 100644 --- a/packages/laravel/src/AutocaptureServiceProvider.php +++ b/packages/laravel/src/AutocaptureServiceProvider.php @@ -20,10 +20,12 @@ public function register(): void { $this->mergeConfigFrom(__DIR__ . '/../config/autocapture.php', 'posthog-autocapture'); $config = $this->app['config']; - $config->set('posthog.autocapture', array_replace( - $config->get('posthog-autocapture', []), - $config->get('posthog.autocapture', []), - )); + $settings = $config->get('posthog-autocapture', []); + foreach ($config->get('posthog.autocapture', []) as $section => $values) { + $settings[$section] = $section !== 'domain_events' && is_array($values) + ? array_replace($settings[$section] ?? [], $values) : $values; + } + $config->set('posthog.autocapture', $settings); } public function boot(): void diff --git a/packages/laravel/src/Testing/PostHogFake.php b/packages/laravel/src/Testing/PostHogFake.php index 61fd6aa9..4d997b7d 100644 --- a/packages/laravel/src/Testing/PostHogFake.php +++ b/packages/laravel/src/Testing/PostHogFake.php @@ -32,6 +32,9 @@ protected function message(string $method, array $message): bool $message['distinctId'] ??= $data['distinctId'] ?? null; $message['groups'] ??= $data['groups'] ?? []; $message['properties'] = array_replace($data['properties'] ?? [], $message['properties'] ?? []); + if (isset($data['sessionId']) && !array_key_exists('$session_id', $message['properties'])) { + $message['properties']['$session_id'] = $data['sessionId']; + } $this->recorded[] = ['method' => $method, 'message' => $message]; return true; } diff --git a/packages/laravel/tests/AutocaptureTest.php b/packages/laravel/tests/AutocaptureTest.php index 0ef10fdb..ccb5bc1d 100644 --- a/packages/laravel/tests/AutocaptureTest.php +++ b/packages/laravel/tests/AutocaptureTest.php @@ -118,6 +118,10 @@ public function testPublishedCollectorConfigAndNestedOverridesAreHonored(): void $config->set('posthog.autocapture.requests', ['enabled' => false]); (new AutocaptureServiceProvider($this->app))->register(); $this->assertFalse($config->get('posthog.autocapture.requests.enabled')); + $this->assertSame($defaults['requests']['exclude'], $config->get('posthog.autocapture.requests.exclude')); + $config->set('posthog.autocapture.requests.exclude', []); + (new AutocaptureServiceProvider($this->app))->register(); + $this->assertSame([], $config->get('posthog.autocapture.requests.exclude')); } finally { if ($original === null) { @unlink($path); @@ -154,6 +158,17 @@ public function testRequestSummaryAndPageviewUseTemplatesAndConfiguredOriginOnly $this->assertSame([], $this->app->make(Context::class)->snapshot()); } + public function testAutomaticRequestsKeepSessionCorrelationWithoutArbitraryProperties(): void + { + $fake = PostHog::fake(); + $this->enable('requests'); + Route::get('/session', fn () => 'ok'); + $this->get('/session', [ + 'X-PostHog-Distinct-Id' => 'person', 'X-PostHog-Session-Id' => 'session', + ])->assertOk(); + $fake->assertCaptured('laravel_request', fn ($event) => $event['properties']['$session_id'] === 'session'); + } + public function testHealthAssetsUnmatchedAndSamplingDoNotCollectAndPageviewIsSeparate(): void { $fake = PostHog::fake(); From 47bec038ac775991b2fb286371656746a3b77e51 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 10:14:51 +0100 Subject: [PATCH 10/37] feat: add an optional read-only Laravel Pennant driver --- .../482cca29796b4cc3bf4b96f0b476e65d.json | 5 + packages/laravel/PENNANT.md | 63 ++++++++++ packages/laravel/README.md | 5 + packages/laravel/composer.json | 4 +- .../laravel/src/Features/PennantCache.php | 25 ++++ .../src/Features/PennantMiddleware.php | 18 +++ .../src/Features/PennantServiceProvider.php | 30 +++++ .../laravel/src/Features/PostHogDriver.php | 78 ++++++++++++ .../laravel/src/PostHogServiceProvider.php | 3 + packages/laravel/tests/PennantTest.php | 115 ++++++++++++++++++ 10 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 .laravel-changes/482cca29796b4cc3bf4b96f0b476e65d.json create mode 100644 packages/laravel/PENNANT.md create mode 100644 packages/laravel/src/Features/PennantCache.php create mode 100644 packages/laravel/src/Features/PennantMiddleware.php create mode 100644 packages/laravel/src/Features/PennantServiceProvider.php create mode 100644 packages/laravel/src/Features/PostHogDriver.php create mode 100644 packages/laravel/tests/PennantTest.php diff --git a/.laravel-changes/482cca29796b4cc3bf4b96f0b476e65d.json b/.laravel-changes/482cca29796b4cc3bf4b96f0b476e65d.json new file mode 100644 index 00000000..830e270f --- /dev/null +++ b/.laravel-changes/482cca29796b4cc3bf4b96f0b476e65d.json @@ -0,0 +1,5 @@ +{ + "package": "posthog-laravel", + "bump": "initial", + "summary": "Add an optional read-only Laravel Pennant driver for PostHog feature flags." +} diff --git a/packages/laravel/PENNANT.md b/packages/laravel/PENNANT.md new file mode 100644 index 00000000..57864304 --- /dev/null +++ b/packages/laravel/PENNANT.md @@ -0,0 +1,63 @@ +# 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. Listing defined keys uses the current native snapshot without +recording exposure. + +## 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. + +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 9ccd055c..d4ca5efa 100644 --- a/packages/laravel/README.md +++ b/packages/laravel/README.md @@ -181,6 +181,11 @@ refresh lazily after the interval. 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 a5fd4d10..44916163 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/Features/PennantCache.php b/packages/laravel/src/Features/PennantCache.php new file mode 100644 index 00000000..400df9e8 --- /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 00000000..ff6b7d6f --- /dev/null +++ b/packages/laravel/src/Features/PennantMiddleware.php @@ -0,0 +1,18 @@ +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 00000000..fe7ede19 --- /dev/null +++ b/packages/laravel/src/Features/PostHogDriver.php @@ -0,0 +1,78 @@ +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 + { + return Container::getInstance()->make(PostHog::class)->evaluateFlags()->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 3c745b2b..4e41eaa7 100644 --- a/packages/laravel/src/PostHogServiceProvider.php +++ b/packages/laravel/src/PostHogServiceProvider.php @@ -35,6 +35,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); } diff --git a/packages/laravel/tests/PennantTest.php b/packages/laravel/tests/PennantTest.php new file mode 100644 index 00000000..7a5b928c --- /dev/null +++ b/packages/laravel/tests/PennantTest.php @@ -0,0 +1,115 @@ +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 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 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): void + { + $http = $this->createMock(HttpClient::class); + $http->expects($this->exactly($requests))->method('sendRequest')->willReturnCallback(function ( + $path, + $payload, + ) { + $id = json_decode($payload, true)['distinct_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], + ]]), 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, 'sdk'))->setValue($this->app->make(PostHog::class), $sdk); + } +} From 3b2ad5a8904fe7baa5d3e849dbce41bb3509d00a Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 10:33:41 +0100 Subject: [PATCH 11/37] fix: use explicit HTTP and console execution lifecycle boundaries --- packages/laravel/README.md | 18 ++- packages/laravel/src/ConsoleKernel.php | 67 +++++++++++ packages/laravel/src/ConsoleLifecycle.php | 87 ++++++++++++++ packages/laravel/src/ExceptionReporter.php | 24 ---- .../laravel/src/Middleware/RequestContext.php | 21 ++-- .../laravel/src/PostHogServiceProvider.php | 30 ++--- packages/laravel/src/RequestLifecycle.php | 43 +++++++ packages/laravel/tests/LifecycleTest.php | 110 ++++++++++++++++++ packages/laravel/tests/RuntimeTest.php | 25 ++-- 9 files changed, 364 insertions(+), 61 deletions(-) create mode 100644 packages/laravel/src/ConsoleKernel.php create mode 100644 packages/laravel/src/ConsoleLifecycle.php create mode 100644 packages/laravel/src/RequestLifecycle.php create mode 100644 packages/laravel/tests/LifecycleTest.php diff --git a/packages/laravel/README.md b/packages/laravel/README.md index 9ccd055c..0de79551 100644 --- a/packages/laravel/README.md +++ b/packages/laravel/README.md @@ -90,8 +90,18 @@ disable propagation. This metadata is analytics context, never authorization evi Jobs may override it explicitly. Nested synchronous jobs and Artisan commands restore the caller's context and flag snapshots when they finish or fail. -Global request middleware, queue events, Artisan command events, and Octane -request/task/tick events flush and clean context automatically. SDK creation is lazy, +Request context remains active through HTTP kernel termination, including +`dispatchAfterResponse()` jobs and callbacks registered during application termination. +The kernel's completion hook flushes and clears it afterward, not when middleware +returns the response. Custom HTTP kernels without Laravel's lifecycle callback API +fall back to middleware cleanup and cannot provide after-response correlation. + +Queue events and Octane request/task/tick boundaries flush and clean context +automatically. Console integration decorates the existing console kernel contract: +nested `Artisan::call()` executions restore their caller in `finally`, while the root +command stays active through kernel termination. Thrown objects retain their own +snapshots; nonzero return codes never supply another exception's identity. The +application's commands, custom kernel, and exception-reporting policy are preserved. SDK creation is lazy, per Laravel scope. Shared flag definitions live in Laravel Cache, not a global SDK. Do not retain scoped services in application singletons or prewarm them in Octane. @@ -107,7 +117,9 @@ Automatic flush failures do not replace an HTTP response or worker failure. Expl Laravel's reportable exception path respects `dontReport`, exception-specific report methods, and preceding report callbacks that stop reporting. The package does not install global PHP exception/error handlers, even if `sdk.error_tracking.enabled` is -set. Disable automatic reporting with `capture_exceptions=false`. +set. Disable automatic reporting with `capture_exceptions=false`. Custom exception handlers +must expose Laravel's `reportable()` API for automatic registration; otherwise add an +explicit report callback using the public capture API. ```php try { diff --git a/packages/laravel/src/ConsoleKernel.php b/packages/laravel/src/ConsoleKernel.php new file mode 100644 index 00000000..7836c58a --- /dev/null +++ b/packages/laravel/src/ConsoleKernel.php @@ -0,0 +1,67 @@ +whenCommandLifecycleIsLongerThan(-INF, static function () { + Container::getInstance()->make(ConsoleLifecycle::class)->finishRoot(); + }); + } + } + + public function bootstrap() + { + return $this->kernel->bootstrap(); + } + + public function handle($input, $output = null) + { + return Container::getInstance()->make(ConsoleLifecycle::class)->handle( + fn () => $this->kernel->handle($input, $output), + ); + } + + public function call($command, array $parameters = [], $outputBuffer = null) + { + return Container::getInstance()->make(ConsoleLifecycle::class)->call( + fn () => $this->kernel->call($command, $parameters, $outputBuffer), + ); + } + + public function queue($command, array $parameters = []) + { + return $this->kernel->queue($command, $parameters); + } + + public function all() + { + return $this->kernel->all(); + } + + public function output() + { + return $this->kernel->output(); + } + + public function terminate($input, $status) + { + try { + return $this->kernel->terminate($input, $status); + } finally { + Container::getInstance()->make(ConsoleLifecycle::class)->finishRoot(); + } + } + + public function __call(string $method, array $arguments): mixed + { + return $this->kernel->$method(...$arguments); + } +} diff --git a/packages/laravel/src/ConsoleLifecycle.php b/packages/laravel/src/ConsoleLifecycle.php new file mode 100644 index 00000000..2e07b629 --- /dev/null +++ b/packages/laravel/src/ConsoleLifecycle.php @@ -0,0 +1,87 @@ +root || $this->calls > 0) { + return; + } + $this->enter(); + $this->root = true; + } + + public function handle(callable $callback): mixed + { + $this->startRoot(); + try { + return $callback(); + } catch (Throwable $exception) { + $this->remember($exception); + $this->finishRoot(); + throw $exception; + } + } + + public function call(callable $callback): mixed + { + $this->enter(); + $this->calls++; + try { + return $callback(); + } catch (Throwable $exception) { + $this->remember($exception); + throw $exception; + } finally { + $this->calls--; + $this->leave(); + } + } + + public function finishRoot(): void + { + if ($this->root) { + $this->root = false; + $this->leave(); + } + } + + private function enter(): void + { + $context = $this->app->make(Context::class); + $data = $context->snapshot(); + $context->enter(); + $context->begin($data); + } + + private function remember(Throwable $exception): void + { + try { + $this->app->make(ExceptionReporter::class)->remember($exception, 'runtime'); + } catch (Throwable) { + } + } + + private function leave(): void + { + try { + $this->app->make(PostHog::class)->flush(); + } catch (Throwable) { + } finally { + $this->app->make(Context::class)->leave(); + } + } +} diff --git a/packages/laravel/src/ExceptionReporter.php b/packages/laravel/src/ExceptionReporter.php index 82c46090..117c104d 100644 --- a/packages/laravel/src/ExceptionReporter.php +++ b/packages/laravel/src/ExceptionReporter.php @@ -10,7 +10,6 @@ class ExceptionReporter { private WeakMap $snapshots; private WeakMap $reported; - private ?array $failedCommand = null; public function __construct(private Container $app) { @@ -18,20 +17,6 @@ public function __construct(private Container $app) $this->reported = new WeakMap(); } - /** @internal Symfony terminates commands before Laravel reports their exceptions. */ - public function commandFinished(int $exitCode): void - { - if ($exitCode !== 0) { - $this->failedCommand ??= $this->app->make(Context::class)->snapshot(); - } - } - - /** @internal A previous completed command must not identify a later failure. */ - public function commandStarting(): void - { - $this->failedCommand = null; - } - public function rememberContext(Throwable $exception): void { $this->snapshots[$exception] ??= ['data' => $this->app->make(Context::class)->snapshot()]; @@ -75,15 +60,6 @@ public function report(Throwable $exception): void $this->reported[$exception] = true; $snapshot = $this->snapshots[$exception] ?? ['data' => $this->app->make(Context::class)->snapshot()]; $options = $snapshot['options'] ?? $this->reportOptions(); - if (!isset($this->snapshots[$exception]) && !$options['handled'] && $this->failedCommand !== null) { - foreach ($exception->getTrace() as $frame) { - if (is_a($frame['class'] ?? '', \Illuminate\Console\Command::class, true)) { - $snapshot['data'] = $this->failedCommand; - $this->failedCommand = null; - break; - } - } - } $manager = $this->app->make(PostHog::class); try { $manager->reportException($exception, $snapshot['data'], $options); diff --git a/packages/laravel/src/Middleware/RequestContext.php b/packages/laravel/src/Middleware/RequestContext.php index d0383438..97f762e6 100644 --- a/packages/laravel/src/Middleware/RequestContext.php +++ b/packages/laravel/src/Middleware/RequestContext.php @@ -3,32 +3,31 @@ namespace PostHog\Laravel\Middleware; use Closure; -use PostHog\Laravel\Context; +use Illuminate\Contracts\Http\Kernel; use PostHog\Laravel\ExceptionReporter; -use PostHog\Laravel\PostHog; +use PostHog\Laravel\RequestLifecycle; use Throwable; class RequestContext { - public function __construct(private Context $context, private ExceptionReporter $reporter, private PostHog $posthog) - { + public function __construct( + private RequestLifecycle $lifecycle, + private ExceptionReporter $reporter, + private Kernel $kernel, + ) { } public function handle($request, Closure $next): mixed { - $this->context->fromRequest($request); + $this->lifecycle->begin($request); try { return $next($request); } catch (Throwable $exception) { $this->reporter->remember($exception, 'middleware'); throw $exception; } finally { - try { - $this->posthog->flush(); - } catch (Throwable) { - // A transport failure must not change the HTTP response. - } finally { - $this->context->end(); + if (!method_exists($this->kernel, 'whenRequestLifecycleIsLongerThan')) { + $this->lifecycle->finish(); } } } diff --git a/packages/laravel/src/PostHogServiceProvider.php b/packages/laravel/src/PostHogServiceProvider.php index d8a512e1..c852345a 100644 --- a/packages/laravel/src/PostHogServiceProvider.php +++ b/packages/laravel/src/PostHogServiceProvider.php @@ -6,7 +6,6 @@ use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Http\Kernel; use Illuminate\Console\Events\CommandStarting; -use Illuminate\Console\Events\CommandFinished; use Illuminate\Queue\Events\JobProcessing; use Illuminate\Queue\Events\JobProcessed; use Illuminate\Queue\Events\JobExceptionOccurred; @@ -24,6 +23,11 @@ public function register(): void $this->app->scoped(PostHog::class); $this->app->alias(PostHog::class, 'posthog'); $this->app->scoped(ExceptionReporter::class); + $this->app->scoped(RequestLifecycle::class); + $this->app->scoped(ConsoleLifecycle::class); + $this->app->extend(\Illuminate\Contracts\Console\Kernel::class, function ($kernel) { + return new ConsoleKernel($kernel); + }); $this->app->scoped(\PostHog\Client::class, fn ($app) => $app->make(PostHog::class)->client()); $this->app->scoped(FlagDefinitionCacheProvider::class, function ($app) { $config = $app['config']->get('posthog'); @@ -52,6 +56,11 @@ public function boot(): void } $this->callAfterResolving(Kernel::class, function ($kernel) { $kernel->prependMiddleware(RequestContextMiddleware::class); + if (method_exists($kernel, 'whenRequestLifecycleIsLongerThan')) { + $kernel->whenRequestLifecycleIsLongerThan(-INF, static function () { + Container::getInstance()->make(RequestLifecycle::class)->finish(); + }); + } }); $this->callAfterResolving(ExceptionHandler::class, function ($handler) { if (method_exists($handler, 'reportable')) { @@ -75,12 +84,8 @@ public function boot(): void Container::getInstance()->make(ExceptionReporter::class)->remember($event->exception, 'task'); }); $events->listen(CommandStarting::class, function () { - $app = Container::getInstance(); - $app->make(ExceptionReporter::class)->commandStarting(); - $context = $app->make(Context::class); - $data = $context->snapshot(); - $context->enter(); - $context->begin($data); + // The first CLI kernel may already be handling input when package providers boot. + Container::getInstance()->make(ConsoleLifecycle::class)->startRoot(); }); foreach ( [ @@ -103,11 +108,6 @@ public function boot(): void }); // End-event consumers must observe the completed scope before cleanup restores its caller. $this->app->booted(function () use ($events) { - $events->listen(CommandFinished::class, function ($event) { - $app = Container::getInstance(); - $app->make(ExceptionReporter::class)->commandFinished($event->exitCode); - $this->finish($app, true); - }); foreach ([JobProcessed::class, JobExceptionOccurred::class] as $event) { $events->listen($event, fn () => $this->finish(Container::getInstance(), true)); } @@ -127,11 +127,15 @@ public function boot(): void $this->finish($event->sandbox); }); }); - $this->app->terminating(fn () => $this->finish(Container::getInstance())); } private function finish(Container $app, bool $restore = false): void { + $request = $app->make(RequestLifecycle::class); + if (!$restore && $request->active()) { + $request->finish(); + return; + } try { $app->make(PostHog::class)->flush(); } catch (Throwable) { diff --git a/packages/laravel/src/RequestLifecycle.php b/packages/laravel/src/RequestLifecycle.php new file mode 100644 index 00000000..39e322c1 --- /dev/null +++ b/packages/laravel/src/RequestLifecycle.php @@ -0,0 +1,43 @@ +app->make(Context::class)->fromRequest($request); + $this->active = true; + } + + public function active(): bool + { + return $this->active; + } + + public function finish(): void + { + if (!$this->active) { + return; + } + $this->active = false; + try { + $this->app->make(PostHog::class)->flush(); + } catch (Throwable) { + // Failed telemetry delivery must not prevent scope cleanup. + } finally { + $this->app->make(Context::class)->end(); + } + } +} diff --git a/packages/laravel/tests/LifecycleTest.php b/packages/laravel/tests/LifecycleTest.php new file mode 100644 index 00000000..d3c439f0 --- /dev/null +++ b/packages/laravel/tests/LifecycleTest.php @@ -0,0 +1,110 @@ +set('posthog.api_key', 'test-key'); + $app['config']->set('posthog.sdk.consumer', 'noop'); + $app['config']->set('queue.default', 'sync'); + } + + public function testAfterResponseAndLateTerminatingJobsKeepRequestIdentityAndSession(): void + { + $fake = PostHog::fake(); + Route::get('/deferred', function () { + $this->app['auth']->setUser(new GenericUser(['id' => 'request-owner'])); + Bus::dispatchAfterResponse(new AmbientCaptureJob()); + $this->app->terminating(function () { + Bus::dispatchAfterResponse(new AmbientCaptureJob()); + }); + return 'ok'; + }); + + $this->get('/deferred', ['X-PostHog-Session-Id' => 'request-session'])->assertOk(); + $messages = array_column($fake->recorded('capture'), 'message'); + $this->assertCount(2, $messages); + foreach ($messages as $message) { + $this->assertSame('request-owner', $message['distinctId']); + $this->assertSame('request-session', $message['properties']['$session_id']); + } + $this->assertSame([], $this->app->make(Context::class)->snapshot()); + } + + public function testChildFailureReturnCannotIdentifyAnIndependentlyThrowingParent(): void + { + $fake = PostHog::fake(); + $kernel = $this->reportingKernel(); + Artisan::command('posthog:child', function () { + app(Context::class)->begin(['distinctId' => 'child']); + return 1; + }); + Artisan::command('posthog:parent', function () { + app(Context::class)->begin(['distinctId' => 'parent']); + Artisan::call('posthog:child'); + throw new RuntimeException('parent failed'); + }); + $input = new ArrayInput(['command' => 'posthog:parent']); + $this->assertSame(1, $kernel->handle($input, new NullOutput())); + $kernel->terminate($input, 1); + $fake->assertExceptionCaptured(fn ($event) => $event['distinctId'] === 'parent' + && $event['options']['handled'] === false); + $this->assertSame([], $this->app->make(Context::class)->snapshot()); + } + + public function testThrowingChildKeepsItsOwnExceptionSnapshotAfterCallerCatchesIt(): void + { + $fake = PostHog::fake(); + $kernel = $this->reportingKernel(); + Artisan::command('posthog:throwing-child', function () { + app(Context::class)->begin(['distinctId' => 'child']); + throw new RuntimeException('child failed'); + }); + Artisan::command('posthog:catching-parent', function () { + app(Context::class)->begin(['distinctId' => 'parent']); + try { + Artisan::call('posthog:throwing-child'); + } catch (RuntimeException $exception) { + report($exception); + } + PostHog::capture('parent continued'); + return 0; + }); + $input = new ArrayInput(['command' => 'posthog:catching-parent']); + $this->assertSame(0, $kernel->handle($input, new NullOutput())); + $kernel->terminate($input, 0); + $fake->assertExceptionCaptured(fn ($event) => $event['distinctId'] === 'child'); + $fake->assertCaptured('parent continued', fn ($event) => $event['distinctId'] === 'parent'); + } + + private function reportingKernel(): ConsoleKernel + { + $this->app->singleton(ConsoleKernel::class, fn ($app) => new ReportingConsoleKernel($app, $app['events'])); + Artisan::clearResolvedInstance(ConsoleKernel::class); + $kernel = $this->app->make(ConsoleKernel::class); + $kernel->rerouteSymfonyCommandEvents(); + return $kernel; + } +} diff --git a/packages/laravel/tests/RuntimeTest.php b/packages/laravel/tests/RuntimeTest.php index f4159252..d518f10f 100644 --- a/packages/laravel/tests/RuntimeTest.php +++ b/packages/laravel/tests/RuntimeTest.php @@ -136,15 +136,19 @@ public function testFailedCommandSnapshotSurvivesTerminationBeforeKernelReportin $fake = Facade::fake(); $context = $this->app->make(Context::class); $context->begin(['distinctId' => 'caller']); - $kernel = new \PostHog\Laravel\Tests\Fixtures\ReportingConsoleKernel($this->app, $this->app['events']); - $this->app->instance(\Illuminate\Contracts\Console\Kernel::class, $kernel); + $this->app->singleton(\Illuminate\Contracts\Console\Kernel::class, fn ($app) => + new \PostHog\Laravel\Tests\Fixtures\ReportingConsoleKernel($app, $app['events'])); \Illuminate\Support\Facades\Artisan::clearResolvedInstance(\Illuminate\Contracts\Console\Kernel::class); + $kernel = $this->app->make(\Illuminate\Contracts\Console\Kernel::class); $kernel->rerouteSymfonyCommandEvents(); \Illuminate\Support\Facades\Artisan::command('posthog:failing', function () { app(Context::class)->begin(['distinctId' => 'failed-command']); throw new RuntimeException('command failure'); }); - $this->assertSame(1, $kernel->handle(new ArrayInput(['command' => 'posthog:failing']), new NullOutput())); + $input = new ArrayInput(['command' => 'posthog:failing']); + $this->assertSame(1, $kernel->handle($input, new NullOutput())); + $this->assertSame('failed-command', $context->data()['distinctId']); + $kernel->terminate($input, 1); $this->assertSame('caller', $context->data()['distinctId']); $this->assertCount(1, $fake->recorded('captureException')); $this->assertSame('failed-command', $fake->recorded('captureException')[0]['message']['distinctId']); @@ -291,11 +295,12 @@ public function testConsoleAndOctaneBoundariesFlushAndReset(): void $manager = $this->app->make(PostHog::class); (new \ReflectionProperty(PostHog::class, 'sdk'))->setValue($manager, $client); $context = $this->app->make(Context::class); - $input = new ArrayInput([]); - $output = new NullOutput(); - $this->app['events']->dispatch(new \Illuminate\Console\Events\CommandStarting('test', $input, $output)); - $context->begin(['distinctId' => 'console']); - $this->app['events']->dispatch(new \Illuminate\Console\Events\CommandFinished('test', $input, $output, 1)); + $this->app->make(\Illuminate\Contracts\Console\Kernel::class)->rerouteSymfonyCommandEvents(); + \Illuminate\Support\Facades\Artisan::command('posthog:boundary', function () { + app(Context::class)->begin(['distinctId' => 'console']); + return 1; + }); + $this->assertSame(1, \Illuminate\Support\Facades\Artisan::call('posthog:boundary')); $this->assertSame([], $context->data()); foreach (['first', 'second'] as $id) { $this->app['events']->dispatch('Laravel\\Octane\\Events\\RequestReceived', [(object) [ @@ -318,8 +323,8 @@ public function testLifecycleCleanupSurvivesFlushFailure(): void $context->begin(['distinctId' => 'stale']); $this->app['events']->dispatch('Laravel\\Octane\\Events\\TaskTerminated', [(object) []]); $this->assertSame([], $context->data()); - $response = $this->app->make(RequestContext::class)->handle(Request::create('/'), fn () => 'ok'); - $this->assertSame('ok', $response); + Route::get('/flush-failure', fn () => 'ok'); + $this->get('/flush-failure')->assertOk(); $this->assertSame([], $context->data()); $client->fail = false; } From b6ead398b48ced2238c9aac18dc7592343c41988 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 10:37:48 +0100 Subject: [PATCH 12/37] feat: demonstrate the Laravel Pennant integration in the example --- examples/laravel/README.md | 6 ++ .../app/Http/Controllers/DemoController.php | 1 + examples/laravel/app/Support/DemoActions.php | 12 +++ examples/laravel/composer.json | 1 + examples/laravel/composer.lock | 85 ++++++++++++++++++- .../resources/views/dashboard.blade.php | 2 + examples/laravel/routes/web.php | 2 +- .../laravel/tests/Feature/DashboardTest.php | 13 +++ 8 files changed, 119 insertions(+), 3 deletions(-) diff --git a/examples/laravel/README.md b/examples/laravel/README.md index 364de044..1cd973e5 100644 --- a/examples/laravel/README.md +++ b/examples/laravel/README.md @@ -64,6 +64,7 @@ After changing configuration, use `php artisan config:clear` and restart workers | Capture two events | explicit identity/groups and ambient scoped capture | `app/Support/DemoActions.php` | | Identify, alias, group identify | synthetic member/visitor relationship, team profile | `DemoActions::identity()` | | Evaluate flags | boolean, variant, decoded payload, one shared snapshot, `onlyAccessed()` attachment, unresolved reasons | `DemoActions::flags()` | +| Read flags through Pennant | optional read-only PostHog store; boolean and variant values | `DemoActions::pennant()` | | Manual exception | explicit handled capture and safe properties | `DemoActions::exception()` | | Laravel report() | handled framework reporting (no global PHP handlers) | `DemoActions::exception()` | | Throw an unhandled 500 | escaping failure reporting and context cleanup | `DemoController::failure()` | @@ -121,6 +122,11 @@ worker may still fetch definitions. Unresolved entries can report missing contex unsupported definitions, unresolved dependencies or experience continuity. Without local definitions, local-only evaluation is empty; it is not proof a flag is off. +The Pennant form uses `Feature::store('posthog')` explicitly, leaving other Pennant +stores unchanged. Its values come from the same native SDK snapshots; write operations +are intentionally unsupported. See the integration's `PENNANT.md` for scope mapping +and when to clear Pennant's cache after targeting changes within a request. + Advanced PHP SDK methods remain accessible without another global SDK instance: ```php diff --git a/examples/laravel/app/Http/Controllers/DemoController.php b/examples/laravel/app/Http/Controllers/DemoController.php index 7c62b74b..13f25174 100644 --- a/examples/laravel/app/Http/Controllers/DemoController.php +++ b/examples/laravel/app/Http/Controllers/DemoController.php @@ -29,6 +29,7 @@ public function run(string $action, DemoActions $demo): RedirectResponse 'capture' => $demo->capture(), 'identity' => $demo->identity(), 'flags' => $demo->flags(), + 'pennant' => $demo->pennant(), 'exception' => $demo->exception(), 'report' => $demo->exception(report: true), 'order' => $demo->order(), diff --git a/examples/laravel/app/Support/DemoActions.php b/examples/laravel/app/Support/DemoActions.php index f49f117f..091566ca 100644 --- a/examples/laravel/app/Support/DemoActions.php +++ b/examples/laravel/app/Support/DemoActions.php @@ -70,6 +70,18 @@ public function flags(): array }); } + public function pennant(): array + { + return $this->ambient(function (): array { + $store = \Laravel\Pennant\Feature::store('posthog'); + return [ + 'enabled' => $store->active('demo-checkout'), + 'variant' => $store->value('demo-layout'), + 'read_only' => true, + ]; + }); + } + public function exception(bool $report = false): array { return $this->ambient(function () use ($report): array { diff --git a/examples/laravel/composer.json b/examples/laravel/composer.json index bc73dc28..9c834549 100644 --- a/examples/laravel/composer.json +++ b/examples/laravel/composer.json @@ -6,6 +6,7 @@ "require": { "php": "^8.2", "laravel/framework": "^12.0 || ^13.0", + "laravel/pennant": "^1.26", "posthog/posthog-php": "^4.20", "posthog/posthog-laravel": "^0.1.0" }, diff --git a/examples/laravel/composer.lock b/examples/laravel/composer.lock index f117d64e..61c15aee 100644 --- a/examples/laravel/composer.lock +++ b/examples/laravel/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a80118cf52afe622a6d4d7834ad42a48", + "content-hash": "42c36e7ac60dda86bb93f8d5b7218ae9", "packages": [ { "name": "brick/math", @@ -1283,6 +1283,83 @@ }, "time": "2026-10-27T14:07:51+00:00" }, + { + "name": "laravel/pennant", + "version": "v1.26.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/pennant.git", + "reference": "a343096b4b01a23a5dfadf92b0907f710134d5e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pennant/zipball/a343096b4b01a23a5dfadf92b0907f710134d5e0", + "reference": "a343096b4b01a23a5dfadf92b0907f710134d5e0", + "shasum": "" + }, + "require": { + "illuminate/console": "^10.0|^11.0|^12.0|^13.0", + "illuminate/container": "^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "illuminate/database": "^10.0|^11.0|^12.0|^13.0", + "illuminate/queue": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "php": "^8.1", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/finder": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "laravel/octane": "^1.4|^2.0", + "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Feature": "Laravel\\Pennant\\Feature" + }, + "providers": [ + "Laravel\\Pennant\\PennantServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php", + "src/functions.php" + ], + "psr-4": { + "Laravel\\Pennant\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "A simple, lightweight library for managing feature flags.", + "homepage": "https://github.com/laravel/pennant", + "keywords": [ + "feature", + "flags", + "laravel", + "pennant" + ], + "support": { + "issues": "https://github.com/laravel/pennant/issues", + "source": "https://github.com/laravel/pennant" + }, + "time": "2026-08-13T13:05:53+00:00" + }, { "name": "laravel/prompts", "version": "v0.3.26", @@ -2499,7 +2576,7 @@ "dist": { "type": "path", "url": "../../packages/laravel", - "reference": "f5941a165259399a769b62dd45af78eaed8c7db4" + "reference": "b1adcaa487824e32426301063f5c26dcba793826" }, "require": { "illuminate/contracts": "^12.0 || ^13.0", @@ -2509,9 +2586,13 @@ }, "require-dev": { "laravel/octane": "^2.0", + "laravel/pennant": "^1.0", "orchestra/testbench": "^10.0 || ^11.0", "phpunit/phpunit": "^11.0 || ^12.0" }, + "suggest": { + "laravel/pennant": "Use PostHog flags through Laravel Pennant (^1.0)." + }, "type": "library", "extra": { "laravel": { diff --git a/examples/laravel/resources/views/dashboard.blade.php b/examples/laravel/resources/views/dashboard.blade.php index f1464ff8..6c207616 100644 --- a/examples/laravel/resources/views/dashboard.blade.php +++ b/examples/laravel/resources/views/dashboard.blade.php @@ -35,6 +35,8 @@

Create boolean demo-checkout and multivariate demo-layout flags in your project. One snapshot supplies enabled state, variant, decoded payload, and an accessed-only event attachment.

@csrf
+
@csrf
+ Pennant uses the same SDK snapshots. The PostHog store is read-only; manage flags in PostHog.

POSTHOG_SECRET_KEY (server only) + POSTHOG_FLAG_CACHE=true enables shared local definitions. POSTHOG_LOCAL_ONLY=true disables remote fallback. Unresolved reasons are shown separately (missing context, unsupported definition, dependency, or experience continuity); an empty result with no key is expected.

diff --git a/examples/laravel/routes/web.php b/examples/laravel/routes/web.php index bea7896c..8d50af9e 100644 --- a/examples/laravel/routes/web.php +++ b/examples/laravel/routes/web.php @@ -8,5 +8,5 @@ Route::post('/demo/logout', [DemoController::class, 'logout'])->name('demo.logout'); Route::post('/demo/failure', [DemoController::class, 'failure'])->name('demo.failure'); Route::post('/demo/{action}', [DemoController::class, 'run']) - ->whereIn('action', ['capture', 'identity', 'flags', 'exception', 'report', 'order', 'queue', 'flush']) + ->whereIn('action', ['capture', 'identity', 'flags', 'pennant', 'exception', 'report', 'order', 'queue', 'flush']) ->name('demo.run'); diff --git a/examples/laravel/tests/Feature/DashboardTest.php b/examples/laravel/tests/Feature/DashboardTest.php index 5ce0cf73..b5294314 100644 --- a/examples/laravel/tests/Feature/DashboardTest.php +++ b/examples/laravel/tests/Feature/DashboardTest.php @@ -88,6 +88,19 @@ public function test_flag_form_uses_variant_payload_snapshot_and_unresolved_reas && $event['flags']->getEventProperties()['$active_feature_flags'] === ['demo-checkout', 'demo-layout']); } + public function test_pennant_form_reads_boolean_and_variant_without_mutating_remote_flags(): void + { + $fake = PostHog::fake(); + $fake->flags(new FeatureFlagEvaluations('demo-visitor', [ + 'demo-checkout' => new EvaluatedFlagRecord('demo-checkout', true, null, null, null, null, null, true), + 'demo-layout' => new EvaluatedFlagRecord('demo-layout', true, 'compact', null, null, null, null, true), + ], [], $fake->client())); + $this->post('/demo/pennant')->assertRedirect('/')->assertSessionHas('result', [ + 'enabled' => true, 'variant' => 'compact', 'read_only' => true, + ]); + $fake->assertNothingCaptured(); + } + public function test_manual_and_report_endpoints_capture_handled_exceptions_without_network(): void { $fake = PostHog::fake(); From 5465c69ccac1ba922457cb247cf0d7aabcc175f0 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 10:47:51 +0100 Subject: [PATCH 13/37] test: construct synthetic URL credentials without secret-scan false positives --- packages/laravel/tests/AutocaptureTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/laravel/tests/AutocaptureTest.php b/packages/laravel/tests/AutocaptureTest.php index ccb5bc1d..e6f403ec 100644 --- a/packages/laravel/tests/AutocaptureTest.php +++ b/packages/laravel/tests/AutocaptureTest.php @@ -137,7 +137,8 @@ public function testRequestSummaryAndPageviewUseTemplatesAndConfiguredOriginOnly $this->enable('requests'); $this->enable('pageviews'); $this->app->register(AutocaptureServiceProvider::class); - $this->app['config']->set('app.url', 'https://user:secret@app.example:8443/base?secret=hidden'); + $origin = implode('', ['https://', 'user', ':', 'secret', '@', 'app.example:8443/base?secret=hidden']); + $this->app['config']->set('app.url', $origin); Route::get('/orders/{order}', fn () => response('

secret

')->header('Content-Type', 'text/html')); foreach (['first-private', 'second-private'] as $id) { $this->get('/orders/' . $id . '?token=secret', ['X-PostHog-Distinct-Id' => $id])->assertOk(); From 2f07fdec75db32c6e3d17fc0b3f5646c4a1847d0 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 10:48:52 +0100 Subject: [PATCH 14/37] test: verify Pennant normalizes custom and model scope contracts --- packages/laravel/tests/PennantTest.php | 30 ++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/laravel/tests/PennantTest.php b/packages/laravel/tests/PennantTest.php index 7a5b928c..280d79a5 100644 --- a/packages/laravel/tests/PennantTest.php +++ b/packages/laravel/tests/PennantTest.php @@ -3,6 +3,7 @@ namespace PostHog\Laravel\Tests; use Illuminate\Support\Facades\Route; +use Laravel\Pennant\Contracts\FeatureScopeable; use Laravel\Pennant\Feature; use Laravel\Pennant\PennantServiceProvider; use LogicException; @@ -68,6 +69,35 @@ public function testModelScopeUsesTheSameIdentifierAsNativeCapture(): void $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 [ From 3554d24c7153fed3dc75508bf7f10f6d2327a47f Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 11:01:17 +0100 Subject: [PATCH 15/37] test: verify database queue commit and rollback behavior --- .../laravel/tests/Feature/DashboardTest.php | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/examples/laravel/tests/Feature/DashboardTest.php b/examples/laravel/tests/Feature/DashboardTest.php index b5294314..7d271701 100644 --- a/examples/laravel/tests/Feature/DashboardTest.php +++ b/examples/laravel/tests/Feature/DashboardTest.php @@ -166,6 +166,26 @@ public function test_database_queue_metadata_contains_only_bounded_analytics_con self::assertSame(['$session_id' => 'queued-session'], $payload['_posthog']['properties']); } + public function test_application_transactions_defer_jobs_until_commit_and_drop_rolled_back_jobs(): void + { + PostHog::fake(); + config(['queue.default' => 'database']); + self::assertTrue(config('queue.connections.database.after_commit')); + \Illuminate\Support\Facades\DB::beginTransaction(); + app(Context::class)->with(['distinctId' => 'committed-owner'], function () { + DemoJob::dispatch('explicit-owner'); + }); + $this->assertDatabaseCount('jobs', 0); + \Illuminate\Support\Facades\DB::commit(); + $this->assertDatabaseCount('jobs', 1); + $payload = json_decode(\Illuminate\Support\Facades\DB::table('jobs')->first()->payload, true); + self::assertSame('committed-owner', $payload['_posthog']['distinctId']); + \Illuminate\Support\Facades\DB::beginTransaction(); + DemoJob::dispatch('rolled-back-owner'); + \Illuminate\Support\Facades\DB::rollBack(); + $this->assertDatabaseCount('jobs', 1); + } + public function test_queue_context_propagation_can_be_disabled(): void { PostHog::fake(); From 0e3243ee46def0be86f153169bc24d31876936d4 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 11:01:17 +0100 Subject: [PATCH 16/37] fix: capture explicitly failed Laravel jobs as failures --- .../src/AutocaptureServiceProvider.php | 8 +++++++- packages/laravel/tests/AutocaptureTest.php | 12 ++++++++++++ .../tests/Fixtures/ExplicitlyFailedJob.php | 19 +++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 packages/laravel/tests/Fixtures/ExplicitlyFailedJob.php diff --git a/packages/laravel/src/AutocaptureServiceProvider.php b/packages/laravel/src/AutocaptureServiceProvider.php index 8f2104b0..a2eecdb1 100644 --- a/packages/laravel/src/AutocaptureServiceProvider.php +++ b/packages/laravel/src/AutocaptureServiceProvider.php @@ -75,7 +75,13 @@ private function registerOutcomes(Dispatcher $events): void $events->listen(\Illuminate\Queue\Events\JobProcessing::class, static function ($event) use ($outcomes) { $outcomes->start('jobs', $event->job); }); - foreach (['JobProcessed' => 'success', 'JobExceptionOccurred' => 'failed'] as $event => $outcome) { + foreach ( + [ + 'JobProcessed' => 'success', + 'JobExceptionOccurred' => 'failed', + 'JobFailed' => 'failed', + ] as $event => $outcome + ) { $events->listen('Illuminate\\Queue\\Events\\' . $event, static function ($event) use ($outcomes, $outcome) { $outcomes->finish('jobs', $event->job, static fn () => [ 'name' => $event->job->resolveQueuedJobClass(), diff --git a/packages/laravel/tests/AutocaptureTest.php b/packages/laravel/tests/AutocaptureTest.php index e6f403ec..856f6a27 100644 --- a/packages/laravel/tests/AutocaptureTest.php +++ b/packages/laravel/tests/AutocaptureTest.php @@ -357,6 +357,18 @@ public function testRealSyncJobSuccessAndFailureKeepOriginalExceptionAndOuterWor $this->assertSame('outer', $this->app->make(Context::class)->data()['distinctId']); } + public function testExplicitlyFailedJobIsNotReportedAsSuccessfulWhenItReturns(): void + { + $fake = PostHog::fake(); + $this->enable('jobs'); + $this->identity('actor'); + \Illuminate\Support\Facades\Bus::dispatchSync(new Fixtures\ExplicitlyFailedJob()); + $fake->assertCapturedCount(1); + $fake->assertCaptured('laravel_job', fn ($event) => $event['properties']['outcome'] === 'failed' + && $event['properties']['name'] === Fixtures\ExplicitlyFailedJob::class); + $this->assertSame('actor', $this->app->make(Context::class)->data()['distinctId']); + } + public function testCommandAndSchedulerMetadataExcludeArgumentsAndDeduplicateFailure(): void { $fake = PostHog::fake(); diff --git a/packages/laravel/tests/Fixtures/ExplicitlyFailedJob.php b/packages/laravel/tests/Fixtures/ExplicitlyFailedJob.php new file mode 100644 index 00000000..e902ccdd --- /dev/null +++ b/packages/laravel/tests/Fixtures/ExplicitlyFailedJob.php @@ -0,0 +1,19 @@ +fail(new RuntimeException('explicit job failure')); + } +} From 0ce49ab1ed24c6f08f29ab73f551dc1379e0060c Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 11:13:34 +0100 Subject: [PATCH 17/37] style: align Laravel example tests with repository PHP standards --- examples/laravel/app/Support/DemoActions.php | 7 +- .../laravel/tests/Feature/CollectorsTest.php | 15 ++-- .../laravel/tests/Feature/DashboardTest.php | 81 ++++++++++++------- .../laravel/tests/Feature/TransportTest.php | 17 ++-- phpcs.xml | 5 ++ 5 files changed, 86 insertions(+), 39 deletions(-) diff --git a/examples/laravel/app/Support/DemoActions.php b/examples/laravel/app/Support/DemoActions.php index 091566ca..a60ba03f 100644 --- a/examples/laravel/app/Support/DemoActions.php +++ b/examples/laravel/app/Support/DemoActions.php @@ -25,7 +25,12 @@ public function ambient(callable $callback): mixed public function capture(): array { return $this->ambient(function (): array { - $explicit = PostHog::capture('demo_explicit', ['source' => 'form'], 'demo-explicit', ['team' => 'demo-team']); + $explicit = PostHog::capture( + 'demo_explicit', + ['source' => 'form'], + 'demo-explicit', + ['team' => 'demo-team'], + ); $ambient = PostHog::capture('demo_ambient', ['source' => 'context']); return ['explicit_accepted' => $explicit, 'ambient_accepted' => $ambient]; }); diff --git a/examples/laravel/tests/Feature/CollectorsTest.php b/examples/laravel/tests/Feature/CollectorsTest.php index a3665c05..1c432118 100644 --- a/examples/laravel/tests/Feature/CollectorsTest.php +++ b/examples/laravel/tests/Feature/CollectorsTest.php @@ -33,14 +33,17 @@ public function createApplication(): Application return $app; } - public function test_request_and_pageview_collectors_exclude_raw_query_body_and_health(): void + public function testRequestAndPageviewCollectorsExcludeRawQueryBodyAndHealth(): void { $fake = PostHog::fake(); $this->withHeader('X-PostHog-Distinct-Id', 'collector-visitor')->get('/?private=do-not-collect') ->assertOk(); $fake->assertCaptured('laravel_request', fn ($event) => $event['properties']['route'] === '/' && $event['properties']['method'] === 'GET' && $event['properties']['status_code'] === 200); - $fake->assertCaptured('$pageview', fn ($event) => !str_contains($event['properties']['$current_url'], 'private')); + $fake->assertCaptured( + '$pageview', + fn ($event) => !str_contains($event['properties']['$current_url'], 'private'), + ); $before = count($fake->recorded()); $this->get('/up')->assertOk(); self::assertCount($before, $fake->recorded()); @@ -49,7 +52,7 @@ public function test_request_and_pageview_collectors_exclude_raw_query_body_and_ self::assertStringNotContainsString('do-not-collect', json_encode($fake->recorded(), JSON_THROW_ON_ERROR)); } - public function test_auth_collectors_use_selected_user_not_credentials(): void + public function testAuthCollectorsUseSelectedUserNotCredentials(): void { $fake = PostHog::fake(); $this->post('/demo/login')->assertRedirect('/'); @@ -59,7 +62,7 @@ public function test_auth_collectors_use_selected_user_not_credentials(): void $fake->assertCaptured('laravel_auth_logout'); } - public function test_model_and_domain_collectors_emit_only_allowlisted_data(): void + public function testModelAndDomainCollectorsEmitOnlyAllowlistedData(): void { $fake = PostHog::fake(); $this->withHeader('X-PostHog-Distinct-Id', 'order-owner')->post('/demo/order')->assertRedirect('/'); @@ -75,7 +78,7 @@ public function test_model_and_domain_collectors_emit_only_allowlisted_data(): v $fake->assertCaptured('laravel_model_deleted'); } - public function test_model_collector_drops_rolled_back_changes(): void + public function testModelCollectorDropsRolledBackChanges(): void { $fake = PostHog::fake(); app(Context::class)->begin(['distinctId' => 'rollback-owner']); @@ -85,7 +88,7 @@ public function test_model_collector_drops_rolled_back_changes(): void $fake->assertNotCaptured('laravel_model_created'); } - public function test_job_command_and_scheduler_collectors_are_executable(): void + public function testJobCommandAndSchedulerCollectorsAreExecutable(): void { $fake = PostHog::fake(); $this->post('/demo/queue')->assertRedirect('/'); diff --git a/examples/laravel/tests/Feature/DashboardTest.php b/examples/laravel/tests/Feature/DashboardTest.php index 7d271701..63f872c2 100644 --- a/examples/laravel/tests/Feature/DashboardTest.php +++ b/examples/laravel/tests/Feature/DashboardTest.php @@ -20,7 +20,7 @@ final class DashboardTest extends TestCase { use RefreshDatabase; - public function test_dashboard_needs_no_key_browser_sdk_or_build_assets(): void + public function testDashboardNeedsNoKeyBrowserSdkOrBuildAssets(): void { $fake = PostHog::fake(); config(['posthog.api_key' => 'project-must-not-render', 'posthog.secret_key' => 'secret-must-not-render']); @@ -34,7 +34,7 @@ public function test_dashboard_needs_no_key_browser_sdk_or_build_assets(): void $fake->assertNothingCaptured(); } - public function test_capture_uses_explicit_and_header_ambient_identity_and_groups(): void + public function testCaptureUsesExplicitAndHeaderAmbientIdentityAndGroups(): void { $fake = PostHog::fake(); $this->withHeaders([ @@ -51,7 +51,7 @@ public function test_capture_uses_explicit_and_header_ambient_identity_and_group self::assertSame([], app(Context::class)->data()); } - public function test_auth_identity_beats_analytics_headers_but_not_explicit_api_identity(): void + public function testAuthIdentityBeatsAnalyticsHeadersButNotExplicitApiIdentity(): void { $fake = PostHog::fake(); $user = DemoUser::create(['name' => 'Synthetic member']); @@ -61,7 +61,7 @@ public function test_auth_identity_beats_analytics_headers_but_not_explicit_api_ $fake->assertCaptured('demo_explicit', fn ($event) => $event['distinctId'] === 'demo-explicit'); } - public function test_identity_form_records_identify_alias_and_group_messages(): void + public function testIdentityFormRecordsIdentifyAliasAndGroupMessages(): void { $fake = PostHog::fake(); $this->post('/demo/identity')->assertRedirect('/'); @@ -71,12 +71,21 @@ public function test_identity_form_records_identify_alias_and_group_messages(): $fake->assertGroupIdentified(fn ($message) => $message['groupKey'] === 'demo-team'); } - public function test_flag_form_uses_variant_payload_snapshot_and_unresolved_reasons(): void + public function testFlagFormUsesVariantPayloadSnapshotAndUnresolvedReasons(): void { $fake = PostHog::fake(); $snapshot = new FeatureFlagEvaluations('demo-visitor', [ 'demo-checkout' => new EvaluatedFlagRecord('demo-checkout', true, null, null, null, null, null, true), - 'demo-layout' => new EvaluatedFlagRecord('demo-layout', true, 'compact', ['color' => 'blue'], null, null, null, true), + 'demo-layout' => new EvaluatedFlagRecord( + 'demo-layout', + true, + 'compact', + ['color' => 'blue'], + null, + null, + null, + true, + ), ], [], $fake->client(), unresolvedFlags: ['needs-plan' => UnresolvedFlagReason::MISSING_CONTEXT]); $fake->flags($snapshot); $this->post('/demo/flags')->assertRedirect('/')->assertSessionHas('result', fn ($result) => @@ -88,7 +97,7 @@ public function test_flag_form_uses_variant_payload_snapshot_and_unresolved_reas && $event['flags']->getEventProperties()['$active_feature_flags'] === ['demo-checkout', 'demo-layout']); } - public function test_pennant_form_reads_boolean_and_variant_without_mutating_remote_flags(): void + public function testPennantFormReadsBooleanAndVariantWithoutMutatingRemoteFlags(): void { $fake = PostHog::fake(); $fake->flags(new FeatureFlagEvaluations('demo-visitor', [ @@ -101,7 +110,7 @@ public function test_pennant_form_reads_boolean_and_variant_without_mutating_rem $fake->assertNothingCaptured(); } - public function test_manual_and_report_endpoints_capture_handled_exceptions_without_network(): void + public function testManualAndReportEndpointsCaptureHandledExceptionsWithoutNetwork(): void { $fake = PostHog::fake(); $this->post('/demo/exception')->assertRedirect('/'); @@ -113,7 +122,7 @@ public function test_manual_and_report_endpoints_capture_handled_exceptions_with && $message['options']['handled'] === true); } - public function test_failing_endpoint_reports_unhandled_and_cleans_context(): void + public function testFailingEndpointReportsUnhandledAndCleansContext(): void { $fake = PostHog::fake(); $this->post('/demo/failure')->assertStatus(500); @@ -122,16 +131,19 @@ public function test_failing_endpoint_reports_unhandled_and_cleans_context(): vo self::assertSame([], app(Context::class)->data()); } - public function test_order_form_persists_synthetic_data_and_dispatches_allowlisted_event(): void + public function testOrderFormPersistsSyntheticDataAndDispatchesAllowlistedEvent(): void { PostHog::fake(); \Illuminate\Support\Facades\Event::fake([\App\Events\OrderCompleted::class]); $this->post('/demo/order')->assertRedirect('/'); $this->assertDatabaseHas('demo_orders', ['amount' => 4200, 'status' => 'completed']); - \Illuminate\Support\Facades\Event::assertDispatched(\App\Events\OrderCompleted::class, fn ($event) => $event->amount === 4200); + \Illuminate\Support\Facades\Event::assertDispatched( + \App\Events\OrderCompleted::class, + fn ($event) => $event->amount === 4200, + ); } - public function test_queue_form_dispatches_job_and_sync_execution_records_scoped_payload(): void + public function testQueueFormDispatchesJobAndSyncExecutionRecordsScopedPayload(): void { $fake = PostHog::fake(); $this->withHeader('X-PostHog-Distinct-Id', 'must-not-leak')->post('/demo/queue')->assertRedirect('/'); @@ -142,23 +154,31 @@ public function test_queue_form_dispatches_job_and_sync_execution_records_scoped self::assertSame([], app(Context::class)->data()); } - public function test_queue_job_object_does_not_copy_request_context(): void + public function testQueueJobObjectDoesNotCopyRequestContext(): void { PostHog::fake(); Queue::fake(); - $this->withHeader('X-PostHog-Distinct-Id', 'private-request-identity')->post('/demo/queue')->assertRedirect('/'); - Queue::assertPushed(DemoJob::class, fn (DemoJob $job) => $job->analyticsId === 'demo-job-owner' - && !str_contains(serialize($job), 'private-request-identity')); + $this->withHeader('X-PostHog-Distinct-Id', 'private-request-identity') + ->post('/demo/queue')->assertRedirect('/'); + Queue::assertPushed( + DemoJob::class, + fn (DemoJob $job) => $job->analyticsId === 'demo-job-owner' + && !str_contains(serialize($job), 'private-request-identity'), + ); } - public function test_database_queue_metadata_contains_only_bounded_analytics_context(): void + public function testDatabaseQueueMetadataContainsOnlyBoundedAnalyticsContext(): void { PostHog::fake(); config(['queue.default' => 'database']); $this->withHeaders([ 'X-PostHog-Distinct-Id' => 'queued-visitor', 'X-PostHog-Session-Id' => 'queued-session', ])->post('/demo/queue')->assertRedirect('/'); - $payload = json_decode(\Illuminate\Support\Facades\DB::table('jobs')->first()->payload, true, flags: JSON_THROW_ON_ERROR); + $payload = json_decode( + \Illuminate\Support\Facades\DB::table('jobs')->first()->payload, + true, + flags: JSON_THROW_ON_ERROR, + ); self::assertSame('queued-visitor', $payload['_posthog']['distinctId']); self::assertSame('queued-session', $payload['_posthog']['sessionId']); self::assertSame(['team' => 'demo-team'], $payload['_posthog']['groups']); @@ -166,7 +186,7 @@ public function test_database_queue_metadata_contains_only_bounded_analytics_con self::assertSame(['$session_id' => 'queued-session'], $payload['_posthog']['properties']); } - public function test_application_transactions_defer_jobs_until_commit_and_drop_rolled_back_jobs(): void + public function testApplicationTransactionsDeferJobsUntilCommitAndDropRolledBackJobs(): void { PostHog::fake(); config(['queue.default' => 'database']); @@ -186,26 +206,33 @@ public function test_application_transactions_defer_jobs_until_commit_and_drop_r $this->assertDatabaseCount('jobs', 1); } - public function test_queue_context_propagation_can_be_disabled(): void + public function testQueueContextPropagationCanBeDisabled(): void { PostHog::fake(); config(['queue.default' => 'database', 'posthog.queue_context' => false]); $this->withHeader('X-PostHog-Distinct-Id', 'must-not-copy')->post('/demo/queue')->assertRedirect('/'); - $payload = json_decode(\Illuminate\Support\Facades\DB::table('jobs')->first()->payload, true, flags: JSON_THROW_ON_ERROR); + $payload = json_decode( + \Illuminate\Support\Facades\DB::table('jobs')->first()->payload, + true, + flags: JSON_THROW_ON_ERROR, + ); self::assertArrayNotHasKey('_posthog', $payload); } - public function test_synthetic_login_and_logout_are_executable(): void + public function testSyntheticLoginAndLogoutAreExecutable(): void { $fake = PostHog::fake(); $this->post('/demo/login')->assertRedirect('/'); $this->assertAuthenticated(); - $fake->assertCaptured('demo_authenticated', fn ($event) => $event['distinctId'] === (string) DemoUser::first()->id); + $fake->assertCaptured( + 'demo_authenticated', + fn ($event) => $event['distinctId'] === (string) DemoUser::first()->id, + ); $this->post('/demo/logout')->assertRedirect('/'); $this->assertGuest(); } - public function test_flush_failure_is_visible_without_breaking_response(): void + public function testFlushFailureIsVisibleWithoutBreakingResponse(): void { $fake = \Mockery::mock(PostHogFake::class, [$this->app])->makePartial(); $fake->shouldReceive('flush')->andReturn(false); @@ -213,7 +240,7 @@ public function test_flush_failure_is_visible_without_breaking_response(): void $this->post('/demo/flush')->assertRedirect('/')->assertSessionHas('result.flushed', false); } - public function test_disabled_sdk_allows_demo_and_flag_routes_without_live_network(): void + public function testDisabledSdkAllowsDemoAndFlagRoutesWithoutLiveNetwork(): void { self::assertFalse(PostHog::enabled()); $this->post('/demo/capture')->assertRedirect('/')->assertSessionHas('result.explicit_accepted', false); @@ -222,7 +249,7 @@ public function test_disabled_sdk_allows_demo_and_flag_routes_without_live_netwo self::assertInstanceOf(Client::class, PostHog::client()); } - public function test_artisan_demo_and_scheduled_command_are_repeatable(): void + public function testArtisanDemoAndScheduledCommandAreRepeatable(): void { $fake = PostHog::fake(); app(\Illuminate\Contracts\Console\Kernel::class)->rerouteSymfonyCommandEvents(); @@ -234,7 +261,7 @@ public function test_artisan_demo_and_scheduled_command_are_repeatable(): void self::assertSame(2, DemoOrder::count()); } - public function test_unknown_demo_action_is_not_executable(): void + public function testUnknownDemoActionIsNotExecutable(): void { $fake = PostHog::fake(); $this->post('/demo/not-a-demo')->assertNotFound(); diff --git a/examples/laravel/tests/Feature/TransportTest.php b/examples/laravel/tests/Feature/TransportTest.php index 3bd9f785..1f66a532 100644 --- a/examples/laravel/tests/Feature/TransportTest.php +++ b/examples/laravel/tests/Feature/TransportTest.php @@ -12,7 +12,7 @@ final class TransportTest extends TestCase { - public function test_real_sdk_serializes_flag_attachment_and_exposures_using_mock_transport(): void + public function testRealSdkSerializesFlagAttachmentAndExposuresUsingMockTransport(): void { $requests = []; $http = $this->createStub(HttpClient::class); @@ -43,7 +43,7 @@ public function test_real_sdk_serializes_flag_attachment_and_exposures_using_moc PostHog::shutdown(); } - public function test_flags_and_transport_failure_do_not_break_demo_response(): void + public function testFlagsAndTransportFailureDoNotBreakDemoResponse(): void { $paths = []; $outage = true; @@ -62,10 +62,13 @@ public function test_flags_and_transport_failure_do_not_break_demo_response(): v PostHog::shutdown(); } - public function test_before_send_callback_drops_explicit_sensitive_properties(): void + public function testBeforeSendCallbackDropsExplicitSensitiveProperties(): void { $http = $this->createMock(HttpClient::class); - $http->expects($this->once())->method('sendRequest')->willReturnCallback(function ($path, $payload): HttpResponse { + $http->expects($this->once())->method('sendRequest')->willReturnCallback(function ( + $path, + $payload, + ): HttpResponse { self::assertSame('/batch/', $path); $properties = json_decode($payload, true, flags: JSON_THROW_ON_ERROR)['batch'][0]['properties']; self::assertArrayNotHasKey('email', $properties); @@ -74,7 +77,11 @@ public function test_before_send_callback_drops_explicit_sensitive_properties(): return new HttpResponse('{}', 200); }); $this->useClient($http); - PostHog::capture('test_redaction', ['email' => 'synthetic', 'password' => 'synthetic', 'plan' => 'demo'], 'demo'); + PostHog::capture( + 'test_redaction', + ['email' => 'synthetic', 'password' => 'synthetic', 'plan' => 'demo'], + 'demo', + ); self::assertTrue(PostHog::flush()); PostHog::shutdown(); } diff --git a/phpcs.xml b/phpcs.xml index 05b9b634..e7565799 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -5,5 +5,10 @@ lib test + */examples/laravel/resources/views/*.blade.php + + + */examples/laravel/public/index.php + \ No newline at end of file From 8773c4377aace83707198b15581925138b740d31 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 11:42:44 +0100 Subject: [PATCH 18/37] fix: address framework integration review feedback --- lib/Client.php | 14 +++--- lib/Consumer/ForkCurl.php | 2 +- lib/QueueConsumer.php | 3 +- lib/RequestContext.php | 16 ++++-- test/ConsumerTransportTestCase.php | 41 ++++++++++++++++ test/FrameworkIntegrationTest.php | 79 ++++++++++++++++++++++++++++++ 6 files changed, 143 insertions(+), 12 deletions(-) diff --git a/lib/Client.php b/lib/Client.php index 88ba9a57..3c5b87af 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -337,10 +337,10 @@ public function __construct( ? $options['release_id'] : ($envReleaseId === false ? null : $envReleaseId); $this->releaseId = StringNormalizer::normalizeOptional($releaseId); - $this->options['library_name'] = StringNormalizer::normalizeOptional($options['library_name'] ?? null) - ?? PostHog::LIBRARY; - $this->options['library_version'] = StringNormalizer::normalizeOptional($options['library_version'] ?? null) - ?? PostHog::VERSION; + foreach (['library_name' => PostHog::LIBRARY, 'library_version' => PostHog::VERSION] as $key => $default) { + $value = StringNormalizer::normalizeOptional($options[$key] ?? null); + $this->options[$key] = $value !== null && !preg_match('/[\x00-\x1f\x7f]/', $value) ? $value : $default; + } $this->options['host'] = StringNormalizer::normalizeHost($options['host'] ?? null); if (!$this->enabled) { if (($this->options['consumer'] ?? null) !== 'noop') { @@ -657,9 +657,11 @@ public function captureException( return false; } + $mechanism = $options['mechanism'] ?? null; + $handled = $options['handled'] ?? null; $exceptionList = ExceptionPayloadBuilder::overridePrimaryMechanism($exceptionList, [ - 'type' => $options['mechanism'] ?? 'generic', - 'handled' => $options['handled'] ?? true, + 'type' => is_string($mechanism) && trim($mechanism) !== '' ? $mechanism : 'generic', + 'handled' => is_bool($handled) ? $handled : true, ]); $traceProperties = array_diff_key( diff --git a/lib/Consumer/ForkCurl.php b/lib/Consumer/ForkCurl.php index 02ab83fe..d3d6afee 100644 --- a/lib/Consumer/ForkCurl.php +++ b/lib/Consumer/ForkCurl.php @@ -94,7 +94,7 @@ public function flushBatch($messages) $cmd .= " '" . $url . "'"; // Send user agent in the form of {library_name}/{library_version} as per RFC 7231. - $cmd .= " -H 'User-Agent: " . $this->userAgent() . "'"; + $cmd .= " -H " . escapeshellarg("User-Agent: " . $this->userAgent()); if ($this->debug()) { try { diff --git a/lib/QueueConsumer.php b/lib/QueueConsumer.php index d7e0eba7..d7297547 100644 --- a/lib/QueueConsumer.php +++ b/lib/QueueConsumer.php @@ -299,6 +299,7 @@ protected function payload($batch) */ protected function userAgent(): string { - return PostHog::LIBRARY . "/" . PostHog::VERSION; + return ($this->options['library_name'] ?? PostHog::LIBRARY) + . '/' . ($this->options['library_version'] ?? PostHog::VERSION); } } diff --git a/lib/RequestContext.php b/lib/RequestContext.php index 62ae3aa9..399c9a07 100644 --- a/lib/RequestContext.php +++ b/lib/RequestContext.php @@ -23,6 +23,8 @@ final class RequestContext private static array $stacks = []; private static int $nextScopeId = 1; + private static ?\WeakMap $fiberKeys = null; + private static int $nextFiberKey = 1; /** * Run a callback with context that is restored automatically afterwards. @@ -52,11 +54,11 @@ public static function withContext( public static function enterContext(array $data, array $options = [], ?int $contextKey = null): ContextScope { $contextKey = self::contextKey($contextKey); - $fiber = \Fiber::getCurrent(); + $fiberKey = self::fiberKey(); $scopeId = self::push($data, (bool) ($options['fresh'] ?? false), $contextKey); - return new ContextScope(static function () use ($scopeId, $contextKey, $fiber): void { - self::pop($scopeId, $contextKey, $fiber === null ? 0 : spl_object_id($fiber)); + return new ContextScope(static function () use ($scopeId, $contextKey, $fiberKey): void { + self::pop($scopeId, $contextKey, $fiberKey); }); } @@ -209,7 +211,13 @@ private static function contextKey(?int $contextKey): int private static function fiberKey(): int { $fiber = \Fiber::getCurrent(); - return $fiber === null ? 0 : spl_object_id($fiber); + if ($fiber === null) { + return 0; + } + + // Fiber object IDs can be reused while a detached scope is still open. + self::$fiberKeys ??= new \WeakMap(); + return self::$fiberKeys[$fiber] ??= self::$nextFiberKey++; } private static function unsetStackIfEmpty(int $contextKey, int $fiberKey): void diff --git a/test/ConsumerTransportTestCase.php b/test/ConsumerTransportTestCase.php index c124cbba..2dc8c9e8 100644 --- a/test/ConsumerTransportTestCase.php +++ b/test/ConsumerTransportTestCase.php @@ -81,6 +81,47 @@ public function testAlias(bool $compressed): void self::assertSame('user-id', $event['properties']['distinct_id']); } + public static function libraryMetadataCases(): array + { + return [ + 'configured metadata' => [ + ['library_name' => 'posthog-laravel', 'library_version' => '0.1.0'], 'posthog-laravel/0.1.0', + ], + 'normalized metadata' => [ + ['library_name' => " posthog-laravel\t", 'library_version' => ' 0.1.0 '], 'posthog-laravel/0.1.0', + ], + 'empty metadata' => [ + ['library_name' => " \t", 'library_version' => null], PostHog::LIBRARY . '/' . PostHog::VERSION, + ], + 'library name only' => [['library_name' => 'posthog-laravel'], 'posthog-laravel/' . PostHog::VERSION], + 'library version only' => [['library_version' => '0.1.0'], PostHog::LIBRARY . '/0.1.0'], + 'quoted metadata' => [ + ['library_name' => "posthog'laravel", 'library_version' => '0.1.0'], "posthog'laravel/0.1.0", + ], + 'line break in name' => [ + ['library_name' => "posthog\r\nX-Injected: yes", 'library_version' => '0.1.0'], + PostHog::LIBRARY . '/0.1.0', + ], + 'null byte in version' => [ + ['library_name' => 'posthog-laravel', 'library_version' => "0.1\0.0"], + 'posthog-laravel/' . PostHog::VERSION, + ], + ]; + } + + #[DataProvider('libraryMetadataCases')] + public function testBatchUserAgentMatchesConfiguredLibrary(array $options, string $expectedUserAgent): void + { + $client = $this->client($options); + self::assertTrue($client->capture(['distinctId' => 'user', 'event' => 'configured-library'])); + self::assertTrue($client->flush()); + $requests = $this->server->requests(); + self::assertCount(1, $requests); + self::assertSame($expectedUserAgent, $requests[0]['headers']['user-agent']); + $event = json_decode($requests[0]['body'], true, 512, JSON_THROW_ON_ERROR)['batch'][0]; + self::assertSame($expectedUserAgent, $event['properties']['$lib'] . '/' . $event['properties']['$lib_version']); + } + protected function flushEvent(Client $client, bool $compressed = false): array { self::assertSame([], $this->server->requests()); diff --git a/test/FrameworkIntegrationTest.php b/test/FrameworkIntegrationTest.php index 2dfd323e..8d48b48e 100644 --- a/test/FrameworkIntegrationTest.php +++ b/test/FrameworkIntegrationTest.php @@ -3,6 +3,7 @@ namespace PostHog\Test; use Fiber; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use PostHog\Client; use RuntimeException; @@ -98,6 +99,84 @@ public function testScopeDestructionClosesItsContext(): void self::assertNull($client->getContext()); } + public function testAbandonedFiberRunsContextCleanupWithoutCyclicCollection(): void + { + $client = $this->client(); + $finished = false; + $gcEnabled = gc_enabled(); + gc_disable(); + try { + $fiber = new Fiber(function () use ($client, &$finished): void { + try { + $client->withContext(['distinctId' => 'abandoned'], static function (): void { + Fiber::suspend(); + }); + } finally { + $finished = true; + } + }); + $fiber->start(); + $reference = \WeakReference::create($fiber); + unset($fiber); + + self::assertTrue($finished, 'Dropping a suspended Fiber must run its finally blocks immediately'); + self::assertNull($reference->get()); + } finally { + gc_collect_cycles(); + if ($gcEnabled) { + gc_enable(); + } + } + } + + public function testDetachedScopeDoesNotLeakIntoAReplacementFiber(): void + { + $client = $this->client(); + $read = static function () use ($client): void { + self::assertNull($client->getContext()); + }; + $enter = static fn () => Fiber::suspend($client->enterContext(['distinctId' => 'abandoned'])); + $fiber = new Fiber($enter); + $scope = $fiber->start(); + try { + unset($fiber); + (new Fiber($read))->start(); + } finally { + $scope->close(); + } + } + + public static function exceptionMetadataCases(): array + { + return [ + 'empty mechanism' => [['mechanism' => '', 'handled' => false], 'generic', false], + 'whitespace mechanism' => [['mechanism' => " \t", 'handled' => false], 'generic', false], + 'numeric mechanism' => [['mechanism' => 123, 'handled' => false], 'generic', false], + 'array mechanism' => [['mechanism' => [], 'handled' => false], 'generic', false], + 'null mechanism' => [['mechanism' => null, 'handled' => false], 'generic', false], + 'string handled' => [['mechanism' => 'task', 'handled' => 'false'], 'task', true], + 'numeric handled' => [['mechanism' => 'task', 'handled' => 0], 'task', true], + 'array handled' => [['mechanism' => 'task', 'handled' => []], 'task', true], + 'null handled' => [['mechanism' => 'task', 'handled' => null], 'task', true], + 'valid overrides' => [['mechanism' => 'auto.framework', 'handled' => false], 'auto.framework', false], + 'nonempty zero mechanism' => [['mechanism' => '0', 'handled' => false], '0', false], + ]; + } + + #[DataProvider('exceptionMetadataCases')] + public function testExceptionMetadataOverridesAreValidatedIndependently( + array $options, + string $expectedMechanism, + bool $expectedHandled + ): void { + $client = $this->client(); + self::assertTrue($client->captureException(new RuntimeException('error'), 'user', [], $options)); + $properties = $this->events($client)[0]['properties']; + self::assertSame($expectedMechanism, $properties['$exception_list'][0]['mechanism']['type']); + self::assertSame($expectedHandled, $properties['$exception_list'][0]['mechanism']['handled']); + self::assertSame($expectedHandled, $properties['$exception_handled']); + } + public function testFrameworkExceptionOptionsPreserveChainAndStack(): void { $client = $this->client(); From 5e93cc57bac7d070f44fecbba235adf284c85b02 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 12:29:05 +0100 Subject: [PATCH 19/37] fix(laravel): discover Pennant flags for the requested scope --- packages/laravel/PENNANT.md | 13 +- .../laravel/src/Features/PostHogDriver.php | 11 +- packages/laravel/tests/PennantTest.php | 132 +++++++++++++++++- 3 files changed, 149 insertions(+), 7 deletions(-) diff --git a/packages/laravel/PENNANT.md b/packages/laravel/PENNANT.md index 57864304..29905103 100644 --- a/packages/laravel/PENNANT.md +++ b/packages/laravel/PENNANT.md @@ -46,8 +46,17 @@ $payload = \PostHog\Laravel\Facades\PostHog::evaluateFlags()->getFlagPayload('ch 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. Listing defined keys uses the current native snapshot without -recording exposure. +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 diff --git a/packages/laravel/src/Features/PostHogDriver.php b/packages/laravel/src/Features/PostHogDriver.php index fe7ede19..3d133449 100644 --- a/packages/laravel/src/Features/PostHogDriver.php +++ b/packages/laravel/src/Features/PostHogDriver.php @@ -6,11 +6,12 @@ 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; -final class PostHogDriver implements Driver +final class PostHogDriver implements Driver, DefinesFeaturesExternally { public function get(string $feature, mixed $scope): mixed { @@ -32,7 +33,13 @@ public function getAll(array $features): array public function defined(): array { - return Container::getInstance()->make(PostHog::class)->evaluateFlags()->getKeys(); + // 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 diff --git a/packages/laravel/tests/PennantTest.php b/packages/laravel/tests/PennantTest.php index 280d79a5..31dde211 100644 --- a/packages/laravel/tests/PennantTest.php +++ b/packages/laravel/tests/PennantTest.php @@ -47,6 +47,121 @@ public function testValuesUseOneSnapshotPerIdentityAndUnknownFlagsAreInactive(): $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 function testAnonymousPennantScopeDoesNotCacheAnotherRequestsIdentity(): void { $events = []; @@ -119,19 +234,30 @@ public function testRemoteFlagsCannotBeMutated(string $method, array $arguments) (new PostHogDriver())->$method(...$arguments); } - private function sdk(int $requests, array &$events): void - { + 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], - ]]), 200); + ] + $extraFlags]), 200); }); $sdk = new Client('test-key', [ 'consumer' => 'noop', From ecbc8cc73ff96ce71f7d485844b3c11e9143f177 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 12:29:35 +0100 Subject: [PATCH 20/37] fix(flags): retain snapshot context for lazy exposure events --- lib/Client.php | 11 +++- .../ContextualFeatureFlagEvaluationsHost.php | 47 +++++++++++++++ test/FeatureFlagEvaluationsTest.php | 59 +++++++++++++++++++ 3 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 lib/Internal/ContextualFeatureFlagEvaluationsHost.php diff --git a/lib/Client.php b/lib/Client.php index 3c5b87af..93f6f2ca 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -1225,13 +1225,18 @@ public function evaluateFlags( ?array $flagKeys = null ): FeatureFlagEvaluations { $distinctId = $this->resolveDistinctId($distinctId); + $host = new Internal\ContextualFeatureFlagEvaluationsHost( + $this, + $this->getContext() ?? [], + $this->contextKey(), + ); if ($distinctId === '') { $this->warnMissingDistinctId('evaluateFlags()'); return new FeatureFlagEvaluations( $distinctId, [], $groups, - $this, + $host, disableGeoip: $disableGeoip, ); } @@ -1241,7 +1246,7 @@ public function evaluateFlags( $distinctId, [], $groups, - $this, + $host, disableGeoip: $disableGeoip, ); } @@ -1407,7 +1412,7 @@ public function evaluateFlags( $distinctId, $records, $groups, - $this, + $host, $requestId, $evaluatedAt, null, diff --git a/lib/Internal/ContextualFeatureFlagEvaluationsHost.php b/lib/Internal/ContextualFeatureFlagEvaluationsHost.php new file mode 100644 index 00000000..0c5d1d8d --- /dev/null +++ b/lib/Internal/ContextualFeatureFlagEvaluationsHost.php @@ -0,0 +1,47 @@ +context, + fn () => $this->client->captureFlagCalledIfNeeded( + $distinctId, + $key, + $response, + $properties, + $groups, + $disableGeoip, + ), + ['fresh' => true], + $this->contextKey, + ); + } + + public function logWarning(string $message): void + { + $this->client->logWarning($message); + } +} diff --git a/test/FeatureFlagEvaluationsTest.php b/test/FeatureFlagEvaluationsTest.php index 4d89543a..b61b66fa 100644 --- a/test/FeatureFlagEvaluationsTest.php +++ b/test/FeatureFlagEvaluationsTest.php @@ -113,6 +113,65 @@ public function testEvaluateFlagsReturnsSnapshotAndMakesOneFlagsRequest(): void $this->assertSame(1, $this->flagsRequestCount()); } + public static function delayedExposureReads(): array + { + return [ + 'boolean read' => ['isEnabled', false], + 'value read' => ['getFlag', false], + 'filtered value read' => ['getFlag', true], + ]; + } + + #[DataProvider('delayedExposureReads')] + public function testLazyExposureRetainsOriginatingContext(string $accessor, bool $filtered): void + { + $this->makeClient(); + $snapshot = $this->client->withContext([ + 'distinctId' => 'origin', + 'sessionId' => 'origin-session', + 'properties' => ['$request_path' => '/orders/{order}', 'boundary' => 'origin'], + ], fn () => $this->client->evaluateFlags()); + if ($filtered) { + $snapshot = $snapshot->only(['simple-test']); + } + $this->client->withContext([ + 'distinctId' => 'later', + 'sessionId' => 'later-session', + 'properties' => ['$request_path' => '/later', 'boundary' => 'later'], + ], function () use ($snapshot, $accessor): void { + self::assertTrue($snapshot->$accessor('simple-test')); + self::assertTrue($snapshot->getFlag('simple-test')); + $this->client->capture(['event' => 'later-event']); + }); + $this->client->flush(); + $events = $this->batchRequests()[0]['batch']; + self::assertCount(2, $events); + self::assertSame('$feature_flag_called', $events[0]['event']); + self::assertSame('origin', $events[0]['distinct_id']); + self::assertSame('origin-session', $events[0]['properties']['$session_id']); + self::assertSame('/orders/{order}', $events[0]['properties']['$request_path']); + self::assertSame('origin', $events[0]['properties']['boundary']); + self::assertSame('later', $events[1]['distinct_id']); + self::assertSame('later-session', $events[1]['properties']['$session_id']); + self::assertSame('later', $events[1]['properties']['boundary']); + self::assertSame(1, $this->flagsRequestCount()); + self::assertNull($this->client->getContext()); + } + + public function testUnscopedSnapshotDoesNotInheritLaterContext(): void + { + $this->makeClient(); + $snapshot = $this->client->evaluateFlags('explicit-user'); + $this->client->withContext([ + 'distinctId' => 'later', 'sessionId' => 'later-session', 'properties' => ['boundary' => 'later'], + ], fn () => $snapshot->getFlag('simple-test')); + $this->client->flush(); + $event = $this->batchRequests()[0]['batch'][0]; + self::assertSame('explicit-user', $event['distinct_id']); + self::assertArrayNotHasKey('$session_id', $event['properties']); + self::assertArrayNotHasKey('boundary', $event['properties']); + } + public function testNoFeatureFlagCalledEventsUntilAccess(): void { $this->makeClient(); From d9d98972565aec431dd1105a76c399c4a3cee3b1 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 12:34:33 +0100 Subject: [PATCH 21/37] fix(laravel): bridge runtime scopes and isolate diagnostics Preserve identity fallback and normalize invokable transport error handlers. Bind flag construction to immutable Laravel data; delayed exposure regression awaits the approved core host dependency. --- packages/laravel/README.md | 29 +- packages/laravel/src/Commands/TestCommand.php | 6 +- packages/laravel/src/Context.php | 66 ++++- packages/laravel/src/PostHog.php | 67 ++++- .../tests/Fixtures/TransportErrorHandler.php | 10 + packages/laravel/tests/RuntimeContextTest.php | 271 ++++++++++++++++++ 6 files changed, 433 insertions(+), 16 deletions(-) create mode 100644 packages/laravel/tests/Fixtures/TransportErrorHandler.php create mode 100644 packages/laravel/tests/RuntimeContextTest.php diff --git a/packages/laravel/README.md b/packages/laravel/README.md index 0de79551..cff4a8cf 100644 --- a/packages/laravel/README.md +++ b/packages/laravel/README.md @@ -82,13 +82,22 @@ app(Context::class)->with([ `Context::begin()`, `data()`, `snapshot()` and `end()` support explicit integrations. `with()` restores the previous data and flag snapshots even when the callback throws. +The manager/facade also exposes `withContext($data, $callback, $options)`, +`enterContext($data, $options)` and `getContext()`. These bridge Laravel context and +raw-client context, accept `distinct_id`/`session_id` aliases, and merge properties +shallowly like the core SDK. Explicit values override active raw-client values, which +override resolved Laravel values. Groups inherit from Laravel unless explicitly +replaced. `['fresh' => true]` excludes ambient identity, request metadata, groups and +resolvers. Close an entered scope in `finally`; closing it twice is harmless. Parent +data and flag snapshots are restored after closing or throwing. Auth is resolved lazily only while handling a request; no user model is retained by the analytics context after cleanup. Queue jobs start fresh and receive only bounded identity, session and scalar group context in `_posthog` payload metadata. Arbitrary properties, models and request data are not propagated. Set `queue_context=false` to disable propagation. This metadata is analytics context, never authorization evidence. -Jobs may override it explicitly. Nested synchronous jobs and Artisan commands restore -the caller's context and flag snapshots when they finish or fail. +Jobs may override it explicitly. Nested synchronous jobs and kernel-level +`Artisan::call()` executions restore the caller's context and flag snapshots when +they finish or fail. Request context remains active through HTTP kernel termination, including `dispatchAfterResponse()` jobs and callbacks registered during application termination. @@ -101,7 +110,13 @@ automatically. Console integration decorates the existing console kernel contrac nested `Artisan::call()` executions restore their caller in `finally`, while the root command stays active through kernel termination. Thrown objects retain their own snapshots; nonzero return codes never supply another exception's identity. The -application's commands, custom kernel, and exception-reporting policy are preserved. SDK creation is lazy, +application's commands, custom kernel, and exception-reporting policy are preserved. +Direct child-command `$this->call()`/`callSilent()` execution bypasses those kernel +boundaries and does **not** automatically restore context. Wrap such calls in an +explicit `PostHog::withContext([], fn () => $this->call(...))` scope when the child +can mutate analytics context; automatic direct child-command isolation remains +unsupported. +SDK creation is lazy, per Laravel scope. Shared flag definitions live in Laravel Cache, not a global SDK. Do not retain scoped services in application singletons or prewarm them in Octane. @@ -152,7 +167,9 @@ if ($flags->isEnabled('new-checkout')) { The same complete evaluation inputs reuse one core snapshot within the current context. New identities, groups, properties, key scopes, or lifecycle scopes do not reuse another user's result. Capture and boot do not evaluate flags. Value reads -emit the core's deduplicated exposure events; payload reads are silent. An explicit +emit the core's deduplicated exposure events with the originating context, even +when read after another request starts or the original scope ends; payload reads +are silent. An explicit empty `flagKeys: []` returns an empty snapshot without evaluation. The full core signature is supported: `distinctId`, `groups`, `personProperties`, @@ -208,6 +225,10 @@ metadata; advanced SDK options remain accessible under `sdk`. - `php artisan posthog:install`: publish config without overwriting an existing file. - `php artisan posthog:test --send`: explicitly opt in to a diagnostic event with a random test ID. +The diagnostic event excludes ambient identity, session, groups and request/user +properties without changing the caller's context. Application-owned `before_send` +callbacks still run and may deliberately enrich or drop it. + No collectors are enabled by this foundation package. Optional collectors can be registered by a separate `AutocaptureServiceProvider` when installed. diff --git a/packages/laravel/src/Commands/TestCommand.php b/packages/laravel/src/Commands/TestCommand.php index 63a0a912..9131b340 100644 --- a/packages/laravel/src/Commands/TestCommand.php +++ b/packages/laravel/src/Commands/TestCommand.php @@ -17,7 +17,11 @@ public function handle(PostHog $posthog): int $this->warn('No event sent. Configure PostHog and pass --send to opt in.'); return self::FAILURE; } - $captured = $posthog->capture('posthog test event', [], 'posthog-test-' . Str::uuid()); + $captured = $posthog->withContext( + [], + fn () => $posthog->capture('posthog test event', [], 'posthog-test-' . Str::uuid()), + ['fresh' => true], + ); $flushed = $posthog->flush(); if (!$captured || !$flushed) { $this->error('The diagnostic event was not accepted or the transport did not flush.'); diff --git a/packages/laravel/src/Context.php b/packages/laravel/src/Context.php index ac851334..5d8167f0 100644 --- a/packages/laravel/src/Context.php +++ b/packages/laravel/src/Context.php @@ -5,6 +5,7 @@ use Illuminate\Contracts\Container\Container; use Illuminate\Http\Request; use Illuminate\Routing\Route; +use PostHog\ContextScope; use PostHog\FeatureFlagEvaluations; use PostHog\PostHog as CorePostHog; use Throwable; @@ -16,6 +17,9 @@ class Context private array $scopes = []; private ?Request $request = null; private bool $headerIdentity = false; + private bool $resolved = false; + private array $contextScopes = []; + private int $nextScopeId = 0; public function __construct(private Container $app) { @@ -27,6 +31,7 @@ public function begin(array $data = []): void $this->flags = []; $this->request = null; $this->headerIdentity = false; + $this->resolved = false; } public function end(): void @@ -37,6 +42,9 @@ public function end(): void public function data(): array { $data = $this->context; + if ($this->resolved) { + return $data; + } if ($this->request !== null) { $data['properties']['$request_method'] ??= $this->request->method(); $route = $this->request->route(); @@ -44,15 +52,20 @@ public function data(): array $data['properties']['$request_path'] ??= '/' . ltrim($route->uri(), '/'); } } - try { - $resolver = $this->app['config']->get('posthog.distinct_id'); - $explicitId = !$this->headerIdentity && isset($data['distinctId']); - $id = $explicitId ? $data['distinctId'] : ($resolver ? ($this->app->make($resolver))($this->app) : null); - if ($id === null && $this->request !== null) { + $resolver = $this->app['config']->get('posthog.distinct_id'); + $explicitId = !$this->headerIdentity && isset($data['distinctId']); + $id = $explicitId ? $data['distinctId'] : null; + if (!$explicitId && $resolver) { + try { + $id = ($this->app->make($resolver))($this->app); + } catch (Throwable) { + } + } + if ($id === null && $this->request !== null) { + try { $id = $this->request->user()?->getAuthIdentifier(); + } catch (Throwable) { } - } catch (Throwable) { - return $data; } if (is_scalar($id) && (string) $id !== '') { $data['distinctId'] = (string) $id; @@ -81,7 +94,9 @@ public function with(array $data, callable $callback): mixed $flags = $this->flags; $request = $this->request; $headerIdentity = $this->headerIdentity; + $resolved = $this->resolved; $this->begin(array_replace_recursive($previous, $data)); + $this->resolved = $resolved; $this->request = $request; $this->headerIdentity = !array_key_exists('distinctId', $data) && $headerIdentity; try { @@ -94,9 +109,42 @@ public function with(array $data, callable $callback): mixed $this->flags = $flags; $this->request = $request; $this->headerIdentity = $headerIdentity; + $this->resolved = $resolved; } } + /** @internal Install resolved manager context without retaining a request or re-running resolvers. */ + public function enterResolvedContext(array $data): ContextScope + { + $id = $this->nextScopeId++; + $this->contextScopes[$id] = $this->state(); + $this->begin($data); + $this->resolved = true; + return new ContextScope(function () use ($id): void { + $ids = array_keys($this->contextScopes); + $index = array_search($id, $ids, true); + if ($index === false) { + return; + } + if (isset($ids[$index + 1])) { + $this->contextScopes[$ids[$index + 1]] = $this->contextScopes[$id]; + } else { + $this->restore($this->contextScopes[$id]); + } + unset($this->contextScopes[$id]); + }); + } + + private function state(): array + { + return [$this->context, $this->flags, $this->request, $this->headerIdentity, $this->resolved]; + } + + private function restore(array $state): void + { + [$this->context, $this->flags, $this->request, $this->headerIdentity, $this->resolved] = $state; + } + public function fromRequest(Request $request): void { $this->begin($this->app['config']->get('posthog.header_context', true) @@ -108,7 +156,7 @@ public function fromRequest(Request $request): void /** @internal Isolate nested queue executions without discarding the caller's context. */ public function enter(): void { - $this->scopes[] = [$this->context, $this->flags, $this->request, $this->headerIdentity]; + $this->scopes[] = $this->state(); $this->begin(); } @@ -119,7 +167,7 @@ public function leave(): void $this->end(); return; } - [$this->context, $this->flags, $this->request, $this->headerIdentity] = array_pop($this->scopes); + $this->restore(array_pop($this->scopes)); } public function flags(string $key, callable $evaluate): FeatureFlagEvaluations diff --git a/packages/laravel/src/PostHog.php b/packages/laravel/src/PostHog.php index 9e1b538f..fc72d73c 100644 --- a/packages/laravel/src/PostHog.php +++ b/packages/laravel/src/PostHog.php @@ -2,8 +2,10 @@ namespace PostHog\Laravel; +use Closure; use Illuminate\Contracts\Container\Container; use PostHog\Client; +use PostHog\ContextScope; use PostHog\FeatureFlagEvaluations; use Throwable; @@ -50,6 +52,9 @@ public function client(): Client $options[$key] = $this->app->make($options[$key]); } } + if (isset($options['error_handler'])) { + $options['error_handler'] = Closure::fromCallable($options['error_handler']); + } if (!isset($options['flag_definition_cache_provider']) && ($config['flag_cache']['enabled'] ?? false)) { $options['flag_definition_cache_provider'] = $this->app->make(FlagDefinitionCacheProvider::class); } @@ -152,11 +157,15 @@ public function evaluateFlags( disableGeoip: $disableGeoip, ); } - return $context->flags(hash('sha256', serialize($inputs)), function () use ($inputs, $flagKeys) { + return $context->flags(hash('sha256', serialize($inputs)), function () use ($inputs, $flagKeys, $data) { if ($flagKeys !== []) { $this->loadDefinitionsIfNeeded(); } - return $this->client()->evaluateFlags(...$inputs); + return $this->client()->withContext( + $data, + fn () => $this->client()->evaluateFlags(...$inputs), + ['fresh' => true], + ); }); } @@ -198,6 +207,60 @@ public function shutdown(): bool return $this->sdk !== null ? $this->sdk->shutdown() : false; } + public function withContext(array $data, callable $fn, array $options = []): mixed + { + $scope = $this->enterContext($data, $options); + try { + return $fn(); + } catch (Throwable $exception) { + $this->app->make(ExceptionReporter::class)->rememberContext($exception); + throw $exception; + } finally { + $scope->close(); + } + } + + public function enterContext(array $data, array $options = []): ContextScope + { + $context = $this->app->make(Context::class); + $client = $this->client(); + $scopes = []; + $groups = []; + if (!($options['fresh'] ?? false)) { + $parent = $context->data(); + $groups = $parent['groups'] ?? []; + $raw = $client->getContext(); + $scopes[] = $client->enterContext($parent, ['fresh' => true]); + if ($raw !== null) { + $scopes[] = $client->enterContext($raw); + } + } + $scopes[] = $client->enterContext($data, $options); + $resolved = $client->getContext(); + if (array_key_exists('groups', $data)) { + $groups = $data['groups']; + } + if ($groups !== []) { + $resolved['groups'] = $groups; + } + $scope = $context->enterResolvedContext($resolved); + return new ContextScope(static function () use ($scope, $scopes): void { + $scope->close(); + foreach (array_reverse($scopes) as $coreScope) { + $coreScope->close(); + } + }); + } + + public function getContext(): ?array + { + return $this->client()->withContext( + $this->app->make(Context::class)->data(), + fn () => $this->client()->getContext(), + ['fresh' => true], + ); + } + public function __call(string $method, array $arguments): mixed { $data = $this->app->make(Context::class)->data(); diff --git a/packages/laravel/tests/Fixtures/TransportErrorHandler.php b/packages/laravel/tests/Fixtures/TransportErrorHandler.php new file mode 100644 index 00000000..0d96a13b --- /dev/null +++ b/packages/laravel/tests/Fixtures/TransportErrorHandler.php @@ -0,0 +1,10 @@ +set('posthog.api_key', 'project-key'); + $app['config']->set('posthog.sdk.consumer', 'noop'); + } + + public function testThrowingIdentityResolverStillUsesAuthAndGroups(): void + { + $this->app->bind('throwing-identity', fn () => fn () => throw new RuntimeException('resolver')); + $this->app['config']->set('posthog.distinct_id', 'throwing-identity'); + $this->app['config']->set('posthog.groups', GroupsResolver::class); + $context = $this->app->make(Context::class); + foreach ([[], ['HTTP_X_POSTHOG_DISTINCT_ID' => 'untrusted']] as $headers) { + $request = Request::create('/', server: $headers); + $request->setUserResolver(fn () => new GenericUser(['id' => 'authenticated'])); + $context->fromRequest($request); + $this->assertSame('authenticated', $context->data()['distinctId']); + $this->assertSame(['team' => 'resolved-team'], $context->data()['groups']); + $context->with(['distinctId' => 'explicit'], function () use ($context) { + $this->assertSame('explicit', $context->data()['distinctId']); + }); + $request->setUserResolver(fn () => throw new RuntimeException('auth')); + $this->assertSame(['team' => 'resolved-team'], $context->data()['groups']); + } + } + + public function testInvokableErrorHandlerConstructsRealLibCurlConsumer(): void + { + $this->app['config']->set('posthog.sdk.consumer', 'lib_curl'); + $this->app['config']->set('posthog.sdk.error_handler', TransportErrorHandler::class); + // Drop the event before dispatch: construction must still exercise LibCurl/HttpClient. + $this->app['config']->set('posthog.sdk.before_send', fn () => null); + $manager = $this->app->make(PostHog::class); + $this->assertInstanceOf(Client::class, $manager->client()); + $this->assertFalse($manager->capture('dropped', [], 'diagnostic')); + $this->assertTrue($manager->flush()); + $this->app['config']->set('posthog.sdk.error_handler', Closure::fromCallable(new TransportErrorHandler())); + $this->assertInstanceOf(Client::class, (new PostHog($this->app))->client()); + } + + public function testDiagnosticSendIsIsolatedAndRestoresBothParents(): void + { + $events = []; + $this->app['config']->set('posthog.sdk.consumer', 'lib_curl'); + $this->app['config']->set('posthog.sdk.before_send', function ($event) use (&$events) { + $events[] = $event; + return null; + }); + $calls = 0; + $this->app->bind('diagnostic-resolver', function () use (&$calls) { + return function () use (&$calls) { + $calls++; + return 'resolver-private'; + }; + }); + $this->app->bind('diagnostic-groups', function () use (&$calls) { + return function () use (&$calls) { + $calls++; + return ['resolver' => 'private-group']; + }; + }); + $this->app['config']->set('posthog.distinct_id', 'diagnostic-resolver'); + $this->app['config']->set('posthog.groups', 'diagnostic-groups'); + $manager = $this->app->make(PostHog::class); + $context = $this->app->make(Context::class); + $context->begin([ + 'distinctId' => 'parent', 'sessionId' => 'private-session', + 'groups' => ['team' => 'private-team'], 'properties' => ['email' => 'private@example.test'], + ]); + $before = $context->snapshot(); + $flags = $context->flags('parent', fn () => $manager->evaluateFlags(flagKeys: [])); + $raw = $manager->client()->enterContext(['distinctId' => 'raw-parent', 'properties' => ['raw' => 'private']]); + $rawBefore = $manager->client()->getContext(); + $calls = 0; + $this->assertSame(1, Artisan::call('posthog:test')); + $this->assertSame([], $events); + // The intentional before_send drop reports failure without dispatching network traffic. + $this->assertSame(1, Artisan::call('posthog:test', ['--send' => true])); + $this->assertCount(1, $events); + // Command boundaries resolve their own snapshot; the diagnostic capture itself must not. + $calls = 0; + $command = new \PostHog\Laravel\Commands\TestCommand(); + $command->setLaravel($this->app); + $command->run( + new \Symfony\Component\Console\Input\ArrayInput(['--send' => true]), + new \Symfony\Component\Console\Output\NullOutput(), + ); + $this->assertSame(0, $calls); + $this->assertCount(2, $events); + foreach ($events as $event) { + $this->assertStringStartsWith('posthog-test-', $event['distinct_id']); + foreach (['$session_id', '$groups', 'email', 'raw'] as $key) { + $this->assertArrayNotHasKey($key, $event['properties']); + } + } + $this->assertSame($rawBefore, $manager->client()->getContext()); + $this->assertSame($before, $context->snapshot()); + $this->assertSame($flags, $context->flags('parent', fn () => throw new RuntimeException('lost snapshot'))); + $raw->close(); + } + + public function testManagerScopesBridgeRawClientAndRestoreSnapshots(): void + { + $events = []; + $this->app['config']->set('posthog.sdk.before_send', function ($event) use (&$events) { + $events[] = $event; + return $event; + }); + $manager = $this->app->make(PostHog::class); + $context = $this->app->make(Context::class); + $context->begin([ + 'distinctId' => 'parent', 'sessionId' => 'parent-session', 'groups' => ['team' => 'parent-team'], + 'properties' => ['parent' => true, 'nested' => ['old' => true]], + ]); + $before = $context->snapshot(); + $flags = $context->flags('parent', fn () => $manager->evaluateFlags(flagKeys: [])); + $raw = $manager->client()->enterContext(['distinctId' => 'raw-parent', 'properties' => ['raw' => true]]); + $rawBefore = $manager->client()->getContext(); + Facade::withContext([ + 'distinct_id' => 'scope', 'session_id' => 'scope-session', + 'properties' => ['nested' => ['new' => true]], 'groups' => ['team' => 'scope-team'], + ], function () use ($manager) { + $this->assertSame('scope', Facade::getContext()['distinctId']); + $this->assertSame(Facade::getContext(), $manager->client()->getContext()); + Facade::capture('manager'); + $manager->client()->capture(['event' => 'raw']); + $this->assertSame(['new' => true], Facade::getContext()['properties']['nested']); + $this->assertTrue(Facade::getContext()['properties']['raw']); + $this->assertTrue(Facade::getContext()['properties']['parent']); + try { + Facade::withContext(['distinctId' => 'inner'], fn () => throw new RuntimeException('restore')); + } catch (RuntimeException) { + } + $this->assertSame('scope', Facade::getContext()['distinctId']); + }); + $this->assertSame(['scope', 'scope'], array_column($events, 'distinct_id')); + $this->assertSame('scope-session', $events[0]['properties']['$session_id']); + $this->assertSame(['team' => 'scope-team'], $events[0]['properties']['$groups']); + $this->assertSame($before, $context->snapshot()); + $this->assertSame($rawBefore, $manager->client()->getContext()); + $this->assertSame($flags, $context->flags('parent', fn () => throw new RuntimeException('lost snapshot'))); + $outer = Facade::enterContext(['distinctId' => 'outer']); + $inner = Facade::enterContext(['distinctId' => 'inner']); + $outer->close(); + $outer->close(); + $this->assertSame('inner', Facade::getContext()['distinctId']); + Facade::capture('entered'); + $this->assertSame('inner', $events[2]['distinct_id']); + $inner->close(); + $inner->close(); + $this->assertSame($before, $context->snapshot()); + $this->assertSame($rawBefore, $manager->client()->getContext()); + $raw->close(); + $this->assertSame('parent', Facade::getContext()['distinctId']); + $this->assertNull($manager->client()->getContext()); + } + + public function testFreshManagerScopeSuppressesRequestAndResolvers(): void + { + $manager = $this->app->make(PostHog::class); + $context = $this->app->make(Context::class); + $request = Request::create('/private', server: ['HTTP_X_POSTHOG_DISTINCT_ID' => 'header']); + $request->setUserResolver(fn () => new GenericUser(['id' => 'auth'])); + $context->fromRequest($request); + $this->app['config']->set('posthog.groups', GroupsResolver::class); + $calls = 0; + $this->app->bind('fresh-resolver', function () use (&$calls) { + return function () use (&$calls) { + $calls++; + return null; + }; + }); + $this->app['config']->set('posthog.distinct_id', 'fresh-resolver'); + $before = $context->snapshot(); + $calls = 0; + Facade::withContext([], function () use ($manager, $context, &$calls) { + $this->assertSame(['distinctId' => null, 'sessionId' => null, 'properties' => []], Facade::getContext()); + $this->assertSame(Facade::getContext(), $manager->client()->getContext()); + $this->assertArrayNotHasKey('groups', $context->data()); + $this->assertSame([], $manager->evaluateFlags()->getKeys()); + $this->assertSame(0, $calls); + }, ['fresh' => true]); + $this->assertSame($before, $context->snapshot()); + } + + public function testDelayedFlagExposuresKeepOriginAfterRequestSwitchAndEnd(): void + { + $events = []; + $http = $this->createMock(HttpClient::class); + $http->expects($this->exactly(2))->method('sendRequest')->willReturn(new HttpResponse(json_encode([ + 'flags' => ['checkout' => ['enabled' => true, 'variant' => 'test']], + ]), 200)); + $sdk = new Client('project-key', [ + 'consumer' => 'noop', + 'before_send' => function ($event) use (&$events) { + $events[] = $event; + return $event; + }, + ], $http, loadFeatureFlags: false); + $manager = new class ($this->app, $sdk) extends PostHog { + public function __construct(Container $app, private Client $testClient) + { + parent::__construct($app); + } + + public function client(): Client + { + return $this->testClient; + } + }; + $context = $this->app->make(Context::class); + foreach (['isEnabled', 'getFlag'] as $accessor) { + $request = Request::create('/origin/123', server: [ + 'HTTP_X_POSTHOG_DISTINCT_ID' => 'origin-' . $accessor, + 'HTTP_X_POSTHOG_SESSION_ID' => 'origin-session', + ]); + $request->setRouteResolver(fn () => new Route('GET', 'origin/{id}', fn () => null)); + $context->fromRequest($request); + $flags = $manager->evaluateFlags(); + $this->assertSame($flags, $manager->evaluateFlags()); + $request->headers->set('X-PostHog-Session-Id', 'mutated'); + $context->begin(['distinctId' => 'next', 'sessionId' => 'next-session']); + $raw = $sdk->enterContext(['distinctId' => 'raw-next', 'sessionId' => 'raw-next-session']); + $before = $sdk->getContext(); + if ($accessor === 'getFlag') { + $context->end(); + } + $flags->$accessor('checkout'); + $flags->only(['checkout'])->$accessor('checkout'); + $flags->$accessor('checkout'); + $this->assertSame($before, $sdk->getContext()); + $raw->close(); + } + $this->assertCount(2, $events); + foreach ($events as $event) { + $this->assertStringStartsWith('origin-', $event['distinct_id']); + $this->assertSame('origin-session', $event['properties']['$session_id']); + $this->assertSame('/origin/{id}', $event['properties']['$request_path']); + $this->assertSame('GET', $event['properties']['$request_method']); + } + } +} From d13fcf7ef91967bc3d4412f393a600d8c8859740 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 12:37:44 +0100 Subject: [PATCH 22/37] fix(laravel): honor local-only mode in the Pennant demo --- examples/laravel/README.md | 4 ++- examples/laravel/app/Support/DemoActions.php | 3 ++ .../resources/views/dashboard.blade.php | 5 +-- .../laravel/tests/Feature/TransportTest.php | 32 +++++++++++++++++++ 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/examples/laravel/README.md b/examples/laravel/README.md index 1cd973e5..8a4ce56b 100644 --- a/examples/laravel/README.md +++ b/examples/laravel/README.md @@ -124,7 +124,9 @@ local definitions, local-only evaluation is empty; it is not proof a flag is off The Pennant form uses `Feature::store('posthog')` explicitly, leaving other Pennant stores unchanged. Its values come from the same native SDK snapshots; write operations -are intentionally unsupported. See the integration's `PENNANT.md` for scope mapping +are intentionally unsupported. This form is disabled when `POSTHOG_LOCAL_ONLY=true`; +use the native flags form for local-only evaluation. Direct POSTs are guarded too. +See the integration's `PENNANT.md` for scope mapping and when to clear Pennant's cache after targeting changes within a request. Advanced PHP SDK methods remain accessible without another global SDK instance: diff --git a/examples/laravel/app/Support/DemoActions.php b/examples/laravel/app/Support/DemoActions.php index a60ba03f..13052240 100644 --- a/examples/laravel/app/Support/DemoActions.php +++ b/examples/laravel/app/Support/DemoActions.php @@ -77,6 +77,9 @@ public function flags(): array public function pennant(): array { + if (config('demo.local_only')) { + return ['skipped' => true, 'reason' => 'Use the native flags form for local-only evaluation.']; + } return $this->ambient(function (): array { $store = \Laravel\Pennant\Feature::store('posthog'); return [ diff --git a/examples/laravel/resources/views/dashboard.blade.php b/examples/laravel/resources/views/dashboard.blade.php index 6c207616..14ca1987 100644 --- a/examples/laravel/resources/views/dashboard.blade.php +++ b/examples/laravel/resources/views/dashboard.blade.php @@ -35,8 +35,9 @@

Create boolean demo-checkout and multivariate demo-layout flags in your project. One snapshot supplies enabled state, variant, decoded payload, and an accessed-only event attachment.

@csrf
-
@csrf
- Pennant uses the same SDK snapshots. The PostHog store is read-only; manage flags in PostHog. +
@csrf
+ Pennant uses the same SDK snapshots. Its demo requires remote fallback; use the native flags form in local-only mode. + The PostHog store is read-only; manage flags in PostHog.

POSTHOG_SECRET_KEY (server only) + POSTHOG_FLAG_CACHE=true enables shared local definitions. POSTHOG_LOCAL_ONLY=true disables remote fallback. Unresolved reasons are shown separately (missing context, unsupported definition, dependency, or experience continuity); an empty result with no key is expected.

diff --git a/examples/laravel/tests/Feature/TransportTest.php b/examples/laravel/tests/Feature/TransportTest.php index 1f66a532..45c2d6d0 100644 --- a/examples/laravel/tests/Feature/TransportTest.php +++ b/examples/laravel/tests/Feature/TransportTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature; use App\Support\RedactDemoProperties; +use PHPUnit\Framework\Attributes\DataProvider; use PostHog\Client; use PostHog\HttpClient; use PostHog\HttpResponse; @@ -43,6 +44,37 @@ public function testRealSdkSerializesFlagAttachmentAndExposuresUsingMockTranspor PostHog::shutdown(); } + public static function pennantModes(): array + { + return ['local only' => [true], 'remote allowed' => [false]]; + } + + #[DataProvider('pennantModes')] + public function testPennantDemoHonorsLocalOnlyMode(bool $localOnly): void + { + config(['demo.local_only' => $localOnly]); + $requests = []; + $http = $this->createStub(HttpClient::class); + $http->method('sendRequest')->willReturnCallback(function ($path) use (&$requests): HttpResponse { + $requests[] = $path; + return new HttpResponse($path === '/flags/?v=2' ? json_encode(['flags' => [ + 'demo-checkout' => ['enabled' => true, 'variant' => null], + 'demo-layout' => ['enabled' => true, 'variant' => 'compact'], + ]]) : '{}', 200); + }); + $this->useClient($http); + $response = $this->post('/demo/pennant')->assertRedirect('/'); + self::assertCount($localOnly ? 0 : 1, array_filter($requests, fn ($path) => $path === '/flags/?v=2')); + if ($localOnly) { + $response->assertSessionHas('result.skipped', true) + ->assertSessionHas('result.reason', 'Use the native flags form for local-only evaluation.'); + self::assertSame([], $requests); + } else { + $response->assertSessionHas('result.enabled', true)->assertSessionHas('result.variant', 'compact'); + } + PostHog::shutdown(); + } + public function testFlagsAndTransportFailureDoNotBreakDemoResponse(): void { $paths = []; From 26ced2f8f0f8da89468ee6e978065e68e69c2dc8 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 12:42:28 +0100 Subject: [PATCH 23/37] fix(laravel): clear Pennant cache after restoring queue context --- .../laravel/src/PostHogServiceProvider.php | 1 + .../Fixtures/PennantEndEventProvider.php | 20 +++++++++ packages/laravel/tests/PennantTest.php | 45 ++++++++++++++++++- 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 packages/laravel/tests/Fixtures/PennantEndEventProvider.php diff --git a/packages/laravel/src/PostHogServiceProvider.php b/packages/laravel/src/PostHogServiceProvider.php index c1ac4973..d169d23a 100644 --- a/packages/laravel/src/PostHogServiceProvider.php +++ b/packages/laravel/src/PostHogServiceProvider.php @@ -146,6 +146,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/Fixtures/PennantEndEventProvider.php b/packages/laravel/tests/Fixtures/PennantEndEventProvider.php new file mode 100644 index 00000000..d513ce06 --- /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 index 31dde211..a22dedfc 100644 --- a/packages/laravel/tests/PennantTest.php +++ b/packages/laravel/tests/PennantTest.php @@ -2,6 +2,7 @@ namespace PostHog\Laravel\Tests; +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Route; use Laravel\Pennant\Contracts\FeatureScopeable; use Laravel\Pennant\Feature; @@ -16,12 +17,16 @@ use PostHog\Laravel\Features\PostHogDriver; use PostHog\Laravel\PostHog; use PostHog\Laravel\PostHogServiceProvider; +use PostHog\Laravel\Tests\Fixtures\CaptureJob; +use PostHog\Laravel\Tests\Fixtures\FailingCaptureJob; +use PostHog\Laravel\Tests\Fixtures\PennantEndEventProvider; +use RuntimeException; class PennantTest extends TestCase { protected function getPackageProviders($app): array { - return [PennantServiceProvider::class, PostHogServiceProvider::class]; + return [PennantServiceProvider::class, PostHogServiceProvider::class, PennantEndEventProvider::class]; } protected function defineEnvironment($app): void @@ -162,6 +167,44 @@ public function testDisabledBulkReadsAreEmptyAndDoNotPerformIo(): void $this->assertFalse(Feature::for('explicit-b')->value('unknown')); } + public static function queueCallers(): 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('queueCallers')] + 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()); + } + } + public function testAnonymousPennantScopeDoesNotCacheAnotherRequestsIdentity(): void { $events = []; From 8526b3285e78d06b33ae1895fa72f86d353b8f8c Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 12:45:29 +0100 Subject: [PATCH 24/37] fix(laravel): preserve raw context precedence in manager getter --- packages/laravel/src/PostHog.php | 6 +-- packages/laravel/tests/RuntimeContextTest.php | 46 +++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/packages/laravel/src/PostHog.php b/packages/laravel/src/PostHog.php index fc72d73c..8cfae735 100644 --- a/packages/laravel/src/PostHog.php +++ b/packages/laravel/src/PostHog.php @@ -254,11 +254,7 @@ public function enterContext(array $data, array $options = []): ContextScope public function getContext(): ?array { - return $this->client()->withContext( - $this->app->make(Context::class)->data(), - fn () => $this->client()->getContext(), - ['fresh' => true], - ); + return $this->withContext([], fn () => $this->client()->getContext()); } public function __call(string $method, array $arguments): mixed diff --git a/packages/laravel/tests/RuntimeContextTest.php b/packages/laravel/tests/RuntimeContextTest.php index b2f4bbbf..ed9a6e88 100644 --- a/packages/laravel/tests/RuntimeContextTest.php +++ b/packages/laravel/tests/RuntimeContextTest.php @@ -184,6 +184,52 @@ public function testManagerScopesBridgeRawClientAndRestoreSnapshots(): void $this->assertNull($manager->client()->getContext()); } + public function testManagerGetContextOverlaysRawContextWithoutChangingEitherParent(): void + { + $manager = $this->app->make(PostHog::class); + $client = $manager->client(); + $context = $this->app->make(Context::class); + $context->begin([ + 'distinctId' => 'laravel', 'sessionId' => 'laravel-session', + 'properties' => ['shared' => 'laravel', 'laravelOnly' => true, 'nested' => ['laravel' => true]], + 'groups' => ['team' => 'laravel-team'], + ]); + $laravelBefore = $context->snapshot(); + $flags = $context->flags('parent', fn () => $manager->evaluateFlags(flagKeys: [])); + $raw = $client->enterContext([ + 'distinctId' => 'raw', 'sessionId' => 'raw-session', + 'properties' => ['shared' => 'raw', 'rawOnly' => true, 'nested' => ['raw' => true]], + ]); + $rawBefore = $client->getContext(); + try { + $expected = [ + 'distinctId' => 'raw', 'sessionId' => 'raw-session', + 'properties' => [ + '$session_id' => 'raw-session', 'shared' => 'raw', 'laravelOnly' => true, + 'nested' => ['raw' => true], 'rawOnly' => true, + ], + ]; + $this->assertSame($expected, $manager->getContext()); + $this->assertSame($expected, Facade::getContext()); + $this->assertSame($laravelBefore, $context->snapshot()); + $this->assertSame($rawBefore, $client->getContext()); + $this->assertSame($flags, $context->flags('parent', fn () => throw new RuntimeException('lost snapshot'))); + $this->assertSame($expected, $manager->withContext([], fn () => $client->getContext())); + } finally { + $raw->close(); + } + $this->assertSame([ + 'distinctId' => 'laravel', 'sessionId' => 'laravel-session', + 'properties' => [ + '$session_id' => 'laravel-session', 'shared' => 'laravel', 'laravelOnly' => true, + 'nested' => ['laravel' => true], + ], + ], $manager->getContext()); + $this->assertSame($laravelBefore, $context->snapshot()); + $this->assertNull($client->getContext()); + $this->assertSame($flags, $context->flags('parent', fn () => throw new RuntimeException('lost snapshot'))); + } + public function testFreshManagerScopeSuppressesRequestAndResolvers(): void { $manager = $this->app->make(PostHog::class); From 9601245725ad6110dd08972fec72c6421a4d4965 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 12:46:23 +0100 Subject: [PATCH 25/37] fix(laravel): bound deferred model events to their owning transactions --- packages/laravel/AUTOCAPTURE.md | 19 +- .../src/Autocapture/DeferredModelEvents.php | 200 ++++++++++ .../laravel/src/Autocapture/ModelEvents.php | 22 +- .../src/AutocaptureServiceProvider.php | 14 + packages/laravel/tests/ModelEventsTest.php | 346 ++++++++++++++++++ 5 files changed, 593 insertions(+), 8 deletions(-) create mode 100644 packages/laravel/src/Autocapture/DeferredModelEvents.php create mode 100644 packages/laravel/tests/ModelEventsTest.php diff --git a/packages/laravel/AUTOCAPTURE.md b/packages/laravel/AUTOCAPTURE.md index 962611e1..be3f625d 100644 --- a/packages/laravel/AUTOCAPTURE.md +++ b/packages/laravel/AUTOCAPTURE.md @@ -133,10 +133,23 @@ omitted. Do not allowlist a sensitive accessor or JSON column. Values, identity, session and groups are snapshotted at the change, then emitted **after the model connection's outer transaction commits**. Rollback (including a nested -savepoint rollback) discards the corresponding pending events. No model object is -retained by the commit callback. Outside a transaction, collection is immediate. +savepoint rollback) discards the corresponding pending events. No model or request +object is retained. Outside a transaction, collection is immediate, even when a +different connection has an open transaction. + +All connections share an internal pending budget of **1,000 events and 1 MiB of +JSON-encoded event names, properties and correlation context**. If either limit +would be exceeded, the newest event is dropped (including an oversized allowlisted +attribute); there are no additional configuration options. Events without a usable +identity consume no pending capacity. Commit and rollback release capacity; +abandoned/disconnected transactions are also discarded before the next admission. +Callback registration happens only after admission, once per transaction record, +and is also capped at 1,000 records (including stale records retained by Laravel +after a connection reset). Admitted events retain their original identity and +operation order within each connection's outer transaction. + This is not a durable outbox; process crashes or transport failures can lose events. -Each actual model lifecycle event generates one event: multiple saves are not +Each admitted model lifecycle event generates one event: multiple saves are not coalesced. Bulk query-builder updates/deletes do not produce model events. Soft deletes produce the normal deleted event; restores are not collected. diff --git a/packages/laravel/src/Autocapture/DeferredModelEvents.php b/packages/laravel/src/Autocapture/DeferredModelEvents.php new file mode 100644 index 00000000..f14c1b8e --- /dev/null +++ b/packages/laravel/src/Autocapture/DeferredModelEvents.php @@ -0,0 +1,200 @@ + + */ + private array $pending = []; + private int $bytes = 0; + /** @var WeakMap Number of pending events per registered record. */ + private WeakMap $records; + /** @var WeakMap Only connections with admitted events. */ + private WeakMap $connections; + + public function __construct(private DatabaseTransactionsManager $transactions) + { + $this->records = new WeakMap(); + $this->connections = new WeakMap(); + } + + public function defer(Connection $connection, string $event, array $properties, array $snapshot): void + { + $this->prune(); + if (count($this->pending) >= self::MAX_EVENTS) { + return; + } + $encoded = json_encode([$event, $properties, $snapshot], JSON_PRESERVE_ZERO_FRACTION); + if ($encoded === false || strlen($encoded) > self::MAX_BYTES - $this->bytes) { + return; + } + // Connection::afterCommit forwards to a shared manager without a connection name. + $record = $this->transactions->getPendingTransactions()->last( + fn ($record) => $record->connection === $connection->getName() + && $record->level === $connection->transactionLevel(), + ); + if ($record === null) { + return; + } + // Laravel 12 can retain old records after a PDO reset, even after their payloads are discarded. + if (!isset($this->records[$record]) && count($this->records) >= self::MAX_EVENTS) { + return; + } + $root = $record; + // A reset can leave an older level-1 record as parent; it is not this transaction's root. + while ($root->parent !== null && $root->parent->level < $root->level) { + $root = $root->parent; + } + $rootReference = WeakReference::create($root); + if (!isset($this->records[$record])) { + // Admission precedes registration: rejected writes cannot grow callback bookkeeping. + // Flush this root's batch even if an earlier application callback starts another transaction. + $record->addCallback(function () use ($rootReference) { + if (($root = $rootReference->get()) !== null) { + $this->flush($root); + } + }); + $this->records[$record] = 0; + } + $this->records[$record]++; + $this->connections[$connection] = [ + 'root' => $rootReference, + 'count' => ($this->connections[$connection]['count'] ?? 0) + 1, + ]; + $this->pending[] = [ + 'connection' => WeakReference::create($connection), + 'pdo' => WeakReference::create($connection->getRawPdo()), + 'transaction' => WeakReference::create($record), + 'root' => $rootReference, + 'encoded' => $encoded, + ]; + $this->bytes += strlen($encoded); + } + + public function beginning(TransactionBeginning $event): void + { + $connection = $event->connection; + if (!isset($this->connections[$connection])) { + return; + } + $root = $this->transactions->getPendingTransactions()->last( + fn ($record) => $record->connection === $connection->getName(), + ); + if ($root === null) { + return; + } + while ($root->parent !== null && $root->parent->level < $root->level) { + $root = $root->parent; + } + // A callback may start/roll back a new root while an older committed root is awaiting its callback. + $state = $this->connections[$connection]; + $state['root'] = WeakReference::create($root); + $this->connections[$connection] = $state; + } + + public function committing(TransactionCommitting $event): void + { + $this->prune(); + } + + public function rolledBack(TransactionRolledBack $event): void + { + $state = $this->connections[$event->connection] ?? null; + $this->prune($state === null ? null : $state['root']->get()); + } + + /** @return array{count: int, bytes: int} */ + public function pendingUsage(): array + { + return ['count' => count($this->pending), 'bytes' => $this->bytes]; + } + + private function flush(DatabaseTransactionRecord $root): void + { + $ready = []; + foreach ($this->pending as $key => $entry) { + if ($entry['root']->get() === $root) { + $ready[] = $entry['encoded']; + $this->remove($key); + } + } + $this->prune(); + // Release capacity before capture and preserve operation order across nested commits. + foreach ($ready as $encoded) { + [$name, $properties, $snapshot] = json_decode($encoded, true); + Collector::capture($name, $properties, $snapshot); + } + } + + private function prune(?DatabaseTransactionRecord $rolledBackRoot = null): void + { + $pending = $this->transactions->getPendingTransactions(); + $committed = $this->transactions->getCommittedTransactions(); + $managed = static fn ($record) => $pending->containsStrict($record) || $committed->containsStrict($record); + foreach ($this->pending as $key => $entry) { + $connection = $entry['connection']->get(); + $record = $entry['transaction']->get(); + $root = $entry['root']->get(); + if ( + $connection === null || $connection->getRawPdo() === null + || $entry['pdo']->get() !== $connection->getRawPdo() + || $record === null || $root === null + || ($rolledBackRoot !== null && $root === $rolledBackRoot && !$managed($record)) + || (!$managed($record) && $managed($root)) + || ($connection->transactionLevel() === 0 && $managed($record)) + ) { + $this->remove($key); + } + // Missing records with a live root can be in Laravel's commit-callback stack. + // Only their own record callback may publish them; another connection cannot discard them. + } + // Keep zero-payload registrations counted until Laravel releases their records. + foreach ($this->records as $record => $count) { + if ($count === 0 && !$managed($record)) { + unset($this->records[$record]); + } + } + } + + private function remove(int $key): void + { + $entry = $this->pending[$key]; + $this->bytes -= strlen($entry['encoded']); + $record = $entry['transaction']->get(); + if ($record !== null && isset($this->records[$record])) { + --$this->records[$record]; + } + $connection = $entry['connection']->get(); + if ($connection !== null && isset($this->connections[$connection])) { + $state = $this->connections[$connection]; + if (--$state['count'] === 0) { + unset($this->connections[$connection]); + } else { + $this->connections[$connection] = $state; + } + } + unset($this->pending[$key]); + if ($this->pending === []) { + // Reset PHP's next numeric index when a long-lived worker drains a batch. + $this->pending = []; + } + } +} diff --git a/packages/laravel/src/Autocapture/ModelEvents.php b/packages/laravel/src/Autocapture/ModelEvents.php index e924610b..ffb503ae 100644 --- a/packages/laravel/src/Autocapture/ModelEvents.php +++ b/packages/laravel/src/Autocapture/ModelEvents.php @@ -18,6 +18,11 @@ public function collect(string $operation, Model $model): void if (!array_key_exists($model::class, $models) || !is_array($models[$model::class])) { return; } + $snapshot = Collector::snapshot(); + $id = $snapshot['distinctId'] ?? null; + if (!is_scalar($id) || (string) $id === '' || !Collector::jsonSafe($snapshot)) { + return; + } $attributes = []; $stored = $model->getAttributes(); foreach ($models[$model::class] as $key) { @@ -29,11 +34,18 @@ public function collect(string $operation, Model $model): void } } $properties = ['model' => $model::class, 'attributes' => $attributes]; - $snapshot = Collector::snapshot(); - // Register with this model's connection; no model or payload survives until commit. - $model->getConnection()->afterCommit(static function () use ($operation, $properties, $snapshot) { - Collector::capture('laravel_model_' . $operation, $properties, $snapshot); - }); + $connection = $model->getConnection(); + $event = 'laravel_model_' . $operation; + if ($connection->transactionLevel() === 0) { + Collector::capture($event, $properties, $snapshot); + return; + } + Container::getInstance()->make(DeferredModelEvents::class)->defer( + $connection, + $event, + $properties, + $snapshot, + ); } catch (Throwable) { } } diff --git a/packages/laravel/src/AutocaptureServiceProvider.php b/packages/laravel/src/AutocaptureServiceProvider.php index a2eecdb1..f72621f7 100644 --- a/packages/laravel/src/AutocaptureServiceProvider.php +++ b/packages/laravel/src/AutocaptureServiceProvider.php @@ -8,6 +8,7 @@ use Illuminate\Support\ServiceProvider; use PostHog\Laravel\Autocapture\AuthEvents; use PostHog\Laravel\Autocapture\Collector; +use PostHog\Laravel\Autocapture\DeferredModelEvents; use PostHog\Laravel\Autocapture\DomainEvents; use PostHog\Laravel\Autocapture\ModelEvents; use PostHog\Laravel\Autocapture\Outcomes; @@ -26,6 +27,10 @@ public function register(): void ? array_replace($settings[$section] ?? [], $values) : $values; } $config->set('posthog.autocapture', $settings); + $this->app->singleton( + DeferredModelEvents::class, + fn ($app) => new DeferredModelEvents($app['db.transactions']), + ); } public function boot(): void @@ -40,6 +45,15 @@ public function boot(): void (new AuthEvents())->collect(strtolower($name), $event); }); } + foreach ( + [ + 'TransactionBeginning' => 'beginning', + 'TransactionCommitting' => 'committing', + 'TransactionRolledBack' => 'rolledBack', + ] as $event => $method + ) { + $events->listen('Illuminate\\Database\\Events\\' . $event, [DeferredModelEvents::class, $method]); + } foreach (['created', 'updated', 'deleted'] as $operation) { $events->listen('eloquent.' . $operation . ': *', static function ($name, $payload) use ($operation) { (new ModelEvents())->collect($operation, $payload[0]); diff --git a/packages/laravel/tests/ModelEventsTest.php b/packages/laravel/tests/ModelEventsTest.php new file mode 100644 index 00000000..7a306ed9 --- /dev/null +++ b/packages/laravel/tests/ModelEventsTest.php @@ -0,0 +1,346 @@ +set('posthog.api_key', 'project-key'); + $app['config']->set('posthog.sdk.consumer', 'noop'); + $app['config']->set('posthog.autocapture.models', [ + 'enabled' => true, 'allow' => [CollectedRecord::class => ['state']], + ]); + $app['config']->set('database.default', 'a'); + foreach (['a', 'b'] as $name) { + $app['config']->set('database.connections.' . $name, ['driver' => 'sqlite', 'database' => ':memory:']); + } + } + + protected function setUp(): void + { + parent::setUp(); + PostHog::fake(); + foreach (['a', 'b'] as $name) { + DB::connection($name)->getSchemaBuilder()->create('collected_records', function (Blueprint $table) { + $table->id(); + $table->text('state'); + $table->text('email'); + }); + } + $this->identity('original'); + } + + private function identity(string $id): void + { + $this->app->make(Context::class)->begin(['distinctId' => $id, 'groups' => ['team' => $id]]); + } + + private function record(string $state, string $connection = 'a'): CollectedRecord + { + return CollectedRecord::on($connection)->create(['state' => $state, 'email' => 'private']); + } + + private function messages(): array + { + return array_column(PostHog::getFacadeRoot()->recorded('capture'), 'message'); + } + + private function states(): array + { + return array_map(fn ($event) => $event['properties']['attributes']['state'], $this->messages()); + } + + private function buffer(): DeferredModelEvents + { + return $this->app->make(DeferredModelEvents::class); + } + + private function assertUsage(int $count, int $bytes): void + { + $this->assertSame(['count' => $count, 'bytes' => $bytes], $this->buffer()->pendingUsage()); + } + + private function encodedSize(string $state): int + { + return strlen(json_encode([ + 'laravel_model_created', + ['model' => CollectedRecord::class, 'attributes' => ['state' => $state]], + ['distinctId' => 'original', 'groups' => ['team' => 'original']], + ])); + } + + private function assertTransactionCallbackCount(int $expected): void + { + $transactions = $this->app['db.transactions']; + $records = $transactions->getPendingTransactions()->merge($transactions->getCommittedTransactions()); + $this->assertSame($expected, $records->sum(fn ($record) => count($record->getCallbacks()))); + } + + public function testOtherConnectionCommitCannotPublishRolledBackEvents(): void + { + DB::connection('a')->beginTransaction(); + DB::connection('b')->beginTransaction(); + $this->record('rolled back'); + DB::connection('b')->commit(); + PostHog::assertNothingCaptured(); + DB::connection('a')->rollBack(); + PostHog::assertNothingCaptured(); + $this->assertUsage(0, 0); + } + + public function testOwningConnectionCommitAndNontransactionalWritesDoNotWaitForOtherConnection(): void + { + DB::connection('a')->beginTransaction(); + DB::connection('b')->beginTransaction(); + $this->record('committed'); + $this->identity('later'); + DB::connection('a')->commit(); + $this->assertSame(['committed'], $this->states()); + $this->assertSame('original', $this->messages()[0]['distinctId']); + $this->record('immediate'); + $this->assertSame(['committed', 'immediate'], $this->states()); + DB::connection('b')->rollBack(); + $this->assertUsage(0, 0); + } + + public function testAfterCommitCallbackCanCommitOtherConnectionWithoutLosingOriginalBatch(): void + { + DB::connection('a')->beginTransaction(); + DB::connection('a')->afterCommit(fn () => DB::connection('b')->commit()); + DB::connection('b')->beginTransaction(); + $this->record('a first'); + $this->record('b', 'b'); + $this->record('a second'); + $this->identity('later'); + DB::connection('a')->commit(); + $this->assertSame(['b', 'a first', 'a second'], $this->states()); + $this->assertSame(['original', 'original', 'original'], array_column($this->messages(), 'distinctId')); + $this->assertUsage(0, 0); + } + + public function testAfterCommitCallbackCanStartAnotherTransactionWithoutPublishingItsEvents(): void + { + DB::beginTransaction(); + DB::afterCommit(function () { + DB::beginTransaction(); + $this->record('next transaction'); + }); + $this->record('original transaction'); + $this->identity('later'); + DB::commit(); + $this->assertSame(['original transaction'], $this->states()); + PostHog::assertCapturedCount(1); + DB::commit(); + $this->assertSame(['original transaction', 'next transaction'], $this->states()); + $this->assertSame(['original', 'later'], array_column($this->messages(), 'distinctId')); + $this->assertUsage(0, 0); + } + + public function testAfterCommitCallbackRollbackCannotDiscardTheCommittedRoot(): void + { + DB::beginTransaction(); + DB::afterCommit(function () { + DB::beginTransaction(); + $this->record('rolled back callback transaction'); + DB::rollBack(); + }); + $this->record('committed original'); + DB::commit(); + $this->assertSame(['committed original'], $this->states()); + $this->assertUsage(0, 0); + } + + public function testRollbackReleasesBudgetEvenWhenApplicationRetainsTransactionRecords(): void + { + DB::beginTransaction(); + $this->record('rolled back'); + $records = $this->app['db.transactions']->getPendingTransactions()->all(); + DB::rollBack(); + $this->assertUsage(0, 0); + $this->assertCount(1, $records); + PostHog::assertNothingCaptured(); + } + + public function testNestedCommitsPreserveEventOrderAndRollbackReleasesSavepointBudget(): void + { + DB::beginTransaction(); + $this->record('first'); + DB::beginTransaction(); + $this->record('discarded'); + DB::beginTransaction(); + $this->record('discarded committed child'); + DB::commit(); + DB::rollBack(); + $this->assertUsage(1, $this->encodedSize('first')); + DB::beginTransaction(); + $this->record('second'); + DB::commit(); + $this->record('third'); + PostHog::assertNothingCaptured(); + $this->identity('later'); + DB::commit(); + $this->assertSame(['first', 'second', 'third'], $this->states()); + $this->assertSame(['original', 'original', 'original'], array_column($this->messages(), 'distinctId')); + $this->assertUsage(0, 0); + DB::beginTransaction(); + DB::beginTransaction(); + $this->record('committed child of rolled back parent'); + DB::commit(); + DB::rollBack(); + $this->assertSame(['first', 'second', 'third'], $this->states()); + $this->assertUsage(0, 0); + } + + public function testCountAdmissionIsAggregateDropNewestAndBoundsCallbackBookkeeping(): void + { + DB::connection('a')->beginTransaction(); + DB::connection('b')->beginTransaction(); + for ($i = 0; $i < 1100; $i++) { + $this->record(sprintf('%04d', $i), $i % 2 === 0 ? 'a' : 'b'); + } + $this->assertTransactionCallbackCount(2); + $this->assertUsage(1000, 1000 * $this->encodedSize('0000')); + PostHog::assertNothingCaptured(); + DB::connection('a')->rollBack(); + $this->assertUsage(500, 500 * $this->encodedSize('0000')); + $this->record('new after release', 'b'); + DB::connection('b')->commit(); + PostHog::assertCapturedCount(501); + $expected = array_map(fn ($i) => sprintf('%04d', $i), range(1, 999, 2)); + $this->assertSame([...$expected, 'new after release'], $this->states()); + $this->assertUsage(0, 0); + } + + public function testDistinctTransactionRecordsCannotGrowCallbacksBeyondAdmissionBudget(): void + { + DB::beginTransaction(); + for ($i = 0; $i < 1100; $i++) { + DB::beginTransaction(); + $this->record('same'); + DB::commit(); + } + $this->assertUsage(1000, 1000 * $this->encodedSize('same')); + $this->assertTransactionCallbackCount(1000); + PostHog::assertNothingCaptured(); + DB::commit(); + PostHog::assertCapturedCount(1000); + $this->assertUsage(0, 0); + } + + public function testConnectionResetsCannotAccumulateUnboundedOrphanedCallbacks(): void + { + $connection = DB::connection('a'); + for ($i = 0; $i < 1100; $i++) { + if ($i !== 0) { + // Laravel 12 disconnect resets PDO/depth without clearing manager records. + $connection->setPdo(new \PDO('sqlite::memory:')); + $connection->getSchemaBuilder()->create('collected_records', function (Blueprint $table) { + $table->id(); + $table->text('state'); + $table->text('email'); + }); + } + $connection->beginTransaction(); + $this->record('abandoned'); + } + $this->assertUsage(0, 0); + $this->assertTransactionCallbackCount(1000); + $connection->commit(); + PostHog::assertNothingCaptured(); + DB::transaction(fn () => $this->record('fresh')); + $this->assertSame(['fresh'], $this->states()); + $this->assertUsage(0, 0); + } + + public function testByteAdmissionIncludesContextAndDropsOversizedApprovedAttributes(): void + { + DB::beginTransaction(); + $this->record(str_repeat('x', 1048576)); + $this->assertTransactionCallbackCount(0); + $this->assertUsage(0, 0); + $state = str_repeat('x', 1048576 - $this->encodedSize('')); + $this->record($state); + $this->assertUsage(1, 1048576); + $this->record('drop newest'); + $this->assertUsage(1, 1048576); + PostHog::assertNothingCaptured(); + DB::commit(); + $this->assertSame([$state], $this->states()); + $this->assertUsage(0, 0); + DB::beginTransaction(); + $this->record('fits after commit'); + DB::commit(); + $this->assertSame([$state, 'fits after commit'], $this->states()); + } + + public function testRetryAndRepeatedSavepointRollbackReleaseCapacityWithoutCallbackAccumulation(): void + { + $attempt = 0; + DB::transaction(function () use (&$attempt) { + $this->record('attempt ' . ++$attempt); + if ($attempt === 1) { + throw new RuntimeException('database is locked'); + } + }, 2); + $this->assertSame(['attempt 2'], $this->states()); + $this->assertUsage(0, 0); + DB::beginTransaction(); + for ($i = 0; $i < 1100; $i++) { + DB::beginTransaction(); + $this->record('discarded'); + DB::rollBack(); + } + $this->assertUsage(0, 0); + $this->assertTransactionCallbackCount(0); + $this->record('final'); + DB::commit(); + $this->assertSame(['attempt 2', 'final'], $this->states()); + } + + public function testNoIdentityConsumesNoPendingCapacityAndModelIsNotRetained(): void + { + $this->app->make(Context::class)->begin([]); + DB::beginTransaction(); + $this->record('no identity'); + $this->assertUsage(0, 0); + $this->assertTransactionCallbackCount(0); + $this->identity('original'); + $model = $this->record('admitted'); + $reference = \WeakReference::create($model); + unset($model); + gc_collect_cycles(); + $this->assertNull($reference->get()); + DB::rollBack(); + $this->assertUsage(0, 0); + PostHog::assertNothingCaptured(); + } + + public function testDisconnectReleasesAbandonedTransactionBudgetBeforeNextAdmission(): void + { + DB::beginTransaction(); + $this->record('abandoned'); + DB::disconnect('a'); + DB::connection('b')->beginTransaction(); + $this->record('fresh', 'b'); + $this->assertUsage(1, $this->encodedSize('fresh')); + DB::connection('b')->commit(); + $this->assertUsage(0, 0); + $this->assertSame(['fresh'], $this->states()); + } +} From 893c8c56b86ef71a8d1e2de2c41304e9feb28277 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 12:55:46 +0100 Subject: [PATCH 26/37] fix(laravel): omit outcomes for overlap-skipped schedules --- packages/laravel/src/Autocapture/Outcomes.php | 38 +++++++++++++ .../src/AutocaptureServiceProvider.php | 9 +-- packages/laravel/tests/AutocaptureTest.php | 2 + .../laravel/tests/SchedulerOutcomeTest.php | 56 +++++++++++++++++++ 4 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 packages/laravel/tests/SchedulerOutcomeTest.php diff --git a/packages/laravel/src/Autocapture/Outcomes.php b/packages/laravel/src/Autocapture/Outcomes.php index 7bd36f0b..7e0ea60d 100644 --- a/packages/laravel/src/Autocapture/Outcomes.php +++ b/packages/laravel/src/Autocapture/Outcomes.php @@ -2,19 +2,25 @@ namespace PostHog\Laravel\Autocapture; +use Illuminate\Console\Scheduling\Event; use Illuminate\Container\Container; use Illuminate\Support\Str; use Throwable; use WeakMap; +use WeakReference; /** @internal Weak keys prevent retaining jobs, command inputs or scheduled tasks in workers. */ class Outcomes { private WeakMap $starts; + private WeakMap $scheduled; + private WeakMap $executed; public function __construct() { $this->starts = new WeakMap(); + $this->scheduled = new WeakMap(); + $this->executed = new WeakMap(); } public function start(string $section, object $key): void @@ -24,6 +30,38 @@ public function start(string $section, object $key): void } } + public function startScheduler(Event $task): void + { + if (!Collector::enabled('scheduler')) { + return; + } + $this->start('scheduler', $task); + $this->executed[$task] = false; + if (!isset($this->scheduled[$task])) { + $this->scheduled[$task] = true; + // The task owns this callback, so it must not capture the task strongly. + $reference = WeakReference::create($task); + $executed = $this->executed; + $task->before(static function () use ($reference, $executed): void { + if (($task = $reference->get()) !== null) { + $executed[$task] = true; + } + }); + } + } + + public function finishScheduler(Event $task, bool $failed): void + { + $executed = $this->executed[$task] ?? false; + unset($this->executed[$task]); + $this->finish('scheduler', $task, static fn () => $failed || $executed ? [ + 'name' => $task::class, + 'outcome' => $failed ? 'failed' : ($task->runInBackground ? 'background_started' + : ($task->exitCode === 0 ? 'success' : 'failed')), + 'exit_code' => $task->exitCode, + ] : []); + } + public function finish(string $section, object $key, callable $metadata): void { if (!isset($this->starts[$key])) { diff --git a/packages/laravel/src/AutocaptureServiceProvider.php b/packages/laravel/src/AutocaptureServiceProvider.php index f72621f7..a17392f5 100644 --- a/packages/laravel/src/AutocaptureServiceProvider.php +++ b/packages/laravel/src/AutocaptureServiceProvider.php @@ -115,19 +115,14 @@ private function registerOutcomes(Dispatcher $events): void $events->listen( \Illuminate\Console\Events\ScheduledTaskStarting::class, static function ($event) use ($outcomes) { - $outcomes->start('scheduler', $event->task); + $outcomes->startScheduler($event->task); }, ); foreach (['ScheduledTaskFinished' => false, 'ScheduledTaskFailed' => true] as $event => $failed) { $events->listen( 'Illuminate\\Console\\Events\\' . $event, static function ($event) use ($outcomes, $failed) { - $outcomes->finish('scheduler', $event->task, static fn () => [ - 'name' => $event->task::class, - 'outcome' => $failed ? 'failed' : ($event->task->runInBackground ? 'background_started' - : ($event->task->exitCode === 0 ? 'success' : 'failed')), - 'exit_code' => $event->task->exitCode, - ]); + $outcomes->finishScheduler($event->task, $failed); }, ); } diff --git a/packages/laravel/tests/AutocaptureTest.php b/packages/laravel/tests/AutocaptureTest.php index 856f6a27..52d87143 100644 --- a/packages/laravel/tests/AutocaptureTest.php +++ b/packages/laravel/tests/AutocaptureTest.php @@ -384,11 +384,13 @@ public function testCommandAndSchedulerMetadataExcludeArgumentsAndDeduplicateFai $task = new Event($this->createStub(EventMutex::class), 'php artisan import --secret=password'); $task->exitCode = 1; $events->dispatch(new ScheduledTaskStarting($task)); + $task->callBeforeCallbacks($this->app); $events->dispatch(new ScheduledTaskFinished($task, 0.1)); $events->dispatch(new ScheduledTaskFailed($task, new RuntimeException('secret'), 0.1)); $task->runInBackground = true; $task->exitCode = null; $events->dispatch(new ScheduledTaskStarting($task)); + $task->callBeforeCallbacks($this->app); $events->dispatch(new ScheduledTaskFinished($task, 0.1)); $fake->assertCapturedCount(3); $this->assertSame('import', $this->events()[0]['properties']['name']); diff --git a/packages/laravel/tests/SchedulerOutcomeTest.php b/packages/laravel/tests/SchedulerOutcomeTest.php new file mode 100644 index 00000000..7da3f703 --- /dev/null +++ b/packages/laravel/tests/SchedulerOutcomeTest.php @@ -0,0 +1,56 @@ +set('posthog.api_key', 'test-key'); + $app['config']->set('posthog.autocapture.scheduler.enabled', true); + } + + public static function taskModes(): array + { + return ['foreground' => [false], 'background' => [true]]; + } + + #[DataProvider('taskModes')] + public function testOverlapLockRacesDoNotProduceAnOutcome(bool $background): void + { + $fake = PostHog::fake(); + $this->app->make(Context::class)->begin(['distinctId' => 'scheduler-actor']); + $mutex = $this->createMock(EventMutex::class); + $mutex->method('exists')->willReturn(false); + $mutex->expects($this->exactly(2))->method('create')->willReturnOnConsecutiveCalls(false, true); + $this->app->instance(EventMutex::class, $mutex); + $schedule = $this->app->make(Schedule::class); + $task = $schedule->exec(PHP_BINARY, ['-r', 'exit(0);'])->withoutOverlapping(); + $task->runInBackground = $background; + + $this->assertSame(0, Artisan::call('schedule:run')); + $fake->assertNotCaptured('laravel_scheduled_task'); + + $task->runInBackground = false; + $this->assertSame(0, Artisan::call('schedule:run')); + $fake->assertCapturedCount(1); + $fake->assertCaptured('laravel_scheduled_task', fn ($event) => + $event['distinctId'] === 'scheduler-actor' + && $event['properties']['outcome'] === 'success' + && $event['properties']['exit_code'] === 0); + } +} From a33384afc1a1babfb8c17007f48ca8a56c0db05c Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 13:05:06 +0100 Subject: [PATCH 27/37] fix: make library metadata and release id internal integration overrides Remove library_name, library_version and release_id from the public client options. Framework integrations override internal Client methods instead, so customers keep POSTHOG_RELEASE_ID as the only documented release id input. --- .changeset/young-onions-hide.md | 2 +- lib/Client.php | 48 +++++++++++++++++------ lib/PostHog.php | 3 -- test/ConsumerTransportTestCase.php | 51 ++++++++++-------------- test/FrameworkIntegrationTest.php | 62 ++++++++++++++++++++++-------- test/IntegrationClient.php | 37 ++++++++++++++++++ 6 files changed, 142 insertions(+), 61 deletions(-) create mode 100644 test/IntegrationClient.php diff --git a/.changeset/young-onions-hide.md b/.changeset/young-onions-hide.md index 9a218d4e..dd1352a2 100644 --- a/.changeset/young-onions-hide.md +++ b/.changeset/young-onions-hide.md @@ -2,4 +2,4 @@ "posthog-php": minor --- -Add instance group identify, lifecycle context scopes, framework exception metadata, and explicit library and release configuration. +Add instance group identify, lifecycle context scopes, and framework exception metadata. diff --git a/lib/Client.php b/lib/Client.php index 93f6f2ca..a185de8a 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -273,9 +273,6 @@ class Client implements FeatureFlagEvaluationsHost * * @param array{ * host?: string, - * library_name?: string, - * library_version?: string, - * release_id?: string|null, * ssl?: bool, * timeout?: int|float, * verify_batch_events_request?: bool, @@ -332,15 +329,10 @@ public function __construct( ); $this->flagDefinitionCacheProviderShutdown = false; $this->shutdownComplete = false; - $envReleaseId = getenv(self::ENV_RELEASE_ID); - $releaseId = array_key_exists('release_id', $options) - ? $options['release_id'] - : ($envReleaseId === false ? null : $envReleaseId); - $this->releaseId = StringNormalizer::normalizeOptional($releaseId); - foreach (['library_name' => PostHog::LIBRARY, 'library_version' => PostHog::VERSION] as $key => $default) { - $value = StringNormalizer::normalizeOptional($options[$key] ?? null); - $this->options[$key] = $value !== null && !preg_match('/[\x00-\x1f\x7f]/', $value) ? $value : $default; - } + $this->releaseId = StringNormalizer::normalizeOptional($this->releaseId()); + // Consumers read these keys for the User-Agent; always overwrite caller-supplied values. + $this->options['library_name'] = $this->libraryName(); + $this->options['library_version'] = $this->libraryVersion(); $this->options['host'] = StringNormalizer::normalizeHost($options['host'] ?? null); if (!$this->enabled) { if (($this->options['consumer'] ?? null) !== 'noop') { @@ -407,6 +399,38 @@ private static function normalizeFlagDefinitionCacheProvider(mixed $provider): ? return $provider; } + /** + * Library name reported as $lib and in the User-Agent. Read once during construction. + * + * @internal Overridden by PostHog framework integrations; not part of the customer API. + */ + protected function libraryName(): string + { + return PostHog::LIBRARY; + } + + /** + * Library version reported as $lib_version and in the User-Agent. Read once during construction. + * + * @internal Overridden by PostHog framework integrations; not part of the customer API. + */ + protected function libraryVersion(): string + { + return PostHog::VERSION; + } + + /** + * Release id reported as $release_id. Read once during construction. + * + * @internal Overridden by PostHog framework integrations whose configuration is cached + * and cannot rely on the process environment; not part of the customer API. + */ + protected function releaseId(): ?string + { + $releaseId = getenv(self::ENV_RELEASE_ID); + return $releaseId === false ? null : $releaseId; + } + /** * Flush and clean up the underlying consumer when the client is destroyed. */ diff --git a/lib/PostHog.php b/lib/PostHog.php index 843706fc..d852e397 100644 --- a/lib/PostHog.php +++ b/lib/PostHog.php @@ -41,9 +41,6 @@ class PostHog * * @param array{ * host?: string, - * library_name?: string, - * library_version?: string, - * release_id?: string|null, * ssl?: bool, * timeout?: int|float, * verify_batch_events_request?: bool, diff --git a/test/ConsumerTransportTestCase.php b/test/ConsumerTransportTestCase.php index 2dc8c9e8..4197421d 100644 --- a/test/ConsumerTransportTestCase.php +++ b/test/ConsumerTransportTestCase.php @@ -81,40 +81,31 @@ public function testAlias(bool $compressed): void self::assertSame('user-id', $event['properties']['distinct_id']); } - public static function libraryMetadataCases(): array + public function testConstructorOptionsCannotOverrideBatchUserAgent(): void { - return [ - 'configured metadata' => [ - ['library_name' => 'posthog-laravel', 'library_version' => '0.1.0'], 'posthog-laravel/0.1.0', - ], - 'normalized metadata' => [ - ['library_name' => " posthog-laravel\t", 'library_version' => ' 0.1.0 '], 'posthog-laravel/0.1.0', - ], - 'empty metadata' => [ - ['library_name' => " \t", 'library_version' => null], PostHog::LIBRARY . '/' . PostHog::VERSION, - ], - 'library name only' => [['library_name' => 'posthog-laravel'], 'posthog-laravel/' . PostHog::VERSION], - 'library version only' => [['library_version' => '0.1.0'], PostHog::LIBRARY . '/0.1.0'], - 'quoted metadata' => [ - ['library_name' => "posthog'laravel", 'library_version' => '0.1.0'], "posthog'laravel/0.1.0", - ], - 'line break in name' => [ - ['library_name' => "posthog\r\nX-Injected: yes", 'library_version' => '0.1.0'], - PostHog::LIBRARY . '/0.1.0', - ], - 'null byte in version' => [ - ['library_name' => 'posthog-laravel', 'library_version' => "0.1\0.0"], - 'posthog-laravel/' . PostHog::VERSION, - ], - ]; + $client = $this->client(['library_name' => 'custom', 'library_version' => '9.9.9']); + self::assertTrue($client->capture(['distinctId' => 'user', 'event' => 'ignored-options'])); + $this->flushEvent($client); } - #[DataProvider('libraryMetadataCases')] - public function testBatchUserAgentMatchesConfiguredLibrary(array $options, string $expectedUserAgent): void + public function testBatchUserAgentMatchesIntegrationLibrary(): void { - $client = $this->client($options); - self::assertTrue($client->capture(['distinctId' => 'user', 'event' => 'configured-library'])); - self::assertTrue($client->flush()); + // The quote covers shell escaping in the fork_curl consumer. + $expectedUserAgent = "posthog'laravel/0.1.0"; + IntegrationClient::$name = "posthog'laravel"; + try { + $client = new IntegrationClient('test-key', [ + 'consumer' => $this->consumer(), + 'host' => $this->server->address(), + 'ssl' => false, + 'debug' => true, + 'maximum_backoff_duration' => 100, + ], null, null, false); + self::assertTrue($client->capture(['distinctId' => 'user', 'event' => 'integration-library'])); + self::assertTrue($client->flush()); + } finally { + IntegrationClient::reset(); + } $requests = $this->server->requests(); self::assertCount(1, $requests); self::assertSame($expectedUserAgent, $requests[0]['headers']['user-agent']); diff --git a/test/FrameworkIntegrationTest.php b/test/FrameworkIntegrationTest.php index 8d48b48e..0813818a 100644 --- a/test/FrameworkIntegrationTest.php +++ b/test/FrameworkIntegrationTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use PostHog\Client; +use PostHog\PostHog; use RuntimeException; class FrameworkIntegrationTest extends TestCase @@ -205,11 +206,21 @@ public function testManualExceptionsStayHandledByDefault(): void self::assertSame('generic', $event['properties']['$exception_list'][0]['mechanism']['type']); } - public function testConfiguredLibraryAndReleaseApplyAcrossEventTypes(): void + private function integrationClient(?string $release = null): Client { - $client = $this->client([ - 'library_name' => 'posthog-laravel', 'library_version' => '0.1.0', 'release_id' => 'release', - ]); + $this->http = new MockedHttpClient('localhost'); + IntegrationClient::$release = $release; + return new IntegrationClient('test-key', [], $this->http, loadFeatureFlags: false); + } + + protected function tearDown(): void + { + IntegrationClient::reset(); + } + + public function testIntegrationLibraryAndReleaseApplyAcrossEventTypes(): void + { + $client = $this->integrationClient('release'); $client->capture(['event' => 'purchase']); $client->identify(['distinctId' => 'user', 'properties' => ['plan' => 'pro']]); $client->alias(['distinctId' => 'user', 'alias' => 'old-user']); @@ -224,34 +235,55 @@ public function testConfiguredLibraryAndReleaseApplyAcrossEventTypes(): void } } - public function testConfiguredLibraryIsUsedForFlagHttpRequests(): void + public function testIntegrationLibraryIsUsedForFlagHttpRequests(): void { - $client = $this->client(['library_name' => 'posthog-laravel', 'library_version' => '0.1.0']); + $client = $this->integrationClient(); $client->evaluateFlags('user'); self::assertContains('User-Agent: posthog-laravel/0.1.0', $this->http->calls[0]['extraHeaders']); } - public function testReleaseOptionOverridesEnvironmentAndNullDisablesIt(): void + public function testIntegrationReleaseOverridesEnvironment(): void { $previous = getenv('POSTHOG_RELEASE_ID'); putenv('POSTHOG_RELEASE_ID=environment-release'); try { - $client = $this->client(['release_id' => 'configured-release']); + $client = $this->integrationClient('configured-release'); $client->capture(['event' => 'configured']); self::assertSame('configured-release', $this->events($client)[0]['properties']['$release_id']); - $client = $this->client(['release_id' => null]); - $client->capture(['event' => 'disabled']); - self::assertArrayNotHasKey('$release_id', $this->events($client)[0]['properties']); + $client = $this->integrationClient(); + $client->capture(['event' => 'fallback']); + self::assertSame('environment-release', $this->events($client)[0]['properties']['$release_id']); } finally { putenv($previous === false ? 'POSTHOG_RELEASE_ID' : 'POSTHOG_RELEASE_ID=' . $previous); } } - public function testExplicitEventMetadataWinsOverClientOptions(): void + public function testConstructorOptionsCannotOverrideLibraryOrRelease(): void { - $client = $this->client([ - 'library_name' => 'integration', 'library_version' => '0.1.0', 'release_id' => 'default', - ]); + $previous = getenv('POSTHOG_RELEASE_ID'); + putenv('POSTHOG_RELEASE_ID'); + try { + $client = $this->client([ + 'library_name' => 'custom', 'library_version' => '9.9.9', 'release_id' => 'configured', + ]); + $client->capture(['event' => 'ignored-options']); + $client->evaluateFlags('user'); + $properties = $this->events($client)[0]['properties']; + self::assertSame(PostHog::LIBRARY, $properties['$lib']); + self::assertSame(PostHog::VERSION, $properties['$lib_version']); + self::assertArrayNotHasKey('$release_id', $properties); + self::assertContains( + 'User-Agent: ' . PostHog::LIBRARY . '/' . PostHog::VERSION, + $this->http->calls[0]['extraHeaders'] + ); + } finally { + putenv($previous === false ? 'POSTHOG_RELEASE_ID' : 'POSTHOG_RELEASE_ID=' . $previous); + } + } + + public function testExplicitEventMetadataWinsOverIntegrationDefaults(): void + { + $client = $this->integrationClient('default'); $client->capture(['event' => 'custom', 'properties' => [ '$lib' => 'custom', '$lib_version' => '2.0.0', '$release_id' => 'explicit', ]]); diff --git a/test/IntegrationClient.php b/test/IntegrationClient.php new file mode 100644 index 00000000..fc8cab7c --- /dev/null +++ b/test/IntegrationClient.php @@ -0,0 +1,37 @@ + Date: Thu, 29 Oct 2026 13:05:59 +0100 Subject: [PATCH 28/37] fix(laravel): use internal client overrides for library metadata and release id --- packages/laravel/src/LaravelClient.php | 33 ++++++++++++++++++++++++++ packages/laravel/src/PostHog.php | 11 +++------ packages/laravel/tests/RuntimeTest.php | 2 ++ 3 files changed, 38 insertions(+), 8 deletions(-) create mode 100644 packages/laravel/src/LaravelClient.php diff --git a/packages/laravel/src/LaravelClient.php b/packages/laravel/src/LaravelClient.php new file mode 100644 index 00000000..3d66ad12 --- /dev/null +++ b/packages/laravel/src/LaravelClient.php @@ -0,0 +1,33 @@ +configuredReleaseId ?? parent::releaseId(); + } +} diff --git a/packages/laravel/src/PostHog.php b/packages/laravel/src/PostHog.php index 8cfae735..526811fa 100644 --- a/packages/laravel/src/PostHog.php +++ b/packages/laravel/src/PostHog.php @@ -38,11 +38,6 @@ public function client(): Client $options = $config['sdk'] ?? []; $options += ['disableGeoip' => true]; $options['host'] = $config['host'] ?? 'https://us.i.posthog.com'; - $options['library_name'] = 'posthog-laravel'; - $options['library_version'] = self::VERSION; - if (($config['release_id'] ?? null) !== null) { - $options['release_id'] = $config['release_id']; - } // Laravel owns reporting and process-wide PHP handlers. $options['error_tracking'] = array_replace($options['error_tracking'] ?? [], [ 'enabled' => false, @@ -58,11 +53,11 @@ public function client(): Client if (!isset($options['flag_definition_cache_provider']) && ($config['flag_cache']['enabled'] ?? false)) { $options['flag_definition_cache_provider'] = $this->app->make(FlagDefinitionCacheProvider::class); } - return $this->sdk = new Client( + return $this->sdk = new LaravelClient( $config['api_key'], $options, - loadFeatureFlags: false, - secretKey: $config['secret_key'] ?? null, + $config['release_id'] ?? null, + $config['secret_key'] ?? null, ); } diff --git a/packages/laravel/tests/RuntimeTest.php b/packages/laravel/tests/RuntimeTest.php index d518f10f..c450c67b 100644 --- a/packages/laravel/tests/RuntimeTest.php +++ b/packages/laravel/tests/RuntimeTest.php @@ -461,6 +461,8 @@ public function testRealClientMetadataGroupsAndCanonicalExceptionAreForwarded(): $this->app->singleton(CaptureCallback::class); $this->app['config']->set('posthog.sdk.before_send', CaptureCallback::class); $this->app['config']->set('posthog.release_id', 'release-123'); + $this->app['config']->set('posthog.sdk.library_name', 'ignored'); + $this->app['config']->set('posthog.sdk.release_id', 'ignored'); $manager = $this->app->make(PostHog::class); $context = $this->app->make(Context::class); $context->begin(['distinctId' => 'user', 'groups' => ['team' => 'one']]); From b63cd37cb1bfbde8dbe49e37584bbf5a3393a135 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 13:16:43 +0100 Subject: [PATCH 29/37] fix(laravel): release model buffer on commit failure retry --- .../src/Autocapture/DeferredModelEvents.php | 19 ++++++++++ packages/laravel/tests/ModelEventsTest.php | 36 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/packages/laravel/src/Autocapture/DeferredModelEvents.php b/packages/laravel/src/Autocapture/DeferredModelEvents.php index f14c1b8e..018f821b 100644 --- a/packages/laravel/src/Autocapture/DeferredModelEvents.php +++ b/packages/laravel/src/Autocapture/DeferredModelEvents.php @@ -91,6 +91,7 @@ public function defer(Connection $connection, string $event, array $properties, public function beginning(TransactionBeginning $event): void { + $this->prune(); $connection = $event->connection; if (!isset($this->connections[$connection])) { return; @@ -104,6 +105,24 @@ public function beginning(TransactionBeginning $event): void while ($root->parent !== null && $root->parent->level < $root->level) { $root = $root->parent; } + if ($connection->transactionLevel() === 1) { + $pending = $this->transactions->getPendingTransactions(); + $committed = $this->transactions->getCommittedTransactions(); + foreach ($this->pending as $key => $entry) { + $olderRoot = $entry['root']->get(); + if ( + $entry['connection']->get() === $connection && $olderRoot !== $root + && ($pending->containsStrict($olderRoot) || $committed->containsStrict($olderRoot)) + ) { + // Laravel 12 commit retries can leave rolled-back roots managed on the same PDO. + // Unmanaged roots may still be delivering successful commit callbacks; keep them. + $this->remove($key); + } + } + } + if (!isset($this->connections[$connection])) { + return; + } // A callback may start/roll back a new root while an older committed root is awaiting its callback. $state = $this->connections[$connection]; $state['root'] = WeakReference::create($root); diff --git a/packages/laravel/tests/ModelEventsTest.php b/packages/laravel/tests/ModelEventsTest.php index 7a306ed9..697c9907 100644 --- a/packages/laravel/tests/ModelEventsTest.php +++ b/packages/laravel/tests/ModelEventsTest.php @@ -2,6 +2,7 @@ namespace PostHog\Laravel\Tests; +use Illuminate\Database\Events\TransactionCommitting; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\DB; use Orchestra\Testbench\TestCase; @@ -289,6 +290,41 @@ public function testByteAdmissionIncludesContextAndDropsOversizedApprovedAttribu $this->assertSame([$state, 'fits after commit'], $this->states()); } + public function testCommitFailureRetryDiscardsFailedAttemptBeforeReadmittingEvents(): void + { + $commits = 0; + $this->app['events']->listen(TransactionCommitting::class, function ($event) use (&$commits) { + if ($event->connectionName === 'a' && ++$commits === 1) { + throw new RuntimeException('database is locked'); + } + }); + $pdo = DB::connection('a')->getPdo(); + $attempt = 0; + $usageOnRetry = null; + DB::transaction(function () use (&$attempt, &$usageOnRetry) { + if (++$attempt === 1) { + for ($i = 0; $i < 1000; $i++) { + $this->record('failed attempt'); + } + $this->assertUsage(1000, 1000 * $this->encodedSize('failed attempt')); + } else { + $usageOnRetry = $this->buffer()->pendingUsage(); + $this->record('committed retry'); + } + PostHog::assertNothingCaptured(); + }, 2); + $this->assertSame(2, $attempt); + $this->assertSame($pdo, DB::connection('a')->getPdo()); + $this->assertSame(['count' => 0, 'bytes' => 0], $usageOnRetry); + $this->assertSame(['committed retry'], $this->states()); + $this->assertSame(1, CollectedRecord::count()); + $this->assertUsage(0, 0); + $this->assertTransactionCallbackCount(0); + DB::transaction(fn () => $this->record('next transaction')); + $this->assertSame(['committed retry', 'next transaction'], $this->states()); + $this->assertUsage(0, 0); + } + public function testRetryAndRepeatedSavepointRollbackReleaseCapacityWithoutCallbackAccumulation(): void { $attempt = 0; From dc28ca9bb1e36de1c377017243391d41aac246a2 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 13:23:08 +0100 Subject: [PATCH 30/37] docs: explain when to use withContext versus enterContext --- lib/Client.php | 29 ++++++++++++++++++++++++++--- lib/PostHog.php | 30 +++++++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/lib/Client.php b/lib/Client.php index a185de8a..e08e7089 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -2447,6 +2447,9 @@ private function formatDateTime(\DateTimeInterface $date): string /** * Run a callback with request context applied to all captures in the callback. * + * Prefer this over enterContext() whenever the work can be wrapped in a callback: the + * previous context is restored when the callback returns or throws. + * * @param array{ * distinctId?: string, * distinct_id?: string, @@ -2468,10 +2471,30 @@ public function withContext(array $data, callable $fn, array $options = []): mix } /** - * Enter context across framework lifecycle callbacks. Close the returned scope in a finally block. + * Apply request context until the returned scope is closed. * - * @param array $data - * @param array{fresh?: bool} $options + * Use this only when the work starts and ends in separate callbacks, such as framework + * lifecycle events or before/after middleware hooks; otherwise prefer withContext(). + * Close the scope on every exit path, including failures. An unclosed scope leaks its context + * into later work until the scope is garbage-collected. Closing a scope is idempotent. + * + * ```php + * // Job started callback + * $this->scope = $client->enterContext(['distinctId' => $userId]); + * + * // Job finished or failed callback + * $this->scope?->close(); + * $this->scope = null; + * ``` + * + * @param array{ + * distinctId?: string, + * distinct_id?: string, + * sessionId?: string, + * session_id?: string, + * properties?: array + * } $data + * @param array{fresh?: bool} $options Use `fresh => true` to avoid inheriting the current context. */ public function enterContext(array $data, array $options = []): ContextScope { diff --git a/lib/PostHog.php b/lib/PostHog.php index d852e397..017f8413 100644 --- a/lib/PostHog.php +++ b/lib/PostHog.php @@ -470,6 +470,9 @@ public static function alias(array $message) /** * Run a callback with request context applied to all captures in the callback. * + * Prefer this over enterContext() whenever the work can be wrapped in a callback: the + * previous context is restored when the callback returns or throws. + * * @param array{ * distinctId?: string, * distinct_id?: string, @@ -490,10 +493,31 @@ public static function withContext(array $data, callable $fn, array $options = [ } /** - * Enter framework context until the returned scope is closed. + * Apply request context until the returned scope is closed. + * + * Use this only when the work starts and ends in separate callbacks, such as framework + * lifecycle events or before/after middleware hooks; otherwise prefer withContext(). + * Close the scope on every exit path, including failures. An unclosed scope leaks its context + * into later work until the scope is garbage-collected. Closing a scope is idempotent. + * + * ```php + * // Job started callback + * $this->scope = PostHog::enterContext(['distinctId' => $userId]); + * + * // Job finished or failed callback + * $this->scope?->close(); + * $this->scope = null; + * ``` * - * @param array $data - * @param array{fresh?: bool} $options + * @param array{ + * distinctId?: string, + * distinct_id?: string, + * sessionId?: string, + * session_id?: string, + * properties?: array + * } $data + * @param array{fresh?: bool} $options Use `fresh => true` to avoid inheriting the current context. + * @throws Exception When the client has not been initialized. */ public static function enterContext(array $data, array $options = []): ContextScope { From e23f1fcddec304ef12635ee8f5b7b058cb1a5cbe Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 14:37:49 +0100 Subject: [PATCH 31/37] feat(laravel): isolate direct child command calls with opt-in trait --- packages/laravel/README.md | 33 ++- .../src/Concerns/IsolatesCommandContext.php | 18 ++ packages/laravel/tests/CommandContextTest.php | 239 ++++++++++++++++++ .../laravel/tests/Fixtures/ScopedCommand.php | 23 ++ 4 files changed, 308 insertions(+), 5 deletions(-) create mode 100644 packages/laravel/src/Concerns/IsolatesCommandContext.php create mode 100644 packages/laravel/tests/CommandContextTest.php create mode 100644 packages/laravel/tests/Fixtures/ScopedCommand.php diff --git a/packages/laravel/README.md b/packages/laravel/README.md index cff4a8cf..405b7eca 100644 --- a/packages/laravel/README.md +++ b/packages/laravel/README.md @@ -111,11 +111,34 @@ nested `Artisan::call()` executions restore their caller in `finally`, while the command stays active through kernel termination. Thrown objects retain their own snapshots; nonzero return codes never supply another exception's identity. The application's commands, custom kernel, and exception-reporting policy are preserved. -Direct child-command `$this->call()`/`callSilent()` execution bypasses those kernel -boundaries and does **not** automatically restore context. Wrap such calls in an -explicit `PostHog::withContext([], fn () => $this->call(...))` scope when the child -can mutate analytics context; automatic direct child-command isolation remains -unsupported. +Direct child-command `$this->call()`/`callSilent()`/`callSilently()` execution bypasses +those kernel boundaries. Opt into isolation by adding `IsolatesCommandContext` to +**the calling command**, or to your application's base command: + +```php +namespace App\Console; + +use Illuminate\Console\Command as BaseCommand; +use PostHog\Laravel\Concerns\IsolatesCommandContext; + +abstract class Command extends BaseCommand +{ + use IsolatesCommandContext; +} +``` + +Have your application commands extend this base class, or apply the trait directly +to each calling command. All three call helpers then restore the caller's context +and cached flag snapshots when the child returns or throws, including closure +children. Arguments, output, exit codes and throwables pass through unchanged. +Root context still lasts through kernel termination. + +This is opt-in, not global interception: uninstrumented callers, including commands +registered with `Artisan::command()`, still need kernel-level `Artisan::call()` or an +explicit `PostHog::withContext([], fn () => $this->call(...))` scope when a child can +mutate analytics context. The trait uses Laravel's inherited `runCommand()`; +custom call implementations that bypass it also need explicit scoping. + SDK creation is lazy, per Laravel scope. Shared flag definitions live in Laravel Cache, not a global SDK. Do not retain scoped services in application singletons or prewarm them in Octane. diff --git a/packages/laravel/src/Concerns/IsolatesCommandContext.php b/packages/laravel/src/Concerns/IsolatesCommandContext.php new file mode 100644 index 00000000..cabdde6e --- /dev/null +++ b/packages/laravel/src/Concerns/IsolatesCommandContext.php @@ -0,0 +1,18 @@ +make(ConsoleLifecycle::class)->call( + fn () => parent::runCommand($command, $arguments, $output), + ); + } +} diff --git a/packages/laravel/tests/CommandContextTest.php b/packages/laravel/tests/CommandContextTest.php new file mode 100644 index 00000000..f9c9847c --- /dev/null +++ b/packages/laravel/tests/CommandContextTest.php @@ -0,0 +1,239 @@ +set('posthog.api_key', 'test-key'); + $app['config']->set('posthog.secret_key', null); + $app['config']->set('posthog.sdk.consumer', 'noop'); + $app['config']->set('posthog.sdk.before_send', function ($event) { + $this->events[] = $event; + return null; + }); + } + + public static function childCalls(): iterable + { + foreach (['call', 'callSilent', 'callSilently'] as $method) { + foreach ([false, true] as $throws) { + yield $method . ($throws ? ' throwing' : ' returning') => [$method, $throws]; + } + } + } + + #[DataProvider('childCalls')] + public function testCallHelpersRestoreContextAndFlagsWithoutChangingCommandBehavior( + string $method, + bool $throws, + ): void { + $context = $this->app->make(Context::class); + $context->begin(['distinctId' => 'caller']); + $parent = [ + 'distinctId' => 'parent', 'sessionId' => 'parent-session', + 'groups' => ['team' => 'parent-team'], 'properties' => ['component' => 'parent'], + ]; + $child = [ + 'distinctId' => 'child', 'sessionId' => 'child-session', + 'groups' => ['team' => 'child-team'], 'properties' => ['component' => 'child'], + ]; + $parentFlags = new FeatureFlagEvaluations('parent', [], [], PostHog::client()); + $childFlags = new FeatureFlagEvaluations('child', [], [], PostHog::client()); + $failure = new RuntimeException('child failed'); + $observed = []; + Artisan::command('posthog:child {--value=}', function () use ( + $context, + $child, + $childFlags, + $throws, + $failure, + &$observed, + ) { + $observed['inherited'] = $context->snapshot(); + $observed['freshFlags'] = $context->flags('snapshot', fn () => $childFlags) === $childFlags; + $context->begin($child); + PostHog::capture('child'); + $this->line($this->option('value')); + if ($throws) { + throw $failure; + } + return 7; + }); + Artisan::registerCommand(new ScopedCommand('posthog:parent', function (Command $command) use ( + $context, + $parent, + $parentFlags, + $childFlags, + $method, + &$observed, + ) { + $context->begin($parent); + $context->flags('snapshot', fn () => $parentFlags); + $observed['result'] = null; + $observed['exception'] = null; + try { + $observed['result'] = $command->{$method}('posthog:child', ['--value' => 'forwarded']); + } catch (RuntimeException $exception) { + $observed['exception'] = $exception; + report($exception); + } + $observed['after'] = $context->snapshot(); + $observed['flags'] = $context->flags('snapshot', fn () => $childFlags); + PostHog::capture('parent continued'); + return 0; + })); + + $this->assertSame(0, Artisan::call('posthog:parent')); + $this->assertSame($parent, $observed['after']); + $this->assertSame($parent, $observed['inherited']); + $this->assertTrue($observed['freshFlags']); + $this->assertSame($parentFlags, $observed['flags']); + $this->assertSame($throws ? null : 7, $observed['result']); + $this->assertSame($throws ? $failure : null, $observed['exception']); + $this->assertSame($method === 'call' ? 'forwarded' . PHP_EOL : '', Artisan::output()); + $this->assertSame(['distinctId' => 'caller'], $context->snapshot()); + $this->assertSame($throws ? ['child', 'child', 'parent'] : ['child', 'parent'], array_column( + $this->events, + 'distinct_id', + )); + $event = $this->events[array_key_last($this->events)]; + $this->assertSame('parent-session', $event['properties']['$session_id']); + $this->assertSame(['team' => 'parent-team'], $event['properties']['$groups']); + $this->assertSame('parent', $event['properties']['component']); + } + + public function testNestedCallsRestoreEachCallerAndKeepTheDeepestThrowableSnapshot(): void + { + $context = $this->app->make(Context::class); + $failure = new RuntimeException('grandchild failed'); + $observed = []; + $grandchild = new ScopedCommand('posthog:grandchild', function () use ($context, $failure) { + $context->begin(['distinctId' => 'grandchild']); + throw $failure; + }); + $this->app->bind(ScopedCommand::class, fn () => $grandchild); + $child = new ScopedCommand('posthog:child', function (Command $command) use ($context, &$observed) { + $context->begin(['distinctId' => 'child']); + try { + $command->call(ScopedCommand::class); + } catch (RuntimeException $exception) { + $observed['child'] = $context->snapshot(); + throw $exception; + } + return 0; + }); + Artisan::registerCommand(new ScopedCommand('posthog:parent', function (Command $command) use ( + $context, + $child, + &$observed, + ) { + $context->begin(['distinctId' => 'parent']); + try { + $command->call($child); + } catch (RuntimeException $exception) { + $observed['exception'] = $exception; + report($exception); + report($exception); + } + $observed['parent'] = $context->snapshot(); + PostHog::capture('parent continued'); + return 0; + })); + + $this->assertSame(0, Artisan::call('posthog:parent')); + $this->assertSame(['distinctId' => 'child'], $observed['child']); + $this->assertSame(['distinctId' => 'parent'], $observed['parent']); + $this->assertSame($failure, $observed['exception']); + $this->assertSame(['grandchild', 'parent'], array_column($this->events, 'distinct_id')); + $this->assertSame('$exception', $this->events[0]['event']); + $mechanism = $this->events[0]['properties']['$exception_list'][0]['mechanism']; + $this->assertFalse($mechanism['handled']); + $this->assertSame('runtime', $mechanism['type']); + } + + public function testChildFailureExitCodeDoesNotReplaceRootIdentityThroughTermination(): void + { + $context = $this->app->make(Context::class); + $this->app->singleton(Kernel::class, fn ($app) => new ReportingConsoleKernel($app, $app['events'])); + Artisan::clearResolvedInstance(Kernel::class); + $kernel = $this->app->make(Kernel::class); + $kernel->rerouteSymfonyCommandEvents(); + Artisan::command('posthog:child', function () use ($context) { + $context->begin(['distinctId' => 'child']); + return 1; + }); + Artisan::registerCommand(new ScopedCommand('posthog:parent', function (Command $command) use ($context) { + $context->begin(['distinctId' => 'parent']); + $command->call('posthog:child'); + throw new RuntimeException('parent failed independently'); + })); + $this->app->terminating(fn () => PostHog::capture('terminating')); + $input = new ArrayInput(['command' => 'posthog:parent']); + + $this->assertSame(1, $kernel->handle($input, new NullOutput())); + $this->assertSame(['distinctId' => 'parent'], $context->snapshot()); + $kernel->terminate($input, 1); + $this->assertSame([], $context->snapshot()); + $this->assertSame(['$exception', 'terminating'], array_column($this->events, 'event')); + $this->assertSame(['parent', 'parent'], array_column($this->events, 'distinct_id')); + } + + public function testFlushFailureCannotMaskAChildThrowableOrPreventRestoration(): void + { + $manager = new class ($this->app) extends Manager { + public function flush(): bool + { + throw new RuntimeException('flush failed'); + } + }; + PostHog::swap($manager); + $context = $this->app->make(Context::class); + $failure = new RuntimeException('child failed'); + $observed = []; + Artisan::command('posthog:child', function () use ($context, $failure) { + $context->begin(['distinctId' => 'child']); + throw $failure; + }); + Artisan::registerCommand(new ScopedCommand('posthog:parent', function (Command $command) use ( + $context, + &$observed, + ) { + $context->begin(['distinctId' => 'parent']); + try { + $command->call('posthog:child'); + } catch (RuntimeException $exception) { + $observed = [$exception, $context->snapshot()]; + } + return 0; + })); + + $this->assertSame(0, Artisan::call('posthog:parent')); + $this->assertSame([$failure, ['distinctId' => 'parent']], $observed); + $this->assertSame([], $context->snapshot()); + } +} diff --git a/packages/laravel/tests/Fixtures/ScopedCommand.php b/packages/laravel/tests/Fixtures/ScopedCommand.php new file mode 100644 index 00000000..317c260f --- /dev/null +++ b/packages/laravel/tests/Fixtures/ScopedCommand.php @@ -0,0 +1,23 @@ +signature = $signature; + parent::__construct(); + } + + public function handle(): int + { + return ($this->action)($this); + } +} From 3321cfbb41b1e62db7a9890c95f51eefc162867a Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 14:49:38 +0100 Subject: [PATCH 32/37] fix(laravel): clear Pennant cache across direct command scopes --- packages/laravel/PENNANT.md | 5 ++- packages/laravel/src/ConsoleLifecycle.php | 2 + packages/laravel/tests/PennantTest.php | 53 ++++++++++++++++++++++- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/packages/laravel/PENNANT.md b/packages/laravel/PENNANT.md index 29905103..5ddca119 100644 --- a/packages/laravel/PENNANT.md +++ b/packages/laravel/PENNANT.md @@ -64,7 +64,10 @@ 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. +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 diff --git a/packages/laravel/src/ConsoleLifecycle.php b/packages/laravel/src/ConsoleLifecycle.php index 2e07b629..3c15de55 100644 --- a/packages/laravel/src/ConsoleLifecycle.php +++ b/packages/laravel/src/ConsoleLifecycle.php @@ -65,6 +65,7 @@ private function enter(): void $data = $context->snapshot(); $context->enter(); $context->begin($data); + Features\PennantCache::flush(); } private function remember(Throwable $exception): void @@ -82,6 +83,7 @@ private function leave(): void } catch (Throwable) { } finally { $this->app->make(Context::class)->leave(); + Features\PennantCache::flush(); } } } diff --git a/packages/laravel/tests/PennantTest.php b/packages/laravel/tests/PennantTest.php index a22dedfc..24aa2a4f 100644 --- a/packages/laravel/tests/PennantTest.php +++ b/packages/laravel/tests/PennantTest.php @@ -2,6 +2,8 @@ namespace PostHog\Laravel\Tests; +use Illuminate\Console\Command; +use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Route; use Laravel\Pennant\Contracts\FeatureScopeable; @@ -20,6 +22,7 @@ use PostHog\Laravel\Tests\Fixtures\CaptureJob; use PostHog\Laravel\Tests\Fixtures\FailingCaptureJob; use PostHog\Laravel\Tests\Fixtures\PennantEndEventProvider; +use PostHog\Laravel\Tests\Fixtures\ScopedCommand; use RuntimeException; class PennantTest extends TestCase @@ -167,7 +170,7 @@ public function testDisabledBulkReadsAreEmptyAndDoNotPerformIo(): void $this->assertFalse(Feature::for('explicit-b')->value('unknown')); } - public static function queueCallers(): array + public static function scopedCallers(): array { return [ 'anonymous caller, success' => [null, false], @@ -177,7 +180,7 @@ public static function queueCallers(): array ]; } - #[DataProvider('queueCallers')] + #[DataProvider('scopedCallers')] public function testQueueCompletionConsumersCannotPolluteTheRestoredCallersCache( ?string $parentId, bool $fail, @@ -205,6 +208,52 @@ public function testQueueCompletionConsumersCannotPolluteTheRestoredCallersCache } } + #[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 = []; From 858c20496e1df664ff275417875e05b34a8fca14 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 17:04:26 +0100 Subject: [PATCH 33/37] fix: address framework primitive review feedback --- lib/Client.php | 30 +++++++- lib/ContextScope.php | 6 ++ lib/PostHog.php | 7 +- test/FrameworkIntegrationTest.php | 117 ++++++++++++++++++++++++++++++ test/PostHogTest.php | 20 +++++ 5 files changed, 176 insertions(+), 4 deletions(-) diff --git a/lib/Client.php b/lib/Client.php index e08e7089..c81eb6df 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -663,8 +663,11 @@ private function shouldSendMinimalFlagCalledEvent(array $message): bool * @param \Throwable|string $exception The exception to capture or a plain string message * @param string|null $distinctId User ID; a random UUID is used when omitted (no person profile created) * @param array $additionalProperties Extra properties merged into the event - * @param array{handled?: bool, mechanism?: string} $options Framework boundary metadata. + * @param array{handled?: bool, mechanism?: string, source?: string} $options Framework boundary metadata. * Manual capture defaults to handled; integrations may explicitly mark a failed boundary. + * Mechanism names the semantic category (e.g. middleware); source names the concrete hook + * (e.g. laravel.middleware). They are independent. A non-empty string source is emitted as + * $exception_source; otherwise it is omitted. Property bags cannot supply this reserved field. * @return bool whether the capture call succeeded */ public function captureException( @@ -683,6 +686,8 @@ public function captureException( $mechanism = $options['mechanism'] ?? null; $handled = $options['handled'] ?? null; + $source = $options['source'] ?? null; + $source = is_string($source) && trim($source) !== '' ? $source : null; $exceptionList = ExceptionPayloadBuilder::overridePrimaryMechanism($exceptionList, [ 'type' => is_string($mechanism) && trim($mechanism) !== '' ? $mechanism : 'generic', 'handled' => is_bool($handled) ? $handled : true, @@ -700,6 +705,10 @@ public function captureException( '$exception_handled' => ExceptionPayloadBuilder::getPrimaryHandled($exceptionList), ] ); + unset($properties['$exception_source']); + if ($source !== null) { + $properties['$exception_source'] = $source; + } $message = [ 'event' => '$exception', @@ -710,6 +719,18 @@ public function captureException( $message['distinctId'] = $distinctId; } + $context = $this->getContext(); + if ($source === null && array_key_exists('$exception_source', $context['properties'] ?? [])) { + unset($context['properties']['$exception_source']); + // Filter ambient provenance without re-enriching the caller's active span. + return RequestContext::withContext( + $context, + fn () => $this->capture($message), + ['fresh' => true], + $this->contextKey() + ); + } + return $this->capture($message); } @@ -722,10 +743,12 @@ public function captureException( public function groupIdentify(array $message): bool { if (empty($message['groupType'])) { - throw new Exception('PostHog::groupIdentify() expects a groupType'); + $this->logWarning('groupIdentify() expects a groupType'); + return false; } if (empty($message['groupKey'])) { - throw new Exception('PostHog::groupIdentify() expects a groupKey'); + $this->logWarning('groupIdentify() expects a groupKey'); + return false; } $distinctId = "\${$message['groupType']}_{$message['groupKey']}"; @@ -2475,6 +2498,7 @@ public function withContext(array $data, callable $fn, array $options = []): mix * * Use this only when the work starts and ends in separate callbacks, such as framework * lifecycle events or before/after middleware hooks; otherwise prefer withContext(). + * Retain the returned scope: discarding it immediately closes the context. * Close the scope on every exit path, including failures. An unclosed scope leaks its context * into later work until the scope is garbage-collected. Closing a scope is idempotent. * diff --git a/lib/ContextScope.php b/lib/ContextScope.php index c6e41901..f6ed7a57 100644 --- a/lib/ContextScope.php +++ b/lib/ContextScope.php @@ -4,6 +4,11 @@ use Closure; +/** + * Owns an entered context until close() or destruction. + * + * Retain the object returned by enterContext(): discarding it immediately closes the scope. + */ final class ContextScope { /** @internal Obtain scopes through Client::enterContext(). */ @@ -11,6 +16,7 @@ public function __construct(private ?Closure $onClose) { } + /** Release this scope once; repeated calls are harmless and younger scopes remain active. */ public function close(): void { $close = $this->onClose; diff --git a/lib/PostHog.php b/lib/PostHog.php index 017f8413..2148f55c 100644 --- a/lib/PostHog.php +++ b/lib/PostHog.php @@ -124,7 +124,9 @@ public static function init( * @param \Throwable|string $exception The exception to capture or a plain string message. * @param string|null $distinctId User ID; a random UUID is used when omitted (no person profile created). * @param array $additionalProperties Extra properties merged into the event. - * @param array{handled?: bool, mechanism?: string} $options Framework boundary metadata. + * @param array{handled?: bool, mechanism?: string, source?: string} $options Framework boundary metadata. + * Source is the concrete integration hook (e.g. laravel.middleware), not a mechanism category. + * Only a non-empty string source is emitted; property bags cannot supply $exception_source. * @return bool Whether the capture call succeeded. * @throws Exception */ @@ -217,6 +219,8 @@ public static function identify(array $message) public static function groupIdentify(array $message) { self::checkClient(); + self::assert(!empty($message['groupType']), 'PostHog::groupIdentify() expects a groupType'); + self::assert(!empty($message['groupKey']), 'PostHog::groupIdentify() expects a groupKey'); return self::$client->groupIdentify($message); } @@ -497,6 +501,7 @@ public static function withContext(array $data, callable $fn, array $options = [ * * Use this only when the work starts and ends in separate callbacks, such as framework * lifecycle events or before/after middleware hooks; otherwise prefer withContext(). + * Retain the returned scope: discarding it immediately closes the context. * Close the scope on every exit path, including failures. An unclosed scope leaks its context * into later work until the scope is garbage-collected. Closing a scope is idempotent. * diff --git a/test/FrameworkIntegrationTest.php b/test/FrameworkIntegrationTest.php index 0813818a..b66ecbd0 100644 --- a/test/FrameworkIntegrationTest.php +++ b/test/FrameworkIntegrationTest.php @@ -50,6 +50,33 @@ function () use ($client) { self::assertArrayNotHasKey('secret', $event['properties']); } + public static function invalidGroupMessages(): array + { + return [ + 'missing identity' => [[], 'groupType'], + 'missing type' => [['groupKey' => 'company'], 'groupType'], + 'empty type' => [['groupType' => '', 'groupKey' => 'company'], 'groupType'], + 'missing key' => [['groupType' => 'company'], 'groupKey'], + 'empty key' => [['groupType' => 'company', 'groupKey' => ''], 'groupKey'], + 'null key' => [['groupType' => 'company', 'groupKey' => null], 'groupKey'], + ]; + } + + #[DataProvider('invalidGroupMessages')] + public function testInvalidInstanceGroupIdentityWarnsAndDropsWithoutThrowing(array $message, string $field): void + { + global $errorMessages; + $client = $this->client(); + $errorMessages = []; + self::assertFalse($client->groupIdentify($message)); + self::assertSame( + ['[PostHog][Client] groupIdentify() expects a ' . $field], + $errorMessages + ); + self::assertTrue($client->capture(['event' => 'still usable', 'distinctId' => 'user'])); + self::assertSame(['still usable'], array_column($this->events($client), 'event')); + } + public function testDetachedScopesRestoreOuterContextAndCloseOnlyOnce(): void { $client = $this->client(); @@ -100,6 +127,16 @@ public function testScopeDestructionClosesItsContext(): void self::assertNull($client->getContext()); } + public function testDiscardingAnEnteredScopeImmediatelyRestoresItsParent(): void + { + $client = $this->client(); + $parent = $client->enterContext(['distinctId' => 'parent']); + $client->enterContext(['distinctId' => 'discarded']); + self::assertSame('parent', $client->getContext()['distinctId']); + $parent->close(); + self::assertNull($client->getContext()); + } + public function testAbandonedFiberRunsContextCleanupWithoutCyclicCollection(): void { $client = $this->client(); @@ -147,6 +184,84 @@ public function testDetachedScopeDoesNotLeakIntoAReplacementFiber(): void } } + public static function exceptionSourceCases(): array + { + $cases = [ + 'manual capture' => [[], null, 'generic', true], + 'framework boundary' => [[ + 'source' => 'laravel.middleware', 'mechanism' => 'middleware', 'handled' => false, + ], 'laravel.middleware', 'middleware', false], + 'source does not imply mechanism' => [[ + 'source' => 'laravel.queue_job', + ], 'laravel.queue_job', 'generic', true], + 'new integration' => [[ + 'source' => 'custom_framework.worker', 'mechanism' => 'task', 'handled' => false, + ], 'custom_framework.worker', 'task', false], + ]; + foreach ( + [ + 'null' => null, + 'empty' => '', + 'whitespace' => " \t", + 'number' => 123, + 'array' => [], + 'bool' => false, + ] as $name => $source + ) { + $cases[$name] = [[ + 'source' => $source, 'mechanism' => 'task', 'handled' => false, + ], null, 'task', false]; + } + return $cases; + } + + #[DataProvider('exceptionSourceCases')] + public function testExceptionSourceUsesOnlyTypedMetadataAndPreservesCallerContext( + array $options, + ?string $expectedSource, + string $expectedMechanism, + bool $expectedHandled + ): void { + $seen = []; + $client = $this->client(['before_send' => function ($event) use (&$seen) { + $seen[] = $event; + return $event; + }]); + $client->withContext([ + 'distinctId' => 'request-user', 'sessionId' => 'request-session', + 'properties' => ['$exception_source' => 'spoofed.context', 'contextOnly' => true], + ], function () use ($client, $options) { + $before = $client->getContext(); + $exception = new RuntimeException('outer', previous: new RuntimeException('root')); + self::assertTrue($client->captureException( + $exception, + null, + ['$exception_source' => 'spoofed.properties', 'custom' => 'kept', 'source' => 'ordinary property'], + $options + )); + self::assertSame($before, $client->getContext()); + }); + $event = $this->events($client)[0]; + $properties = $event['properties']; + foreach ([$properties, $seen[0]['properties']] as $captured) { + if ($expectedSource === null) { + self::assertArrayNotHasKey('$exception_source', $captured); + } else { + self::assertSame($expectedSource, $captured['$exception_source']); + } + } + self::assertSame('request-user', $event['distinct_id']); + self::assertSame('request-session', $properties['$session_id']); + self::assertTrue($properties['contextOnly']); + self::assertSame('kept', $properties['custom']); + self::assertSame('ordinary property', $properties['source']); + self::assertSame($expectedMechanism, $properties['$exception_list'][0]['mechanism']['type']); + self::assertSame($expectedHandled, $properties['$exception_list'][0]['mechanism']['handled']); + self::assertArrayNotHasKey('source', $properties['$exception_list'][0]['mechanism']); + self::assertSame('root', $properties['$exception_list'][1]['value']); + self::assertNull($client->getContext()); + } + public static function exceptionMetadataCases(): array { return [ @@ -233,6 +348,8 @@ public function testIntegrationLibraryAndReleaseApplyAcrossEventTypes(): void self::assertSame('0.1.0', $event['properties']['$lib_version']); self::assertSame('release', $event['properties']['$release_id']); } + self::assertSame('$exception', $events[4]['event']); + self::assertArrayNotHasKey('$exception_source', $events[4]['properties']); } public function testIntegrationLibraryIsUsedForFlagHttpRequests(): void diff --git a/test/PostHogTest.php b/test/PostHogTest.php index ce60ecaa..202222da 100644 --- a/test/PostHogTest.php +++ b/test/PostHogTest.php @@ -1575,6 +1575,26 @@ public function testGroupIdentify(): void ); } + public function testCaptureExceptionForwardsTypedIntegrationSourceWithoutAmbientContext(): void + { + self::assertTrue(PostHog::captureException(new RuntimeException('boundary'), 'user', [ + '$exception_source' => 'spoofed.properties', + ], ['mechanism' => 'middleware', 'source' => 'laravel.middleware', 'handled' => false])); + self::assertTrue(PostHog::flush()); + $properties = $this->firstBatchEvent()['properties']; + self::assertSame('laravel.middleware', $properties['$exception_source']); + self::assertSame('middleware', $properties['$exception_list'][0]['mechanism']['type']); + self::assertFalse($properties['$exception_list'][0]['mechanism']['handled']); + self::assertArrayNotHasKey('source', $properties['$exception_list'][0]['mechanism']); + } + + public function testGroupIdentifyKeyValidation(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('PostHog::groupIdentify() expects a groupKey'); + PostHog::groupIdentify(['groupType' => 'company']); + } + public function testGroupIdentifyValidation(): void { $this->expectException(Exception::class); From 5e92bd8271f90ed5b562c009fcdb1d870142c34d Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 3 Nov 2026 11:02:19 +0100 Subject: [PATCH 34/37] fix: normalize URL-style hosts for feature flag requests --- .changeset/salty-signs-poke.md | 5 +++ lib/HttpClient.php | 8 +++- test/FeatureFlagHostTest.php | 82 ++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 .changeset/salty-signs-poke.md create mode 100644 test/FeatureFlagHostTest.php diff --git a/.changeset/salty-signs-poke.md b/.changeset/salty-signs-poke.md new file mode 100644 index 00000000..81bad7b3 --- /dev/null +++ b/.changeset/salty-signs-poke.md @@ -0,0 +1,5 @@ +--- +"posthog-php": patch +--- + +Fix URL-style hosts for feature flag evaluation and definition requests. diff --git a/lib/HttpClient.php b/lib/HttpClient.php index fc9f0e38..b85fa0b3 100644 --- a/lib/HttpClient.php +++ b/lib/HttpClient.php @@ -51,7 +51,7 @@ class HttpClient /** * Create an HTTP client. * - * @param string $host PostHog host without protocol. + * @param string $host PostHog host, optionally including an HTTP or HTTPS scheme. * @param bool $useSsl Whether to use HTTPS. * @param int $maximumBackoffDuration Maximum retry backoff duration in milliseconds. * @param bool $compressRequests Whether to gzip request bodies. @@ -68,7 +68,11 @@ public function __construct( ?Closure $errorHandler = null, int $curlTimeoutMilliseconds = 10000 ) { - $this->host = $host; + if (preg_match('#^https?://#i', $host, $matches)) { + $useSsl = strtolower($matches[0]) === 'https://'; + $host = substr($host, strlen($matches[0])); + } + $this->host = rtrim($host, '/'); $this->useSsl = $useSsl; $this->maximumBackoffDurationMs = $maximumBackoffDuration; $this->compressRequests = $compressRequests; diff --git a/test/FeatureFlagHostTest.php b/test/FeatureFlagHostTest.php new file mode 100644 index 00000000..24cb8d00 --- /dev/null +++ b/test/FeatureFlagHostTest.php @@ -0,0 +1,82 @@ + ['http://', '', []], + 'URL with trailing slash' => ['http://', '/', []], + 'bare host preserves TLS option' => ['', '', ['ssl' => false]], + ]; + } + + #[DataProvider('hosts')] + public function testDefinitionsEvaluationAndIngestionUseTheConfiguredHost( + string $prefix, + string $suffix, + array $options, + ): void { + $server = new LocalHttpServer([ + ['body' => json_encode(['flags' => [], 'group_type_mapping' => [], 'cohorts' => []])], + ['body' => json_encode([ + 'flags' => [ + 'demo-checkout' => ['enabled' => true, 'variant' => null], + 'demo-layout' => [ + 'enabled' => true, + 'variant' => 'blue', + 'metadata' => ['payload' => '{"color":"blue"}'], + ], + ], + ])], + ['body' => '{"status":"Ok"}'], + ]); + $client = new Client('test-key', $options + [ + 'host' => $prefix . $server->address() . $suffix, + 'consumer' => 'lib_curl', + 'feature_flag_request_max_retries' => 0, + ], loadFeatureFlags: false, secretKey: 'test-secret'); + + try { + $client->loadFlags(); + $flags = $client->evaluateFlags('test-visitor'); + self::assertTrue($flags->isEnabled('demo-checkout')); + self::assertSame('blue', $flags->getFlag('demo-layout')); + self::assertSame(['color' => 'blue'], $flags->getFlagPayload('demo-layout')); + self::assertTrue($client->capture([ + 'event' => 'demo_flag_decision', + 'distinctId' => 'test-visitor', + 'flags' => $flags->onlyAccessed(), + ])); + self::assertTrue($client->flush()); + + $requests = $server->requests(); + self::assertCount(3, $requests); + self::assertSame( + 'GET /flags/definitions?send_cohorts&token=test-key HTTP/1.1', + $requests[0]['requestLine'], + ); + self::assertSame('Bearer test-secret', $requests[0]['headers']['authorization']); + self::assertSame('POST /flags/?v=2 HTTP/1.1', $requests[1]['requestLine']); + $evaluation = json_decode($requests[1]['body'], true, 512, JSON_THROW_ON_ERROR); + self::assertSame('test-key', $evaluation['api_key']); + self::assertSame('test-visitor', $evaluation['distinct_id']); + self::assertSame('POST /batch/ HTTP/1.1', $requests[2]['requestLine']); + $batch = json_decode($requests[2]['body'], true, 512, JSON_THROW_ON_ERROR); + self::assertCount(3, $batch['batch']); + $event = $batch['batch'][2]; + self::assertSame('demo_flag_decision', $event['event']); + self::assertSame(true, $event['properties']['$feature/demo-checkout']); + self::assertSame('blue', $event['properties']['$feature/demo-layout']); + } finally { + $client->shutdown(); + $server->stop(); + } + } +} From 382fb2bbb6f4110638588c79f87242ac2e0ba952 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 3 Nov 2026 11:08:50 +0100 Subject: [PATCH 35/37] Revert "fix: normalize URL-style hosts for feature flag requests" This reverts commit 5e92bd8271f90ed5b562c009fcdb1d870142c34d. --- .changeset/salty-signs-poke.md | 5 --- lib/HttpClient.php | 8 +--- test/FeatureFlagHostTest.php | 82 ---------------------------------- 3 files changed, 2 insertions(+), 93 deletions(-) delete mode 100644 .changeset/salty-signs-poke.md delete mode 100644 test/FeatureFlagHostTest.php diff --git a/.changeset/salty-signs-poke.md b/.changeset/salty-signs-poke.md deleted file mode 100644 index 81bad7b3..00000000 --- a/.changeset/salty-signs-poke.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"posthog-php": patch ---- - -Fix URL-style hosts for feature flag evaluation and definition requests. diff --git a/lib/HttpClient.php b/lib/HttpClient.php index b85fa0b3..fc9f0e38 100644 --- a/lib/HttpClient.php +++ b/lib/HttpClient.php @@ -51,7 +51,7 @@ class HttpClient /** * Create an HTTP client. * - * @param string $host PostHog host, optionally including an HTTP or HTTPS scheme. + * @param string $host PostHog host without protocol. * @param bool $useSsl Whether to use HTTPS. * @param int $maximumBackoffDuration Maximum retry backoff duration in milliseconds. * @param bool $compressRequests Whether to gzip request bodies. @@ -68,11 +68,7 @@ public function __construct( ?Closure $errorHandler = null, int $curlTimeoutMilliseconds = 10000 ) { - if (preg_match('#^https?://#i', $host, $matches)) { - $useSsl = strtolower($matches[0]) === 'https://'; - $host = substr($host, strlen($matches[0])); - } - $this->host = rtrim($host, '/'); + $this->host = $host; $this->useSsl = $useSsl; $this->maximumBackoffDurationMs = $maximumBackoffDuration; $this->compressRequests = $compressRequests; diff --git a/test/FeatureFlagHostTest.php b/test/FeatureFlagHostTest.php deleted file mode 100644 index 24cb8d00..00000000 --- a/test/FeatureFlagHostTest.php +++ /dev/null @@ -1,82 +0,0 @@ - ['http://', '', []], - 'URL with trailing slash' => ['http://', '/', []], - 'bare host preserves TLS option' => ['', '', ['ssl' => false]], - ]; - } - - #[DataProvider('hosts')] - public function testDefinitionsEvaluationAndIngestionUseTheConfiguredHost( - string $prefix, - string $suffix, - array $options, - ): void { - $server = new LocalHttpServer([ - ['body' => json_encode(['flags' => [], 'group_type_mapping' => [], 'cohorts' => []])], - ['body' => json_encode([ - 'flags' => [ - 'demo-checkout' => ['enabled' => true, 'variant' => null], - 'demo-layout' => [ - 'enabled' => true, - 'variant' => 'blue', - 'metadata' => ['payload' => '{"color":"blue"}'], - ], - ], - ])], - ['body' => '{"status":"Ok"}'], - ]); - $client = new Client('test-key', $options + [ - 'host' => $prefix . $server->address() . $suffix, - 'consumer' => 'lib_curl', - 'feature_flag_request_max_retries' => 0, - ], loadFeatureFlags: false, secretKey: 'test-secret'); - - try { - $client->loadFlags(); - $flags = $client->evaluateFlags('test-visitor'); - self::assertTrue($flags->isEnabled('demo-checkout')); - self::assertSame('blue', $flags->getFlag('demo-layout')); - self::assertSame(['color' => 'blue'], $flags->getFlagPayload('demo-layout')); - self::assertTrue($client->capture([ - 'event' => 'demo_flag_decision', - 'distinctId' => 'test-visitor', - 'flags' => $flags->onlyAccessed(), - ])); - self::assertTrue($client->flush()); - - $requests = $server->requests(); - self::assertCount(3, $requests); - self::assertSame( - 'GET /flags/definitions?send_cohorts&token=test-key HTTP/1.1', - $requests[0]['requestLine'], - ); - self::assertSame('Bearer test-secret', $requests[0]['headers']['authorization']); - self::assertSame('POST /flags/?v=2 HTTP/1.1', $requests[1]['requestLine']); - $evaluation = json_decode($requests[1]['body'], true, 512, JSON_THROW_ON_ERROR); - self::assertSame('test-key', $evaluation['api_key']); - self::assertSame('test-visitor', $evaluation['distinct_id']); - self::assertSame('POST /batch/ HTTP/1.1', $requests[2]['requestLine']); - $batch = json_decode($requests[2]['body'], true, 512, JSON_THROW_ON_ERROR); - self::assertCount(3, $batch['batch']); - $event = $batch['batch'][2]; - self::assertSame('demo_flag_decision', $event['event']); - self::assertSame(true, $event['properties']['$feature/demo-checkout']); - self::assertSame('blue', $event['properties']['$feature/demo-layout']); - } finally { - $client->shutdown(); - $server->stop(); - } - } -} From c9bb1bf333b8b69950d95772e69ec5ac96af568d Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 3 Nov 2026 11:22:51 +0100 Subject: [PATCH 36/37] fix: retain URL-host fix in Laravel example pending standalone merge --- .changeset/salty-signs-poke.md | 5 +++ lib/HttpClient.php | 8 +++- test/FeatureFlagHostTest.php | 82 ++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 .changeset/salty-signs-poke.md create mode 100644 test/FeatureFlagHostTest.php diff --git a/.changeset/salty-signs-poke.md b/.changeset/salty-signs-poke.md new file mode 100644 index 00000000..81bad7b3 --- /dev/null +++ b/.changeset/salty-signs-poke.md @@ -0,0 +1,5 @@ +--- +"posthog-php": patch +--- + +Fix URL-style hosts for feature flag evaluation and definition requests. diff --git a/lib/HttpClient.php b/lib/HttpClient.php index fc9f0e38..b85fa0b3 100644 --- a/lib/HttpClient.php +++ b/lib/HttpClient.php @@ -51,7 +51,7 @@ class HttpClient /** * Create an HTTP client. * - * @param string $host PostHog host without protocol. + * @param string $host PostHog host, optionally including an HTTP or HTTPS scheme. * @param bool $useSsl Whether to use HTTPS. * @param int $maximumBackoffDuration Maximum retry backoff duration in milliseconds. * @param bool $compressRequests Whether to gzip request bodies. @@ -68,7 +68,11 @@ public function __construct( ?Closure $errorHandler = null, int $curlTimeoutMilliseconds = 10000 ) { - $this->host = $host; + if (preg_match('#^https?://#i', $host, $matches)) { + $useSsl = strtolower($matches[0]) === 'https://'; + $host = substr($host, strlen($matches[0])); + } + $this->host = rtrim($host, '/'); $this->useSsl = $useSsl; $this->maximumBackoffDurationMs = $maximumBackoffDuration; $this->compressRequests = $compressRequests; diff --git a/test/FeatureFlagHostTest.php b/test/FeatureFlagHostTest.php new file mode 100644 index 00000000..24cb8d00 --- /dev/null +++ b/test/FeatureFlagHostTest.php @@ -0,0 +1,82 @@ + ['http://', '', []], + 'URL with trailing slash' => ['http://', '/', []], + 'bare host preserves TLS option' => ['', '', ['ssl' => false]], + ]; + } + + #[DataProvider('hosts')] + public function testDefinitionsEvaluationAndIngestionUseTheConfiguredHost( + string $prefix, + string $suffix, + array $options, + ): void { + $server = new LocalHttpServer([ + ['body' => json_encode(['flags' => [], 'group_type_mapping' => [], 'cohorts' => []])], + ['body' => json_encode([ + 'flags' => [ + 'demo-checkout' => ['enabled' => true, 'variant' => null], + 'demo-layout' => [ + 'enabled' => true, + 'variant' => 'blue', + 'metadata' => ['payload' => '{"color":"blue"}'], + ], + ], + ])], + ['body' => '{"status":"Ok"}'], + ]); + $client = new Client('test-key', $options + [ + 'host' => $prefix . $server->address() . $suffix, + 'consumer' => 'lib_curl', + 'feature_flag_request_max_retries' => 0, + ], loadFeatureFlags: false, secretKey: 'test-secret'); + + try { + $client->loadFlags(); + $flags = $client->evaluateFlags('test-visitor'); + self::assertTrue($flags->isEnabled('demo-checkout')); + self::assertSame('blue', $flags->getFlag('demo-layout')); + self::assertSame(['color' => 'blue'], $flags->getFlagPayload('demo-layout')); + self::assertTrue($client->capture([ + 'event' => 'demo_flag_decision', + 'distinctId' => 'test-visitor', + 'flags' => $flags->onlyAccessed(), + ])); + self::assertTrue($client->flush()); + + $requests = $server->requests(); + self::assertCount(3, $requests); + self::assertSame( + 'GET /flags/definitions?send_cohorts&token=test-key HTTP/1.1', + $requests[0]['requestLine'], + ); + self::assertSame('Bearer test-secret', $requests[0]['headers']['authorization']); + self::assertSame('POST /flags/?v=2 HTTP/1.1', $requests[1]['requestLine']); + $evaluation = json_decode($requests[1]['body'], true, 512, JSON_THROW_ON_ERROR); + self::assertSame('test-key', $evaluation['api_key']); + self::assertSame('test-visitor', $evaluation['distinct_id']); + self::assertSame('POST /batch/ HTTP/1.1', $requests[2]['requestLine']); + $batch = json_decode($requests[2]['body'], true, 512, JSON_THROW_ON_ERROR); + self::assertCount(3, $batch['batch']); + $event = $batch['batch'][2]; + self::assertSame('demo_flag_decision', $event['event']); + self::assertSame(true, $event['properties']['$feature/demo-checkout']); + self::assertSame('blue', $event['properties']['$feature/demo-layout']); + } finally { + $client->shutdown(); + $server->stop(); + } + } +} From 2aa242c5f5eed792ad957a4e358ce6b48d97d4ae Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 3 Nov 2026 11:27:33 +0100 Subject: [PATCH 37/37] chore: keep host regression test and release intent in standalone PR --- .changeset/salty-signs-poke.md | 5 --- test/FeatureFlagHostTest.php | 82 ---------------------------------- 2 files changed, 87 deletions(-) delete mode 100644 .changeset/salty-signs-poke.md delete mode 100644 test/FeatureFlagHostTest.php diff --git a/.changeset/salty-signs-poke.md b/.changeset/salty-signs-poke.md deleted file mode 100644 index 81bad7b3..00000000 --- a/.changeset/salty-signs-poke.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"posthog-php": patch ---- - -Fix URL-style hosts for feature flag evaluation and definition requests. diff --git a/test/FeatureFlagHostTest.php b/test/FeatureFlagHostTest.php deleted file mode 100644 index 24cb8d00..00000000 --- a/test/FeatureFlagHostTest.php +++ /dev/null @@ -1,82 +0,0 @@ - ['http://', '', []], - 'URL with trailing slash' => ['http://', '/', []], - 'bare host preserves TLS option' => ['', '', ['ssl' => false]], - ]; - } - - #[DataProvider('hosts')] - public function testDefinitionsEvaluationAndIngestionUseTheConfiguredHost( - string $prefix, - string $suffix, - array $options, - ): void { - $server = new LocalHttpServer([ - ['body' => json_encode(['flags' => [], 'group_type_mapping' => [], 'cohorts' => []])], - ['body' => json_encode([ - 'flags' => [ - 'demo-checkout' => ['enabled' => true, 'variant' => null], - 'demo-layout' => [ - 'enabled' => true, - 'variant' => 'blue', - 'metadata' => ['payload' => '{"color":"blue"}'], - ], - ], - ])], - ['body' => '{"status":"Ok"}'], - ]); - $client = new Client('test-key', $options + [ - 'host' => $prefix . $server->address() . $suffix, - 'consumer' => 'lib_curl', - 'feature_flag_request_max_retries' => 0, - ], loadFeatureFlags: false, secretKey: 'test-secret'); - - try { - $client->loadFlags(); - $flags = $client->evaluateFlags('test-visitor'); - self::assertTrue($flags->isEnabled('demo-checkout')); - self::assertSame('blue', $flags->getFlag('demo-layout')); - self::assertSame(['color' => 'blue'], $flags->getFlagPayload('demo-layout')); - self::assertTrue($client->capture([ - 'event' => 'demo_flag_decision', - 'distinctId' => 'test-visitor', - 'flags' => $flags->onlyAccessed(), - ])); - self::assertTrue($client->flush()); - - $requests = $server->requests(); - self::assertCount(3, $requests); - self::assertSame( - 'GET /flags/definitions?send_cohorts&token=test-key HTTP/1.1', - $requests[0]['requestLine'], - ); - self::assertSame('Bearer test-secret', $requests[0]['headers']['authorization']); - self::assertSame('POST /flags/?v=2 HTTP/1.1', $requests[1]['requestLine']); - $evaluation = json_decode($requests[1]['body'], true, 512, JSON_THROW_ON_ERROR); - self::assertSame('test-key', $evaluation['api_key']); - self::assertSame('test-visitor', $evaluation['distinct_id']); - self::assertSame('POST /batch/ HTTP/1.1', $requests[2]['requestLine']); - $batch = json_decode($requests[2]['body'], true, 512, JSON_THROW_ON_ERROR); - self::assertCount(3, $batch['batch']); - $event = $batch['batch'][2]; - self::assertSame('demo_flag_decision', $event['event']); - self::assertSame(true, $event['properties']['$feature/demo-checkout']); - self::assertSame('blue', $event['properties']['$feature/demo-layout']); - } finally { - $client->shutdown(); - $server->stop(); - } - } -}