Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 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
238db28
Merge branch 'feat/laravel-package-runtime' into feat/laravel-example
marandaneto Oct 29, 2026
53cdbc5
fix: preserve explicit and nested Laravel scopes through lifecycle ev…
marandaneto Oct 29, 2026
f9b83d1
Merge branch 'feat/laravel-package-runtime' into feat/laravel-example
marandaneto Oct 29, 2026
4a9e95b
feat: add runnable Laravel SDK dashboard example
marandaneto Oct 29, 2026
460d2d7
fix: retain failed command context until Laravel reports the exception
marandaneto Oct 29, 2026
33a5dc3
Merge branch 'feat/laravel-package-runtime' into feat/laravel-autocap…
marandaneto Oct 29, 2026
caa7a73
fix: preserve collector privacy defaults and session correlation
marandaneto Oct 29, 2026
47bec03
feat: add an optional read-only Laravel Pennant driver
marandaneto Oct 29, 2026
3f4e01d
Merge branch 'feat/laravel-autocapture' into feat/laravel-pennant
marandaneto Oct 29, 2026
3b2ad5a
fix: use explicit HTTP and console execution lifecycle boundaries
marandaneto Oct 29, 2026
f375d53
Merge branch 'feat/laravel-package-runtime' into feat/laravel-autocap…
marandaneto Oct 29, 2026
8480a6f
Merge branch 'feat/laravel-autocapture' into feat/laravel-pennant
marandaneto Oct 29, 2026
e14bf4e
Merge branch 'feat/laravel-pennant' into feat/laravel-example
marandaneto Oct 29, 2026
b6ead39
feat: demonstrate the Laravel Pennant integration in the example
marandaneto Oct 29, 2026
5465c69
test: construct synthetic URL credentials without secret-scan false p…
marandaneto Oct 29, 2026
2f07fde
test: verify Pennant normalizes custom and model scope contracts
marandaneto Oct 29, 2026
d274d6f
Merge branch 'feat/laravel-pennant' into feat/laravel-example
marandaneto Oct 29, 2026
ce94a3f
Merge branch 'feat/laravel-autocapture' into feat/laravel-pennant
marandaneto Oct 29, 2026
3554d24
test: verify database queue commit and rollback behavior
marandaneto Oct 29, 2026
0377f64
Merge branch 'feat/laravel-autocapture' into feat/laravel-pennant
marandaneto Oct 29, 2026
0e3243e
fix: capture explicitly failed Laravel jobs as failures
marandaneto Oct 29, 2026
9e9b8af
Merge branch 'feat/laravel-pennant' into feat/laravel-example
marandaneto Oct 29, 2026
0ce49ab
style: align Laravel example tests with repository PHP standards
marandaneto Oct 29, 2026
8773c43
fix: address framework integration review feedback
marandaneto Oct 29, 2026
5e93cc5
fix(laravel): discover Pennant flags for the requested scope
marandaneto Oct 29, 2026
ecbc8cc
fix(flags): retain snapshot context for lazy exposure events
marandaneto Oct 29, 2026
d9d9897
fix(laravel): bridge runtime scopes and isolate diagnostics
marandaneto Oct 29, 2026
07e50c1
Merge signed core flag exposure origin-context dependency
marandaneto Oct 29, 2026
d13fcf7
fix(laravel): honor local-only mode in the Pennant demo
marandaneto Oct 29, 2026
26ced2f
fix(laravel): clear Pennant cache after restoring queue context
marandaneto Oct 29, 2026
8526b32
fix(laravel): preserve raw context precedence in manager getter
marandaneto Oct 29, 2026
9601245
fix(laravel): bound deferred model events to their owning transactions
marandaneto Oct 29, 2026
893c8c5
fix(laravel): omit outcomes for overlap-skipped schedules
marandaneto Oct 29, 2026
4387cf8
Merge commit '8526b3285e78d06b33ae1895fa72f86d353b8f8c' into feat/lar…
marandaneto Oct 29, 2026
4e953ec
Merge branch 'feat/laravel-autocapture' into feat/laravel-pennant
marandaneto Oct 29, 2026
fd7b567
Merge branch 'feat/laravel-pennant' into feat/laravel-example
marandaneto Oct 29, 2026
a33384a
fix: make library metadata and release id internal integration overrides
marandaneto Oct 29, 2026
66063ab
Merge internal core integration overrides for Laravel runtime
marandaneto Oct 29, 2026
5a9958f
fix(laravel): use internal client overrides for library metadata and …
marandaneto Oct 29, 2026
b63cd37
fix(laravel): release model buffer on commit failure retry
marandaneto Oct 29, 2026
71ac3bc
Merge commit '5a9958f239dfe68744597c5df894cd6aca4fac11' into feat/lar…
marandaneto Oct 29, 2026
b2d9730
Merge branch 'feat/laravel-autocapture' into feat/laravel-pennant
marandaneto Oct 29, 2026
1cd9025
Merge branch 'feat/laravel-pennant' into feat/laravel-example
marandaneto Oct 29, 2026
dc28ca9
docs: explain when to use withContext versus enterContext
marandaneto Oct 29, 2026
4848a82
Merge commit 'dc28ca9bb1e36de1c377017243391d41aac246a2' into feat/lar…
marandaneto Oct 29, 2026
bffd72a
Merge branch 'feat/laravel-autocapture' into feat/laravel-pennant
marandaneto Oct 29, 2026
8ffd0ad
Merge branch 'feat/laravel-package-runtime' into feat/laravel-autocap…
marandaneto Oct 29, 2026
212980c
Merge branch 'feat/laravel-pennant' into feat/laravel-example
marandaneto Oct 29, 2026
e23f1fc
feat(laravel): isolate direct child command calls with opt-in trait
marandaneto Oct 29, 2026
d6848b6
Merge command isolation into Pennant integration
marandaneto Oct 29, 2026
c8cf8b0
Merge runtime command isolation into collectors
marandaneto Oct 29, 2026
3321cfb
fix(laravel): clear Pennant cache across direct command scopes
marandaneto Oct 29, 2026
64c8c51
Merge scoped command and Pennant isolation into example
marandaneto Oct 29, 2026
858c204
fix: address framework primitive review feedback
marandaneto Oct 29, 2026
944a3bd
Merge framework review fixes into runtime
marandaneto Oct 29, 2026
c70c493
Merge framework review fixes into autocapture
marandaneto Oct 29, 2026
31abada
Merge framework review fixes into pennant
marandaneto Oct 29, 2026
c8ff914
Merge framework review fixes into example
marandaneto Oct 29, 2026
64fac86
Merge main into Laravel example and retain reviewed package fixes
marandaneto Nov 3, 2026
5e92bd8
fix: normalize URL-style hosts for feature flag requests
marandaneto Nov 3, 2026
382fb2b
Revert "fix: normalize URL-style hosts for feature flag requests"
marandaneto Nov 3, 2026
c9bb1bf
fix: retain URL-host fix in Laravel example pending standalone merge
marandaneto Nov 3, 2026
2aa242c
chore: keep host regression test and release intent in standalone PR
marandaneto Nov 3, 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
33 changes: 33 additions & 0 deletions examples/laravel/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
APP_NAME="PostHog Laravel Demo"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost:8000
LOG_CHANNEL=single
DB_CONNECTION=sqlite
# Leave DB_DATABASE unset to use database/database.sqlite.
SESSION_DRIVER=file
CACHE_STORE=file
QUEUE_CONNECTION=sync

