diff --git a/.laravel-changes/eb4dfed4373d440294ff58970818d3e7.json b/.laravel-changes/eb4dfed4373d440294ff58970818d3e7.json
new file mode 100644
index 0000000..0721ba9
--- /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
new file mode 100644
index 0000000..da0f0ab
--- /dev/null
+++ b/packages/laravel/AUTOCAPTURE.md
@@ -0,0 +1,216 @@
+# 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.
+
+The collector classes and their service provider are internal implementation details,
+not supported extension points. The documented configuration, publish tag and
+`@posthogFeature` Blade conditional are the supported integration surface.
+
+Collectors skip context resolution and model buffering when PostHog is disabled or
+its API key is empty. `PostHog::fake()` still enables collection for assertions.
+
+With `posthog.sdk.debug=true`, collector failures log at most once per collector
+site per Laravel container scope through Laravel's logger. Diagnostics include
+only the collector site and exception class, not exception messages or event data.
+Logger failures never escape into the application.
+
+## 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, 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.
+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, 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 or request
+object is retained. Outside a transaction, collection is immediate, even when a
+different connection has an open transaction.
+
+In tests using Laravel's `DatabaseTransactions` or `RefreshDatabase`, collection
+follows Laravel's after-commit rules: the test-wrapper transaction alone does not
+delay capture, while application-owned nested transactions still defer until
+commit and discard rolled-back changes.
+
+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 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.
+
+### 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 4a2915c..2a083a2 100644
--- a/packages/laravel/README.md
+++ b/packages/laravel/README.md
@@ -298,8 +298,13 @@ 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.
+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 0000000..17d3636
--- /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 0000000..f7eeaa0
--- /dev/null
+++ b/packages/laravel/src/Autocapture/AuthEvents.php
@@ -0,0 +1,35 @@
+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 $exception) {
+ Collector::logFailure(__METHOD__, $exception);
+ }
+ }
+}
diff --git a/packages/laravel/src/Autocapture/Collector.php b/packages/laravel/src/Autocapture/Collector.php
new file mode 100644
index 0000000..c0e5389
--- /dev/null
+++ b/packages/laravel/src/Autocapture/Collector.php
@@ -0,0 +1,89 @@
+get('posthog.autocapture.' . $section . '.enabled', false))
+ && $app->make(PostHog::class)->enabled();
+ }
+
+ public static function snapshot(): array
+ {
+ return array_intersect_key(Container::getInstance()->make(Context::class)->snapshot(), [
+ 'distinctId' => true, 'sessionId' => true, 'groups' => true,
+ ]);
+ }
+
+ public static function capture(string $event, array $properties, ?array $snapshot = null): void
+ {
+ $context = null;
+ try {
+ $app = Container::getInstance();
+ if (!self::enabled()) {
+ return;
+ }
+ $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 $exception) {
+ self::logFailure(__METHOD__, $exception);
+ } finally {
+ $context?->leave();
+ }
+ }
+
+ public static function logFailure(string $collector, Throwable $exception): void
+ {
+ try {
+ $app = Container::getInstance();
+ if (!$app['config']->get('posthog.sdk.debug', false)) {
+ return;
+ }
+ $instance = $app->make(self::class);
+ if (isset($instance->loggedFailures[$collector])) {
+ return;
+ }
+ $instance->loggedFailures[$collector] = true;
+ $app->make('log')->debug('[PostHog] Automatic event collection failed.', [
+ 'collector' => $collector, 'exception_class' => $exception::class,
+ ]);
+ } catch (Throwable) {
+ }
+ }
+
+ 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/DeferredModelEvents.php b/packages/laravel/src/Autocapture/DeferredModelEvents.php
new file mode 100644
index 0000000..13cbba5
--- /dev/null
+++ b/packages/laravel/src/Autocapture/DeferredModelEvents.php
@@ -0,0 +1,235 @@
+
+ */
+ 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();
+ // Connection::afterCommit forwards to a shared manager without a connection name.
+ $matches = fn ($record) => $record->connection === $connection->getName()
+ && $record->level === $connection->transactionLevel();
+ $record = $this->transactions->callbackApplicableTransactions()->last($matches);
+ if ($record === null) {
+ // Laravel's testing manager excludes test-wrapper transactions from commit callbacks.
+ if ($this->transactions->getPendingTransactions()->contains($matches)) {
+ Collector::capture($event, $properties, $snapshot);
+ }
+ return;
+ }
+ 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;
+ }
+ // 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
+ {
+ $this->prune();
+ $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;
+ }
+ if ($connection->transactionLevel() === 1) {
+ $managed = $this->managedTransactions();
+ foreach ($this->pending as $key => $entry) {
+ $olderRoot = $entry['root']->get();
+ if (
+ $entry['connection']->get() === $connection && $olderRoot !== $root
+ && $olderRoot !== null && isset($managed[$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);
+ $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 managedTransactions(): WeakMap
+ {
+ $managed = new WeakMap();
+ foreach (
+ [$this->transactions->getPendingTransactions(), $this->transactions->getCommittedTransactions()] as $set
+ ) {
+ foreach ($set as $record) {
+ $managed[$record] = true;
+ }
+ }
+ return $managed;
+ }
+
+ private function prune(?DatabaseTransactionRecord $rolledBackRoot = null): void
+ {
+ if ($this->pending === [] && count($this->records) === 0) {
+ return;
+ }
+ $managed = $this->managedTransactions();
+ 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 && !isset($managed[$record]))
+ || (!isset($managed[$record]) && isset($managed[$root]))
+ || ($connection->transactionLevel() === 0 && isset($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 && !isset($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/DomainEvents.php b/packages/laravel/src/Autocapture/DomainEvents.php
new file mode 100644
index 0000000..6283ace
--- /dev/null
+++ b/packages/laravel/src/Autocapture/DomainEvents.php
@@ -0,0 +1,42 @@
+ $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 $exception) {
+ Collector::logFailure(__METHOD__, $exception);
+ }
+ }
+}
diff --git a/packages/laravel/src/Autocapture/ModelEvents.php b/packages/laravel/src/Autocapture/ModelEvents.php
new file mode 100644
index 0000000..6824644
--- /dev/null
+++ b/packages/laravel/src/Autocapture/ModelEvents.php
@@ -0,0 +1,54 @@
+get('posthog.autocapture.models.allow', []);
+ 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) {
+ 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];
+ $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 $exception) {
+ Collector::logFailure(__METHOD__, $exception);
+ }
+ }
+}
diff --git a/packages/laravel/src/Autocapture/Outcomes.php b/packages/laravel/src/Autocapture/Outcomes.php
new file mode 100644
index 0000000..2684d8d
--- /dev/null
+++ b/packages/laravel/src/Autocapture/Outcomes.php
@@ -0,0 +1,95 @@
+starts = new WeakMap();
+ $this->scheduled = new WeakMap();
+ $this->executed = new WeakMap();
+ }
+
+ public function start(string $section, object $key): void
+ {
+ if (Collector::enabled($section)) {
+ $this->starts[$key] = hrtime(true);
+ }
+ }
+
+ 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])) {
+ 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 $exception) {
+ Collector::logFailure(__METHOD__, $exception);
+ }
+ }
+}
diff --git a/packages/laravel/src/Autocapture/RequestSummary.php b/packages/laravel/src/Autocapture/RequestSummary.php
new file mode 100644
index 0000000..72c33da
--- /dev/null
+++ b/packages/laravel/src/Autocapture/RequestSummary.php
@@ -0,0 +1,82 @@
+collect($request, $response, $started);
+ } catch (Throwable $exception) {
+ Collector::logFailure(__METHOD__, $exception);
+ }
+ }
+ }
+
+ 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 0000000..7e2312a
--- /dev/null
+++ b/packages/laravel/src/AutocaptureServiceProvider.php
@@ -0,0 +1,133 @@
+mergeConfigFrom(__DIR__ . '/../config/autocapture.php', 'posthog-autocapture');
+ $this->app->scoped(Collector::class);
+ $config = $this->app['config'];
+ $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);
+ $this->app->singleton(
+ DeferredModelEvents::class,
+ fn ($app) => new DeferredModelEvents($app['db.transactions']),
+ );
+ }
+
+ 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 (
+ [
+ '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]);
+ });
+ }
+ 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 $exception) {
+ Collector::logFailure(self::class . '::posthogFeature', $exception);
+ 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',
+ '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(),
+ '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->startScheduler($event->task);
+ },
+ );
+ foreach (['ScheduledTaskFinished' => false, 'ScheduledTaskFailed' => true] as $event => $failed) {
+ $events->listen(
+ 'Illuminate\\Console\\Events\\' . $event,
+ static function ($event) use ($outcomes, $failed) {
+ $outcomes->finishScheduler($event->task, $failed);
+ },
+ );
+ }
+ }
+}
diff --git a/packages/laravel/src/Testing/PostHogFake.php b/packages/laravel/src/Testing/PostHogFake.php
index 9f45b62..d555c60 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/ApiSurfaceTest.php b/packages/laravel/tests/ApiSurfaceTest.php
index 3974051..153b0c5 100644
--- a/packages/laravel/tests/ApiSurfaceTest.php
+++ b/packages/laravel/tests/ApiSurfaceTest.php
@@ -183,6 +183,20 @@ public function testPublicApiMatchesItsSnapshot(): void
require_once $root . '/scripts/PublicApiSnapshot.php';
$builder = new ReflectionMethod(PublicApiSnapshot::class, 'buildPublicApi');
$api = $builder->invoke(null, dirname(__DIR__) . '/src', 'PostHog\\Laravel\\', true);
+ foreach (
+ [
+ \PostHog\Laravel\AutocaptureServiceProvider::class,
+ \PostHog\Laravel\Autocapture\AuthEvents::class,
+ \PostHog\Laravel\Autocapture\Collector::class,
+ \PostHog\Laravel\Autocapture\DeferredModelEvents::class,
+ \PostHog\Laravel\Autocapture\DomainEvents::class,
+ \PostHog\Laravel\Autocapture\ModelEvents::class,
+ \PostHog\Laravel\Autocapture\Outcomes::class,
+ \PostHog\Laravel\Autocapture\RequestSummary::class,
+ ] as $internal
+ ) {
+ $this->assertArrayNotHasKey($internal, $api['symbols']);
+ }
$this->assertArrayNotHasKey(\PostHog\Laravel\ExceptionReporter::class, $api['symbols']);
$this->assertArrayNotHasKey('fromRequest', $api['symbols'][Context::class]['methods']);
$this->assertArrayHasKey('getFeatureFlagResult', $api['symbols'][PostHog::class]['methods']);
diff --git a/packages/laravel/tests/AutocaptureTest.php b/packages/laravel/tests/AutocaptureTest.php
new file mode 100644
index 0000000..b19bd88
--- /dev/null
+++ b/packages/laravel/tests/AutocaptureTest.php
@@ -0,0 +1,513 @@
+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 testDisabledRequestCollectionBypassesPostResponseChecks(): void
+ {
+ $request = Request::create('/off');
+ $response = response('ok');
+ $original = $this->app['config'];
+ $nextCalled = false;
+ $checksAfterNext = [];
+ $config = $this->createMock(\Illuminate\Config\Repository::class);
+ $config->expects($this->exactly(2))->method('get')->willReturnCallback(
+ function () use (&$nextCalled, &$checksAfterNext) {
+ $checksAfterNext[] = $nextCalled;
+ return false;
+ },
+ );
+ $this->app->instance('config', $config);
+ try {
+ $this->assertSame($response, (new RequestSummary())->handle(
+ $request,
+ function ($actual) use ($request, $response, &$nextCalled) {
+ $this->assertSame($request, $actual);
+ $nextCalled = true;
+ return $response;
+ },
+ ));
+ } finally {
+ $this->app->instance('config', $original);
+ }
+ $this->assertTrue($nextCalled);
+ $this->assertSame([false, false], $checksAfterNext, 'Disabled collection must bypass post-response work.');
+ }
+
+ 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'));
+ $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);
+ } else {
+ file_put_contents($path, $original);
+ }
+ }
+ }
+
+ public function testRequestSummaryAndPageviewUseTemplatesAndConfiguredOriginOnly(): void
+ {
+ $fake = PostHog::fake();
+ $this->enable('requests');
+ $this->enable('pageviews');
+ $this->app->register(AutocaptureServiceProvider::class);
+ $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();
+ }
+ $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 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();
+ $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 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();
+ $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));
+ $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']);
+ $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, 'client'))->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/CollectorDiagnosticsTest.php b/packages/laravel/tests/CollectorDiagnosticsTest.php
new file mode 100644
index 0000000..a2134ee
--- /dev/null
+++ b/packages/laravel/tests/CollectorDiagnosticsTest.php
@@ -0,0 +1,139 @@
+set('posthog.api_key', 'project-key');
+ $app['config']->set('posthog.sdk.consumer', 'noop');
+ foreach (['auth', 'models', 'jobs', 'requests', 'blade'] as $section) {
+ $app['config']->set('posthog.autocapture.' . $section . '.enabled', true);
+ }
+ }
+
+ private function failingManager(TypeError $failure): PostHog
+ {
+ $manager = $this->getMockBuilder(PostHog::class)
+ ->setConstructorArgs([$this->app])
+ ->onlyMethods(['capture', 'evaluateFlags'])
+ ->getMock();
+ $manager->expects($this->atLeastOnce())->method('capture')->willThrowException($failure);
+ $manager->method('evaluateFlags')->willThrowException($failure);
+ $this->app->instance(PostHog::class, $manager);
+ return $manager;
+ }
+
+ public static function diagnosticModes(): array
+ {
+ return [
+ 'quiet by default' => [false, false],
+ 'debug' => [true, false],
+ 'broken logger' => [true, true],
+ ];
+ }
+
+ #[DataProvider('diagnosticModes')]
+ public function testCollectorFailuresAreDeduplicatedRedactedAndNeverEscape(bool $debug, bool $loggerThrows): void
+ {
+ $this->app['config']->set('posthog.sdk.debug', $debug);
+ $failure = new TypeError('private credentials and event data');
+ $this->failingManager($failure);
+ $this->app->make(Context::class)->begin(['distinctId' => 'private actor']);
+ $logs = [];
+ $logger = $this->createMock(LoggerInterface::class);
+ $logger->expects($this->exactly($debug ? 7 : 0))->method('debug')->willReturnCallback(
+ function ($message, $context) use (&$logs, $loggerThrows) {
+ $logs[] = [$message, $context];
+ $this->assertSame(['collector', 'exception_class'], array_keys($context));
+ $this->assertSame(TypeError::class, $context['exception_class']);
+ if ($loggerThrows) {
+ throw new RuntimeException('logger failed');
+ }
+ },
+ );
+ $this->app->instance('log', $logger);
+ $model = $this->createStub(CollectedRecord::class);
+ $model->method('getAttributes')->willThrowException($failure);
+ $this->app['config']->set('posthog.autocapture.models.allow', [$model::class => ['state']]);
+ $user = new class (['id' => 1]) extends GenericUser {
+ public function getAuthIdentifier()
+ {
+ throw new TypeError('private user data');
+ }
+ };
+ $request = Request::create('/private');
+ $request->setRouteResolver(fn () => throw $failure);
+ $applicationFailure = new RuntimeException('original application failure');
+ for ($i = 0; $i < 2; $i++) {
+ Collector::capture('private event', ['private' => 'data']);
+ (new AuthEvents())->collect('login', (object) ['user' => $user, 'guard' => 'web']);
+ (new DomainEvents())->collect(new \stdClass(), [
+ 'event' => 'private event',
+ 'properties' => (function () use ($failure) {
+ throw $failure;
+ yield;
+ })(),
+ ]);
+ (new ModelEvents())->collect('created', $model);
+ $outcomes = new Outcomes();
+ $job = new \stdClass();
+ $outcomes->start('jobs', $job);
+ $outcomes->finish('jobs', $job, fn () => throw $failure);
+ try {
+ (new RequestSummary())->handle($request, fn () => throw $applicationFailure);
+ $this->fail('The application exception must escape unchanged.');
+ } catch (RuntimeException $caught) {
+ $this->assertSame($applicationFailure, $caught);
+ }
+ $this->assertSame('off', trim(Blade::render("@posthogFeature('flag') on @else off @endposthogFeature")));
+ }
+ $this->assertCount($debug ? 7 : 0, $logs);
+ $this->assertStringNotContainsString('private', json_encode($logs));
+ if ($debug) {
+ $this->assertCount(7, array_unique(array_column(array_column($logs, 1), 'collector')));
+ }
+ $this->assertSame('private actor', $this->app->make(Context::class)->data()['distinctId']);
+ }
+
+ public function testDiagnosticDeduplicationResetsWithTheApplicationScope(): void
+ {
+ $this->app['config']->set('posthog.sdk.debug', true);
+ $manager = $this->failingManager(new TypeError('private'));
+ $logger = $this->createMock(LoggerInterface::class);
+ $logger->expects($this->exactly(2))->method('debug');
+ $this->app->instance('log', $logger);
+ for ($scope = 0; $scope < 2; $scope++) {
+ for ($i = 0; $i < 2; $i++) {
+ Collector::capture('event', [], ['distinctId' => 'actor']);
+ }
+ $this->app->forgetScopedInstances();
+ $this->app->instance(PostHog::class, $manager);
+ }
+ }
+}
diff --git a/packages/laravel/tests/CollectorDisabledTest.php b/packages/laravel/tests/CollectorDisabledTest.php
new file mode 100644
index 0000000..dba70b5
--- /dev/null
+++ b/packages/laravel/tests/CollectorDisabledTest.php
@@ -0,0 +1,108 @@
+set('posthog.api_key', 'project-key');
+ $app['config']->set('posthog.sdk.consumer', 'noop');
+ $app['config']->set('database.default', 'testing');
+ $app['config']->set('database.connections.testing', ['driver' => 'sqlite', 'database' => ':memory:']);
+ foreach (['requests', 'auth', 'models', 'jobs', 'blade'] as $section) {
+ $app['config']->set('posthog.autocapture.' . $section . '.enabled', true);
+ }
+ $app['config']->set('posthog.autocapture.models.allow', [CollectedRecord::class => ['state']]);
+ $app['config']->set('posthog.autocapture.domain_events', [OrderCompleted::class => [
+ 'event' => 'order_completed', 'properties' => ['amount' => 'amount'],
+ ]]);
+ }
+
+ public static function disabledConfigurations(): array
+ {
+ return [
+ 'disabled' => [false, 'project-key'],
+ 'empty key' => [true, ''],
+ 'whitespace key' => [true, ' '],
+ ];
+ }
+
+ #[DataProvider('disabledConfigurations')]
+ public function testDisabledCollectorsDoNotResolveContextOrBufferModels(bool $enabled, string $key): void
+ {
+ $this->app['config']->set('posthog.enabled', $enabled);
+ $this->app['config']->set('posthog.api_key', $key);
+ $calls = ['identity' => 0, 'groups' => 0, 'user' => 0];
+ foreach (['identity' => 'distinct_id', 'groups' => 'groups'] as $name => $option) {
+ $this->app->instance('collector.' . $name, function () use (&$calls, $name) {
+ $calls[$name]++;
+ return $name === 'groups' ? [] : null;
+ });
+ $this->app['config']->set('posthog.' . $option, 'collector.' . $name);
+ }
+ $request = Request::create('/disabled');
+ $request->setUserResolver(function () use (&$calls) {
+ $calls['user']++;
+ return new GenericUser(['id' => 'actor']);
+ });
+ $this->app->make(Context::class)->fromRequest($request);
+ DB::getSchemaBuilder()->create('collected_records', function (Blueprint $table) {
+ $table->id();
+ $table->string('state');
+ $table->string('email');
+ });
+ DB::beginTransaction();
+ try {
+ for ($i = 0; $i < 50; $i++) {
+ CollectedRecord::create(['state' => 'new', 'email' => 'private']);
+ }
+ $this->assertSame(
+ ['count' => 0, 'bytes' => 0],
+ $this->app->make(DeferredModelEvents::class)->pendingUsage(),
+ );
+ $this->app['events']->dispatch(new OrderCompleted());
+ $this->app['events']->dispatch(new \Illuminate\Auth\Events\Login(
+ 'web',
+ new GenericUser(['id' => 1]),
+ false,
+ ));
+ $response = response('ok');
+ $this->assertSame($response, (new RequestSummary())->handle($request, fn () => $response));
+ Collector::capture('direct collector call', []);
+ $this->assertSame(['identity' => 0, 'groups' => 0, 'user' => 0], $calls);
+ } finally {
+ DB::rollBack();
+ }
+ }
+
+ public function testFakeStillCollectsWithDisabledConfigurationAndNoKey(): void
+ {
+ $this->app['config']->set('posthog.enabled', false);
+ $this->app['config']->set('posthog.api_key', '');
+ $fake = PostHog::fake();
+ $this->app->make(Context::class)->begin(['distinctId' => 'actor']);
+ $this->app['events']->dispatch(new OrderCompleted());
+ $fake->assertCaptured('order_completed');
+ }
+}
diff --git a/packages/laravel/tests/Fixtures/CollectedRecord.php b/packages/laravel/tests/Fixtures/CollectedRecord.php
new file mode 100644
index 0000000..4d40bd4
--- /dev/null
+++ b/packages/laravel/tests/Fixtures/CollectedRecord.php
@@ -0,0 +1,12 @@
+items as $key => $record) {
+ $this->inspections++;
+ yield $key => $record;
+ }
+ }
+}
diff --git a/packages/laravel/tests/Fixtures/ExplicitlyFailedJob.php b/packages/laravel/tests/Fixtures/ExplicitlyFailedJob.php
new file mode 100644
index 0000000..e902ccd
--- /dev/null
+++ b/packages/laravel/tests/Fixtures/ExplicitlyFailedJob.php
@@ -0,0 +1,19 @@
+fail(new RuntimeException('explicit job failure'));
+ }
+}
diff --git a/packages/laravel/tests/Fixtures/OrderCompleted.php b/packages/laravel/tests/Fixtures/OrderCompleted.php
new file mode 100644
index 0000000..de75085
--- /dev/null
+++ b/packages/laravel/tests/Fixtures/OrderCompleted.php
@@ -0,0 +1,14 @@
+ 'USD'],
+ public mixed $model = null,
+ ) {
+ }
+}
diff --git a/packages/laravel/tests/ModelEventsTest.php b/packages/laravel/tests/ModelEventsTest.php
new file mode 100644
index 0000000..efa5b7a
--- /dev/null
+++ b/packages/laravel/tests/ModelEventsTest.php
@@ -0,0 +1,430 @@
+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 testTransactionMembershipWorkStaysLinearAtCapacity(): void
+ {
+ $root = new DatabaseTransactionRecord('a', 1);
+ $pending = new CountedTransactionCollection([$root]);
+ $committed = new CountedTransactionCollection();
+ $transactions = $this->createStub(DatabaseTransactionsManager::class);
+ $transactions->method('getPendingTransactions')->willReturn($pending);
+ $transactions->method('callbackApplicableTransactions')->willReturn($pending);
+ $transactions->method('getCommittedTransactions')->willReturn($committed);
+ $connection = new SQLiteConnection(new \PDO('sqlite::memory:'), '', '', ['name' => 'a']);
+ $connection->beginTransaction();
+ $connection->beginTransaction();
+ $buffer = new DeferredModelEvents($transactions);
+ $buffer->committing(new TransactionCommitting($connection));
+ $this->assertSame(0, $pending->inspections + $committed->inspections);
+ for ($i = 0; $i < 1000; $i++) {
+ $pending->push(new DatabaseTransactionRecord('a', 2, $root));
+ $buffer->defer($connection, 'model changed', [], ['distinctId' => 'actor']);
+ $committed->push($pending->pop());
+ }
+ $this->assertSame(1000, $buffer->pendingUsage()['count']);
+ foreach (['overflow', 'commit', 'begin'] as $operation) {
+ $pending->inspections = $committed->inspections = 0;
+ if ($operation === 'overflow') {
+ $buffer->defer($connection, 'drop newest', [], ['distinctId' => 'actor']);
+ } elseif ($operation === 'commit') {
+ $buffer->committing(new TransactionCommitting($connection));
+ } else {
+ $pending->push(new DatabaseTransactionRecord('a', 1));
+ $connection->rollBack(0);
+ $connection->beginTransaction();
+ $buffer->beginning(new TransactionBeginning($connection));
+ }
+ $this->assertLessThanOrEqual(
+ 4 * ($pending->count() + $committed->count()),
+ $pending->inspections + $committed->inspections,
+ $operation . ' must not rescan transaction collections for each buffered event.',
+ );
+ $this->assertSame($operation === 'begin' ? 0 : 1000, $buffer->pendingUsage()['count']);
+ }
+ PostHog::assertNothingCaptured();
+ }
+
+ 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 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;
+ 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());
+ }
+}
diff --git a/packages/laravel/tests/SchedulerOutcomeTest.php b/packages/laravel/tests/SchedulerOutcomeTest.php
new file mode 100644
index 0000000..7da3f70
--- /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);
+ }
+}
diff --git a/packages/laravel/tests/TransactionalModelEventsTest.php b/packages/laravel/tests/TransactionalModelEventsTest.php
new file mode 100644
index 0000000..2a85c63
--- /dev/null
+++ b/packages/laravel/tests/TransactionalModelEventsTest.php
@@ -0,0 +1,126 @@
+set('posthog.api_key', 'project-key');
+ $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();
+ $this->app->make(Context::class)->begin(['distinctId' => 'actor']);
+ foreach (['a', 'b'] as $name) {
+ DB::connection($name)->getSchemaBuilder()->create('collected_records', function (Blueprint $table) {
+ $table->id();
+ $table->string('state');
+ $table->string('email');
+ });
+ }
+ }
+
+ private function record(string $state, string $connection = 'a'): void
+ {
+ CollectedRecord::on($connection)->create(['state' => $state, 'email' => 'private']);
+ }
+
+ private function states(): array
+ {
+ return array_map(
+ fn ($call) => $call['message']['properties']['attributes']['state'],
+ PostHog::getFacadeRoot()->recorded('capture'),
+ );
+ }
+
+ public function testWrapperOnlyWritesCaptureImmediatelyLikeAfterCommit(): void
+ {
+ $afterCommitRan = false;
+ DB::afterCommit(function () use (&$afterCommitRan) {
+ $afterCommitRan = true;
+ });
+ $this->assertTrue($afterCommitRan);
+ $this->record('wrapper only');
+ $this->assertSame(['wrapper only'], $this->states());
+ $this->assertSame(['count' => 0, 'bytes' => 0], $this->app->make(DeferredModelEvents::class)->pendingUsage());
+ }
+
+ public function testApplicationTransactionsStillDeferAndRemainConnectionCorrect(): void
+ {
+ DB::connection('a')->beginTransaction();
+ $this->record('a committed');
+ $this->record('b wrapper only', 'b');
+ $this->assertSame(['b wrapper only'], $this->states());
+ DB::connection('b')->beginTransaction();
+ $this->record('b rolled back', 'b');
+ DB::connection('a')->commit();
+ $this->assertSame(['b wrapper only', 'a committed'], $this->states());
+ DB::connection('b')->rollBack();
+ $this->assertSame(['b wrapper only', 'a committed'], $this->states());
+ $this->assertSame(['count' => 0, 'bytes' => 0], $this->app->make(DeferredModelEvents::class)->pendingUsage());
+ }
+
+ public function testWrapperOnlyWritesDoNotWaitForAnotherConnectionsFullBuffer(): void
+ {
+ DB::connection('a')->beginTransaction();
+ for ($i = 0; $i < 1000; $i++) {
+ $this->record('pending a');
+ }
+ $this->assertSame(1000, $this->app->make(DeferredModelEvents::class)->pendingUsage()['count']);
+ $this->record('b wrapper only', 'b');
+ $this->assertSame(['b wrapper only'], $this->states());
+ DB::connection('a')->rollBack();
+ $this->assertSame(['b wrapper only'], $this->states());
+ $this->assertSame(['count' => 0, 'bytes' => 0], $this->app->make(DeferredModelEvents::class)->pendingUsage());
+ }
+
+ public function testNestedCommitAndRollbackFollowApplicationTransactionBoundaries(): void
+ {
+ DB::beginTransaction();
+ $this->record('outer');
+ DB::beginTransaction();
+ $this->record('discarded child');
+ DB::rollBack();
+ DB::beginTransaction();
+ $this->record('committed child');
+ DB::commit();
+ PostHog::assertNothingCaptured();
+ DB::commit();
+ $this->assertSame(['outer', 'committed child'], $this->states());
+ DB::beginTransaction();
+ $this->record('discarded outer');
+ DB::rollBack();
+ $this->record('wrapper again');
+ $this->assertSame(['outer', 'committed child', 'wrapper again'], $this->states());
+ $this->assertSame(['count' => 0, 'bytes' => 0], $this->app->make(DeferredModelEvents::class)->pendingUsage());
+ }
+}