jeffersongoncalves/laravel-posthog
A Laravel integration for PostHog that makes it easy to send analytics events, identify users, and track feature usage from your PHP application through a simple, configurable package API.
Install and wire up two credentials, not one. The package deliberately mirrors PostHog's split auth model, and almost every setup failure comes from mixing them up:
POSTHOG_API_KEY=phc_... # project key — ingestion and flag evaluation payloads
POSTHOG_PERSONAL_API_KEY=phx_... # personal key — Bearer token for the project API
POSTHOG_HOST=https://us.i.posthog.com
Use the EU host (https://eu.i.posthog.com) if your project lives there; a wrong host looks like an auth failure.
Bind the client as a singleton in a service provider (or rely on the package's auto-discovered provider if it ships one). Read config once, not per call:
$this->app->singleton(PostHog::class, fn () => new PostHog(
apiKey: config('services.posthog.key'),
personalApiKey: config('services.posthog.personal_key'),
host: config('services.posthog.host'),
));
Check the actual factory/constructor signature in the README — this is the one place the release notes don't spell out.
First use case: capture a real business event and read a flag in the same request path. After registration, identify the user, then check a multivariate onboarding flag to pick a flow. That exercises ingestion, identity, and flag evaluation in one go and proves both keys are correct.
Where to look first: the client's public methods map almost 1:1 to the release notes (capture, identify, alias, getAllFlags, getFeatureFlag, isFeatureEnabled, person lookup, query/insights/recordings). Then read PostHogException — it carries the raw API error body, which is your primary debugging tool for 4xx responses.
Never capture inline in a hot request path. Dispatch a queued job or listener instead; analytics latency and outages must not affect checkout or login.
final class CapturePostHogEvent implements ShouldQueue
{
public function __construct(public array $payload) {}
public function handle(PostHog $posthog): void
{
$posthog->capture($this->payload);
}
}
Use the batch ingestion path for backfills and imports (historical orders, migrated users) rather than looping single captures.
Fetch flags once per request, then evaluate locally. getAllFlags($distinctId, $groups) is the workhorse: one round trip, then read variants out of the array in middleware, policies, and Blade/Inertia props.
$flags = Cache::remember("posthog:flags:{$user->id}", 60, fn () =>
$posthog->getAllFlags((string) $user->id, ['company' => (string) $user->company_id])
);
$variant = $flags['new-checkout'] ?? 'control';
Expose them to the frontend as a single shared prop instead of letting JS call PostHog separately.
Keep distinct_id stable and non-PII. Use your database user ID (cast to string), not email. Generate an anonymous ID for guests, then alias on signup so pre-login behaviour stitches onto the real person. Call identify on registration and on profile changes worth syncing.
Pass groups consistently everywhere. If you send ['company' => $id] on capture but omit it on flag calls, group-targeted flags silently won't match. Centralise this in a small PostHogContext helper that derives distinct_id and groups from the authenticated user.
Use the project API surface for internal tooling, not user-facing code. Person lookup by distinct_id belongs in an admin/support screen; HogQL queries and insights fit scheduled Artisan commands (daily metrics to Slack, cohort exports); session recording listings fit an internal QA dashboard. All of these need the personal API key, so they must stay server-side.
Wrap the client in a thin decorator that catches PostHogException, logs the body at warning level, and returns safe defaults (e.g. false for flag checks). Bind the decorator in the container so call sites stay clean and tests can swap in a fake.
Test with a fake, never the network. Bind an in-memory implementation in tests/TestCase.php and assert on captured payloads; add one optional integration test gated behind an env flag for the real client.
/decide-style flag calls use the project key in the payload; everything under the project API needs Authorization: Bearer <personal key>. A 401 on a flag check usually means the method is hitting the project API, not that your key is wrong. Verify per-method which credential is required before debugging blind.getFeatureFlag returns a string variant (and null/false when absent or disabled), while isFeatureEnabled collapses to a boolean. Decide once which one your codebase uses per flag and don't mix — "false"-as-string and missing-flag cases are the classic bug.PostHogException preserves the response body, you can distinguish rate limits from bad payloads. Retry only the transient class; drop and log the rest rather than blocking a queue worker.php artisan config:cache will freeze your keys — remember to re-cache after changing env vars in deploy pipelines.^1.0 is fine, but read the changelog on every minor bump and keep your decorator as the single integration seam — that's where upstream API changes will land.How can I help you explore Laravel packages today?