# Blank key = no SDK network, including feature flags. Synthetic demo identities only.
POSTHOG_ENABLED=true
POSTHOG_API_KEY=
POSTHOG_HOST=https://us.i.posthog.com
# Optional SERVER-ONLY secret for local flag definitions. Never render in HTML.
POSTHOG_SECRET_KEY=
POSTHOG_RELEASE_ID=laravel-example-local
POSTHOG_CONSUMER=lib_curl
POSTHOG_FLAG_CACHE=false
POSTHOG_LOCAL_ONLY=false
POSTHOG_QUEUE_CONTEXT=true

# Independent opt-ins; restart workers after changing configuration.
POSTHOG_COLLECT_REQUESTS=false
POSTHOG_COLLECT_PAGEVIEWS=false
POSTHOG_COLLECT_AUTH=false
POSTHOG_COLLECT_MODELS=false
POSTHOG_COLLECT_DOMAIN=false
POSTHOG_COLLECT_JOBS=false
POSTHOG_COLLECT_COMMANDS=false
POSTHOG_COLLECT_SCHEDULER=false
6 changes: 6 additions & 0 deletions examples/laravel/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/.env
/vendor/
/.phpunit.cache/
/bootstrap/cache/*.php
/database/*.sqlite*
/storage/*.key
204 changes: 204 additions & 0 deletions examples/laravel/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
# PostHog Laravel example

An original, minimal Laravel 12/13 application using `posthog/posthog-laravel` and
the PHP SDK from this checkout. No JavaScript build, npm, external database, or
PostHog credentials are needed to run it. **Local development only:** synthetic
login deliberately has no password; never expose this application publicly.

## Run

Requirements: PHP 8.2+ with Laravel's extensions (including DOM/XML, mbstring,
PDO SQLite) and cURL for live SDK transport; Composer 2. Dependency installation
requires access to Composer's package repositories. No vendor files are committed.

From this directory (`examples/laravel`):

```sh
composer install
cp .env.example .env
php artisan key:generate
php artisan migrate --force
php artisan serve
```

Open <http://localhost:8000>. Migration creates the SQLite file automatically.
The checked-in lock targets Laravel 12 / PHP 8.2. The Composer constraint also
supports Laravel 13 (PHP 8.3+); to try it on a compatible interpreter:

```sh
composer config platform.php 8.3.0
composer update --with-all-dependencies
composer test
```

The example's **development-only path repositories** point to `../..` (PHP SDK,
version override `4.20.x-dev`) and `../../packages/laravel` (integration, override
`0.1.0`). Run inside this repository, not as an isolated copy. They do not belong
in a published package manifest. The lock does not replace the local source trees.

## Safe defaults and configuration

With a blank `POSTHOG_API_KEY` or `POSTHOG_ENABLED=false`, **all SDK network paths
are disabled**, including flags and raw client access. Forms still run and show
false/empty SDK results. SQLite operations and synthetic login still work.

To send synthetic events to a development project, set `POSTHOG_API_KEY` and the
project's ingestion `POSTHOG_HOST` in `.env`. Keep `.env` private. Do not use
production credentials/data for this demo. Never put `POSTHOG_SECRET_KEY` in HTML,
browser code, event properties, logs, or a browser configuration endpoint.

`config/posthog.php` demonstrates SDK options, release metadata, GeoIP disabled,
and a container-resolved `before_send` callback. `RedactDemoProperties` strips
email/password top-level properties as defense in depth; it is **not** a complete
PII scrubber. Application properties and exception messages/stacks may be sensitive.
Request collection does not include raw URLs, query strings, bodies, cookies,
credentials, email, or IP. Synthetic model fields are explicitly allowlisted.

After changing configuration, use `php artisan config:clear` and restart workers.
`php artisan config:cache` also works (no closures in config).

## Dashboard and source map

| Form / command | What it demonstrates | Source |
| --- | --- | --- |
| Capture two events | explicit identity/groups and ambient scoped capture | `app/Support/DemoActions.php` |
| Identify, alias, group identify | synthetic member/visitor relationship, team profile | `DemoActions::identity()` |
| Evaluate flags | boolean, variant, decoded payload, one shared snapshot, `onlyAccessed()` attachment, unresolved reasons | `DemoActions::flags()` |
| Read flags through Pennant | optional read-only PostHog store; boolean and variant values | `DemoActions::pennant()` |
| Manual exception | explicit handled capture and safe properties | `DemoActions::exception()` |
| Laravel report() | handled framework reporting (no global PHP handlers) | `DemoActions::exception()` |
| Throw an unhandled 500 | escaping failure reporting and context cleanup | `DemoController::failure()` |
| Create order | SQLite transaction, model collector, explicitly mapped domain event | `DemoActions::order()`, `app/Events/OrderCompleted.php` |
| Login / logout | real Laravel session auth events using a synthetic user | `app/Http/Controllers/DemoController.php` |
| Dispatch job | default sync queue; bounded analytics propagation plus explicit job scope override | `app/Jobs/DemoJob.php` |
| Flush | explicit transport result; lifecycle flush is automatic | `DemoController::run()` |
| `php artisan demo:posthog --flags` | the same APIs from Artisan (`--flags` is optional) | `app/Console/Commands/DemoCommand.php` |
| `php artisan schedule:run` | named once-per-minute scheduled command | `routes/console.php` |

The example-only `DemoServiceProvider` assigns `demo-console` to the known
`demo:posthog`, `demo:scheduled`, and `schedule:run` commands for their complete
lifetime, so command/task outcomes have an actor. This is a synthetic diagnostic
identity, not real-user attribution, and does not change HTTP identity.

Captures use `demo_*` event names. These are custom business events, **not**
automatic duplicate `$pageview` events. `flush()` is not a delivery receipt.
Transport defaults to `lib_curl`; `POSTHOG_CONSUMER=noop` drops event delivery but
**does not disable flag network requests** when a project key is present. Use
`POSTHOG_ENABLED=false` or blank credentials for a genuinely offline app.

### Context and tracing headers

`Context::with()` restores prior context on success/failure. Request auth identity
wins over the `X-PostHog-Distinct-Id` anonymous analytics fallback; explicit capture
identity wins over both. `X-PostHog-Session-Id` adds `$session_id`. This application's
explicit synthetic fallback is `demo-visitor`, not an SDK-generated anonymous ID.
**Headers are untrusted analytics context, never authentication/authorization.**

The runtime propagates only bounded identity, session and scalar groups to queue
payload `_posthog` metadata. It does not serialize arbitrary context properties,
models or request data. Set `POSTHOG_QUEUE_CONTEXT=false` to opt out. The job first
captures with the propagated identity, then overrides it with `demo-job-owner`.
For a persistent worker instead of sync:

```sh
# Set QUEUE_CONNECTION=database in .env, then click Dispatch job.
php artisan queue:work --once
```

### Feature flags and local evaluation

Create `demo-checkout` (boolean) and `demo-layout` (multivariate, optionally with a
JSON payload) in your development project. Flag evaluation is explicit: dashboard
GET and ordinary capture do not evaluate flags. Value reads emit deduplicated core
exposure events; payload reads are silent. Snapshot attachment adds
`$feature/<key>` and `$active_feature_flags` without reevaluating. Missing or
disabled keys are distinct from local unresolved reasons.

For local evaluation set the **server-only** `POSTHOG_SECRET_KEY` and optionally
`POSTHOG_FLAG_CACHE=true`. Definitions load/refresh lazily and use Laravel Cache;
the default file store is suitable for a local demo, not a distributed production
cache. `POSTHOG_LOCAL_ONLY=true` prevents remote flag fallback, but a secret-key
worker may still fetch definitions. Unresolved entries can report missing context,
unsupported definitions, unresolved dependencies or experience continuity. Without
local definitions, local-only evaluation is empty; it is not proof a flag is off.

The Pennant form uses `Feature::store('posthog')` explicitly, leaving other Pennant
stores unchanged. Its values come from the same native SDK snapshots; write operations
are intentionally unsupported. This form is disabled when `POSTHOG_LOCAL_ONLY=true`;
use the native flags form for local-only evaluation. Direct POSTs are guarded too.
See the integration's `PENNANT.md` for scope mapping
and when to clear Pennant's cache after targeting changes within a request.

Advanced PHP SDK methods remain accessible without another global SDK instance:

```php
$client = \PostHog\Laravel\Facades\PostHog::client();
$scope = $client->enterContext(['distinctId' => 'demo-advanced'], ['fresh' => true]);
try {
$client->capture(['event' => 'demo_advanced', 'properties' => ['source' => 'raw-client']]);
} finally {
$scope->close();
}
```

### Collector opt-ins

All collector flags in `.env.example` default to false. The dashboard displays
configuration, not a remote configuration editor. The separate collector provider
must be present for automatic events; explicit runtime examples do not depend on it.

| Toggle | Events / limits |
| --- | --- |
| `POSTHOG_COLLECT_REQUESTS` | `laravel_request`: matched route template, method, status, duration; excludes health routes |
| `POSTHOG_COLLECT_PAGEVIEWS` | canonical `$pageview`, successful matched HTML GET only, sanitized template URL; don't also enable browser pageviews |
| `POSTHOG_COLLECT_AUTH` | `laravel_auth_registered/login/logout/failed/lockout`; no attempted credentials/identity for failure events |
| `POSTHOG_COLLECT_MODELS` | `laravel_model_created/updated/deleted`, after commit; `DemoOrder` amount/status only, rollback discarded |
| `POSTHOG_COLLECT_DOMAIN` | explicit `OrderCompleted` → `order_completed`, amount only; no whole-event serialization |
| `POSTHOG_COLLECT_JOBS` | `laravel_job`, safe name/outcome/duration, no payload |
| `POSTHOG_COLLECT_COMMANDS` | `laravel_command`, command name/outcome/duration, no arguments |
| `POSTHOG_COLLECT_SCHEDULER` | `laravel_scheduled_task`, safe task name/outcome/duration |

### Optional browser JavaScript

No browser SDK, script tags, or key-bearing HTML are installed. If you add browser
analytics, use only its public project token and ingestion host. Configure tracing
headers for your application's host only and keep secret keys server-side.
Choose **either** browser pageviews **or** the server pageview collector to avoid
double-counting. Browser identity headers do not authenticate the synthetic login.

## Repeatable offline tests

```sh
composer test
```

Tests use a fresh in-memory SQLite database, `PostHog::fake()`/mocked transport,
no real keys, no live PostHog, and no queue server. They exercise dashboard HTML,
POST forms, auth/header precedence, flag variant/payload/snapshot/unresolved output,
handled and unhandled exceptions, queue payloads/opt-out, transport failure results,
Artisan, and each collector family (including commit/rollback). The collector provider is required for those integration tests; no collector
coverage is silently skipped. Tests explicitly enable Symfony command event
rerouting because Laravel normally omits it under `runningUnitTests()`.

Example fake assertions (see `tests/Feature/DashboardTest.php`):

```php
$fake = \PostHog\Laravel\Facades\PostHog::fake();
$this->post('/demo/capture')->assertRedirect('/');
$fake->assertCaptured('demo_explicit', fn ($event) => $event['distinctId'] === 'demo-explicit');
$fake->assertCapturedCount(2);
$fake->assertNotCaptured('$pageview');
```

The fake supports identify/alias/group/exception assertions and fixture flag
snapshots. Raw `client()` escape-hatch calls on the fake are disabled and **not
recorded**. Tests assert message payloads; they do not claim server delivery.

`php artisan posthog:doctor` checks configuration without network (nonzero exit is
expected with blank credentials). `posthog:test --send` is a deliberate live-send
opt-in and is not used by this application's tests.

The examples follow the SDK specs for capture, identify, alias, group identify,
exception capture, flags, tracing headers, flush and autocapture. No changes to
core SDK semantics or public APIs are made here; server collector names are the
Laravel integration contract, not new canonical reserved SDK events.
30 changes: 30 additions & 0 deletions examples/laravel/app/Console/Commands/DemoCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace App\Console\Commands;

use App\Support\DemoActions;
use Illuminate\Console\Command;
use PostHog\Laravel\Facades\PostHog;

final class DemoCommand extends Command
{
protected $signature = 'demo:posthog {--flags : Evaluate flags (may contact configured host)}';
protected $description = 'Capture synthetic analytics, identity, exception, order, and queue examples';

public function handle(DemoActions $demo): int
{
$result = [
'capture' => $demo->capture(),
'identity' => $demo->identity(),
'exception' => $demo->exception(),
'order' => $demo->order(),
'queue' => $demo->queue(),
];
if ($this->option('flags')) {
$result['flags'] = $demo->flags();
}
$result['flushed'] = PostHog::flush();
$this->line(json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
return self::SUCCESS;
}
}
10 changes: 10 additions & 0 deletions examples/laravel/app/Events/OrderCompleted.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace App\Events;

final class OrderCompleted
{
public function __construct(public readonly int $amount)
{
}
}
68 changes: 68 additions & 0 deletions examples/laravel/app/Http/Controllers/DemoController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

namespace App\Http\Controllers;

use App\Models\DemoUser;
use App\Support\DemoActions;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
use PostHog\Laravel\Facades\PostHog;
use RuntimeException;

final class DemoController
{
public function dashboard(): View
{
return view('dashboard', [
'enabled' => PostHog::enabled(),
'collectorsAvailable' => class_exists(\PostHog\Laravel\AutocaptureServiceProvider::class),
'collectors' => config('posthog.autocapture'),
]);
}

public function run(string $action, DemoActions $demo): RedirectResponse
{
$result = match ($action) {
'capture' => $demo->capture(),
'identity' => $demo->identity(),
'flags' => $demo->flags(),
'pennant' => $demo->pennant(),
'exception' => $demo->exception(),
'report' => $demo->exception(report: true),
'order' => $demo->order(),
'queue' => $demo->queue(),
'flush' => ['flushed' => PostHog::flush(), 'note' => 'Not a delivery receipt.'],
default => abort(404),
};
return redirect()->route('dashboard')->with('result', $result);
}

public function login(Request $request): RedirectResponse
{
// LOCAL DEMO ONLY: no password. Never deploy this synthetic login in production.
$user = DemoUser::firstOrCreate(['name' => 'Synthetic demo member']);
if ($user->wasRecentlyCreated) {
event(new Registered($user));
}
Auth::login($user);
$request->session()->regenerate();
PostHog::capture('demo_authenticated');
return redirect()->route('dashboard')->with('result', ['synthetic_login' => true]);
}

public function logout(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('dashboard')->with('result', ['logout' => true]);
}

public function failure(DemoActions $demo): never
{
$demo->ambient(fn () => throw new RuntimeException('Synthetic unhandled demo failure'));
}
}
28 changes: 28 additions & 0 deletions examples/laravel/app/Jobs/DemoJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use PostHog\Laravel\Context;
use PostHog\Laravel\Facades\PostHog;

final class DemoJob implements ShouldQueue
{
use Queueable;

public function __construct(public readonly string $analyticsId)
{
}

public function handle(Context $context): void
{
PostHog::capture('demo_job_ambient', ['rows' => 3]);
// Explicit job scope may override the safely propagated analytics identity.
$context->with([
'distinctId' => $this->analyticsId,
'groups' => ['team' => 'demo-team'],
'properties' => ['component' => 'demo-job'],
], fn () => PostHog::capture('demo_job_completed', ['rows' => 3]));
}
}
Loading
Loading