Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
34e0710
feat: add PHP framework integration primitives
marandaneto Oct 29, 2026
3501917
Merge framework integration APIs for Laravel runtime
marandaneto Oct 29, 2026
bec01c1
feat: add Laravel runtime foundation
marandaneto Oct 29, 2026
1536956
feat: propagate safe Laravel queue context and prepare package distri…
marandaneto Oct 29, 2026
18d9ac6
feat: enrich Laravel captures with safe request metadata
marandaneto Oct 29, 2026
04967bf
feat: add opt-in Laravel server event collectors
marandaneto Oct 29, 2026
53cdbc5
fix: preserve explicit and nested Laravel scopes through lifecycle ev…
marandaneto Oct 29, 2026
460d2d7
fix: retain failed command context until Laravel reports the exception
marandaneto Oct 29, 2026
33a5dc3
Merge branch 'feat/laravel-package-runtime' into feat/laravel-autocap…
marandaneto Oct 29, 2026
caa7a73
fix: preserve collector privacy defaults and session correlation
marandaneto Oct 29, 2026
3b2ad5a
fix: use explicit HTTP and console execution lifecycle boundaries
marandaneto Oct 29, 2026
f375d53
Merge branch 'feat/laravel-package-runtime' into feat/laravel-autocap…
marandaneto Oct 29, 2026
5465c69
test: construct synthetic URL credentials without secret-scan false p…
marandaneto Oct 29, 2026
0e3243e
fix: capture explicitly failed Laravel jobs as failures
marandaneto Oct 29, 2026
8773c43
fix: address framework integration review feedback
marandaneto Oct 29, 2026
ecbc8cc
fix(flags): retain snapshot context for lazy exposure events
marandaneto Oct 29, 2026
d9d9897
fix(laravel): bridge runtime scopes and isolate diagnostics
marandaneto Oct 29, 2026
07e50c1
Merge signed core flag exposure origin-context dependency
marandaneto Oct 29, 2026
8526b32
fix(laravel): preserve raw context precedence in manager getter
marandaneto Oct 29, 2026
9601245
fix(laravel): bound deferred model events to their owning transactions
marandaneto Oct 29, 2026
893c8c5
fix(laravel): omit outcomes for overlap-skipped schedules
marandaneto Oct 29, 2026
4387cf8
Merge commit '8526b3285e78d06b33ae1895fa72f86d353b8f8c' into feat/lar…
marandaneto Oct 29, 2026
a33384a
fix: make library metadata and release id internal integration overrides
marandaneto Oct 29, 2026
66063ab
Merge internal core integration overrides for Laravel runtime
marandaneto Oct 29, 2026
5a9958f
fix(laravel): use internal client overrides for library metadata and …
marandaneto Oct 29, 2026
b63cd37
fix(laravel): release model buffer on commit failure retry
marandaneto Oct 29, 2026
71ac3bc
Merge commit '5a9958f239dfe68744597c5df894cd6aca4fac11' into feat/lar…
marandaneto Oct 29, 2026
dc28ca9
docs: explain when to use withContext versus enterContext
marandaneto Oct 29, 2026
4848a82
Merge commit 'dc28ca9bb1e36de1c377017243391d41aac246a2' into feat/lar…
marandaneto Oct 29, 2026
8ffd0ad
Merge branch 'feat/laravel-package-runtime' into feat/laravel-autocap…
marandaneto Oct 29, 2026
e23f1fc
feat(laravel): isolate direct child command calls with opt-in trait
marandaneto Oct 29, 2026
c8cf8b0
Merge runtime command isolation into collectors
marandaneto Oct 29, 2026
858c204
fix: address framework primitive review feedback
marandaneto Oct 29, 2026
944a3bd
Merge framework review fixes into runtime
marandaneto Oct 29, 2026
c70c493
Merge framework review fixes into autocapture
marandaneto Oct 29, 2026
5c89a6e
Merge main into feat/laravel-autocapture
marandaneto Nov 2, 2026
a1083be
chore: remove stale release intents from Laravel autocapture
marandaneto Nov 2, 2026
9417415
fix(laravel): keep collector plumbing internal and bypass disabled re…
marandaneto Nov 2, 2026
ea2bc2a
fix(laravel): index managed transactions before pruning model events
marandaneto Nov 2, 2026
36ac51e
fix(laravel): gate collectors and honor transactional test callbacks
marandaneto Nov 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .laravel-changes/eb4dfed4373d440294ff58970818d3e7.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"package": "posthog-laravel",
"bump": "initial",
"summary": "Add opt-in Laravel request, auth, domain, model and worker event collectors."
}
216 changes: 216 additions & 0 deletions packages/laravel/AUTOCAPTURE.md
Original file line number Diff line number Diff line change
@@ -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')
<x-checkout />
@else
<x-classic-checkout />
@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**.
9 changes: 7 additions & 2 deletions packages/laravel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 23 additions & 0 deletions packages/laravel/config/autocapture.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

return [
'requests' => [
'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],
];
35 changes: 35 additions & 0 deletions packages/laravel/src/Autocapture/AuthEvents.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

namespace PostHog\Laravel\Autocapture;

use Illuminate\Container\Container;
use Throwable;

/** @internal */
class AuthEvents
{
public function collect(string $name, object $event): void
{
if (
!Collector::enabled('auth') || !in_array($name, Container::getInstance()['config']->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);
}
}
}
89 changes: 89 additions & 0 deletions packages/laravel/src/Autocapture/Collector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

namespace PostHog\Laravel\Autocapture;

use Illuminate\Container\Container;
use PostHog\Laravel\Context;
use PostHog\Laravel\PostHog;
use Throwable;

/** @internal Collectors retain correlation IDs and groups, never arbitrary context payloads. */
class Collector
{
private array $loggedFailures = [];

public static function enabled(?string $section = null): bool
{
$app = Container::getInstance();
return ($section === null || $app['config']->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;
}
}
Loading
Loading