posthog / posthog-laravel
PostHog for Laravel
Requires
- php: >=8.2
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
- posthog/posthog-php: 4.21.0
Requires (Dev)
- laravel/octane: ^2.0
- laravel/pennant: ^1.0
- orchestra/testbench: ^10.0 || ^11.0
- phpunit/phpunit: ^11.0 || ^12.0
Suggests
- laravel/pennant: Use PostHog flags through Laravel Pennant (^1.0).
Provides
None
Conflicts
None
Replaces
None
README
Server-side analytics, feature flags and exception reporting for Laravel 12 and 13. Requires PHP 8.2+ (Laravel 13 requires PHP 8.3+).
Install
composer require posthog/posthog-laravel php artisan posthog:install
Discovery registers the provider, facade and scoped container bindings automatically.
For applications without discovery, register PostHog\Laravel\PostHogServiceProvider.
Configure your server environment:
POSTHOG_API_KEY=phc_your_project_key POSTHOG_HOST=https://us.i.posthog.com POSTHOG_ENABLED=true
Use your project's ingestion host. POSTHOG_SECRET_KEY is optional and enables local
flag evaluation. Never publish secret keys in browser configuration, logs, or event
properties. No browser SDK or browser configuration endpoint is installed.
A missing/blank project key or POSTHOG_ENABLED=false disables all SDK network paths,
including flag requests and the client() escape hatch. Setting sdk.consumer=noop
only changes event transport: a valid-key client can still evaluate flags remotely.
Capture and identity
use PostHog\Laravel\Facades\PostHog; PostHog::capture('checkout completed', ['items' => 2]); PostHog::capture('import completed', ['rows' => 42], 'account-123', ['team' => 'team-1']); PostHog::identify(['distinctId' => 'account-123', 'properties' => ['plan' => 'paid']]); PostHog::alias(['distinctId' => 'account-123', 'alias' => 'old-account']); PostHog::groupIdentify(['groupType' => 'team', 'groupKey' => 'team-1', 'properties' => ['plan' => 'paid']]);
Inject PostHog\Laravel\PostHog to use the same API without a facade. posthog is a
container alias. PostHog\Client is also injectable, but raw client calls require
explicit identity or an explicit core context.
Identity precedence: an explicit API distinctId wins, then an explicitly scoped
Context identity, then the custom identity resolver or authenticated request user's
auth identifier, then X-PostHog-Distinct-Id as an anonymous analytics fallback. X-PostHog-Session-Id provides session correlation. Headers are untrusted
analytics input, never authentication or authorization. Set header_context=false
to ignore both headers.
Without an explicit or contextual identity, captures and exception events use a fresh
random UUID for each event and default to $process_person_profile=false. An
explicit $process_person_profile property still wins. Enabled automatic collectors
use the same personless capture behavior. These IDs are not persistent visitor IDs:
they are never stored in Laravel sessions, cookies, request context or queue metadata,
and do not link events from the same anonymous visitor. A frontend session-ID header
can still correlate events through $session_id without supplying a person identity.
Feature-flag evaluation never generates an identity: without one, it returns an empty
snapshot without loading definitions, making flag requests or sending exposure events.
Captures include the request method and matched route template (for example
/orders/{order}), never concrete route parameters. Request context also adds the
visitor's IP address as $ip using Laravel's request()->ip(). Captures, exceptions
and enabled automatic collectors inherit it. Explicit event or scoped $ip values,
including null, win. Identification, alias and group-identify calls retain their
explicit-message behavior rather than inheriting capture metadata.
IP collection is on by default, matching the other backend framework integrations.
To disable automatic IP collection, set POSTHOG_CAPTURE_IP=false (or
posthog.capture_ip=false). This does not remove IPs your application explicitly
supplies. Configure Laravel's trusted proxies before collecting events behind a
reverse proxy or load balancer. The SDK does not parse forwarding headers itself or
change the application's proxy trust configuration. Without trusted-proxy setup,
Laravel may return a proxy address instead of the visitor address.
IPs are resolved only during HTTP request context, not inferred for standalone commands or jobs. Request IPs are not copied into queue payload metadata. Deferred model events and exception snapshots retain the originating IP in memory; later requests cannot replace it. Fresh context scopes exclude inherited request IPs.
GeoIP enrichment remains disabled by default (sdk.disableGeoip=true), independently
of IP collection. Supplying $ip alone does not turn geographic enrichment on.
Use sdk.disableGeoip=false or a per-capture disableGeoip: false option only when
you want that enrichment. IP addresses can be personal data; apply your consent and
retention policies and use sdk.before_send to redact or drop them.
By default there is no collection of raw URLs, query strings, request bodies, cookies, email, or user profile fields. Application-supplied properties and exception messages may still contain sensitive information. Exception stacks are serialized by the PHP SDK.
For a custom identity resolver, set distinct_id to an invokable container class.
It receives the application and returns a string identifier or null. A non-null
custom result replaces the request auth identifier. Otherwise auth/header fallback
applies. Resolver errors do not break the application. With sdk.debug=true, failed
identity, authenticated-user and group lookups log at most one debug message each
per context scope through Laravel's logger. Diagnostics contain the exception class,
not its message or resolved data; enable debug output on your Laravel log channel to
see them. Set groups to an invokable container class receiving the application and
returning group identifiers. Explicit
context groups override matching resolver groups; exception snapshots inherit them.
Scoped context and workers
use PostHog\Laravel\Context; app(Context::class)->with([ 'distinctId' => 'job-owner-123', 'groups' => ['team' => 'team-1'], 'properties' => ['component' => 'import'], ], function () { PostHog::capture('import started'); });
Context::begin(), data(), snapshot() and end() support explicit integrations.
with() restores the previous data and flag snapshots even when the callback throws.
It accepts distinct_id/session_id aliases; canonical distinctId/sessionId keys
win if both forms are supplied.
The manager/facade also exposes withContext($data, $callback, $options),
enterContext($data, $options) and getContext(). These bridge Laravel context and
raw-client context, accept distinct_id/session_id aliases, and merge properties
shallowly like the core SDK. Explicit values override active raw-client values, which
override resolved Laravel values. Groups inherit from Laravel unless explicitly
replaced. ['fresh' => true] excludes ambient identity, request metadata, groups and
resolvers. Close an entered scope in finally; closing it twice is harmless. Parent
data and flag snapshots are restored after closing or throwing.
Auth is resolved lazily only while handling a request; no user model is retained by
the analytics context after cleanup. Queue jobs start fresh and receive only bounded
identity, session and scalar group context in _posthog payload metadata. Arbitrary
properties, models and request data are not propagated. Set queue_context=false to
disable propagation. This metadata is analytics context, never authorization evidence.
Jobs may override it explicitly. Nested synchronous jobs and kernel-level
Artisan::call() executions restore the caller's context and flag snapshots when
they finish or fail.
Request context remains active through HTTP kernel termination, including
dispatchAfterResponse() jobs and callbacks registered during application termination.
The kernel's completion hook flushes and clears it afterward, not when middleware
returns the response. Custom HTTP kernels without Laravel's lifecycle callback API
fall back to middleware cleanup and cannot provide after-response correlation.
Queue events and Octane request/task/tick boundaries flush and clean context
automatically. Console integration decorates the existing console kernel contract:
nested Artisan::call() executions restore their caller in finally, while the root
command stays active through kernel termination. Nested calls defer flushing to the
outer command or HTTP termination. A standalone Artisan::call() still flushes on
exit. Thrown objects retain their own snapshots; nonzero return codes never supply another exception's identity. The
application's commands, custom kernel, and exception-reporting policy are preserved.
Direct child-command $this->call()/callSilent()/callSilently() execution bypasses
those kernel boundaries. Opt into isolation by adding IsolatesCommandContext to
the calling command, or to your application's base command:
namespace App\Console; use Illuminate\Console\Command as BaseCommand; use PostHog\Laravel\Concerns\IsolatesCommandContext; abstract class Command extends BaseCommand { use IsolatesCommandContext; }
Have your application commands extend this base class, or apply the trait directly to each calling command. All three call helpers then restore the caller's context and cached flag snapshots when the child returns or throws, including closure children. Arguments, output, exit codes and throwables pass through unchanged. Root context still lasts through kernel termination.
This is opt-in, not global interception: uninstrumented callers, including commands
registered with Artisan::command(), still need kernel-level Artisan::call() or an
explicit PostHog::withContext([], fn () => $this->call(...)) scope when a child can
mutate analytics context. The trait uses Laravel's inherited runCommand();
custom call implementations that bypass it also need explicit scoping.
SDK creation is lazy, per Laravel scope. Shared flag definitions live in Laravel Cache, not a global SDK. Do not retain scoped services in application singletons or prewarm them in Octane.
Supported execution models are ordinary HTTP, Laravel queue workers (including sync
jobs), console commands, and sequential Octane sandbox lifecycles. The package does
not claim isolation for overlapping Swoole coroutines sharing one Laravel container.
Core Fiber context support alone does not make Laravel scopes coroutine-local.
Automatic flush failures do not replace an HTTP response or worker failure. Explicit
flush()/shutdown() expose the SDK results; flush is not a delivery receipt.
Exceptions
Laravel's reportable exception path respects dontReport, exception-specific report
methods, and preceding report callbacks that stop reporting. The package does not
install global PHP exception/error handlers, even if sdk.error_tracking.enabled is
set. Disable automatic reporting with capture_exceptions=false. Custom exception handlers
must expose Laravel's reportable() API for automatic registration; otherwise add an
explicit report callback using the public capture API.
try { // Application work... } catch (Throwable $exception) { PostHog::captureException($exception, properties: ['component' => 'import']); }
The manager/facade uses the named argument properties: for exception properties,
not the core client's additionalProperties:. Other exception parameters match core.
Manual captures and ordinary report() calls are handled and use mechanism generic
unless a known integration boundary supplies a more specific category. Failures
reported from Laravel's routing pipeline or HTTP/console kernel failure paths, escaping request
middleware or queue execution, or reported by Octane's worker error listener are
unhandled. Boundary detection inspects only framework call names, never arguments.
Weakly held exception snapshots preserve the originating identity after cleanup;
automatic reports of the same exception object are deduplicated. A report already
emitted by an earlier framework listener cannot be retroactively relabeled by a
later boundary event. Explicit manual capture is intentionally independent of
automatic-report deduplication.
Automatic reports include $exception_source from the first known SDK boundary:
laravel.queue_job, laravel.console_lifecycle, or laravel.octane_worker_error.
Normal HTTP pipeline failures are reported and rendered by Laravel before the outer
middleware can catch them: their source is laravel.reportable and mechanism is
middleware. The safety-net middleware catch supplies laravel.middleware only
when an exception escapes to it, such as in a custom pipeline. Without a remembered
boundary source, the registered Laravel reportable callback supplies laravel.reportable.
Sources are never inferred from stack frames. Ordinary manual captureException()
calls omit the source unless an integration explicitly supplies it through the typed
source option.
Feature flags
$flags = PostHog::evaluateFlags(); // Current request identity, evaluated on demand. if ($flags->isEnabled('new-checkout')) { $variant = $flags->getFlag('checkout-layout'); $payload = $flags->getFlagPayload('checkout-layout'); }
The same complete evaluation inputs reuse one core snapshot within the current
context. New identities, groups, properties, key scopes, or lifecycle scopes do not
reuse another user's result. Capture and boot do not evaluate flags. Value reads
emit the core's deduplicated exposure events with the originating context, even
when read after another request starts or the original scope ends; payload reads
are silent. An explicit
empty flagKeys: [] returns an empty snapshot without evaluation.
The full core signature is supported: distinctId, groups, personProperties,
groupProperties, onlyEvaluateLocally, disableGeoip, and flagKeys.
The manager also supports the core-compatible getAllFlags(), getFeatureFlag(),
getFeatureFlagResult(), getFeatureFlagPayload(), and isFeatureEnabled() methods
with current context, plus loadFlags() and contextFromHeaders(). These methods
are declared explicitly; new core methods are not exposed automatically. Use
client() for other lower-level PHP SDK operations:
$result = PostHog::getFeatureFlagResult('flag-key', 'account-123'); $client = PostHog::client(); // Advanced PHP SDK access; pass identity explicitly. $scope = $client->enterContext(['distinctId' => 'account-123'], ['fresh' => true]); try { $client->capture(['event' => 'advanced', 'properties' => ['component' => 'worker']]); } finally { $scope->close(); }
Array-form capture() forwards the PHP message unchanged, including its advanced
options. Prefer evaluateFlags() over the legacy getters for shared flag decisions.
Forwarded legacy flag getters also lazily load configured local definitions on first
identified use, preserving local-only options. Like evaluateFlags(), they inherit
context groups when the groups argument is empty; explicit nonempty groups override
them. With the raw client() escape hatch, call loadFlags() explicitly before local
evaluation if definitions have not loaded.
Shared local definitions
Enable flag_cache.enabled to use PostHog\Laravel\FlagDefinitionCacheProvider.
flag_cache.store selects a Laravel cache store, ttl controls definition retention,
and refresh_interval controls refresh eligibility (seconds). The key is isolated
by ingestion host and project-key hash. Complete definition documents, including
property_matching_version, round-trip unchanged. Cache backend failures are handled
by the core's defensive provider contract. Use a shared production cache store whose
add() operation is atomic for best-effort fetch coordination; the array store is
process-local, not distributed coordination.
With a secret key, a worker can publish definitions; without it, workers only read
the cache. Definitions load lazily during the first nonempty flag evaluation and
refresh lazily after the interval. The interval only throttles within a single
scoped manager: without a cache provider, each new request/job that evaluates flags
with a secret key performs a blocking definition fetch, even within the interval.
For production local evaluation, enable flag_cache with a shared store, or configure
a custom provider. posthog:doctor warns about a secret key without a provider.
No cache is enabled implicitly, and there is no background timer. The PHP client's
loadFlags() remains available for an explicit refresh. You can instead supply a
custom provider class in sdk.flag_definition_cache_provider.
Laravel Pennant
An optional read-only PostHog store supports Pennant boolean and variant reads. See Pennant setup, scope mapping and cache behavior.
Environment-based SDK tuning
The package configuration maps these environment variables to PHP SDK options
under posthog.sdk:
| Environment variable | SDK option | SDK default when unset |
|---|---|---|
POSTHOG_TIMEOUT_MS |
timeout for cURL delivery; http_timeout_ms for SDK HTTP requests |
10000 milliseconds |
POSTHOG_SOCKET_TIMEOUT_SECONDS |
timeout for the socket consumer |
5 seconds |
POSTHOG_BATCH_SIZE |
batch_size |
100 events |
POSTHOG_MAX_QUEUE_SIZE |
max_queue_size |
10000 events |
POSTHOG_FLUSH_INTERVAL_SECONDS |
flush_interval_seconds |
5 seconds |
POSTHOG_COMPRESS_REQUEST |
compress_request |
false |
POSTHOG_CONSUMER |
consumer |
lib_curl |
POSTHOG_FF_REQUEST_TIMEOUT_MS |
feature_flag_request_timeout_ms |
3000 milliseconds |
POSTHOG_FF_REQUEST_MAX_RETRIES |
feature_flag_request_max_retries |
1 retry |
For example, with the default cURL consumer:
POSTHOG_TIMEOUT_MS=5000 POSTHOG_BATCH_SIZE=50 POSTHOG_FF_REQUEST_MAX_RETRIES=0
The event-delivery timeout is selected by POSTHOG_CONSUMER: socket uses
POSTHOG_SOCKET_TIMEOUT_SECONDS; cURL consumers use POSTHOG_TIMEOUT_MS.
The inactive transport's timeout does not affect event delivery. For example, use
POSTHOG_CONSUMER=socket with POSTHOG_SOCKET_TIMEOUT_SECONDS=0.25 for a
quarter-second socket timeout.
POSTHOG_TIMEOUT_MS also controls SDK HTTP requests, including local flag-definition
fetches, even when events use the socket consumer. Those requests retain their
10000-millisecond default when it is unset; socket seconds never become HTTP
milliseconds. Positive fractional milliseconds round up rather than disabling the
timeout. Explicit POSTHOG_TIMEOUT_MS=0 disables the HTTP timeout.
POSTHOG_FF_REQUEST_TIMEOUT_MS separately controls remote flag evaluation requests.
There is no ambiguous POSTHOG_TIMEOUT mapping.
Unset or empty variables leave the SDK's defaults in control. Numeric strings are
converted to integers or floats, including fractional socket timeouts and flush
intervals. Explicit false compression and 0 retries are preserved. A zero flush
interval means flush on every enqueue; otherwise the interval is checked when
another event is enqueued, not by a background timer. HTTP, job and command lifecycle
flushes still apply. Consumer names are lib_curl, socket, fork_curl, file and
noop; noop disables event delivery, not feature-flag requests.
If you already published config/posthog.php, update it using the package's
config/posthog.php as a reference. Copy the numeric conversion
helpers and consumer lookup above return, as well as the environment-mapped sdk
section. Updating the package or running posthog:install does not overwrite an
existing published config file. Keep your custom SDK settings and callback classes
when updating that section.
You can also configure the existing SDK options directly there instead of using
environment variables. No new flag fallback or local-only evaluation behavior is
introduced by these mappings.
Environment variables are read only while loading Laravel configuration. After
changing them in a deployment that caches config, rebuild it with
php artisan config:cache and restart long-running workers. Do not call env()
from runtime SDK callbacks or application services; use config() instead.
Configuration callbacks and full PHP access
posthog.sdk passes PHP SDK options through. before_send, error_handler, and
flag_definition_cache_provider accept container class names, so published config
can be cached without serializing closures. Callback classes must implement the
corresponding SDK callable/interface contract. The Laravel library metadata and
framework handler ownership are enforced by the wrapper. release_id sets release
metadata; advanced SDK options remain accessible under sdk.
Client construction remains lazy. Invalid SDK configuration raises an initialization
error on first client use rather than silently disabling analytics. Run
php artisan posthog:doctor as a deployment check: it constructs the client and
reports initialization failures without printing credentials, loading flag
definitions or sending a diagnostic event. Custom container callbacks/providers
are resolved, so their constructors should not perform network work.
The supported PHP surface consists of the manager, facade, documented Context
methods, IsolatesCommandContext trait, test fake, service provider registration,
and Laravel flag-definition cache provider. Classes and methods marked @internal
are framework plumbing, not supported customer APIs or extension points, even when
Laravel or other package classes require their methods to be public.
Diagnostics
php artisan posthog:doctor: local configuration validation, no network or keys in output.php artisan posthog:install: publish config without overwriting an existing file.php artisan posthog:test --send: explicitly opt in to a diagnostic event with a random test ID.
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.
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 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
$fake = PostHog::fake(); PostHog::capture('checkout completed', ['items' => 2], 'account-123'); $fake->assertCaptured('checkout completed', fn ($event) => $event['properties']['items'] === 2); $fake->assertCapturedCount(1); $fake->assertNotCaptured('checkout failed');
Also available: assertNothingCaptured(), assertIdentified(), assertAliased(),
assertGroupIdentified(), assertExceptionCaptured(), and recorded(). Message API
assertions accept an optional predicate. flags($coreSnapshot) configures fake flag
results; the default is an empty snapshot. The fake's raw client is always disabled,
so escape-hatch calls cannot send traffic (they are not recorded).
The production manifest deliberately has no development repository overrides. For
local development before the required PHP SDK release is published, use the root
scripts/test-laravel.sh 12 or scripts/test-laravel.sh 13 helper once the tooling
PR in this stack is merged. It installs local dependencies in a disposable source
copy and runs the matching framework tests without changing package metadata.
Tests use Testbench, mocked transports and real Laravel lifecycle events; no live PostHog service or Octane server process is required.