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/39] 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 e66554e5f0d321c092c44e5fa5fdd54bcb798fec Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 09:34:42 +0100 Subject: [PATCH 02/39] feat: add independent Laravel CI and reviewed distribution releases --- .github/workflows/laravel-release.yml | 203 ++++++++++++++++ .github/workflows/laravel.yml | 73 ++++++ .github/workflows/php.yml | 9 +- CONTRIBUTING.md | 27 +- RELEASING.md | 143 ++++++++++- packages/laravel/package.json | 6 + pnpm-workspace.yaml | 2 + scripts/check-laravel-release-refs.sh | 31 +++ scripts/commit_laravel_release.py | 84 +++++++ scripts/laravel_release.py | 230 ++++++++++++++++++ scripts/test-laravel.sh | 63 +++++ scripts/tests/test_commit_laravel_release.py | 43 ++++ scripts/tests/test_laravel_release.py | 210 ++++++++++++++++ .../tests/test_laravel_workflow_scripts.py | 82 +++++++ 14 files changed, 1198 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/laravel-release.yml create mode 100644 .github/workflows/laravel.yml create mode 100644 packages/laravel/package.json create mode 100644 scripts/check-laravel-release-refs.sh create mode 100644 scripts/commit_laravel_release.py create mode 100644 scripts/laravel_release.py create mode 100644 scripts/test-laravel.sh create mode 100644 scripts/tests/test_commit_laravel_release.py create mode 100644 scripts/tests/test_laravel_release.py create mode 100644 scripts/tests/test_laravel_workflow_scripts.py diff --git a/.github/workflows/laravel-release.yml b/.github/workflows/laravel-release.yml new file mode 100644 index 00000000..16bb5ca4 --- /dev/null +++ b/.github/workflows/laravel-release.yml @@ -0,0 +1,203 @@ +name: Laravel Release + +# Separate intents and manual dispatch deliberately do not trigger the core publisher. +on: + push: + branches: [main] + paths: ['.laravel-changes/*.json'] + workflow_dispatch: + +permissions: + contents: read + +env: + PYTHONDONTWRITEBYTECODE: '1' + +concurrency: + group: laravel-release + cancel-in-progress: false + +jobs: + prepare: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + outputs: + source: ${{ steps.candidate.outputs.source }} + version: ${{ steps.candidate.outputs.version }} + patch-sha256: ${{ steps.candidate.outputs.patch_sha256 }} + archive-sha256: ${{ steps.candidate.outputs.archive_sha256 }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Require current main + env: + SOURCE: ${{ github.sha }} + run: test "$(git rev-parse origin/main)" = "$SOURCE" + - uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1 + with: + version: 11.13.1 + runtime: node@24 + install: false + - run: python3 -m unittest discover -s scripts/tests -v + - name: Prepare immutable candidate (no release credentials) + id: candidate + env: + REPOSITORY: ${{ github.repository }} + run: | + python3 scripts/laravel_release.py prepare --repository "$REPOSITORY" --output "$RUNNER_TEMP/candidate" + python3 - "$RUNNER_TEMP/candidate/candidate.json" >> "$GITHUB_OUTPUT" <<'PY' + import json, sys + for key, value in json.load(open(sys.argv[1])).items(): + print(f'{key}={value}') + PY + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: laravel-candidate-${{ github.sha }} + path: ${{ runner.temp }}/candidate/ + if-no-files-found: error + retention-days: 5 + + verify: + needs: prepare + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.prepare.outputs.source }} + persist-credentials: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: laravel-candidate-${{ needs.prepare.outputs.source }} + path: ${{ runner.temp }}/candidate + - name: Independently reproduce candidate bytes + env: + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + python3 scripts/laravel_release.py prepare --repository "$REPOSITORY" --output "$RUNNER_TEMP/reproduced" + for file in candidate.json release.patch package.tar.gz notes.md; do + cmp "$RUNNER_TEMP/candidate/$file" "$RUNNER_TEMP/reproduced/$file" + done + git apply --check "$RUNNER_TEMP/candidate/release.patch" + - name: Check source and distribution release refs are unused + env: + GH_TOKEN: ${{ github.token }} + SOURCE_REPOSITORY: ${{ github.repository }} + DISTRIBUTION_REPOSITORY: ${{ vars.LARAVEL_DISTRIBUTION_REPOSITORY }} + VERSION: ${{ needs.prepare.outputs.version }} + run: bash scripts/check-laravel-release-refs.sh + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # v2.40.0 + with: + php-version: '8.3' + extensions: curl, mbstring, dom, xml, sqlite3, pdo_sqlite + tools: composer + - name: Test approved source on both supported frameworks + run: | + bash scripts/test-laravel.sh 12 + bash scripts/test-laravel.sh 13 + - name: Validate distribution Composer metadata + run: | + mkdir "$RUNNER_TEMP/composer-check" + tar -xzf "$RUNNER_TEMP/candidate/package.tar.gz" -C "$RUNNER_TEMP/composer-check" + cd "$RUNNER_TEMP/composer-check" + composer validate --strict --no-check-version + + publish: + needs: [prepare, verify] + runs-on: ubuntu-latest + # Administrators MUST configure required reviewers and main-only deployment rules first. + environment: Laravel Release + permissions: + contents: read + actions: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.prepare.outputs.source }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: laravel-candidate-${{ needs.prepare.outputs.source }} + path: ${{ runner.temp }}/candidate + - name: Recheck refs, main and approved candidate digests + env: + GH_TOKEN: ${{ secrets.LARAVEL_SOURCE_TOKEN }} + SOURCE_REPOSITORY: ${{ github.repository }} + DISTRIBUTION_REPOSITORY: ${{ vars.LARAVEL_DISTRIBUTION_REPOSITORY }} + VERSION: ${{ needs.prepare.outputs.version }} + EXPECTED_SOURCE: ${{ needs.prepare.outputs.source }} + EXPECTED_PATCH: ${{ needs.prepare.outputs.patch-sha256 }} + EXPECTED_ARCHIVE: ${{ needs.prepare.outputs.archive-sha256 }} + DISTRIBUTION_TOKEN: ${{ secrets.LARAVEL_DISTRIBUTION_TOKEN }} + run: | + set -euo pipefail + test -n "$GH_TOKEN" && test -n "$DISTRIBUTION_TOKEN" + test "$(gh api "repos/$SOURCE_REPOSITORY/git/ref/heads/main" --jq '.object.sha')" = "$EXPECTED_SOURCE" + bash scripts/check-laravel-release-refs.sh + echo "$EXPECTED_PATCH $RUNNER_TEMP/candidate/release.patch" | sha256sum --check --strict + echo "$EXPECTED_ARCHIVE $RUNNER_TEMP/candidate/package.tar.gz" | sha256sum --check --strict + git apply --index "$RUNNER_TEMP/candidate/release.patch" + - name: Atomic GitHub-signed source version commit + id: source-commit + env: + GH_TOKEN: ${{ secrets.LARAVEL_SOURCE_TOKEN }} + REPOSITORY: ${{ github.repository }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + python3 scripts/commit_laravel_release.py --checkout "$GITHUB_WORKSPACE" --repository "$REPOSITORY" --mode source --message "chore: release laravel-v$VERSION [skip ci]" >> "$GITHUB_OUTPUT" + - name: Require signed source commit + env: + GH_TOKEN: ${{ secrets.LARAVEL_SOURCE_TOKEN }} + REPOSITORY: ${{ github.repository }} + COMMIT: ${{ steps.source-commit.outputs.commit-hash }} + run: | + test -n "$COMMIT" + test "$(gh api "repos/$REPOSITORY/commits/$COMMIT" --jq '.commit.verification.verified')" = true + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ vars.LARAVEL_DISTRIBUTION_REPOSITORY }} + ref: main + path: laravel-distribution + token: ${{ secrets.LARAVEL_DISTRIBUTION_TOKEN }} + persist-credentials: false + - name: Replace dedicated distribution tree with package-only files + env: + REPOSITORY: ${{ vars.LARAVEL_DISTRIBUTION_REPOSITORY }} + run: python3 scripts/laravel_release.py install --candidate "$RUNNER_TEMP/candidate" --destination "$GITHUB_WORKSPACE/laravel-distribution" --repository "$REPOSITORY" + - name: Atomic GitHub-signed distribution commit + id: distribution-commit + env: + GH_TOKEN: ${{ secrets.LARAVEL_DISTRIBUTION_TOKEN }} + REPOSITORY: ${{ vars.LARAVEL_DISTRIBUTION_REPOSITORY }} + VERSION: ${{ needs.prepare.outputs.version }} + SOURCE: ${{ needs.prepare.outputs.source }} + run: | + python3 scripts/commit_laravel_release.py --checkout "$GITHUB_WORKSPACE/laravel-distribution" --repository "$REPOSITORY" --mode distribution --message "Release v$VERSION from source $SOURCE" >> "$GITHUB_OUTPUT" + - name: Publish distribution version + env: + GH_TOKEN: ${{ secrets.LARAVEL_DISTRIBUTION_TOKEN }} + REPOSITORY: ${{ vars.LARAVEL_DISTRIBUTION_REPOSITORY }} + COMMIT: ${{ steps.distribution-commit.outputs.commit-hash }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + set -euo pipefail + test -n "$COMMIT" + test "$(gh api "repos/$REPOSITORY/commits/$COMMIT" --jq '.commit.verification.verified')" = true + gh release create "v$VERSION" --repo "$REPOSITORY" --target "$COMMIT" --title "v$VERSION" --notes-file "$RUNNER_TEMP/candidate/notes.md" + - name: Publish independent source release and archive + env: + GH_TOKEN: ${{ secrets.LARAVEL_SOURCE_TOKEN }} + REPOSITORY: ${{ github.repository }} + COMMIT: ${{ steps.source-commit.outputs.commit-hash }} + VERSION: ${{ needs.prepare.outputs.version }} + DISTRIBUTION_REPOSITORY: ${{ vars.LARAVEL_DISTRIBUTION_REPOSITORY }} + DISTRIBUTION_COMMIT: ${{ steps.distribution-commit.outputs.commit-hash }} + ARCHIVE_SHA256: ${{ needs.prepare.outputs.archive-sha256 }} + run: | + set -euo pipefail + printf '\nDistribution: %s@%s\nArchive SHA256: %s\n' "$DISTRIBUTION_REPOSITORY" "$DISTRIBUTION_COMMIT" "$ARCHIVE_SHA256" >> "$RUNNER_TEMP/candidate/notes.md" + gh release create "laravel-v$VERSION" --latest=false --repo "$REPOSITORY" --target "$COMMIT" --title "Laravel v$VERSION" --notes-file "$RUNNER_TEMP/candidate/notes.md" "$RUNNER_TEMP/candidate/package.tar.gz" "$RUNNER_TEMP/candidate/candidate.json" diff --git a/.github/workflows/laravel.yml b/.github/workflows/laravel.yml new file mode 100644 index 00000000..771a2c42 --- /dev/null +++ b/.github/workflows/laravel.yml @@ -0,0 +1,73 @@ +name: Laravel CI + +on: + pull_request: + paths: + - 'packages/laravel/**' + - 'examples/laravel/**' + - 'lib/**' + - 'composer.json' + - 'scripts/**' + - 'pnpm-workspace.yaml' + - '.github/workflows/laravel*.yml' + push: + branches: [main] + paths: + - 'packages/laravel/**' + - 'examples/laravel/**' + - 'lib/**' + - 'composer.json' + - 'scripts/**' + - 'pnpm-workspace.yaml' + - '.github/workflows/laravel*.yml' + +permissions: + contents: read + +env: + PYTHONDONTWRITEBYTECODE: '1' + +concurrency: + group: laravel-ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + release-tooling: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1 + with: + version: 11.13.1 + runtime: node@24 + install: false + - run: python3 -m unittest discover -s scripts/tests -v + + framework: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - { laravel: '12', php: '8.2' } + - { laravel: '12', php: '8.3' } + - { laravel: '12', php: '8.4' } + - { laravel: '12', php: '8.5' } + - { laravel: '13', php: '8.3' } + - { laravel: '13', php: '8.4' } + - { laravel: '13', php: '8.5' } + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # v2.40.0 + with: + php-version: ${{ matrix.php }} + extensions: curl, mbstring, dom, xml, sqlite3, pdo_sqlite + tools: composer + - name: Test package and available example in isolation + env: + LARAVEL: ${{ matrix.laravel }} + run: bash scripts/test-laravel.sh "$LARAVEL" diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 4fb1eff3..7ffdd676 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -155,10 +155,7 @@ jobs: ./vendor/bin/phpcbf --standard=phpcs.xml --no-colors --runtime-set ignore_non_auto_fixable_on_exit 1 git diff --exit-code -- lib test - - uses: tinovyatkin/action-php-codesniffer@0043b33b3629611c37e8bc7ee8a4e061dc9a7ea2 # v1 + - name: Check core coding standard if: needs.detect-markdown-only.outputs.markdown_only != 'true' - with: - files: "**.php" # you may customize glob as needed - phpcs_path: ./vendor/bin/phpcs - standard: phpcs.xml - scope: "file" + # Explicit paths must stay within the core ruleset; Laravel has independent CI. + run: ./vendor/bin/phpcs --standard=phpcs.xml --extensions=php lib test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d22535a0..bb95516d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,9 +43,34 @@ python3 -m unittest discover -s sdk_compliance_adapter -v After the Composer install above, run PHP_CodeSniffer with the Composer-installed tool and `phpcs.xml`, matching CI: ```bash -./vendor/bin/phpcs --standard=phpcs.xml --extensions=php . +./vendor/bin/phpcs --standard=phpcs.xml --extensions=php lib test ``` +## Laravel development + +`packages/laravel` is an independent Composer package, not a pnpm workspace project. +Its CI covers Laravel 12 on PHP 8.2–8.5 and Laravel 13 on PHP 8.3–8.5. +From a committed checkout, run: + +```bash +bash scripts/test-laravel.sh 12 +bash scripts/test-laravel.sh 13 +PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s scripts/tests -v +``` + +The framework checks lint tracked package/example PHP files, then install and test in +an automatically cleaned temporary source copy. They inject local Composer path +repositories for the checked-out core and Laravel package there, never into your +checkout or distribution metadata. The example's `composer test` runs when its +`composer.json` is available; a missing test command then fails rather than skips. +The release fixtures use pnpm 11.13.1 and fake publishing tools; they never publish +or use credentials. Root PHPCS checks remain scoped to `lib` and `test`. + +Record Laravel release changes with +`python3 scripts/laravel_release.py intent patch "Summary"`, **not** `pnpm change`. +See [the Laravel release guide](RELEASING.md#laravel-package-independent-releases) +for the first-release intent and required administrator setup. + ## Public API changes Public API is hard to change once it ships, so agree on it before writing the implementation. Our [SDK guidelines](https://posthog.com/handbook/engineering/sdks/guidelines) explain how we design it. diff --git a/RELEASING.md b/RELEASING.md index cd927517..c95dddd2 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,6 +1,6 @@ # Releasing -This repository uses [pnpm's native release management](https://pnpm.io/versioning) and an automated GitHub Actions workflow for releases. +The core PHP SDK uses [pnpm's native release management](https://pnpm.io/versioning) and an automated GitHub Actions workflow for releases. The [Laravel package](#laravel-package-independent-releases) has a separate release lane; its changes must not be recorded with `pnpm change`. ## How to Release @@ -91,3 +91,144 @@ git push origin main ``` After rollback, add or restore the needed change intent and let the release workflow prepare a new release candidate. + +## Laravel package: independent releases + +`packages/laravel` contains `posthog/posthog-laravel`, initially **0.1.0**, depending +on `posthog/posthog-php ^4.20`. It is not part of the pnpm workspace. **Do not select +it in `pnpm change`, add it to the workspace, or put Laravel intents in +`.changeset/`.** Core and Laravel changes in the same PR need separate intents. + +Packagist reads `composer.json` at a repository's root, not under +`packages/laravel`. The source repository therefore cannot itself be registered +as this second Composer package. Publishing uses a separate, dedicated public +**distribution repository**, recommended `PostHog/posthog-laravel`. Its root is +replaced with only tracked package `composer.json`, `README.md`, `LICENSE.md`, +`src/**/*.php`, `config/**/*.php`, and generated `posthog-source.json`. Hidden +files, vendors, tests, environment files, locks, development tooling and non-PHP +config files are excluded; symlinks fail preparation. Never keep unrelated files +in that repository: obsolete distribution files are deleted on release. + +### Prerequisites — administrator actions, not performed by this tooling change + +Before enabling publication: + +1. Create the dedicated public distribution repository and initialize its `main` + branch with a signed bootstrap commit (a README is sufficient). Do not create + the repository or publish while merely testing this tooling. +2. Set source repository variable `LARAVEL_DISTRIBUTION_REPOSITORY` to its + `owner/repository` name. It must differ from the source repository. +3. Create the source **`Laravel Release` environment**, require maintainer review, + prevent self-review, and restrict deployments to protected `main`. Apply + normal source PR/branch protections and require Laravel CI before merging. + Merely naming an environment in YAML does not configure its protection rules. +4. Store **environment-only** secrets `LARAVEL_SOURCE_TOKEN` and + `LARAVEL_DISTRIBUTION_TOKEN` in that reviewed environment. Use a narrowly scoped + fine-grained token for each repository (Contents read/write, including the + GitHub GraphQL `createCommitOnBranch` API and releases). Do not put these + secrets at repository/org scope or expose them to preparation/verification. + Configure branch/ruleset permissions for those actors to create GitHub-signed + version commits, without disabling required signatures or force-push rules. + No local signing key is uploaded. The workflow checks GitHub commit signature + verification before tagging either repository. +5. Publish core PHP **4.20 or later** first, so the Laravel Composer dependency + can resolve without a development path override. Remove any local Composer + `repositories` from the committed Laravel package; preparation rejects them. +6. Register `posthog/posthog-laravel` on Packagist using the **distribution** repo, + configure its normal GitHub update integration/webhook, and verify ownership. + This workflow does not create repositories, register Packagist packages, or + configure webhooks. The distribution repo must be public so pre-approval, + read-only jobs can verify its refs without release credentials. + +Unconfigured variables, secrets, repositories, or refs fail closed. Administrators +must configure review rules before adding the environment secrets; no publication +has been performed as part of implementing these files. + +### Record a Laravel change + +From the source repository root: + +```bash +# Once only, for the initial 0.1.0 release: +python3 scripts/laravel_release.py intent initial "Initial Laravel package" + +# Subsequent releases (choose patch, minor, or major): +python3 scripts/laravel_release.py intent patch "Describe the Laravel change" +``` + +Commit the generated `.laravel-changes/.json` alongside the change. The +largest pending bump wins. `initial` preserves 0.1.0 and cannot be combined with +other bump types; the changelog prevents releasing that version twice. Later +bumps follow normal SemVer (minor 0.1.0 becomes 0.2.0). Preparation synchronizes +`packages/laravel/package.json`, `composer.json`, and `src/PostHog.php::VERSION`, +updates root `LARAVEL_CHANGELOG.md`, and removes only the consumed Laravel intents. +It does not change the root/core version, changelog, Composer lock, or intents. + +A push changing `.laravel-changes/*.json` on `main` starts **Laravel Release**; +manual dispatch on `main` is also available. A stale trigger fails rather than +publishing old source: dispatch again on current `main` if intents remain. +There must be at least one pending Laravel intent. The core `Release` workflow +and its script hash, signing, verification and approval protections are unchanged. + +### Candidate, approval and publication + +1. A read-only job clones the exact committed source into a temporary directory, + runs deterministic fixtures, and prepares a version patch, package-only + archive, source metadata, release notes and SHA256 manifest. Uncommitted local + files are never used. Nothing modifies the original checkout. +2. A separate read-only job independently reproduces every candidate byte, + checks both repositories for duplicate tags/releases (only explicit 404 means + missing), validates Composer metadata, and tests Laravel 12 and 13 on PHP 8.3. + Normal Laravel CI additionally covers 12/PHP 8.2–8.5 and 13/PHP 8.3–8.5 and + the runnable example when present. No release credentials enter those jobs. +3. After environment approval, the publishing job rechecks `main`, refs and + candidate digests. It applies only the verified patch and uses GitHub's + `createCommitOnBranch` API to create a signed source commit, with + `expectedHeadOid` rejecting concurrent branch changes atomically. It executes + no Composer installs, package scripts or git hooks with release credentials. +4. It replaces the dedicated distribution tree, creates another atomically + guarded GitHub-signed commit, and publishes distribution **`vX.Y.Z`**. Packagist + can discover the root Composer package from that tag. +5. It publishes source **`laravel-vX.Y.Z`** against the signed source version + commit with the package archive, manifest, checksums and distribution commit + reference. The source release is not marked latest, preserving the core SDK's + latest-release status. `posthog-source.json` records the original input source + SHA, source directory and source release tag; the tag includes the version + patch on top of that SHA. + +Preview without credentials or publication, using an output path that does not +already exist: + +```bash +python3 scripts/laravel_release.py prepare \ + --repository PostHog/posthog-php --output /tmp/laravel-candidate +cat /tmp/laravel-candidate/candidate.json +``` + +Fixtures run without credentials, temporary repositories only: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s scripts/tests -v +actionlint .github/workflows/laravel.yml .github/workflows/laravel-release.yml +``` + +The fixtures pin **pnpm 11.13.1**, including native core-only releases and +selective-filter experiments: `--filter posthog-php version -r` leaves Laravel +unchanged and retains a mixed intent until both packages are released (the +ledger prevents rebumping core). This does **not** make registering Laravel safe: +the existing core publisher runs **unfiltered** `pnpm version -r`. Keeping Laravel +outside that workspace and using a different intent directory avoids consuming +or bumping the other package, without refactoring core publishing. + +### Partial-failure recovery + +Publication spans two repositories and cannot be transactional. A failure may +leave a signed source version commit without a distribution commit/tag, or a +published distribution version without the source release. Do not blindly retry +or restore a consumed intent: that can bump again or create duplicate versions. +Inspect the retained candidate artifact and both repository histories/ref states. +A maintainer must verify signatures, candidate checksums and matching root +Composer/runtime versions, then complete only the missing commit/tag/release +using the original version and recorded source. Never move an already published +version/tag or overwrite distribution history. Correct a bad version with new +signed commits and a new intent/version; use signed reverts, not history rewrites. diff --git a/packages/laravel/package.json b/packages/laravel/package.json new file mode 100644 index 00000000..9c1c0172 --- /dev/null +++ b/packages/laravel/package.json @@ -0,0 +1,6 @@ +{ + "name": "posthog-laravel", + "version": "0.1.0", + "private": true, + "description": "Independent release metadata for posthog/posthog-laravel" +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9b963ae6..a10c201e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,5 @@ +# Laravel deliberately uses scripts/laravel_release.py and .laravel-changes/. +# Do not add it here: the core publisher runs an unfiltered pnpm version -r. packages: - '.' diff --git a/scripts/check-laravel-release-refs.sh b/scripts/check-laravel-release-refs.sh new file mode 100644 index 00000000..001c328e --- /dev/null +++ b/scripts/check-laravel-release-refs.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${SOURCE_REPOSITORY:?Source repository is required}" +: "${DISTRIBUTION_REPOSITORY:?Configure LARAVEL_DISTRIBUTION_REPOSITORY first}" +: "${VERSION:?Version is required}" +[[ "$SOURCE_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] +[[ "$DISTRIBUTION_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] +[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] +[ "$(printf %s "$SOURCE_REPOSITORY" | tr '[:upper:]' '[:lower:]')" != "$(printf %s "$DISTRIBUTION_REPOSITORY" | tr '[:upper:]' '[:lower:]')" ] + +# Only an explicit HTTP 404 means absent. Permissions, rate limits and outages fail closed. +missing() { + local endpoint=$1 output + if output=$(gh api "$endpoint" 2>&1); then + echo "Release ref already exists: $endpoint" >&2 + exit 1 + fi + if ! grep -q 'HTTP 404' <<< "$output"; then + echo "Could not check release ref: $output" >&2 + exit 1 + fi +} + +# The distribution must already be a public, dedicated repository with a main branch. +[ "$(gh api "repos/$DISTRIBUTION_REPOSITORY" --jq '.private')" = false ] +gh api "repos/$DISTRIBUTION_REPOSITORY/git/ref/heads/main" --jq '.object.sha' > /dev/null +for endpoint in releases/tags git/ref/tags; do + missing "repos/$SOURCE_REPOSITORY/$endpoint/laravel-v$VERSION" + missing "repos/$DISTRIBUTION_REPOSITORY/$endpoint/v$VERSION" +done diff --git a/scripts/commit_laravel_release.py b/scripts/commit_laravel_release.py new file mode 100644 index 00000000..d4fd2136 --- /dev/null +++ b/scripts/commit_laravel_release.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Create a GitHub-signed commit, atomically rejecting a moved destination branch. + +Only called by the environment-approved workflow. Does not run Composer, package +hooks, git hooks, or shell code. Authentication is supplied to gh by the environment. +""" + +import argparse +import base64 +import json +from pathlib import Path +import re +import subprocess +import sys + +sys.dont_write_bytecode = True +from laravel_release import allowed_distribution_file + +SOURCE_FILES = { + "packages/laravel/package.json", "packages/laravel/composer.json", + "packages/laravel/src/PostHog.php", "LARAVEL_CHANGELOG.md", +} +QUERY = """ +mutation($input: CreateCommitOnBranchInput!) { + createCommitOnBranch(input: $input) { commit { oid } } +} +""" + + +def request(checkout, repository, mode, message): + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository): + raise ValueError("Invalid repository") + def git(*args): + return subprocess.check_output(["git", *args], cwd=checkout) + expected = git("rev-parse", "HEAD").decode().strip() + # Include untracked distribution files, with no hooks or identity/signing configuration. + paths = set(filter(None, git("diff", "--name-only", "--no-renames", "-z", "HEAD").decode().split("\0"))) + paths.update(filter(None, git("ls-files", "--others", "--exclude-standard", "-z").decode().split("\0"))) + additions, deletions = [], [] + for name in sorted(paths): + permitted = (name in SOURCE_FILES or bool(re.fullmatch(r"\.laravel-changes/[A-Za-z0-9_-]+\.json", name))) if mode == "source" else ( + allowed_distribution_file(name) or name == "posthog-source.json" + ) + path = checkout / name + # Any obsolete tracked distribution file may be deleted, but only allowlisted + # package files may be added. Source changes always use the narrow allowlist. + if not permitted and (mode == "source" or path.exists() or path.is_symlink()): + raise ValueError(f"Unexpected {mode} release change: {name}") + if path.is_symlink(): + raise ValueError(f"Symlink in release: {name}") + if path.exists(): + additions.append({"path": name, "contents": base64.b64encode(path.read_bytes()).decode()}) + else: + deletions.append({"path": name}) + if not paths: + raise ValueError("No release changes") + return {"query": QUERY, "variables": {"input": { + "branch": {"repositoryNameWithOwner": repository, "branchName": "main"}, + "expectedHeadOid": expected, + "message": {"headline": message}, + "fileChanges": {"additions": additions, "deletions": deletions}, + }}} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--checkout", type=Path, required=True) + parser.add_argument("--repository", required=True) + parser.add_argument("--mode", choices=["source", "distribution"], required=True) + parser.add_argument("--message", required=True) + args = parser.parse_args() + payload = request(args.checkout, args.repository, args.mode, args.message) + result = subprocess.check_output(["gh", "api", "graphql", "--input", "-"], input=json.dumps(payload).encode()) + response = json.loads(result) + if response.get("errors"): + raise RuntimeError(response["errors"]) + commit = response["data"]["createCommitOnBranch"]["commit"]["oid"] + if not re.fullmatch(r"[a-f0-9]{40}", commit): + raise ValueError("Invalid commit returned by GitHub") + print(f"commit-hash={commit}") + + +if __name__ == "__main__": + main() diff --git a/scripts/laravel_release.py b/scripts/laravel_release.py new file mode 100644 index 00000000..bd8f3ba9 --- /dev/null +++ b/scripts/laravel_release.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Prepare Laravel releases without modifying the source checkout or using credentials.""" + +import argparse +import gzip +import hashlib +import io +import json +from pathlib import Path, PurePosixPath +import re +import shutil +import subprocess +import tarfile +import tempfile +import uuid + +PACKAGE = Path("packages/laravel") +INTENTS = Path(".laravel-changes") +CHANGELOG = Path("LARAVEL_CHANGELOG.md") +VERSION = re.compile(r"0|[1-9][0-9]*") +FORBIDDEN = {"vendor", "tests", "test", "node_modules", "storage", "cache", "secrets"} + + +def run(*args, cwd): + return subprocess.check_output(args, cwd=cwd, stderr=subprocess.PIPE) + + +def read_json(path): + return json.loads(path.read_text()) + + +def write_json(path, value): + path.write_text(json.dumps(value, indent=2) + "\n") + + +def version_parts(version): + parts = version.split(".") + if len(parts) != 3 or not all(VERSION.fullmatch(part) for part in parts): + raise ValueError(f"Not a stable version: {version}") + return [int(part) for part in parts] + + +def next_version(current, bumps): + parts = version_parts(current) + if "initial" in bumps: + if set(bumps) != {"initial"} or current != "0.1.0": + raise ValueError("initial intents are only valid alone at 0.1.0") + return current + index = min({"major": 0, "minor": 1, "patch": 2}[bump] for bump in bumps) + parts[index] += 1 + parts[index + 1:] = [0] * (2 - index) + return ".".join(map(str, parts)) + + +def allowed_distribution_file(name): + path = PurePosixPath(name) + if path.is_absolute() or any(p.startswith(".") or p.lower() in FORBIDDEN for p in path.parts): + return False + return name in {"composer.json", "README.md", "LICENSE.md"} or ( + len(path.parts) > 1 and path.parts[0] in {"src", "config"} and path.suffix == ".php" + ) + + +def archive(files): + """Fixed ordering, ownership, mode and timestamps make byte-for-byte verification possible.""" + output = io.BytesIO() + with gzip.GzipFile(fileobj=output, mode="wb", mtime=0, filename="") as zipped: + with tarfile.open(fileobj=zipped, mode="w", format=tarfile.USTAR_FORMAT) as tar: + for name, data in sorted(files.items()): + entry = tarfile.TarInfo(name) + entry.size = len(data) + entry.mode = 0o644 + tar.addfile(entry, io.BytesIO(data)) + return output.getvalue() + + +def prepare(root, output, repository): + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository): + raise ValueError("Expected owner/repository") + if output.exists(): + raise ValueError("Candidate output must not already exist") + source = run("git", "rev-parse", "HEAD", cwd=root).decode().strip() + # Only committed files enter the candidate; local vendors, .env and modifications never enter. + with tempfile.TemporaryDirectory(prefix="laravel-release-") as temporary: + work = Path(temporary) / "source" + run("git", "clone", "--quiet", "--no-hardlinks", "--no-local", str(root), str(work), cwd=root) + run("git", "checkout", "--quiet", "--detach", source, cwd=work) + pending = sorted((work / INTENTS).glob("*.json")) + if not pending: + raise ValueError("No pending Laravel change intents") + intents = [read_json(path) for path in pending] + for intent in intents: + if set(intent) != {"package", "bump", "summary"} or intent["package"] != "posthog-laravel": + raise ValueError("Invalid Laravel change intent") + if intent["bump"] not in {"initial", "patch", "minor", "major"}: + raise ValueError("Invalid Laravel bump") + if not isinstance(intent["summary"], str) or not intent["summary"].strip(): + raise ValueError("Empty Laravel release summary") + metadata = read_json(work / PACKAGE / "package.json") + composer = read_json(work / PACKAGE / "composer.json") + current = metadata["version"] + if composer.get("version", current) != current: + raise ValueError("Laravel package.json/composer.json version mismatch") + if composer["name"] != "posthog/posthog-laravel" or "repositories" in composer: + raise ValueError("Expected production Laravel Composer metadata (no repository overrides)") + new = next_version(current, [intent["bump"] for intent in intents]) + previous = (work / CHANGELOG).read_text() if (work / CHANGELOG).exists() else "# Laravel changelog\n\n" + if not previous.startswith("# Laravel changelog\n\n"): + raise ValueError("Invalid Laravel changelog header") + if f"## {new}\n" in previous: + raise ValueError(f"Laravel {new} has already been released") + notes = f"## {new}\n\n" + "\n".join("- " + i["summary"].strip() for i in intents) + "\n\n" + (work / CHANGELOG).write_text(previous.replace("# Laravel changelog\n\n", "# Laravel changelog\n\n" + notes, 1)) + runtime = work / PACKAGE / "src/PostHog.php" + declaration = re.compile(r"(public const VERSION = ')([^']+)(';)") + declarations = declaration.findall(runtime.read_text()) + if len(declarations) != 1 or declarations[0][1] != current: + raise ValueError("Laravel runtime VERSION mismatch") + runtime.write_text(declaration.sub(lambda match: match[1] + new + match[3], runtime.read_text())) + metadata["version"] = composer["version"] = new + write_json(work / PACKAGE / "package.json", metadata) + write_json(work / PACKAGE / "composer.json", composer) + for path in pending: + path.unlink() + tracked = run("git", "ls-files", "-z", "--", str(PACKAGE), cwd=work).decode().split("\0") + files = {} + for name in filter(None, tracked): + relative = Path(name).relative_to(PACKAGE).as_posix() + if not allowed_distribution_file(relative): + continue + path = work / name + if path.is_symlink() or not path.is_file(): + raise ValueError(f"Not a regular distribution file: {name}") + files[relative] = path.read_bytes() + if not {"composer.json", "README.md", "LICENSE.md"}.issubset(files) or not any(n.startswith("src/") for n in files): + raise ValueError("Incomplete Laravel package") + tag = f"laravel-v{new}" + files["posthog-source.json"] = (json.dumps({ + "repository": repository, "commit": source, "tag": tag, + "directory": PACKAGE.as_posix(), "version": new, + }, indent=2) + "\n").encode() + run("git", "add", "--intent-to-add", str(CHANGELOG), cwd=work) + patch = run("git", "diff", "--binary", cwd=work) + changed = run("git", "diff", "--name-only", cwd=work).decode().splitlines() + permitted = {str(PACKAGE / "composer.json"), str(PACKAGE / "package.json"), + str(PACKAGE / "src/PostHog.php"), str(CHANGELOG)} + permitted.update(str(path.relative_to(work)) for path in pending) + if not set(changed).issubset(permitted): + raise ValueError(f"Unexpected release changes: {changed}") + payload = archive(files) + output.mkdir(parents=True) + (output / "release.patch").write_bytes(patch) + (output / "package.tar.gz").write_bytes(payload) + (output / "notes.md").write_text(notes + f"Source: {repository}@{source} ({PACKAGE})\n") + write_json(output / "candidate.json", { + "version": new, "source": source, "source_tag": tag, "distribution_tag": f"v{new}", + "patch_sha256": hashlib.sha256(patch).hexdigest(), + "archive_sha256": hashlib.sha256(payload).hexdigest(), + }) + + +def install(candidate, destination, repository): + """Replace a dedicated distribution checkout; never extract tar paths unchecked.""" + manifest = read_json(candidate / "candidate.json") + payload = (candidate / "package.tar.gz").read_bytes() + if hashlib.sha256(payload).hexdigest() != manifest["archive_sha256"]: + raise ValueError("Distribution archive digest mismatch") + if not (destination / ".git").exists(): + raise ValueError("Destination must be a dedicated distribution Git checkout") + files = {} + with tarfile.open(fileobj=io.BytesIO(payload), mode="r:gz") as tar: + for entry in tar: + if not entry.isfile() or entry.name in files or not ( + allowed_distribution_file(entry.name) or entry.name == "posthog-source.json" + ): + raise ValueError(f"Unexpected archive member: {entry.name}") + files[entry.name] = tar.extractfile(entry).read() + if json.loads(files["composer.json"])["version"] != manifest["version"]: + raise ValueError("Distribution version mismatch") + source = json.loads(files["posthog-source.json"]) + if repository.lower() == source["repository"].lower() or not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository): + raise ValueError("Distribution repository must be separate from source") + remote = run("git", "remote", "get-url", "origin", cwd=destination).decode().strip() + if remote not in {f"https://github.com/{repository}", f"https://github.com/{repository}.git"}: + raise ValueError("Distribution checkout origin does not match configured repository") + for path in destination.iterdir(): + if path.name == ".git": + continue + if path.is_dir() and not path.is_symlink(): + shutil.rmtree(path) + else: + path.unlink() + for name, data in files.items(): + path = destination / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + intent = sub.add_parser("intent") + intent.add_argument("bump", choices=["initial", "patch", "minor", "major"]) + intent.add_argument("summary") + intent.add_argument("--root", type=Path, default=Path.cwd()) + candidate = sub.add_parser("prepare") + candidate.add_argument("--root", type=Path, default=Path.cwd()) + candidate.add_argument("--output", type=Path, required=True) + candidate.add_argument("--repository", required=True) + unpack = sub.add_parser("install") + unpack.add_argument("--candidate", type=Path, required=True) + unpack.add_argument("--destination", type=Path, required=True) + unpack.add_argument("--repository", required=True) + args = parser.parse_args() + if args.command == "intent": + if not args.summary.strip(): + parser.error("summary must not be empty") + directory = args.root / INTENTS + directory.mkdir(exist_ok=True) + path = directory / f"{uuid.uuid4().hex}.json" + write_json(path, {"package": "posthog-laravel", "bump": args.bump, "summary": args.summary}) + print(path) + elif args.command == "prepare": + prepare(args.root.resolve(), args.output.resolve(), args.repository) + else: + install(args.candidate.resolve(), args.destination.resolve(), args.repository) + + +if __name__ == "__main__": + main() diff --git a/scripts/test-laravel.sh b/scripts/test-laravel.sh new file mode 100644 index 00000000..c5cec4cb --- /dev/null +++ b/scripts/test-laravel.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Composer overrides and generated locks live only in this disposable source copy. +LARAVEL=${1:?Usage: scripts/test-laravel.sh <12|13>} +case "$LARAVEL" in + 12) TESTBENCH=10 ;; + 13) TESTBENCH=11 ;; + *) echo 'Supported Laravel versions: 12, 13' >&2; exit 1 ;; +esac +ROOT=$(git rev-parse --show-toplevel) +TEMP=$(mktemp -d) +trap 'rm -rf "$TEMP"' EXIT +git -C "$ROOT" archive HEAD | tar -x -C "$TEMP" + +# Lint only tracked source, before Composer can populate dependency directories. +while IFS= read -r -d '' file; do + case "$file" in + *.php) php -l "$TEMP/$file" ;; + esac +done < <(git -C "$ROOT" ls-files -z -- packages/laravel examples/laravel) + +# Tests use the checked-out core API before 4.20 is available on Packagist. +# Never write these development repositories into the distribution metadata. +python3 - "$TEMP" <<'PY' +import json +from pathlib import Path +import sys +root = Path(sys.argv[1]) +for relative in ['packages/laravel', 'examples/laravel']: + path = root / relative / 'composer.json' + if not path.exists(): + continue + data = json.loads(path.read_text()) + repositories = [{ + 'type': 'path', 'url': str(root), + 'options': {'symlink': False, 'versions': {'posthog/posthog-php': '4.20.0'}}, + }] + if relative.startswith('examples/'): + repositories.append({ + 'type': 'path', 'url': str(root / 'packages/laravel'), + 'options': {'symlink': False, 'versions': {'posthog/posthog-laravel': '0.1.0'}}, + }) + data['repositories'] = repositories + path.write_text(json.dumps(data, indent=2) + '\n') + path.with_name('composer.lock').unlink(missing_ok=True) +PY + +cd "$TEMP/packages/laravel" +composer validate --no-check-version --no-check-publish +composer require --dev --no-update --no-interaction "orchestra/testbench:^$TESTBENCH.0" "laravel/framework:^$LARAVEL.0" +composer update --prefer-dist --no-progress --no-interaction --no-scripts +./vendor/bin/phpunit --configuration phpunit.xml + +if [ -f "$TEMP/examples/laravel/composer.json" ]; then + cd "$TEMP/examples/laravel" + composer require --no-update --no-interaction "laravel/framework:^$LARAVEL.0" + composer update --prefer-dist --no-progress --no-interaction --no-scripts + # The example owns its executable test command; do not silently skip a missing command. + composer run test +else + echo 'Laravel example not yet present; package tests completed.' +fi diff --git a/scripts/tests/test_commit_laravel_release.py b/scripts/tests/test_commit_laravel_release.py new file mode 100644 index 00000000..64086b8b --- /dev/null +++ b/scripts/tests/test_commit_laravel_release.py @@ -0,0 +1,43 @@ +"""Test the credential-free GraphQL payload, not the live publishing endpoint.""" + +import base64 +import importlib.util +import json +import sys + +from test_laravel_release import LaravelReleaseFixture, SCRIPT, git, snapshot + +sys.path.insert(0, str(SCRIPT.parent)) +spec = importlib.util.spec_from_file_location("commit_laravel_release", SCRIPT.with_name("commit_laravel_release.py")) +commits = importlib.util.module_from_spec(spec) +spec.loader.exec_module(commits) + + +class CommitLaravelReleaseTest(LaravelReleaseFixture): + def test_signed_commit_payload_has_atomic_expected_head_and_scoped_changes(self): + out = self.prepare() + expected = git(self.root, "rev-parse", "HEAD").decode().strip() + git(self.root, "apply", "--index", str(out / "release.patch")) + payload = commits.request(self.root, "PostHog/posthog-php", "source", "Release Laravel") + request = payload["variables"]["input"] + self.assertEqual(request["expectedHeadOid"], expected) + self.assertEqual(request["branch"], {"repositoryNameWithOwner": "PostHog/posthog-php", "branchName": "main"}) + self.assertIn("createCommitOnBranch", payload["query"]) + additions = {item["path"]: base64.b64decode(item["contents"]) for item in request["fileChanges"]["additions"]} + self.assertEqual(json.loads(additions["packages/laravel/package.json"])["version"], "0.1.1") + self.assertEqual(request["fileChanges"]["deletions"], [{"path": ".laravel-changes/release.json"}]) + self.assertFalse(any(name in additions for name in ["composer.json", "package.json", "lib/PostHog.php"])) + self.write("composer.json", "unexpected core modification") + with self.assertRaisesRegex(ValueError, "Unexpected source release change"): + commits.request(self.root, "PostHog/posthog-php", "source", "Release Laravel") + + def test_distribution_payload_removes_obsolete_files_but_cannot_add_secrets(self): + # A standalone distribution repository may need to remove old development files. + self.write("old-secret.txt", "old development file") + snapshot(self.root) + (self.root / "old-secret.txt").unlink() + payload = commits.request(self.root, "PostHog/posthog-laravel", "distribution", "Release") + self.assertEqual(payload["variables"]["input"]["fileChanges"]["deletions"], [{"path": "old-secret.txt"}]) + self.write("auth.json", "new secret") + with self.assertRaisesRegex(ValueError, "Unexpected distribution release change"): + commits.request(self.root, "PostHog/posthog-laravel", "distribution", "Release") diff --git a/scripts/tests/test_laravel_release.py b/scripts/tests/test_laravel_release.py new file mode 100644 index 00000000..6d590249 --- /dev/null +++ b/scripts/tests/test_laravel_release.py @@ -0,0 +1,210 @@ +"""Release boundaries, tested entirely in disposable repositories (no credentials).""" + +import hashlib +import importlib.util +import json +from pathlib import Path +import shutil +import subprocess +import tarfile +import tempfile +import unittest + +SCRIPT = Path(__file__).resolve().parents[1] / "laravel_release.py" +REPO = SCRIPT.parents[1] +spec = importlib.util.spec_from_file_location("laravel_release", SCRIPT) +release = importlib.util.module_from_spec(spec) +spec.loader.exec_module(release) + + +def git(root, *args, input=None): + return subprocess.check_output(["git", *args], cwd=root, input=input, stderr=subprocess.PIPE) + + +def snapshot(root): + """Create a synthetic fixture commit object, not a user commit or signing operation.""" + git(root, "add", ".") + tree = git(root, "write-tree").decode().strip() + parent = git(root, "rev-parse", "--verify", "HEAD").decode().strip() if (root / ".git/refs/heads/main").exists() else None + data = f"tree {tree}\n" + (f"parent {parent}\n" if parent else "") + data += "author Fixture 1700000000 +0000\ncommitter Fixture 1700000000 +0000\n\nFixture\n" + sha = git(root, "hash-object", "-t", "commit", "-w", "--stdin", input=data.encode()).decode().strip() + git(root, "update-ref", "refs/heads/main", sha) + + +class LaravelReleaseFixture(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.directory = Path(self.temp.name) + self.root = self.directory / "source" + self.root.mkdir() + git(self.root, "init", "-q", "-b", "main") + self.write("package.json", json.dumps({"name": "posthog-php", "version": "4.20.0", "private": True, "packageManager": "pnpm@11.13.1"})) + self.write("pnpm-workspace.yaml", (REPO / "pnpm-workspace.yaml").read_text()) + self.write("composer.json", '{"name":"posthog/posthog-php","version":"4.20.0"}\n') + self.write("lib/PostHog.php", " Date: Thu, 29 Oct 2026 09:39:22 +0100 Subject: [PATCH 03/39] 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 04/39] 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 05/39] 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 06/39] 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 07/39] 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 08/39] 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 09/39] 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 10/39] 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 11/39] 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 12/39] 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 c69f815163c09fa26fa21890a11f2f2bfdb9a04e Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 10:37:48 +0100 Subject: [PATCH 13/39] fix: validate distributable Laravel installs and framework test platforms --- .github/workflows/laravel-release.yml | 1 + scripts/laravel_release.py | 2 +- scripts/test-laravel.sh | 4 +++- scripts/tests/test_laravel_release.py | 4 +++- scripts/tests/test_laravel_workflow_scripts.py | 6 +++++- 5 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/laravel-release.yml b/.github/workflows/laravel-release.yml index 16bb5ca4..8da047b3 100644 --- a/.github/workflows/laravel-release.yml +++ b/.github/workflows/laravel-release.yml @@ -104,6 +104,7 @@ jobs: tar -xzf "$RUNNER_TEMP/candidate/package.tar.gz" -C "$RUNNER_TEMP/composer-check" cd "$RUNNER_TEMP/composer-check" composer validate --strict --no-check-version + composer install --no-dev --prefer-dist --no-progress --no-interaction --no-scripts --no-plugins publish: needs: [prepare, verify] diff --git a/scripts/laravel_release.py b/scripts/laravel_release.py index bd8f3ba9..11082477 100644 --- a/scripts/laravel_release.py +++ b/scripts/laravel_release.py @@ -56,7 +56,7 @@ def allowed_distribution_file(name): path = PurePosixPath(name) if path.is_absolute() or any(p.startswith(".") or p.lower() in FORBIDDEN for p in path.parts): return False - return name in {"composer.json", "README.md", "LICENSE.md"} or ( + return name in {"composer.json", "README.md", "LICENSE.md", "AUTOCAPTURE.md", "PENNANT.md"} or ( len(path.parts) > 1 and path.parts[0] in {"src", "config"} and path.suffix == ".php" ) diff --git a/scripts/test-laravel.sh b/scripts/test-laravel.sh index c5cec4cb..ba2221d1 100644 --- a/scripts/test-laravel.sh +++ b/scripts/test-laravel.sh @@ -22,7 +22,7 @@ done < <(git -C "$ROOT" ls-files -z -- packages/laravel examples/laravel) # Tests use the checked-out core API before 4.20 is available on Packagist. # Never write these development repositories into the distribution metadata. -python3 - "$TEMP" <<'PY' +python3 - "$TEMP" "$LARAVEL" <<'PY' import json from pathlib import Path import sys @@ -42,6 +42,8 @@ for relative in ['packages/laravel', 'examples/laravel']: 'options': {'symlink': False, 'versions': {'posthog/posthog-laravel': '0.1.0'}}, }) data['repositories'] = repositories + if relative.startswith('examples/') and sys.argv[2] == '13': + data.setdefault('config', {}).setdefault('platform', {})['php'] = '8.3.0' path.write_text(json.dumps(data, indent=2) + '\n') path.with_name('composer.lock').unlink(missing_ok=True) PY diff --git a/scripts/tests/test_laravel_release.py b/scripts/tests/test_laravel_release.py index 6d590249..316a759f 100644 --- a/scripts/tests/test_laravel_release.py +++ b/scripts/tests/test_laravel_release.py @@ -106,6 +106,8 @@ def test_initial_release_and_largest_bump(self): release.next_version("0.1.0", ["initial", "patch"]) def test_archive_is_deterministic_and_excludes_development_files(self): + self.write("packages/laravel/AUTOCAPTURE.md", "# Automatic collection\n") + self.write("packages/laravel/PENNANT.md", "# Pennant integration\n") for name in ["vendor/autoload.php", "tests/Test.php", ".env", ".env.testing", "composer.lock", "phpunit.xml", "auth.json", "src/.env.php", "src/vendor/private.php", "src/secrets/private.php", "config/key.pem", "config/.secret.php"]: self.write("packages/laravel/" + name, "secret-development-value") snapshot(self.root) @@ -115,7 +117,7 @@ def test_archive_is_deterministic_and_excludes_development_files(self): for name in ["package.tar.gz", "release.patch", "candidate.json", "notes.md"]: self.assertEqual((first / name).read_bytes(), (second / name).read_bytes()) with tarfile.open(first / "package.tar.gz") as archive: - self.assertEqual(set(archive.getnames()), {"composer.json", "README.md", "LICENSE.md", "src/PostHog.php", "config/posthog.php", "posthog-source.json"}) + self.assertEqual(set(archive.getnames()), {"composer.json", "README.md", "LICENSE.md", "AUTOCAPTURE.md", "PENNANT.md", "src/PostHog.php", "config/posthog.php", "posthog-source.json"}) self.assertFalse(any(b"secret" in archive.extractfile(item).read() for item in archive)) composer = json.load(archive.extractfile("composer.json")) self.assertEqual(composer["require"]["posthog/posthog-php"], "^4.20") diff --git a/scripts/tests/test_laravel_workflow_scripts.py b/scripts/tests/test_laravel_workflow_scripts.py index b2ad4465..59b08642 100644 --- a/scripts/tests/test_laravel_workflow_scripts.py +++ b/scripts/tests/test_laravel_workflow_scripts.py @@ -19,7 +19,7 @@ def executable(self, name, text): return path def test_composer_overrides_and_example_tests_run_only_in_temporary_copy(self): - self.write("examples/laravel/composer.json", '{"name":"posthog/laravel-example","scripts":{"test":"php artisan test"}}') + self.write("examples/laravel/composer.json", '{"name":"posthog/laravel-example","scripts":{"test":"php artisan test"},"config":{"platform":{"php":"8.2.0"}}}') snapshot(self.root) self.write("packages/laravel/.env", "local-secret") self.write("packages/laravel/vendor/local.txt", "local vendor") @@ -49,6 +49,10 @@ def test_composer_overrides_and_example_tests_run_only_in_temporary_copy(self): self.assertNotEqual(Path(entry["cwd"]), self.root / "packages/laravel") for repository in entry["metadata"]["repositories"]: self.assertFalse(Path(repository["url"]).exists(), "Temporary dependency copy should be cleaned up") + example_entries = [e for e in entries if e['cwd'].endswith('/examples/laravel')] + self.assertTrue(example_entries) + self.assertTrue(all(e['metadata']['config']['platform']['php'] == '8.3.0' for e in example_entries)) + self.assertEqual(json.loads((self.root / 'examples/laravel/composer.json').read_text())['config']['platform']['php'], '8.2.0') self.assertNotIn("repositories", json.loads((self.root / "packages/laravel/composer.json").read_text())) From b6ead398b48ced2238c9aac18dc7592343c41988 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 10:37:48 +0100 Subject: [PATCH 14/39] 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 15/39] 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 16/39] 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 90dfec29112d957e2f071627e208ca56a1a1ad7c Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 10:48:54 +0100 Subject: [PATCH 17/39] ci: lint changed core files without enforcing unrelated baseline warnings --- .github/workflows/php.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 7ffdd676..97ea55d8 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -155,7 +155,13 @@ jobs: ./vendor/bin/phpcbf --standard=phpcs.xml --no-colors --runtime-set ignore_non_auto_fixable_on_exit 1 git diff --exit-code -- lib test - - name: Check core coding standard + - name: Check changed core PHP files if: needs.detect-markdown-only.outputs.markdown_only != 'true' - # Explicit paths must stay within the core ruleset; Laravel has independent CI. - run: ./vendor/bin/phpcs --standard=phpcs.xml --extensions=php lib test + env: + BASE_REF: ${{ github.base_ref }} + BEFORE_SHA: ${{ github.event.before }} + run: | + range="${BEFORE_SHA:-HEAD^}...HEAD" + if [ -n "$BASE_REF" ]; then range="origin/$BASE_REF...HEAD"; fi + git diff --name-only --diff-filter=ACM -z "$range" -- ':(glob)lib/**/*.php' ':(glob)test/**/*.php' | + xargs -0 -r ./vendor/bin/phpcs --standard=phpcs.xml --extensions=php From 3554d24c7153fed3dc75508bf7f10f6d2327a47f Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 11:01:17 +0100 Subject: [PATCH 18/39] 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 19/39] 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 20/39] 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 21/39] 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 22/39] 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 23/39] 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 24/39] 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 25/39] 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 26/39] 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 27/39] 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 28/39] 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 29/39] 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 30/39] 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 31/39] 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 32/39] 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 33/39] 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 34/39] 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 35/39] 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 36/39] 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 6ffa95a5230cdc97de60d0307e765ad23b5efc16 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 3 Nov 2026 13:56:48 +0100 Subject: [PATCH 37/39] fix: preserve Laravel source in isolated CI checkouts --- .gitignore | 1 - scripts/test-laravel.sh | 4 +++- scripts/tests/test_laravel_workflow_scripts.py | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 56f19768..d4565274 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,3 @@ xdebug.log .DS_Store .env node_modules/ -/.laravel-changes/ diff --git a/scripts/test-laravel.sh b/scripts/test-laravel.sh index ba2221d1..ee63895b 100644 --- a/scripts/test-laravel.sh +++ b/scripts/test-laravel.sh @@ -11,7 +11,9 @@ esac ROOT=$(git rev-parse --show-toplevel) TEMP=$(mktemp -d) trap 'rm -rf "$TEMP"' EXIT -git -C "$ROOT" archive HEAD | tar -x -C "$TEMP" +SOURCE=$(git -C "$ROOT" rev-parse HEAD) +git clone --quiet --no-hardlinks --no-local "$ROOT" "$TEMP" +git -C "$TEMP" checkout --quiet --detach "$SOURCE" # Lint only tracked source, before Composer can populate dependency directories. while IFS= read -r -d '' file; do diff --git a/scripts/tests/test_laravel_workflow_scripts.py b/scripts/tests/test_laravel_workflow_scripts.py index 59b08642..bd7b2859 100644 --- a/scripts/tests/test_laravel_workflow_scripts.py +++ b/scripts/tests/test_laravel_workflow_scripts.py @@ -20,6 +20,7 @@ def executable(self, name, text): def test_composer_overrides_and_example_tests_run_only_in_temporary_copy(self): self.write("examples/laravel/composer.json", '{"name":"posthog/laravel-example","scripts":{"test":"php artisan test"},"config":{"platform":{"php":"8.2.0"}}}') + self.write(".gitattributes", "/packages/laravel export-ignore\n") snapshot(self.root) self.write("packages/laravel/.env", "local-secret") self.write("packages/laravel/vendor/local.txt", "local vendor") From 2445857016fb9525a485c455a6c26c8eefc07dde Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 3 Nov 2026 14:15:19 +0100 Subject: [PATCH 38/39] fix: use shared pnpm changesets for Laravel releases --- .changeset/breezy-baths-kneel.md | 5 ++ .github/workflows/laravel-release.yml | 33 +++++++- .github/workflows/release.yml | 61 ++++----------- .../413684c92c1a43b38f4ea69024be861c.json | 5 -- .../482cca29796b4cc3bf4b96f0b476e65d.json | 5 -- .../eb4dfed4373d440294ff58970818d3e7.json | 5 -- CONTRIBUTING.md | 9 ++- RELEASING.md | 70 +++++++++-------- pnpm-lock.yaml | 2 + pnpm-workspace.yaml | 3 +- scripts/check-release-changes.sh | 33 ++++++++ scripts/commit_laravel_release.py | 4 +- scripts/laravel_release.py | 75 ++++--------------- scripts/tests/test_commit_laravel_release.py | 4 +- scripts/tests/test_laravel_release.py | 57 +++++++++----- .../tests/test_laravel_workflow_scripts.py | 41 ++++++++++ 16 files changed, 224 insertions(+), 188 deletions(-) create mode 100644 .changeset/breezy-baths-kneel.md delete mode 100644 .laravel-changes/413684c92c1a43b38f4ea69024be861c.json delete mode 100644 .laravel-changes/482cca29796b4cc3bf4b96f0b476e65d.json delete mode 100644 .laravel-changes/eb4dfed4373d440294ff58970818d3e7.json create mode 100644 scripts/check-release-changes.sh diff --git a/.changeset/breezy-baths-kneel.md b/.changeset/breezy-baths-kneel.md new file mode 100644 index 00000000..01b8c0e7 --- /dev/null +++ b/.changeset/breezy-baths-kneel.md @@ -0,0 +1,5 @@ +--- +"posthog-laravel": patch +--- + +Initial Laravel package with request and worker context, exception reporting, feature flags, opt-in autocapture, and Pennant integration. diff --git a/.github/workflows/laravel-release.yml b/.github/workflows/laravel-release.yml index 8da047b3..71f92f05 100644 --- a/.github/workflows/laravel-release.yml +++ b/.github/workflows/laravel-release.yml @@ -1,10 +1,9 @@ name: Laravel Release -# Separate intents and manual dispatch deliberately do not trigger the core publisher. on: push: branches: [main] - paths: ['.laravel-changes/*.json'] + paths: ['.changeset/*.md'] workflow_dispatch: permissions: @@ -18,9 +17,32 @@ concurrency: cancel-in-progress: false jobs: - prepare: + check-changesets: if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest + outputs: + has-changesets: ${{ steps.check.outputs.has-changesets }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1 + with: + version: 11.13.1 + runtime: node@24 + install: false + - name: Check for Laravel change intents + id: check + env: + TRIGGER_SHA: ${{ github.sha }} + run: bash scripts/check-release-changes.sh posthog-laravel + + prepare: + needs: check-changesets + if: needs.check-changesets.outputs.has-changesets == 'true' + runs-on: ubuntu-latest outputs: source: ${{ steps.candidate.outputs.source }} version: ${{ steps.candidate.outputs.version }} @@ -72,6 +94,11 @@ jobs: with: name: laravel-candidate-${{ needs.prepare.outputs.source }} path: ${{ runner.temp }}/candidate + - uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1 + with: + version: 11.13.1 + runtime: node@24 + install: false - name: Independently reproduce candidate bytes env: REPOSITORY: ${{ github.repository }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26364d8c..753aa521 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,53 +30,18 @@ jobs: fetch-depth: 0 persist-credentials: false + - name: Setup pnpm and Node.js + uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1 + with: + version: 11.13.1 + runtime: node@24 + install: false + - name: Check for change intents id: check env: TRIGGER_SHA: ${{ github.sha }} - run: | - set -euo pipefail - - count_releasable_intents() { - local ref=$1 count=0 path - for path in $(git ls-tree --name-only "$ref" .changeset/); do - [[ "$path" == *.md ]] || continue - if awk -v pattern="^[[:space:]]*['\"]?posthog-php['\"]?[[:space:]]*:[[:space:]]*(patch|minor|major)[[:space:]]*$" ' - NR == 1 { if ($0 != "---") exit 1; next } - $0 == "---" { found_end = 1; exit } - $0 ~ pattern { releasable = 1 } - END { if (!found_end || !releasable) exit 1 } - ' <<< "$(git show "$ref:$path")"; then - count=$((count + 1)) - fi - done - echo "$count" - } - - # Serialized runs can be stale. If main moved on, skip only when no releasable - # intents remain there; a push that doesn't touch .changeset/ starts no release run. - main_sha=$(git rev-parse --verify origin/main) - if [ "$main_sha" != "$TRIGGER_SHA" ]; then - pending_count=$(count_releasable_intents "$main_sha") - if [ "$pending_count" -gt 0 ]; then - echo "main moved from $TRIGGER_SHA to $main_sha with $pending_count releasable change intent(s) still pending; dispatch the Release workflow on main" >&2 - exit 1 - fi - - echo "has-changesets=false" >> "$GITHUB_OUTPUT" - echo "main is at $main_sha, not $TRIGGER_SHA, and has no pending change intents; skipping stale run" - exit 0 - fi - - releasable_count=$(count_releasable_intents "$TRIGGER_SHA") - - if [ "$releasable_count" -gt 0 ]; then - echo "has-changesets=true" >> "$GITHUB_OUTPUT" - echo "Found $releasable_count releasable change intent(s)" - else - echo "has-changesets=false" >> "$GITHUB_OUTPUT" - echo "No releasable change intents found" - fi + run: bash scripts/check-release-changes.sh posthog-php - name: Send failure event to PostHog if: ${{ failure() }} @@ -145,7 +110,7 @@ jobs: set -euo pipefail source_sha=$(git rev-parse HEAD) - pnpm version -r + pnpm --filter posthog-php version -r git add --intent-to-add .changeset/ledger.yaml new_version=$(node -p "require('./package.json').version") @@ -184,9 +149,11 @@ jobs: git diff --binary > release.patch patch_sha256=$(sha256sum release.patch | awk '{print $1}') - echo "source-sha=$source_sha" >> "$GITHUB_OUTPUT" - echo "new-version=$new_version" >> "$GITHUB_OUTPUT" - echo "patch-sha256=$patch_sha256" >> "$GITHUB_OUTPUT" + { + echo "source-sha=$source_sha" + echo "new-version=$new_version" + echo "patch-sha256=$patch_sha256" + } >> "$GITHUB_OUTPUT" echo "Release candidate $new_version prepared from $source_sha with patch sha256 $patch_sha256" - name: Upload release candidate patch diff --git a/.laravel-changes/413684c92c1a43b38f4ea69024be861c.json b/.laravel-changes/413684c92c1a43b38f4ea69024be861c.json deleted file mode 100644 index c17b3645..00000000 --- a/.laravel-changes/413684c92c1a43b38f4ea69024be861c.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "package": "posthog-laravel", - "bump": "initial", - "summary": "Add the Laravel package with request and worker context, exception reporting, flags, and test fakes." -} diff --git a/.laravel-changes/482cca29796b4cc3bf4b96f0b476e65d.json b/.laravel-changes/482cca29796b4cc3bf4b96f0b476e65d.json deleted file mode 100644 index 830e270f..00000000 --- a/.laravel-changes/482cca29796b4cc3bf4b96f0b476e65d.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "package": "posthog-laravel", - "bump": "initial", - "summary": "Add an optional read-only Laravel Pennant driver for PostHog feature flags." -} diff --git a/.laravel-changes/eb4dfed4373d440294ff58970818d3e7.json b/.laravel-changes/eb4dfed4373d440294ff58970818d3e7.json deleted file mode 100644 index 0721ba99..00000000 --- a/.laravel-changes/eb4dfed4373d440294ff58970818d3e7.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "package": "posthog-laravel", - "bump": "initial", - "summary": "Add opt-in Laravel request, auth, domain, model and worker event collectors." -} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4f6f0fb..fe27b980 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,7 +48,8 @@ After the Composer install above, run PHP_CodeSniffer with the Composer-installe ## Laravel development -`packages/laravel` is an independent Composer package, not a pnpm workspace project. +`packages/laravel` is an independent Composer package and a pnpm workspace project +for release metadata. Both packages record change intents in `.changeset/`. Its CI covers Laravel 12 on PHP 8.2–8.5 and Laravel 13 on PHP 8.3–8.5. From a committed checkout, run: @@ -66,10 +67,10 @@ checkout or distribution metadata. The example's `composer test` runs when its The release fixtures use pnpm 11.13.1 and fake publishing tools; they never publish or use credentials. Root PHPCS checks remain scoped to `lib` and `test`. -Record Laravel release changes with -`python3 scripts/laravel_release.py intent patch "Summary"`, **not** `pnpm change`. +Record Laravel release changes with `pnpm change`, selecting `posthog-laravel`. +Select `posthog-php` as well when the change also affects the core SDK. See [the Laravel release guide](RELEASING.md#laravel-package-independent-releases) -for the first-release intent and required administrator setup. +for package-filtered releases and required administrator setup. ## Public API changes diff --git a/RELEASING.md b/RELEASING.md index 428e17ed..8ed4477d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,6 +1,6 @@ # Releasing -The core PHP SDK uses [pnpm's native release management](https://pnpm.io/versioning) and an automated GitHub Actions workflow for releases. The [Laravel package](#laravel-package-independent-releases) has a separate release lane; its changes must not be recorded with `pnpm change`. +The core PHP SDK and [Laravel package](#laravel-package-independent-releases) use [pnpm's native release management](https://pnpm.io/versioning), with shared `.changeset/` intents and independent, package-filtered publishing workflows. ## How to Release @@ -12,7 +12,7 @@ When making a change that should be released, record a change intent: pnpm change ``` -Select `posthog-php`, choose the version bump (`patch`, `minor`, or `major`), and enter a short release summary. Commit the generated `.changeset/*.md` file with your pull request. +Select `posthog-php`, `posthog-laravel`, or both, choose the version bump (`patch`, `minor`, or `major`), and enter a short release summary. Commit the generated `.changeset/*.md` file with your pull request. ### 2. Merge the Pull Request @@ -21,7 +21,7 @@ After review, merge the PR to `main`. No GitHub release label is required. A push to `main` that includes `.changeset/*.md` changes automatically starts the release workflow. The workflow then: 1. Checks for pending change intents. A run whose trigger commit is no longer the tip of `main` skips itself if no releasable intents remain there, and fails if some do, since a later push that didn't touch `.changeset/` starts no release run of its own. In that case, dispatch the Release workflow on `main` -2. Uses `pnpm version -r` to determine and apply the version bump, consume the intents, update `CHANGELOG.md` and `.changeset/ledger.yaml`, and refresh `composer.lock` +2. Uses `pnpm --filter posthog-php version -r` to apply only the core version bump, update `CHANGELOG.md` and `.changeset/ledger.yaml`, and refresh `composer.lock`. Laravel-only intents remain pending; mixed intents remain until both packages have released 3. Prepares a release candidate patch for the triggering commit in a read-only job without release secrets, after verifying the release bump script hash 4. Verifies the release candidate in a separate read-only job and fails if the tag or GitHub Release already exists 5. Notifies the client libraries team in Slack for approval only after candidate preparation and verification both succeed @@ -98,17 +98,19 @@ After rollback, add or restore the needed change intent and let the release work ## Laravel package: independent releases -`packages/laravel` contains `posthog/posthog-laravel`, initially **0.1.0**, depending -on `posthog/posthog-php ^4.20`. It is not part of the pnpm workspace. **Do not select -it in `pnpm change`, add it to the workspace, or put Laravel intents in -`.changeset/`.** Core and Laravel changes in the same PR need separate intents. +`packages/laravel` contains `posthog/posthog-laravel`, depending on +`posthog/posthog-php ^4.20`. Its pnpm workspace name is `posthog-laravel`. +Both packages use `pnpm change` and `.changeset/*.md`, but each publisher runs +`pnpm --filter version -r` so versions remain independent. Never run an +unfiltered `pnpm version -r` in a single-package publisher. Packagist reads `composer.json` at a repository's root, not under `packages/laravel`. The source repository therefore cannot itself be registered as this second Composer package. Publishing uses a separate, dedicated public **distribution repository**, recommended `PostHog/posthog-laravel`. Its root is replaced with only tracked package `composer.json`, `README.md`, `LICENSE.md`, -`src/**/*.php`, `config/**/*.php`, and generated `posthog-source.json`. Hidden +`CHANGELOG.md`, `AUTOCAPTURE.md`, `PENNANT.md`, `src/**/*.php`, `config/**/*.php`, +and generated `posthog-source.json`. Hidden files, vendors, tests, environment files, locks, development tooling and non-PHP config files are excluded; symlinks fail preparation. Never keep unrelated files in that repository: obsolete distribution files are deleted on release. @@ -153,26 +155,31 @@ has been performed as part of implementing these files. From the source repository root: ```bash -# Once only, for the initial 0.1.0 release: -python3 scripts/laravel_release.py intent initial "Initial Laravel package" - -# Subsequent releases (choose patch, minor, or major): -python3 scripts/laravel_release.py intent patch "Describe the Laravel change" +pnpm change posthog-laravel --bump patch --summary "Describe the Laravel change" ``` -Commit the generated `.laravel-changes/.json` alongside the change. The -largest pending bump wins. `initial` preserves 0.1.0 and cannot be combined with -other bump types; the changelog prevents releasing that version twice. Later -bumps follow normal SemVer (minor 0.1.0 becomes 0.2.0). Preparation synchronizes -`packages/laravel/package.json`, `composer.json`, and `src/PostHog.php::VERSION`, -updates root `LARAVEL_CHANGELOG.md`, and removes only the consumed Laravel intents. -It does not change the root/core version, changelog, Composer lock, or intents. - -A push changing `.laravel-changes/*.json` on `main` starts **Laravel Release**; -manual dispatch on `main` is also available. A stale trigger fails rather than -publishing old source: dispatch again on current `main` if intents remain. -There must be at least one pending Laravel intent. The core `Release` workflow -and its script hash, signing, verification and approval protections are unchanged. +Or run `pnpm change` interactively and select one or both packages. Commit the +resulting `.changeset/*.md` alongside the change. pnpm chooses the largest pending +bump and records consumption per package in `.changeset/ledger.yaml`. A mixed +intent remains until both packages have consumed it; the ledger prevents a second +bump of the package already released. + +Both release-metadata packages are private, so pnpm always applies the pending +bump to the manifest version without consulting npm. The initial Laravel patch +intent therefore produces **0.1.1** from the current 0.1.0. There is no custom +`initial` bump or separate intent format. + +Laravel preparation runs `pnpm --filter posthog-laravel version -r`, synchronizes +its Composer/runtime versions, and uses `packages/laravel/CHANGELOG.md` for release +notes. It does not change the root/core version, changelog or Composer lock. + +A push changing `.changeset/*.md` on `main` starts both release workflows. Each +checks its filtered pnpm plan and skips when it has no pending release, including +when only the other package has changes. Manual dispatch on `main` is also +available. Source freshness checks remain fail-closed: if the other publisher or +another merge advances `main`, dispatch the remaining workflow on current `main` +while its intents remain. The core bump-script hash, signing, verification and +approval protections are unchanged. ### Candidate, approval and publication @@ -216,13 +223,10 @@ PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s scripts/tests -v actionlint .github/workflows/laravel.yml .github/workflows/laravel-release.yml ``` -The fixtures pin **pnpm 11.13.1**, including native core-only releases and -selective-filter experiments: `--filter posthog-php version -r` leaves Laravel -unchanged and retains a mixed intent until both packages are released (the -ledger prevents rebumping core). This does **not** make registering Laravel safe: -the existing core publisher runs **unfiltered** `pnpm version -r`. Keeping Laravel -outside that workspace and using a different intent directory avoids consuming -or bumping the other package, without refactoring core publishing. +The fixtures pin **pnpm 11.13.1** and exercise both filtered release directions, +shared-intent retention and ledger-based duplicate prevention, package-scoped +pending checks, and deterministic candidate preparation. No custom code computes +SemVer bumps or parses/consumes change intents. ### Partial-failure recovery diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b60ae17..2b295396 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,3 +7,5 @@ settings: importers: .: {} + + packages/laravel: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a10c201e..c0258fa5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,7 +1,6 @@ -# Laravel deliberately uses scripts/laravel_release.py and .laravel-changes/. -# Do not add it here: the core publisher runs an unfiltered pnpm version -r. packages: - '.' + - 'packages/laravel' minimumReleaseAge: 10080 diff --git a/scripts/check-release-changes.sh b/scripts/check-release-changes.sh new file mode 100644 index 00000000..2f0d31e7 --- /dev/null +++ b/scripts/check-release-changes.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +PACKAGE=${1:?Usage: check-release-changes.sh } +case "$PACKAGE" in + posthog-php) MANIFEST=package.json ;; + posthog-laravel) MANIFEST=packages/laravel/package.json ;; + *) echo "Unknown release package: $PACKAGE" >&2; exit 1 ;; +esac + +MAIN_SHA=$(git rev-parse --verify origin/main) +if [ "$MAIN_SHA" != "$TRIGGER_SHA" ]; then + git checkout --quiet --detach "$MAIN_SHA" +fi + +version() { + python3 -c 'import json, sys; print(json.load(open(sys.argv[1]))["version"])' "$MANIFEST" +} + +BEFORE=$(version) +pnpm --filter "$PACKAGE" version -r +AFTER=$(version) + +if [ "$BEFORE" = "$AFTER" ]; then + echo 'has-changesets=false' >> "$GITHUB_OUTPUT" + echo "No pending change intents for $PACKAGE" +elif [ "$MAIN_SHA" != "$TRIGGER_SHA" ]; then + echo "main moved from $TRIGGER_SHA to $MAIN_SHA with pending changes for $PACKAGE; dispatch its release workflow on main" >&2 + exit 1 +else + echo 'has-changesets=true' >> "$GITHUB_OUTPUT" + echo "Pending release: $PACKAGE $BEFORE -> $AFTER" +fi diff --git a/scripts/commit_laravel_release.py b/scripts/commit_laravel_release.py index d4fd2136..9205bad9 100644 --- a/scripts/commit_laravel_release.py +++ b/scripts/commit_laravel_release.py @@ -18,7 +18,7 @@ SOURCE_FILES = { "packages/laravel/package.json", "packages/laravel/composer.json", - "packages/laravel/src/PostHog.php", "LARAVEL_CHANGELOG.md", + "packages/laravel/src/PostHog.php", "packages/laravel/CHANGELOG.md", ".changeset/ledger.yaml", } QUERY = """ mutation($input: CreateCommitOnBranchInput!) { @@ -38,7 +38,7 @@ def git(*args): paths.update(filter(None, git("ls-files", "--others", "--exclude-standard", "-z").decode().split("\0"))) additions, deletions = [], [] for name in sorted(paths): - permitted = (name in SOURCE_FILES or bool(re.fullmatch(r"\.laravel-changes/[A-Za-z0-9_-]+\.json", name))) if mode == "source" else ( + permitted = (name in SOURCE_FILES or bool(re.fullmatch(r"\.changeset/[A-Za-z0-9_-]+\.md", name))) if mode == "source" else ( allowed_distribution_file(name) or name == "posthog-source.json" ) path = checkout / name diff --git a/scripts/laravel_release.py b/scripts/laravel_release.py index 11082477..9044d800 100644 --- a/scripts/laravel_release.py +++ b/scripts/laravel_release.py @@ -12,12 +12,9 @@ import subprocess import tarfile import tempfile -import uuid PACKAGE = Path("packages/laravel") -INTENTS = Path(".laravel-changes") -CHANGELOG = Path("LARAVEL_CHANGELOG.md") -VERSION = re.compile(r"0|[1-9][0-9]*") +CHANGELOG = PACKAGE / "CHANGELOG.md" FORBIDDEN = {"vendor", "tests", "test", "node_modules", "storage", "cache", "secrets"} @@ -33,30 +30,11 @@ def write_json(path, value): path.write_text(json.dumps(value, indent=2) + "\n") -def version_parts(version): - parts = version.split(".") - if len(parts) != 3 or not all(VERSION.fullmatch(part) for part in parts): - raise ValueError(f"Not a stable version: {version}") - return [int(part) for part in parts] - - -def next_version(current, bumps): - parts = version_parts(current) - if "initial" in bumps: - if set(bumps) != {"initial"} or current != "0.1.0": - raise ValueError("initial intents are only valid alone at 0.1.0") - return current - index = min({"major": 0, "minor": 1, "patch": 2}[bump] for bump in bumps) - parts[index] += 1 - parts[index + 1:] = [0] * (2 - index) - return ".".join(map(str, parts)) - - def allowed_distribution_file(name): path = PurePosixPath(name) if path.is_absolute() or any(p.startswith(".") or p.lower() in FORBIDDEN for p in path.parts): return False - return name in {"composer.json", "README.md", "LICENSE.md", "AUTOCAPTURE.md", "PENNANT.md"} or ( + return name in {"composer.json", "README.md", "LICENSE.md", "CHANGELOG.md", "AUTOCAPTURE.md", "PENNANT.md"} or ( len(path.parts) > 1 and path.parts[0] in {"src", "config"} and path.suffix == ".php" ) @@ -85,17 +63,7 @@ def prepare(root, output, repository): work = Path(temporary) / "source" run("git", "clone", "--quiet", "--no-hardlinks", "--no-local", str(root), str(work), cwd=root) run("git", "checkout", "--quiet", "--detach", source, cwd=work) - pending = sorted((work / INTENTS).glob("*.json")) - if not pending: - raise ValueError("No pending Laravel change intents") - intents = [read_json(path) for path in pending] - for intent in intents: - if set(intent) != {"package", "bump", "summary"} or intent["package"] != "posthog-laravel": - raise ValueError("Invalid Laravel change intent") - if intent["bump"] not in {"initial", "patch", "minor", "major"}: - raise ValueError("Invalid Laravel bump") - if not isinstance(intent["summary"], str) or not intent["summary"].strip(): - raise ValueError("Empty Laravel release summary") + pending = sorted((work / ".changeset").glob("*.md")) metadata = read_json(work / PACKAGE / "package.json") composer = read_json(work / PACKAGE / "composer.json") current = metadata["version"] @@ -103,25 +71,21 @@ def prepare(root, output, repository): raise ValueError("Laravel package.json/composer.json version mismatch") if composer["name"] != "posthog/posthog-laravel" or "repositories" in composer: raise ValueError("Expected production Laravel Composer metadata (no repository overrides)") - new = next_version(current, [intent["bump"] for intent in intents]) - previous = (work / CHANGELOG).read_text() if (work / CHANGELOG).exists() else "# Laravel changelog\n\n" - if not previous.startswith("# Laravel changelog\n\n"): - raise ValueError("Invalid Laravel changelog header") - if f"## {new}\n" in previous: - raise ValueError(f"Laravel {new} has already been released") - notes = f"## {new}\n\n" + "\n".join("- " + i["summary"].strip() for i in intents) + "\n\n" - (work / CHANGELOG).write_text(previous.replace("# Laravel changelog\n\n", "# Laravel changelog\n\n" + notes, 1)) runtime = work / PACKAGE / "src/PostHog.php" declaration = re.compile(r"(public const VERSION = ')([^']+)(';)") declarations = declaration.findall(runtime.read_text()) if len(declarations) != 1 or declarations[0][1] != current: raise ValueError("Laravel runtime VERSION mismatch") + run("pnpm", "--filter", "posthog-laravel", "version", "-r", cwd=work) + new = read_json(work / PACKAGE / "package.json")["version"] + if new == current: + raise ValueError("No pending Laravel change intents") runtime.write_text(declaration.sub(lambda match: match[1] + new + match[3], runtime.read_text())) - metadata["version"] = composer["version"] = new - write_json(work / PACKAGE / "package.json", metadata) + composer["version"] = new write_json(work / PACKAGE / "composer.json", composer) - for path in pending: - path.unlink() + changelog = (work / CHANGELOG).read_text() + notes = changelog[changelog.index(f"## {new}\n"):].split("\n## ", 1)[0] + run("git", "add", "--intent-to-add", str(CHANGELOG), ".changeset/ledger.yaml", cwd=work) tracked = run("git", "ls-files", "-z", "--", str(PACKAGE), cwd=work).decode().split("\0") files = {} for name in filter(None, tracked): @@ -139,11 +103,10 @@ def prepare(root, output, repository): "repository": repository, "commit": source, "tag": tag, "directory": PACKAGE.as_posix(), "version": new, }, indent=2) + "\n").encode() - run("git", "add", "--intent-to-add", str(CHANGELOG), cwd=work) patch = run("git", "diff", "--binary", cwd=work) changed = run("git", "diff", "--name-only", cwd=work).decode().splitlines() permitted = {str(PACKAGE / "composer.json"), str(PACKAGE / "package.json"), - str(PACKAGE / "src/PostHog.php"), str(CHANGELOG)} + str(PACKAGE / "src/PostHog.php"), str(CHANGELOG), ".changeset/ledger.yaml"} permitted.update(str(path.relative_to(work)) for path in pending) if not set(changed).issubset(permitted): raise ValueError(f"Unexpected release changes: {changed}") @@ -199,10 +162,6 @@ def install(candidate, destination, repository): def main(): parser = argparse.ArgumentParser(description=__doc__) sub = parser.add_subparsers(dest="command", required=True) - intent = sub.add_parser("intent") - intent.add_argument("bump", choices=["initial", "patch", "minor", "major"]) - intent.add_argument("summary") - intent.add_argument("--root", type=Path, default=Path.cwd()) candidate = sub.add_parser("prepare") candidate.add_argument("--root", type=Path, default=Path.cwd()) candidate.add_argument("--output", type=Path, required=True) @@ -212,15 +171,7 @@ def main(): unpack.add_argument("--destination", type=Path, required=True) unpack.add_argument("--repository", required=True) args = parser.parse_args() - if args.command == "intent": - if not args.summary.strip(): - parser.error("summary must not be empty") - directory = args.root / INTENTS - directory.mkdir(exist_ok=True) - path = directory / f"{uuid.uuid4().hex}.json" - write_json(path, {"package": "posthog-laravel", "bump": args.bump, "summary": args.summary}) - print(path) - elif args.command == "prepare": + if args.command == "prepare": prepare(args.root.resolve(), args.output.resolve(), args.repository) else: install(args.candidate.resolve(), args.destination.resolve(), args.repository) diff --git a/scripts/tests/test_commit_laravel_release.py b/scripts/tests/test_commit_laravel_release.py index 64086b8b..80b489e2 100644 --- a/scripts/tests/test_commit_laravel_release.py +++ b/scripts/tests/test_commit_laravel_release.py @@ -25,7 +25,9 @@ def test_signed_commit_payload_has_atomic_expected_head_and_scoped_changes(self) self.assertIn("createCommitOnBranch", payload["query"]) additions = {item["path"]: base64.b64decode(item["contents"]) for item in request["fileChanges"]["additions"]} self.assertEqual(json.loads(additions["packages/laravel/package.json"])["version"], "0.1.1") - self.assertEqual(request["fileChanges"]["deletions"], [{"path": ".laravel-changes/release.json"}]) + self.assertEqual(request["fileChanges"]["deletions"], [{"path": ".changeset/release.md"}]) + self.assertIn(".changeset/ledger.yaml", additions) + self.assertIn("packages/laravel/CHANGELOG.md", additions) self.assertFalse(any(name in additions for name in ["composer.json", "package.json", "lib/PostHog.php"])) self.write("composer.json", "unexpected core modification") with self.assertRaisesRegex(ValueError, "Unexpected source release change"): diff --git a/scripts/tests/test_laravel_release.py b/scripts/tests/test_laravel_release.py index 316a759f..1f6d12ba 100644 --- a/scripts/tests/test_laravel_release.py +++ b/scripts/tests/test_laravel_release.py @@ -4,6 +4,8 @@ import importlib.util import json from pathlib import Path +import re +import shlex import shutil import subprocess import tarfile @@ -59,7 +61,7 @@ def write(self, name, data): path.write_text(data) def intent(self, bump, name="release"): - self.write(f".laravel-changes/{name}.json", json.dumps({"package": "posthog-laravel", "bump": bump, "summary": "Laravel change"})) + self.write(f".changeset/{name}.md", f'---\n"posthog-laravel": {bump}\n---\n\nLaravel change\n') def prepare(self, name="candidate"): out = self.directory / name @@ -80,30 +82,41 @@ def test_version_sync_and_laravel_only_patch(self): self.assertEqual(json.loads((package / "composer.json").read_text())["version"], "0.1.1") self.assertIn("VERSION = '0.1.1'", (package / "src/PostHog.php").read_text()) self.assertTrue((self.root / ".changeset/core.md").exists()) - self.assertFalse((self.root / ".laravel-changes/release.json").exists()) + self.assertFalse((self.root / ".changeset/release.md").exists()) + self.assertIn("posthog-laravel@0.1.1:", (self.root / ".changeset/ledger.yaml").read_text()) + self.assertIn("Laravel change", (package / "CHANGELOG.md").read_text()) self.assertEqual(json.loads((self.root / "package.json").read_text())["version"], "4.20.0") self.assertEqual(json.loads((self.root / "composer.json").read_text())["version"], "4.20.0") self.assertIn("4.20.0", (self.root / "lib/PostHog.php").read_text()) self.assertEqual(git(self.root, "diff", "--cached", "--name-only"), b"") def test_core_only_intent_cannot_prepare_laravel_release(self): - (self.root / ".laravel-changes/release.json").unlink() + (self.root / ".changeset/release.md").unlink() self.write(".changeset/core.md", '---\n"posthog-php": patch\n---\n\nCore only\n') snapshot(self.root) with self.assertRaisesRegex(ValueError, "No pending Laravel change intents"): self.prepare() - def test_initial_release_and_largest_bump(self): - self.intent("initial") - snapshot(self.root) - self.assertEqual(release.read_json(self.prepare() / "candidate.json")["version"], "0.1.0") - self.intent("patch") + def test_native_versioning_applies_the_largest_pending_bump(self): self.intent("minor", "second") snapshot(self.root) - self.assertEqual(release.read_json(self.prepare("second") / "candidate.json")["version"], "0.2.0") - self.assertEqual(release.next_version("1.2.3", ["patch", "major"]), "2.0.0") - with self.assertRaises(ValueError): - release.next_version("0.1.0", ["initial", "patch"]) + self.assertEqual(release.read_json(self.prepare() / "candidate.json")["version"], "0.2.0") + + def test_shared_intent_is_retained_until_both_packages_release(self): + self.write(".changeset/mixed.md", '---\n"posthog-php": patch\n"posthog-laravel": minor\n---\n\nShared change\n') + snapshot(self.root) + out = self.prepare() + git(self.root, "apply", str(out / "release.patch")) + self.assertTrue((self.root / ".changeset/mixed.md").exists()) + self.assertFalse((self.root / ".changeset/release.md").exists()) + self.assertEqual(release.read_json(self.root / "package.json")["version"], "4.20.0") + snapshot(self.root) + with self.assertRaisesRegex(ValueError, "No pending Laravel change intents"): + self.prepare("second") + subprocess.check_output(["pnpm", "--filter", "posthog-php", "version", "-r"], cwd=self.root, stderr=subprocess.STDOUT) + self.assertEqual(release.read_json(self.root / "package.json")["version"], "4.20.1") + self.assertEqual(release.read_json(self.root / release.PACKAGE / "package.json")["version"], "0.2.0") + self.assertFalse((self.root / ".changeset/mixed.md").exists()) def test_archive_is_deterministic_and_excludes_development_files(self): self.write("packages/laravel/AUTOCAPTURE.md", "# Automatic collection\n") @@ -117,7 +130,7 @@ def test_archive_is_deterministic_and_excludes_development_files(self): for name in ["package.tar.gz", "release.patch", "candidate.json", "notes.md"]: self.assertEqual((first / name).read_bytes(), (second / name).read_bytes()) with tarfile.open(first / "package.tar.gz") as archive: - self.assertEqual(set(archive.getnames()), {"composer.json", "README.md", "LICENSE.md", "AUTOCAPTURE.md", "PENNANT.md", "src/PostHog.php", "config/posthog.php", "posthog-source.json"}) + self.assertEqual(set(archive.getnames()), {"composer.json", "README.md", "LICENSE.md", "CHANGELOG.md", "AUTOCAPTURE.md", "PENNANT.md", "src/PostHog.php", "config/posthog.php", "posthog-source.json"}) self.assertFalse(any(b"secret" in archive.extractfile(item).read() for item in archive)) composer = json.load(archive.extractfile("composer.json")) self.assertEqual(composer["require"]["posthog/posthog-php"], "^4.20") @@ -182,17 +195,18 @@ def test_native_core_release_does_not_consume_laravel_intents(self): self.write(".changeset/core.md", '---\n"posthog-php": patch\n---\n\nCore change\n') snapshot(self.root) before = {p: p.read_bytes() for p in (self.root / release.PACKAGE).rglob("*") if p.is_file()} - intent = (self.root / ".laravel-changes/release.json").read_bytes() - output = subprocess.check_output(["pnpm", "version", "-r", "--no-git-checks"], cwd=self.root, stderr=subprocess.STDOUT) + intent = (self.root / ".changeset/release.md").read_bytes() + workflow = (REPO / ".github/workflows/release.yml").read_text() + command = re.search(r"^\s+(pnpm[^\n]*version -r)$", workflow, re.MULTILINE).group(1) + output = subprocess.check_output(shlex.split(command), cwd=self.root, stderr=subprocess.STDOUT) self.assertIn(b"4.20.1", output) self.assertEqual(json.loads((self.root / "package.json").read_text())["version"], "4.20.1") self.assertFalse((self.root / ".changeset/core.md").exists()) - self.assertEqual((self.root / ".laravel-changes/release.json").read_bytes(), intent) + self.assertEqual((self.root / ".changeset/release.md").read_bytes(), intent) self.assertEqual(before, {p: p.read_bytes() for p in before}) @unittest.skipUnless(shutil.which("pnpm"), "pnpm 11.13.1 is required") - def test_native_selective_filter_retains_mixed_intent_until_both_released(self): - self.write("pnpm-workspace.yaml", (self.root / "pnpm-workspace.yaml").read_text().replace(" - '.'", " - '.'\n - 'packages/*'")) + def test_preparation_respects_core_consumption_of_shared_intent(self): self.write(".changeset/mixed.md", '---\n"posthog-php": patch\n"posthog-laravel": minor\n---\n\nShared change\n') snapshot(self.root) def apply(package): @@ -203,9 +217,14 @@ def apply(package): self.assertTrue((self.root / ".changeset/mixed.md").exists()) apply("posthog-php") self.assertEqual(release.read_json(self.root / "package.json")["version"], "4.20.1") - apply("posthog-laravel") + snapshot(self.root) + git(self.root, "apply", str(self.prepare() / "release.patch")) self.assertEqual(release.read_json(self.root / release.PACKAGE / "package.json")["version"], "0.2.0") + self.assertEqual(release.read_json(self.root / "package.json")["version"], "4.20.1") self.assertFalse((self.root / ".changeset/mixed.md").exists()) + ledger = (self.root / ".changeset/ledger.yaml").read_text() + self.assertIn("posthog-php@4.20.1:", ledger) + self.assertIn("posthog-laravel@0.2.0:", ledger) if __name__ == "__main__": diff --git a/scripts/tests/test_laravel_workflow_scripts.py b/scripts/tests/test_laravel_workflow_scripts.py index bd7b2859..221cf00f 100644 --- a/scripts/tests/test_laravel_workflow_scripts.py +++ b/scripts/tests/test_laravel_workflow_scripts.py @@ -57,6 +57,47 @@ def test_composer_overrides_and_example_tests_run_only_in_temporary_copy(self): self.assertNotIn("repositories", json.loads((self.root / "packages/laravel/composer.json").read_text())) +class ReleaseChangeCheckTest(LaravelReleaseFixture): + def check(self, package, trigger=None): + head = git(self.root, "rev-parse", "HEAD").decode().strip() + git(self.root, "update-ref", "refs/remotes/origin/main", head) + output = self.directory / "outputs" + output.unlink(missing_ok=True) + result = subprocess.run( + ["bash", str(SCRIPT.with_name("check-release-changes.sh")), package], + cwd=self.root, + env={**os.environ, "TRIGGER_SHA": trigger or head, "GITHUB_OUTPUT": str(output)}, + capture_output=True, text=True, + ) + return result, output.read_text() if output.exists() else "" + + def test_checks_are_filtered_and_do_not_reconsume_shared_intents(self): + self.write(".changeset/mixed.md", '---\n"posthog-php": patch\n"posthog-laravel": minor\n---\n\nShared change\n') + snapshot(self.root) + for package in ["posthog-php", "posthog-laravel"]: + result, output = self.check(package) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("has-changesets=true", output) + snapshot(self.root) + self.assertEqual(json.loads((self.root / "package.json").read_text())["version"], "4.20.1") + self.assertEqual(json.loads((self.root / "packages/laravel/package.json").read_text())["version"], "0.2.0") + for package in ["posthog-php", "posthog-laravel"]: + result, output = self.check(package) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("has-changesets=false", output) + + def test_stale_runs_only_fail_for_relevant_pending_changes(self): + trigger = git(self.root, "rev-parse", "HEAD").decode().strip() + self.write("new.txt", "main moved") + snapshot(self.root) + result, output = self.check("posthog-php", trigger) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("has-changesets=false", output) + result, _ = self.check("posthog-laravel", trigger) + self.assertNotEqual(result.returncode, 0) + self.assertIn("main moved", result.stderr) + + class ReleaseRefCheckTest(unittest.TestCase): def test_missing_refs_pass_but_network_permissions_existing_refs_and_same_repo_fail(self): with tempfile.TemporaryDirectory() as temporary: From 9cd56c6275307fa3ab5f4721551a0572558da033 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 3 Nov 2026 14:25:56 +0100 Subject: [PATCH 39/39] fix: consolidate PHP and Laravel releases in one workflow --- .changeset/breezy-baths-kneel.md | 2 +- .github/workflows/laravel-release.yml | 231 ------------------ .github/workflows/laravel.yml | 2 + .github/workflows/release.yml | 231 +++++++++++++++++- RELEASING.md | 39 +-- packages/laravel/LICENSE.md | 24 +- packages/laravel/composer.json | 2 +- packages/laravel/package.json | 2 +- packages/laravel/src/PostHog.php | 2 +- scripts/laravel_release.py | 2 + scripts/tests/test_laravel_release.py | 42 ++++ .../tests/test_laravel_workflow_scripts.py | 14 +- 12 files changed, 318 insertions(+), 275 deletions(-) delete mode 100644 .github/workflows/laravel-release.yml mode change 100644 => 120000 packages/laravel/LICENSE.md diff --git a/.changeset/breezy-baths-kneel.md b/.changeset/breezy-baths-kneel.md index 01b8c0e7..9cc9a152 100644 --- a/.changeset/breezy-baths-kneel.md +++ b/.changeset/breezy-baths-kneel.md @@ -1,5 +1,5 @@ --- -"posthog-laravel": patch +"posthog-laravel": minor --- Initial Laravel package with request and worker context, exception reporting, feature flags, opt-in autocapture, and Pennant integration. diff --git a/.github/workflows/laravel-release.yml b/.github/workflows/laravel-release.yml deleted file mode 100644 index 71f92f05..00000000 --- a/.github/workflows/laravel-release.yml +++ /dev/null @@ -1,231 +0,0 @@ -name: Laravel Release - -on: - push: - branches: [main] - paths: ['.changeset/*.md'] - workflow_dispatch: - -permissions: - contents: read - -env: - PYTHONDONTWRITEBYTECODE: '1' - -concurrency: - group: laravel-release - cancel-in-progress: false - -jobs: - check-changesets: - if: github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - outputs: - has-changesets: ${{ steps.check.outputs.has-changesets }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - persist-credentials: false - - uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1 - with: - version: 11.13.1 - runtime: node@24 - install: false - - name: Check for Laravel change intents - id: check - env: - TRIGGER_SHA: ${{ github.sha }} - run: bash scripts/check-release-changes.sh posthog-laravel - - prepare: - needs: check-changesets - if: needs.check-changesets.outputs.has-changesets == 'true' - runs-on: ubuntu-latest - outputs: - source: ${{ steps.candidate.outputs.source }} - version: ${{ steps.candidate.outputs.version }} - patch-sha256: ${{ steps.candidate.outputs.patch_sha256 }} - archive-sha256: ${{ steps.candidate.outputs.archive_sha256 }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - persist-credentials: false - - name: Require current main - env: - SOURCE: ${{ github.sha }} - run: test "$(git rev-parse origin/main)" = "$SOURCE" - - uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1 - with: - version: 11.13.1 - runtime: node@24 - install: false - - run: python3 -m unittest discover -s scripts/tests -v - - name: Prepare immutable candidate (no release credentials) - id: candidate - env: - REPOSITORY: ${{ github.repository }} - run: | - python3 scripts/laravel_release.py prepare --repository "$REPOSITORY" --output "$RUNNER_TEMP/candidate" - python3 - "$RUNNER_TEMP/candidate/candidate.json" >> "$GITHUB_OUTPUT" <<'PY' - import json, sys - for key, value in json.load(open(sys.argv[1])).items(): - print(f'{key}={value}') - PY - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: laravel-candidate-${{ github.sha }} - path: ${{ runner.temp }}/candidate/ - if-no-files-found: error - retention-days: 5 - - verify: - needs: prepare - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ needs.prepare.outputs.source }} - persist-credentials: false - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: laravel-candidate-${{ needs.prepare.outputs.source }} - path: ${{ runner.temp }}/candidate - - uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1 - with: - version: 11.13.1 - runtime: node@24 - install: false - - name: Independently reproduce candidate bytes - env: - REPOSITORY: ${{ github.repository }} - run: | - set -euo pipefail - python3 scripts/laravel_release.py prepare --repository "$REPOSITORY" --output "$RUNNER_TEMP/reproduced" - for file in candidate.json release.patch package.tar.gz notes.md; do - cmp "$RUNNER_TEMP/candidate/$file" "$RUNNER_TEMP/reproduced/$file" - done - git apply --check "$RUNNER_TEMP/candidate/release.patch" - - name: Check source and distribution release refs are unused - env: - GH_TOKEN: ${{ github.token }} - SOURCE_REPOSITORY: ${{ github.repository }} - DISTRIBUTION_REPOSITORY: ${{ vars.LARAVEL_DISTRIBUTION_REPOSITORY }} - VERSION: ${{ needs.prepare.outputs.version }} - run: bash scripts/check-laravel-release-refs.sh - - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # v2.40.0 - with: - php-version: '8.3' - extensions: curl, mbstring, dom, xml, sqlite3, pdo_sqlite - tools: composer - - name: Test approved source on both supported frameworks - run: | - bash scripts/test-laravel.sh 12 - bash scripts/test-laravel.sh 13 - - name: Validate distribution Composer metadata - run: | - mkdir "$RUNNER_TEMP/composer-check" - tar -xzf "$RUNNER_TEMP/candidate/package.tar.gz" -C "$RUNNER_TEMP/composer-check" - cd "$RUNNER_TEMP/composer-check" - composer validate --strict --no-check-version - composer install --no-dev --prefer-dist --no-progress --no-interaction --no-scripts --no-plugins - - publish: - needs: [prepare, verify] - runs-on: ubuntu-latest - # Administrators MUST configure required reviewers and main-only deployment rules first. - environment: Laravel Release - permissions: - contents: read - actions: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ needs.prepare.outputs.source }} - fetch-depth: 0 - persist-credentials: false - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: laravel-candidate-${{ needs.prepare.outputs.source }} - path: ${{ runner.temp }}/candidate - - name: Recheck refs, main and approved candidate digests - env: - GH_TOKEN: ${{ secrets.LARAVEL_SOURCE_TOKEN }} - SOURCE_REPOSITORY: ${{ github.repository }} - DISTRIBUTION_REPOSITORY: ${{ vars.LARAVEL_DISTRIBUTION_REPOSITORY }} - VERSION: ${{ needs.prepare.outputs.version }} - EXPECTED_SOURCE: ${{ needs.prepare.outputs.source }} - EXPECTED_PATCH: ${{ needs.prepare.outputs.patch-sha256 }} - EXPECTED_ARCHIVE: ${{ needs.prepare.outputs.archive-sha256 }} - DISTRIBUTION_TOKEN: ${{ secrets.LARAVEL_DISTRIBUTION_TOKEN }} - run: | - set -euo pipefail - test -n "$GH_TOKEN" && test -n "$DISTRIBUTION_TOKEN" - test "$(gh api "repos/$SOURCE_REPOSITORY/git/ref/heads/main" --jq '.object.sha')" = "$EXPECTED_SOURCE" - bash scripts/check-laravel-release-refs.sh - echo "$EXPECTED_PATCH $RUNNER_TEMP/candidate/release.patch" | sha256sum --check --strict - echo "$EXPECTED_ARCHIVE $RUNNER_TEMP/candidate/package.tar.gz" | sha256sum --check --strict - git apply --index "$RUNNER_TEMP/candidate/release.patch" - - name: Atomic GitHub-signed source version commit - id: source-commit - env: - GH_TOKEN: ${{ secrets.LARAVEL_SOURCE_TOKEN }} - REPOSITORY: ${{ github.repository }} - VERSION: ${{ needs.prepare.outputs.version }} - run: | - python3 scripts/commit_laravel_release.py --checkout "$GITHUB_WORKSPACE" --repository "$REPOSITORY" --mode source --message "chore: release laravel-v$VERSION [skip ci]" >> "$GITHUB_OUTPUT" - - name: Require signed source commit - env: - GH_TOKEN: ${{ secrets.LARAVEL_SOURCE_TOKEN }} - REPOSITORY: ${{ github.repository }} - COMMIT: ${{ steps.source-commit.outputs.commit-hash }} - run: | - test -n "$COMMIT" - test "$(gh api "repos/$REPOSITORY/commits/$COMMIT" --jq '.commit.verification.verified')" = true - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ vars.LARAVEL_DISTRIBUTION_REPOSITORY }} - ref: main - path: laravel-distribution - token: ${{ secrets.LARAVEL_DISTRIBUTION_TOKEN }} - persist-credentials: false - - name: Replace dedicated distribution tree with package-only files - env: - REPOSITORY: ${{ vars.LARAVEL_DISTRIBUTION_REPOSITORY }} - run: python3 scripts/laravel_release.py install --candidate "$RUNNER_TEMP/candidate" --destination "$GITHUB_WORKSPACE/laravel-distribution" --repository "$REPOSITORY" - - name: Atomic GitHub-signed distribution commit - id: distribution-commit - env: - GH_TOKEN: ${{ secrets.LARAVEL_DISTRIBUTION_TOKEN }} - REPOSITORY: ${{ vars.LARAVEL_DISTRIBUTION_REPOSITORY }} - VERSION: ${{ needs.prepare.outputs.version }} - SOURCE: ${{ needs.prepare.outputs.source }} - run: | - python3 scripts/commit_laravel_release.py --checkout "$GITHUB_WORKSPACE/laravel-distribution" --repository "$REPOSITORY" --mode distribution --message "Release v$VERSION from source $SOURCE" >> "$GITHUB_OUTPUT" - - name: Publish distribution version - env: - GH_TOKEN: ${{ secrets.LARAVEL_DISTRIBUTION_TOKEN }} - REPOSITORY: ${{ vars.LARAVEL_DISTRIBUTION_REPOSITORY }} - COMMIT: ${{ steps.distribution-commit.outputs.commit-hash }} - VERSION: ${{ needs.prepare.outputs.version }} - run: | - set -euo pipefail - test -n "$COMMIT" - test "$(gh api "repos/$REPOSITORY/commits/$COMMIT" --jq '.commit.verification.verified')" = true - gh release create "v$VERSION" --repo "$REPOSITORY" --target "$COMMIT" --title "v$VERSION" --notes-file "$RUNNER_TEMP/candidate/notes.md" - - name: Publish independent source release and archive - env: - GH_TOKEN: ${{ secrets.LARAVEL_SOURCE_TOKEN }} - REPOSITORY: ${{ github.repository }} - COMMIT: ${{ steps.source-commit.outputs.commit-hash }} - VERSION: ${{ needs.prepare.outputs.version }} - DISTRIBUTION_REPOSITORY: ${{ vars.LARAVEL_DISTRIBUTION_REPOSITORY }} - DISTRIBUTION_COMMIT: ${{ steps.distribution-commit.outputs.commit-hash }} - ARCHIVE_SHA256: ${{ needs.prepare.outputs.archive-sha256 }} - run: | - set -euo pipefail - printf '\nDistribution: %s@%s\nArchive SHA256: %s\n' "$DISTRIBUTION_REPOSITORY" "$DISTRIBUTION_COMMIT" "$ARCHIVE_SHA256" >> "$RUNNER_TEMP/candidate/notes.md" - gh release create "laravel-v$VERSION" --latest=false --repo "$REPOSITORY" --target "$COMMIT" --title "Laravel v$VERSION" --notes-file "$RUNNER_TEMP/candidate/notes.md" "$RUNNER_TEMP/candidate/package.tar.gz" "$RUNNER_TEMP/candidate/candidate.json" diff --git a/.github/workflows/laravel.yml b/.github/workflows/laravel.yml index 771a2c42..710a32c5 100644 --- a/.github/workflows/laravel.yml +++ b/.github/workflows/laravel.yml @@ -10,6 +10,7 @@ on: - 'scripts/**' - 'pnpm-workspace.yaml' - '.github/workflows/laravel*.yml' + - '.github/workflows/release.yml' push: branches: [main] paths: @@ -20,6 +21,7 @@ on: - 'scripts/**' - 'pnpm-workspace.yaml' - '.github/workflows/laravel*.yml' + - '.github/workflows/release.yml' permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 753aa521..595306e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,9 @@ on: permissions: contents: read +env: + PYTHONDONTWRITEBYTECODE: '1' + # Concurrency control: only one release process can run at a time # This prevents race conditions if multiple releasable change intents merge simultaneously concurrency: @@ -305,7 +308,9 @@ jobs: posthog_project_api_key: ${{ secrets.POSTHOG_PROJECT_API_KEY }} release: - name: Publish release + name: Publish PHP release + outputs: + commit-hash: ${{ steps.commit-version-bump.outputs.commit-hash }} needs: [check-changesets, prepare-release-candidate, verify-release-candidate, notify-approval-needed] runs-on: ubuntu-latest if: always() && needs.check-changesets.outputs.has-changesets == 'true' && needs.verify-release-candidate.result == 'success' && needs.notify-approval-needed.result == 'success' @@ -496,3 +501,227 @@ jobs: thread_ts: ${{ needs.notify-approval-needed.outputs.slack_ts }} message: '${{ steps.check-failure.outputs.message }}' emoji_reaction: 'no_entry_sign' + + laravel-check-changesets: + name: Check for Laravel change intents + needs: [check-changesets, release] + if: >- + always() && !cancelled() && github.ref == 'refs/heads/main' && + needs.check-changesets.result == 'success' && + (needs.check-changesets.outputs.has-changesets != 'true' || needs.release.result == 'success') + runs-on: ubuntu-latest + outputs: + source: ${{ steps.source.outputs.sha }} + has-changesets: ${{ steps.check.outputs.has-changesets }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.release.outputs.commit-hash || github.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Record immutable release source + id: source + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1 + with: + version: 11.13.1 + runtime: node@24 + install: false + - name: Check for Laravel change intents + id: check + env: + TRIGGER_SHA: ${{ needs.release.outputs.commit-hash || github.sha }} + run: bash scripts/check-release-changes.sh posthog-laravel + + prepare-laravel-release: + needs: laravel-check-changesets + if: always() && !cancelled() && needs.laravel-check-changesets.result == 'success' && needs.laravel-check-changesets.outputs.has-changesets == 'true' + runs-on: ubuntu-latest + outputs: + source: ${{ steps.candidate.outputs.source }} + version: ${{ steps.candidate.outputs.version }} + patch-sha256: ${{ steps.candidate.outputs.patch_sha256 }} + archive-sha256: ${{ steps.candidate.outputs.archive_sha256 }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.laravel-check-changesets.outputs.source }} + fetch-depth: 0 + persist-credentials: false + - name: Require current main + env: + SOURCE: ${{ needs.laravel-check-changesets.outputs.source }} + run: test "$(git rev-parse origin/main)" = "$SOURCE" + - uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1 + with: + version: 11.13.1 + runtime: node@24 + install: false + - run: python3 -m unittest discover -s scripts/tests -v + - name: Prepare immutable candidate (no release credentials) + id: candidate + env: + REPOSITORY: ${{ github.repository }} + run: | + python3 scripts/laravel_release.py prepare --repository "$REPOSITORY" --output "$RUNNER_TEMP/candidate" + python3 - "$RUNNER_TEMP/candidate/candidate.json" >> "$GITHUB_OUTPUT" <<'PY' + import json, sys + for key, value in json.load(open(sys.argv[1])).items(): + print(f'{key}={value}') + PY + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: laravel-candidate-${{ needs.laravel-check-changesets.outputs.source }} + path: ${{ runner.temp }}/candidate/ + if-no-files-found: error + retention-days: 5 + + verify-laravel-release: + needs: prepare-laravel-release + if: always() && !cancelled() && needs.prepare-laravel-release.result == 'success' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.prepare-laravel-release.outputs.source }} + persist-credentials: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: laravel-candidate-${{ needs.prepare-laravel-release.outputs.source }} + path: ${{ runner.temp }}/candidate + - uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1 + with: + version: 11.13.1 + runtime: node@24 + install: false + - name: Independently reproduce candidate bytes + env: + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + python3 scripts/laravel_release.py prepare --repository "$REPOSITORY" --output "$RUNNER_TEMP/reproduced" + for file in candidate.json release.patch package.tar.gz notes.md; do + cmp "$RUNNER_TEMP/candidate/$file" "$RUNNER_TEMP/reproduced/$file" + done + git apply --check "$RUNNER_TEMP/candidate/release.patch" + - name: Check source and distribution release refs are unused + env: + GH_TOKEN: ${{ github.token }} + SOURCE_REPOSITORY: ${{ github.repository }} + DISTRIBUTION_REPOSITORY: ${{ vars.LARAVEL_DISTRIBUTION_REPOSITORY }} + VERSION: ${{ needs.prepare-laravel-release.outputs.version }} + run: bash scripts/check-laravel-release-refs.sh + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # v2.40.0 + with: + php-version: '8.3' + extensions: curl, mbstring, dom, xml, sqlite3, pdo_sqlite + tools: composer + - name: Test approved source on both supported frameworks + run: | + bash scripts/test-laravel.sh 12 + bash scripts/test-laravel.sh 13 + - name: Validate distribution Composer metadata + run: | + mkdir "$RUNNER_TEMP/composer-check" + tar -xzf "$RUNNER_TEMP/candidate/package.tar.gz" -C "$RUNNER_TEMP/composer-check" + cd "$RUNNER_TEMP/composer-check" + composer validate --strict --no-check-version + composer install --no-dev --prefer-dist --no-progress --no-interaction --no-scripts --no-plugins + + publish-laravel: + needs: [prepare-laravel-release, verify-laravel-release] + if: always() && !cancelled() && needs.prepare-laravel-release.result == 'success' && needs.verify-laravel-release.result == 'success' + runs-on: ubuntu-latest + # Administrators MUST configure required reviewers and main-only deployment rules first. + environment: Laravel Release + permissions: + contents: read + actions: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.prepare-laravel-release.outputs.source }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: laravel-candidate-${{ needs.prepare-laravel-release.outputs.source }} + path: ${{ runner.temp }}/candidate + - name: Recheck refs, main and approved candidate digests + env: + GH_TOKEN: ${{ secrets.LARAVEL_SOURCE_TOKEN }} + SOURCE_REPOSITORY: ${{ github.repository }} + DISTRIBUTION_REPOSITORY: ${{ vars.LARAVEL_DISTRIBUTION_REPOSITORY }} + VERSION: ${{ needs.prepare-laravel-release.outputs.version }} + EXPECTED_SOURCE: ${{ needs.prepare-laravel-release.outputs.source }} + EXPECTED_PATCH: ${{ needs.prepare-laravel-release.outputs.patch-sha256 }} + EXPECTED_ARCHIVE: ${{ needs.prepare-laravel-release.outputs.archive-sha256 }} + DISTRIBUTION_TOKEN: ${{ secrets.LARAVEL_DISTRIBUTION_TOKEN }} + run: | + set -euo pipefail + test -n "$GH_TOKEN" && test -n "$DISTRIBUTION_TOKEN" + test "$(gh api "repos/$SOURCE_REPOSITORY/git/ref/heads/main" --jq '.object.sha')" = "$EXPECTED_SOURCE" + bash scripts/check-laravel-release-refs.sh + echo "$EXPECTED_PATCH $RUNNER_TEMP/candidate/release.patch" | sha256sum --check --strict + echo "$EXPECTED_ARCHIVE $RUNNER_TEMP/candidate/package.tar.gz" | sha256sum --check --strict + git apply --index "$RUNNER_TEMP/candidate/release.patch" + - name: Atomic GitHub-signed source version commit + id: source-commit + env: + GH_TOKEN: ${{ secrets.LARAVEL_SOURCE_TOKEN }} + REPOSITORY: ${{ github.repository }} + VERSION: ${{ needs.prepare-laravel-release.outputs.version }} + run: | + python3 scripts/commit_laravel_release.py --checkout "$GITHUB_WORKSPACE" --repository "$REPOSITORY" --mode source --message "chore: release laravel-v$VERSION [skip ci]" >> "$GITHUB_OUTPUT" + - name: Require signed source commit + env: + GH_TOKEN: ${{ secrets.LARAVEL_SOURCE_TOKEN }} + REPOSITORY: ${{ github.repository }} + COMMIT: ${{ steps.source-commit.outputs.commit-hash }} + run: | + test -n "$COMMIT" + test "$(gh api "repos/$REPOSITORY/commits/$COMMIT" --jq '.commit.verification.verified')" = true + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ vars.LARAVEL_DISTRIBUTION_REPOSITORY }} + ref: main + path: laravel-distribution + token: ${{ secrets.LARAVEL_DISTRIBUTION_TOKEN }} + persist-credentials: false + - name: Replace dedicated distribution tree with package-only files + env: + REPOSITORY: ${{ vars.LARAVEL_DISTRIBUTION_REPOSITORY }} + run: python3 scripts/laravel_release.py install --candidate "$RUNNER_TEMP/candidate" --destination "$GITHUB_WORKSPACE/laravel-distribution" --repository "$REPOSITORY" + - name: Atomic GitHub-signed distribution commit + id: distribution-commit + env: + GH_TOKEN: ${{ secrets.LARAVEL_DISTRIBUTION_TOKEN }} + REPOSITORY: ${{ vars.LARAVEL_DISTRIBUTION_REPOSITORY }} + VERSION: ${{ needs.prepare-laravel-release.outputs.version }} + SOURCE: ${{ needs.prepare-laravel-release.outputs.source }} + run: | + python3 scripts/commit_laravel_release.py --checkout "$GITHUB_WORKSPACE/laravel-distribution" --repository "$REPOSITORY" --mode distribution --message "Release v$VERSION from source $SOURCE" >> "$GITHUB_OUTPUT" + - name: Publish distribution version + env: + GH_TOKEN: ${{ secrets.LARAVEL_DISTRIBUTION_TOKEN }} + REPOSITORY: ${{ vars.LARAVEL_DISTRIBUTION_REPOSITORY }} + COMMIT: ${{ steps.distribution-commit.outputs.commit-hash }} + VERSION: ${{ needs.prepare-laravel-release.outputs.version }} + run: | + set -euo pipefail + test -n "$COMMIT" + test "$(gh api "repos/$REPOSITORY/commits/$COMMIT" --jq '.commit.verification.verified')" = true + gh release create "v$VERSION" --repo "$REPOSITORY" --target "$COMMIT" --title "v$VERSION" --notes-file "$RUNNER_TEMP/candidate/notes.md" + - name: Publish independent source release and archive + env: + GH_TOKEN: ${{ secrets.LARAVEL_SOURCE_TOKEN }} + REPOSITORY: ${{ github.repository }} + COMMIT: ${{ steps.source-commit.outputs.commit-hash }} + VERSION: ${{ needs.prepare-laravel-release.outputs.version }} + DISTRIBUTION_REPOSITORY: ${{ vars.LARAVEL_DISTRIBUTION_REPOSITORY }} + DISTRIBUTION_COMMIT: ${{ steps.distribution-commit.outputs.commit-hash }} + ARCHIVE_SHA256: ${{ needs.prepare-laravel-release.outputs.archive-sha256 }} + run: | + set -euo pipefail + printf '\nDistribution: %s@%s\nArchive SHA256: %s\n' "$DISTRIBUTION_REPOSITORY" "$DISTRIBUTION_COMMIT" "$ARCHIVE_SHA256" >> "$RUNNER_TEMP/candidate/notes.md" + gh release create "laravel-v$VERSION" --latest=false --repo "$REPOSITORY" --target "$COMMIT" --title "Laravel v$VERSION" --notes-file "$RUNNER_TEMP/candidate/notes.md" "$RUNNER_TEMP/candidate/package.tar.gz" "$RUNNER_TEMP/candidate/candidate.json" diff --git a/RELEASING.md b/RELEASING.md index 8ed4477d..c1f3b5c6 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,6 +1,6 @@ # Releasing -The core PHP SDK and [Laravel package](#laravel-package-independent-releases) use [pnpm's native release management](https://pnpm.io/versioning), with shared `.changeset/` intents and independent, package-filtered publishing workflows. +The core PHP SDK and [Laravel package](#laravel-package-independent-releases) use [pnpm's native release management](https://pnpm.io/versioning), with shared `.changeset/` intents and separate, package-filtered releases in one `.github/workflows/release.yml` workflow. ## How to Release @@ -18,7 +18,9 @@ Select `posthog-php`, `posthog-laravel`, or both, choose the version bump (`patc After review, merge the PR to `main`. No GitHub release label is required. -A push to `main` that includes `.changeset/*.md` changes automatically starts the release workflow. The workflow then: +A push to `main` that includes `.changeset/*.md` changes automatically starts the **Release** workflow. Like the Ruby SDK's multi-package release flow, PHP is processed before Laravel because the framework package depends on core. Each package is released only when its filtered pnpm plan has pending changes. + +For a core PHP release, the workflow: 1. Checks for pending change intents. A run whose trigger commit is no longer the tip of `main` skips itself if no releasable intents remain there, and fails if some do, since a later push that didn't touch `.changeset/` starts no release run of its own. In that case, dispatch the Release workflow on `main` 2. Uses `pnpm --filter posthog-php version -r` to apply only the core version bump, update `CHANGELOG.md` and `.changeset/ledger.yaml`, and refresh `composer.lock`. Laravel-only intents remain pending; mixed intents remain until both packages have released @@ -112,8 +114,11 @@ replaced with only tracked package `composer.json`, `README.md`, `LICENSE.md`, `CHANGELOG.md`, `AUTOCAPTURE.md`, `PENNANT.md`, `src/**/*.php`, `config/**/*.php`, and generated `posthog-source.json`. Hidden files, vendors, tests, environment files, locks, development tooling and non-PHP -config files are excluded; symlinks fail preparation. Never keep unrelated files -in that repository: obsolete distribution files are deleted on release. +config files are excluded. `packages/laravel/LICENSE.md` links to `../../LICENSE.md` +in source; preparation embeds the regular root license contents in the archive. +Other symlinks, alternate license targets, and a symlinked root license fail +preparation. Never keep unrelated files in that repository: obsolete distribution +files are deleted on release. ### Prerequisites — administrator actions, not performed by this tooling change @@ -165,21 +170,25 @@ intent remains until both packages have consumed it; the ledger prevents a secon bump of the package already released. Both release-metadata packages are private, so pnpm always applies the pending -bump to the manifest version without consulting npm. The initial Laravel patch -intent therefore produces **0.1.1** from the current 0.1.0. There is no custom -`initial` bump or separate intent format. +bump to the manifest version without consulting npm. Laravel's metadata and +runtime are seeded at **0.0.0**; the initial **minor** intent produces **0.1.0**. +There is no custom `initial` bump or separate intent format. Laravel preparation runs `pnpm --filter posthog-laravel version -r`, synchronizes its Composer/runtime versions, and uses `packages/laravel/CHANGELOG.md` for release notes. It does not change the root/core version, changelog or Composer lock. -A push changing `.changeset/*.md` on `main` starts both release workflows. Each -checks its filtered pnpm plan and skips when it has no pending release, including -when only the other package has changes. Manual dispatch on `main` is also -available. Source freshness checks remain fail-closed: if the other publisher or -another merge advances `main`, dispatch the remaining workflow on current `main` -while its intents remain. The core bump-script hash, signing, verification and -approval protections are unchanged. +The single **Release** workflow checks Laravel after core publishing succeeds or +is skipped because there is no pending core release. If core was released, Laravel +checks and candidate preparation use that exact signed version commit, not the +workflow's now-stale trigger SHA. Mixed intents are consumed per package across +those commits. A failed core release prevents Laravel publication in that run. + +Laravel-only changes skip core publishing; core-only changes skip Laravel +publishing. Manual dispatch uses the same routing. Source freshness checks remain +fail-closed: if an unrelated merge advances `main`, dispatch **Release** again on +current `main` while intents remain. The core bump-script hash, signing, +verification and approval protections are unchanged. ### Candidate, approval and publication @@ -220,7 +229,7 @@ Fixtures run without credentials, temporary repositories only: ```bash PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s scripts/tests -v -actionlint .github/workflows/laravel.yml .github/workflows/laravel-release.yml +actionlint .github/workflows/laravel.yml .github/workflows/release.yml ``` The fixtures pin **pnpm 11.13.1** and exercise both filtered release directions, diff --git a/packages/laravel/LICENSE.md b/packages/laravel/LICENSE.md deleted file mode 100644 index 2c58e5d9..00000000 --- a/packages/laravel/LICENSE.md +++ /dev/null @@ -1,23 +0,0 @@ -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/LICENSE.md b/packages/laravel/LICENSE.md new file mode 120000 index 00000000..f0608a63 --- /dev/null +++ b/packages/laravel/LICENSE.md @@ -0,0 +1 @@ +../../LICENSE.md \ No newline at end of file diff --git a/packages/laravel/composer.json b/packages/laravel/composer.json index 44916163..459e8e70 100644 --- a/packages/laravel/composer.json +++ b/packages/laravel/composer.json @@ -1,7 +1,7 @@ { "name": "posthog/posthog-laravel", "description": "PostHog for Laravel", - "version": "0.1.0", + "version": "0.0.0", "license": "MIT", "require": { "php": ">=8.2", diff --git a/packages/laravel/package.json b/packages/laravel/package.json index 9c1c0172..5112d3de 100644 --- a/packages/laravel/package.json +++ b/packages/laravel/package.json @@ -1,6 +1,6 @@ { "name": "posthog-laravel", - "version": "0.1.0", + "version": "0.0.0", "private": true, "description": "Independent release metadata for posthog/posthog-laravel" } diff --git a/packages/laravel/src/PostHog.php b/packages/laravel/src/PostHog.php index b9b94804..0887962e 100644 --- a/packages/laravel/src/PostHog.php +++ b/packages/laravel/src/PostHog.php @@ -12,7 +12,7 @@ class PostHog { - public const VERSION = '0.1.0'; + public const VERSION = '0.0.0'; private ?Client $client = null; private ?int $definitionsLoadedAt = null; diff --git a/scripts/laravel_release.py b/scripts/laravel_release.py index 9044d800..84d2b386 100644 --- a/scripts/laravel_release.py +++ b/scripts/laravel_release.py @@ -93,6 +93,8 @@ def prepare(root, output, repository): if not allowed_distribution_file(relative): continue path = work / name + if relative == "LICENSE.md" and path.is_symlink() and path.readlink() == Path("../../LICENSE.md"): + path = work / "LICENSE.md" if path.is_symlink() or not path.is_file(): raise ValueError(f"Not a regular distribution file: {name}") files[relative] = path.read_bytes() diff --git a/scripts/tests/test_laravel_release.py b/scripts/tests/test_laravel_release.py index 1f6d12ba..16d525d7 100644 --- a/scripts/tests/test_laravel_release.py +++ b/scripts/tests/test_laravel_release.py @@ -138,6 +138,48 @@ def test_archive_is_deterministic_and_excludes_development_files(self): self.assertEqual(metadata["commit"], git(self.root, "rev-parse", "HEAD").decode().strip()) self.assertEqual(metadata["tag"], "laravel-v0.1.1") + def test_root_license_link_is_embedded_as_a_regular_distribution_file(self): + self.write("LICENSE.md", "Root MIT license\n") + license_path = self.root / release.PACKAGE / "LICENSE.md" + license_path.unlink() + license_path.symlink_to("../../LICENSE.md") + snapshot(self.root) + with tarfile.open(self.prepare() / "package.tar.gz") as archive: + self.assertTrue(archive.getmember("LICENSE.md").isfile()) + self.assertEqual(archive.extractfile("LICENSE.md").read(), b"Root MIT license\n") + self.assertTrue(license_path.is_symlink()) + + def test_license_link_rejects_other_targets_and_a_linked_root_license(self): + self.write("LICENSE.md", "MIT\n") + self.write("README.md", "Not a license\n") + license_path = self.root / release.PACKAGE / "LICENSE.md" + license_path.unlink() + license_path.symlink_to("../../README.md") + snapshot(self.root) + with self.assertRaisesRegex(ValueError, "regular distribution file"): + self.prepare() + license_path.unlink() + license_path.symlink_to("../../LICENSE.md") + (self.root / "LICENSE.md").unlink() + (self.root / "LICENSE.md").symlink_to("README.md") + snapshot(self.root) + with self.assertRaisesRegex(ValueError, "regular distribution file"): + self.prepare() + + def test_initial_minor_release_starts_at_zero(self): + for name in ["package.json", "composer.json"]: + metadata = release.read_json(self.root / release.PACKAGE / name) + metadata["version"] = "0.0.0" + self.write(str(release.PACKAGE / name), json.dumps(metadata)) + self.write("packages/laravel/src/PostHog.php", "