posthog/posthog-php
PostHog PHP SDK for server-side product analytics: capture events, identify users, group accounts, evaluate feature flags, and send data to PostHog from Laravel or plain PHP applications.
composer require posthog/posthog-php, then grab your project API key (phc_...) from PostHog → Project Settings. Note your region host — https://us.i.posthog.com or https://eu.i.posthog.com. The default is not always what you want.PostHogServiceProvider (or a small singleton in App\Support\Analytics). Because the SDK is static, keep initialization in one place and out of config:cache bootstrapping logic:// config/services.php
'posthog' => [
'key' => env('POSTHOG_API_KEY'),
'host' => env('POSTHOG_HOST', 'https://us.i.posthog.com'),
],
// PostHogServiceProvider::register()
PostHog::init(config('services.posthog.key'), [
'host' => config('services.posthog.host'),
'consumer' => 'lib-curl',
'debug' => config('app.debug'),
'error_handler' => fn ($e) => report($e), // analytics must never break checkout
]);
PostHog::capture([
'distinctId' => (string) $user->id,
'event' => 'user_signed_up',
'properties' => [
'plan' => $user->plan,
'$set' => ['email' => $user->email, 'created_at' => $user->created_at->toIso8601String()],
],
]);
PostHog::flush();
PostHog class for method signatures, the Consumer/* classes to understand delivery, and the config array in PostHog::init. There is no deep API to learn — capture, identify, alias, groupIdentify, isFeatureEnabled/getFeatureFlag/getAllFlags, flush, shutdown.PostHog::flush(), then check Activity → Live events in the PostHog UI filtered by your event name. Don't trust a green test suite for this.Wrap it, don't sprinkle it. Put a thin Analytics class (or contract) in front of PostHog::* so you have one place to resolve the current distinctId, tenant group, and to no-op in tests. Calling the static class from 40 controllers is the fastest way to an unmaintainable integration.
Identity lifecycle.
distinctId (cast to string — integers work but string is the safe contract).identify() on profile changes, not on every request; use $set for mutable attributes and $set_once for first-touch values.PostHog::alias(['distinctId' => $userId, 'alias' => $anonId]) at login and switch to the user ID going forward.Groups for multi-tenant apps. Do groupIdentify() when a tenant is created/updated, and then attach 'groups' => ['company' => $tenant->id] to every capture. Doing groups half-way (some events grouped, some not) produces dashboards that undercount.
Feature flags.
PostHog::isFeatureEnabled('new-checkout', (string) $user->id) is a network call by default. Don't put it inside a loop or a Blade template.personalApiKey (the personal key, not the project key), the matching host, and featureFlagsPollingInterval. Then pass personProperties in the options so conditions that reference person props actually match.getAllFlags($distinctId, ['personProperties' => [...]]) once per request and resolve flags from the returned array.'onlyEvaluateLocally' => true when you need to know a flag wasn't resolvable — otherwise the SDK silently falls back to a network evaluation.Batching and lifecycle. The SDK queues events and flushes on a timer/destructor. In long-lived processes (queue workers, Octane, Horizon) flush explicitly:
PostHog::flush() after the response is sent.PostHog::flush() in a finally block, or dispatch a small "flush" job at the end of a batch.Non-critical work off the hot path. Emit analytics from queued event/listener classes (UserSignedUp → SendSignupAnalytics) rather than inline in the request. This also gives you retries for free and keeps p95 latency clean.
Testing. Point the consumer at file (or your own fake consumer), run the flow, then read the JSON lines and assert on event/distinctId/properties. Always flush() in tearDown(), and be aware that the static client persists across tests in the same process.
PostHog::init() can't be called twice cleanly. Guard it with a hasInitialized check, and call PostHog::shutdown() between tests or in long-running processes where you need a fresh client. Test-suite cross-contamination ("events attributed to the wrong user") almost always traces back to this.exit/die, fatal errors, uncaught signals, and pcntl_fork can drop the queue. If an event matters (e.g. subscription_started), flush() explicitly. If it doesn't, let it batch.flush() blocks. Never call it per event in a web request. One flush in a terminable middleware or job finally is the pattern.lib-curl is the sane default. fork-curl requires pcntl and commonly isn't available in shared/FPM setups; file and socket are debugging aids, not production transports. Misconfigured consumers fail quietly, which is why 'debug' => true in local is worth it.distinctId, not distinct_id; $set/$set_once for person properties; anything else in properties is an event property. PostHog won't error — it just won't do what you meant.isFeatureEnabled() returns false for both "flag is off" and "flag not found / evaluation failed". If that distinction matters, use getFeatureFlag() and treat null as unknown.personalApiKey, wrong host, stale polling interval, or missing personProperties all cause flags to fall back or evaluate incorrectly — and it looks like a flag-logic bug. Start with server-side evaluation on, confirm the flag, then move to local.beforeSend-style sanitizer in your Analytics wrapper if you can't trust callers.max_queue_size will balloon worker memory. Lower batch_size / flush periodically inside the loop.error_handler (log, never throw), timeout, ssl, and passing a pre-built Consumer instance to init — the cleanest way to inject a fake consumer in tests or route to a proxy.How can I help you explore Laravel packages today?