Repository navigation
Conversation
…with the posthog mailer A Laravel app adds `'posthog' => ['transport' => 'posthog']` to config/mail.php and sets MAIL_MAILER=posthog. The mailer registers the core PostHogTransport with templates off and the marker laravel/<posthog-laravel version>, only once the app builds a mailer. Each Mailable or mail notification sends under its class name in kebab case as its template key, unless the mail sets X-PostHog-Template-Key; mail without a class uses the mailer's default_template_key. CI installs the Laravel package against the current SDK, as main already does. Ticket: PostHog/michaelr-context#50 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
posthog-php-socket Compliance ReportDate: 2026-11-13T08:03:21.813591+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 27ms |
| Format Validation.Event Has Uuid | ✅ | 522ms |
| Format Validation.Event Has Lib Properties | ✅ | 523ms |
| Format Validation.Distinct Id Is String | ✅ | 523ms |
| Format Validation.Token Is Present | ✅ | 524ms |
| Format Validation.Custom Properties Preserved | ✅ | 523ms |
| Format Validation.Event Has Timestamp | ✅ | 523ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ✅ | 523ms |
| Retry Behavior.Retries On 503 | ✅ | 5831ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 2524ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 2527ms |
| Retry Behavior.Respects Retry After Header | ❌ | 6331ms |
| Retry Behavior.Implements Backoff | ✅ | 16245ms |
| Retry Behavior.Retries On 500 | ✅ | 5630ms |
| Retry Behavior.Retries On 502 | ✅ | 5631ms |
| Retry Behavior.Retries On 504 | ✅ | 5632ms |
| Retry Behavior.Max Retries Respected | ✅ | 16246ms |
| Deduplication.Generates Unique Uuids | ✅ | 528ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5629ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10838ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5635ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 528ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 525ms |
| Compression.Sends Gzip When Enabled | ✅ | 523ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 523ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 520ms |
| Batch Format.Multiple Events Batched Together | ✅ | 510ms |
| Error Handling.Does Not Retry On 403 | ✅ | 2524ms |
| Error Handling.Does Not Retry On 413 | ✅ | 2527ms |
| Error Handling.Retries On 408 | ✅ | 5629ms |
Failures
retry_behavior.respects_retry_after_header
Retry delay too short: 802ms < 2500ms
Feature_Flags Tests
✅ 17/17 tests passed
View Details
| Test | Status | Duration |
|---|---|---|
| Request Payload.Request With Person Properties Device Id | ✅ | 524ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 522ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 521ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 521ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 521ms |
| Request Payload.Groups Round Trip | ✅ | 521ms |
| Request Payload.Groups Default To Empty Object | ✅ | 521ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 521ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ✅ | 521ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 522ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 517ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 527ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 525ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 523ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 624ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 624ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 523ms |
posthog-php-lib_curl Compliance ReportDate: 2026-11-13T08:03:00.393553+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 46ms |
| Format Validation.Event Has Uuid | ✅ | 518ms |
| Format Validation.Event Has Lib Properties | ✅ | 520ms |
| Format Validation.Distinct Id Is String | ✅ | 521ms |
| Format Validation.Token Is Present | ✅ | 520ms |
| Format Validation.Custom Properties Preserved | ✅ | 521ms |
| Format Validation.Event Has Timestamp | ✅ | 520ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ✅ | 522ms |
| Retry Behavior.Retries On 503 | ✅ | 5829ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 2522ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 2521ms |
| Retry Behavior.Respects Retry After Header | ❌ | 6331ms |
| Retry Behavior.Implements Backoff | ✅ | 16241ms |
| Retry Behavior.Retries On 500 | ✅ | 5630ms |
| Retry Behavior.Retries On 502 | ✅ | 5628ms |
| Retry Behavior.Retries On 504 | ✅ | 5627ms |
| Retry Behavior.Max Retries Respected | ✅ | 17032ms |
| Deduplication.Generates Unique Uuids | ✅ | 238ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5627ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10835ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5630ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 527ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 522ms |
| Compression.Sends Gzip When Enabled | ✅ | 522ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 521ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 517ms |
| Batch Format.Multiple Events Batched Together | ✅ | 510ms |
| Error Handling.Does Not Retry On 403 | ✅ | 2523ms |
| Error Handling.Does Not Retry On 413 | ✅ | 2522ms |
| Error Handling.Retries On 408 | ✅ | 5630ms |
Failures
retry_behavior.respects_retry_after_header
Retry delay too short: 804ms < 2500ms
Feature_Flags Tests
✅ 17/17 tests passed
View Details
| Test | Status | Duration |
|---|---|---|
| Request Payload.Request With Person Properties Device Id | ✅ | 520ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 518ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 519ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 518ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 520ms |
| Request Payload.Groups Round Trip | ✅ | 518ms |
| Request Payload.Groups Default To Empty Object | ✅ | 518ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 518ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ✅ | 518ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 519ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 515ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 507ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 523ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 519ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 623ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 620ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 520ms |
posthog-php-fork_curl Compliance ReportDate: 2026-11-13T08:03:00.064954+00:00
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 30ms |
| Format Validation.Event Has Uuid | ✅ | 524ms |
| Format Validation.Event Has Lib Properties | ✅ | 527ms |
| Format Validation.Distinct Id Is String | ✅ | 526ms |
| Format Validation.Token Is Present | ✅ | 526ms |
| Format Validation.Custom Properties Preserved | ✅ | 526ms |
| Format Validation.Event Has Timestamp | ✅ | 528ms |
| Format Validation.Non Utc Event Timestamp Is Converted To Utc | ✅ | 526ms |
| Retry Behavior.Retries On 503 | ✅ | 5847ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 2530ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 2529ms |
| Retry Behavior.Respects Retry After Header | ❌ | 6339ms |
| Retry Behavior.Implements Backoff | ✅ | 16269ms |
| Retry Behavior.Retries On 500 | ✅ | 5642ms |
| Retry Behavior.Retries On 502 | ✅ | 5639ms |
| Retry Behavior.Retries On 504 | ✅ | 5640ms |
| Retry Behavior.Max Retries Respected | ✅ | 16270ms |
| Deduplication.Generates Unique Uuids | ✅ | 530ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 5638ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 10856ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 5644ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 529ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 527ms |
| Compression.Sends Gzip When Enabled | ✅ | 528ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 526ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 516ms |
| Batch Format.Multiple Events Batched Together | ✅ | 516ms |
| Error Handling.Does Not Retry On 403 | ✅ | 2528ms |
| Error Handling.Does Not Retry On 413 | ✅ | 2528ms |
| Error Handling.Retries On 408 | ✅ | 5641ms |
Failures
retry_behavior.respects_retry_after_header
Retry delay too short: 809ms < 2500ms
Feature_Flags Tests
✅ 17/17 tests passed
View Details
| Test | Status | Duration |
|---|---|---|
| Request Payload.Request With Person Properties Device Id | ✅ | 519ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 519ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 519ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 519ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 519ms |
| Request Payload.Groups Round Trip | ✅ | 520ms |
| Request Payload.Groups Default To Empty Object | ✅ | 519ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 519ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ✅ | 519ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 519ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 516ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 513ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 522ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 520ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 621ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 622ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 527ms |
…nto feat/laravel-posthog-mail-driver # Conflicts: # packages/laravel/README.md
…and send with each job's client The posthog transport now remembers each mail's class name when Laravel sends it and reads it at the transport, so Mailables keep their class-name template key inside failover, round-robin and on-demand mailers, while every other transport sends the mail untouched. It builds the core transport per send, so each queued job, request or Octane worker sends with its own scoped client and configuration. Derived keys are cut to the send API's 256 characters. The tests use the core SDK's loopback server, and check that an app that sends no mail builds no mailer, in place of an autoload spy. Ticket: PostHog/michaelr-context#50 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mailer Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…template key A failover or round-robin mailer hands the transport a clone, which the driver matched to the first equal mail it had named. When the app still held an earlier mail with the same content, the new mail sent under the earlier class's key, and a mail with no class took that key over the mailer's default. The driver now names every mail, classless ones too, and takes the newest equal one: Laravel names a mail just before its transport sends it. The derived key now goes to the core transport as its default template key instead of a header on the mail; the core keeps an explicit X-PostHog-Template-Key first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Note 🤖 Claude Opus 5.5 responding on behalf of Michael Decisions made while building and reviewing this PR:
|
…nto feat/laravel-posthog-mail-driver
…se a bad default key up front A failover or round-robin mailer hands the PostHog transport a clone of the mail, which the driver matched to the newest equal mail it had named. A listener that sent an equal mail while another was sending gave the outer mail the inner one's key. Laravel's envelope, which Symfony hands every transport of the mailer, holds the mail Laravel named, so the driver now looks the key up by that mail and matches nothing by content. Since mail with a class sends under its own key, a default_template_key the send API rejects failed only mail without one. The mailer now refuses that key when it is built. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The mailer checked default_template_key against its own copy of the send API's template key rule. It now uses the core mapper's pattern, which moves with the exact core version posthog-laravel pins, and the README says which characters the key takes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed a31e858: 1 Should fix.
See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.
| return $this->transportForThisSend(self::$templateKeys[$mail] ?? $this->defaultTemplateKey) | ||
| ->send($message, $envelope); |
There was a problem hiding this comment.
P2 · Remove reserved PostHog headers before fallback transports receive the mail
When a Mailable sets X-PostHog-Distinct-Id or X-PostHog-Idempotency-Key and PostHog fails over to SMTP, recipients receive those headers, exposing internal identifiers. send() delegates to a core transport that clones the message, leaving the fallback's original headers intact. Extract reserved options out of band and strip them from the shared message.
…ext mailer A Mailable sets its PostHog options, such as X-PostHog-Distinct-Id and X-PostHog-Idempotency-Key, as headers. When the PostHog send failed, a failover mailer handed the next transport, such as SMTP, a clone of the mail with those headers still on it, so the recipient got them. Once PostHog has tried a mail, the driver now removes its X-PostHog-* headers from the mail Laravel handed over. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nto feat/laravel-posthog-mail-driver
| } finally { | ||
| self::removePostHogHeaders($mail); |
There was a problem hiding this comment.
P2 · Preserve idempotency metadata when retrying the same email
When a caller retries the same email object after a PostHogTransportException, this finally block has removed its X-PostHog-Idempotency-Key. The next send() generates a fresh key, so a timeout after PostHog accepted the first request results in duplicate delivery. Preserve the extracted options out of band across retries while keeping fallback headers clean.
💡 Motivation and Context
A Laravel app that already sends mail through Mailables and notifications can't pick PostHog as its mail provider today: it would have to rewrite each mail as a direct
emails()->send()call and lose Laravel's queueing, previews and test helpers.This adds a
posthogmail transport to posthog-laravel. An app adds a mailer toconfig/mail.phpand setsMAIL_MAILER=posthog, and every Mailable and mail notification sends through PostHog:Mail::extend('posthog', ...)registersMailDriver, a thin transport that sends each mail through the corePostHogTransportfrom feat(mailer): send Symfony Mailer email through PostHog with posthog+api:// #305. It registers only once the app builds a mailer, so an app that sends no mail never builds Laravel's mail manager.App\Mail\OrderShippedgroups asorder-shipped. That holds through failover, round-robin andMail::build()mailers too. AnX-PostHog-Template-Keyheader on the mail wins. Mail with no class, such asMail::raw(), uses the mailer'sdefault_template_key, orlaravel. Derived keys are cut to the send API's 256 characters.MessageSendinglistener remembers each mail's key, and the transport passes it to the core as its default key. So the next mailer of a failover, or any other transport, never sends a PostHog header. A failover or round-robin mailer hands the transport a clone, so the driver finds the key through Laravel's envelope, which holds the mail Laravel named.default_template_keythe send API rejects fails when the mailer is built, checked with the core's own rule.laravel/<posthog-laravel version>. Templates made in PostHog are off, as the adapter contract says for in-package adapters. Refusals and send errors are the core transport's (PostHog cannot send this email: <code>,PostHogTransportException).Stacked on #305 (merge that first). Spec: the in-package adapters section of the email-frameworks spec. Ticket: https://github.com/PostHog/michaelr-context/issues/50
Impact
Pays into Grow Workflows (Messaging): an API send is a billed Workflows email action. After release, watch weekly organizations whose
transactional email api sendevents come from a framework integration, broken down byclient; this PR's sends count asclient = laravel. Success: Laravel sends appear and grow after release day. Theclientproperty and its insight come from the instrumentation stack.Note
🔵 Merge risk: low · one-way door once posthog-laravel releases: the
posthogtransport name, itsdefault_template_keyoption and the class-name template keys become a contract apps configure against'transport' => 'posthog'(none today: the transport is new) send their mail through PostHog's send API, US or EU byPOSTHOG_HOST. Covered byMailDriverTestthrough a booted app and a loopback send API: Mailable, notification, raw mail, failover clones, a mail sent while another sends,Mail::build(), header options, per-job configuration, a rejected default key, refusal, send error and (on Symfony Mailer 8.2) a refused template.MessageSendinglistener that records the mail's key in a weak map and never changes the mail. Covered by the tests that send through another mailer and fail over from PostHog to the next mailer with no PostHog header, and by the unchanged Laravel suite on Laravel 12 and 13.testAnAppThatSendsNoMailBuildsNoMailer.@internalDelayedEnvelope, a private property. If a later Symfony Mailer renames it, mail through a failover or round-robin mailer sends under the default key (laravelordefault_template_key) with no error; the send itself goes out. The failover tests in the Laravel 12 and 13 CI matrix catch it on their next run.posthogtransport. Fix forward.mainreleases posthog-laravel with this transport)💚 How did you test it?
packages/laravel/tests/MailDriverTest.phpboots a Laravel app withMAIL_MAILER=posthogand sends through the real SDK to the core's loopback test server (test/LocalHttpServer.php). It asserts the request PostHog receives: path,Authorization,X-PostHog-Integration: laravel/<version>,Idempotency-Key, and the Laravel-owned body fields (template_key, to, from, subject, html).Unsupported mail transport [posthog]. Each review fix started from a failing test. Failover mail lost its key; a job sent with the previous job's key; a long class name was refused; two mails that read alike sent under the first one's key; a classless mail took an earlier mail's key; a mail sent from a listener while another sent took the outer mail's key; a rejected default key went unnoticed for mail with a class.scripts/test-laravel.sh. The template refusal test needs Symfony Mailer 8.2 (only a beta today) and skips below it; it passes on 8.2.0-BETA1. The core suite,composer api:checkandcomposer api:check:laravelpass; the new class is@internal.📝 Checklist
If releasing new changes
pnpm changeto generate a change intent file (posthog-laravelminor)🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Built by Claude Code (Opus 5.5) as one stack of the email-frameworks work, following the shared adapter contract. Reviews ran on GPT-6.1 Sol, with a fresh-eyes pass on Claude Opus 5.5.
Decisions
MessageSendinglistener records each mail's key in a weak map; the mail is never changeddefault_template_keythe send API rejectsInvolvement: no grilling or prototype rounds; four review rounds of six lenses and a final read; local proof captured.
🤖 Generated with Claude Code