# PHP and Laravel SDK

> Send events and confirm contact consent from PHP or Laravel with whisperr/php, including idempotent billing events.

`whisperr/php` sends events and identify calls from a PHP backend, with a service provider and facade for Laravel. Use it for facts only your server knows, such as payment failures, cancellations and plan changes, and to confirm contact consent with the server key.

## Install

```bash
composer require whisperr/php
```

The package is [`whisperr/php`](https://packagist.org/packages/whisperr/php) on Packagist. The current version is 0.3.0. It needs PHP 8.0 or later with `ext-curl` and `ext-json`, and has no Composer dependencies.

## Laravel

Package discovery registers the service provider and the `Whisperr` facade. Set the server key in `.env`:

`.env`

```dotenv
WHISPERR_API_KEY=wrk_your_server_key
WHISPERR_APP_VERSION=2026.10.1   # optional: your backend release
```

To edit the config file, publish it:

```bash
php artisan vendor:publish --tag=whisperr-config
```

```php
use Whisperr\Laravel\Facades\Whisperr;


// In a controller: pass the user id explicitly.
Whisperr::track((string) auth()->id(), 'plan_upgraded', ['plan' => 'pro']);


// In a webhook or job: use the user id from your own data.
Whisperr::track((string) $subscription->user_id, 'subscription_cancelled', ['reason' => 'too_expensive']);


// Traits and a contact the user agreed to:
Whisperr::identify((string) $user->id, [
    'traits' => ['first_name' => $user->first_name, 'plan' => 'pro'],
    'channels' => [
        [
            'type' => 'email',
            'address' => $user->email,
            'opted_in' => $user->email_consent,
            'verified' => $user->hasVerifiedEmail(),
        ],
    ],
]);
```

The Laravel integration buffers events during the request and sends them in the app’s `terminating` hook, after the response is sent.

Laravel config keys (`config/whisperr.php`):

| Key           | Environment variable   | Default                    |
| ------------- | ---------------------- | -------------------------- |
| `api_key`     | `WHISPERR_API_KEY`     | `""`                       |
| `base_url`    | `WHISPERR_BASE_URL`    | `https://api.whisperr.net` |
| `app_version` | `WHISPERR_APP_VERSION` | —                          |
| `disabled`    | `WHISPERR_DISABLED`    | `false`                    |
| `debug`       | `WHISPERR_DEBUG`       | `false`                    |

Create the server key as a **Server** key in **Settings → API keys**.

## Plain PHP

`whisperr.php`

```php
use Whisperr\Whisperr;


$whisperr = new Whisperr(['api_key' => getenv('WHISPERR_API_KEY')]);


$whisperr->track('user_8842', 'subscription_cancelled', ['reason' => 'too_expensive']);


$whisperr->flush(); // the client also flushes at shutdown
```

`track()` and `identify()` always take the user id first. Pass the same id your app passes to `identify()`, and backend and frontend events land on [one timeline](/concepts/events/#one-user-one-timeline). Event names must be lowercase `snake_case`; the SDK drops any other name and reports it to `on_error` as `dropped`.

## How do I confirm contact consent with the PHP SDK?

The PHP SDK runs with the server key, so its `identify()` is how you confirm consent. Send `'opted_in' => true` only when the user agreed to messages on that channel, and `'verified' => true` only for an email address your app confirmed. Send `'opted_in' => false` to record an opt-out.

> The PHP SDK sends `opted_in: true` for a channel when you leave `'opted_in'` out. The `email`, `phone` and `push_token` shortcuts do the same. Send a contact only when you hold consent for it, or pass `'opted_in' => false`.

See [Contact consent](/concepts/consent/).

## How do I record an event only once?

Cron jobs and payment callbacks can run twice. Pass a stable message ID and the original time to `track()`, and Whisperr keeps one event per message ID for each app:

```php
Whisperr::track(
    (string) $invoice->user_id,
    'billing_issue_started',
    ['has_access' => true],
    messageId: "stripe_invoice_{$invoice->id}_billing_issue_started",
    occurredAt: $invoice->failed_at->toIso8601ZuluString(), // e.g. 2026-10-12T08:00:00Z
);
```

- Build the message ID from your own data, not from the current time. It must be non-empty, have no leading or trailing spaces, and be at most 200 bytes.
- `occurredAt` must be RFC 3339 with a time zone (`Z` or `+04:00`). Whisperr accepts times up to 30 days in the past and 5 minutes in the future.
- An invalid message ID or time drops the event and reports `dropped`. `track()` never throws.
- Without these arguments, `track()` uses a random ID and the current time.

## How do I set the message language from PHP?

Pass the language the user picked in your product as the `app_language` trait. Use a BCP 47 tag such as `de` or `pt-BR`. The PHP SDK has no separate method for it and never guesses a language.

```php
$whisperr->identify((string) $user->id, ['traits' => ['app_language' => 'pt-BR']]);
```

## What the PHP SDK adds to every event

| `context` key | Value                                    |
| ------------- | ---------------------------------------- |
| `sdk_name`    | `whisperr-php`                           |
| `sdk_version` | `Whisperr::VERSION`, for example `0.3.0` |
| `app_version` | The `app_version` option. Empty: no key. |

A key you pass in the `$context` argument of `track()` wins over these.

## How do I send billing events from PHP?

When Whisperr has no billing source connected for your app (such as [RevenueCat](/integrations/revenuecat/)), send membership changes from your backend with the reserved names in `Whisperr\BillingEvent`. Never send them from the browser or the app. `BillingEvent::properties()` builds the money properties and puts the event’s own properties from `extra` next to them:

```php
use Whisperr\BillingEvent;


$whisperr->track((string) $payment->user_id, BillingEvent::RENEWAL_SUCCEEDED, BillingEvent::properties(
    planId: 'pro_monthly',
    price: 9.99,          // major units: 9.99, not 999 cents
    currency: 'usd',      // upper-cased to 'USD'
    planPeriod: 'P1M',    // ISO 8601 duration
    transactionId: (string) $payment->id,
    store: 'stripe',
    extra: ['paid_periods_count' => 2, 'current_period_ends_at' => '2026-12-12T08:00:00Z'],
), messageId: "renewal_{$payment->id}", occurredAt: $payment->paid_at->toIso8601ZuluString());
```

- An argument you leave out adds no property.
- `properties()` throws `InvalidArgumentException` when `currency` is not three letters, or `price` is negative or not a finite number.
- The constants are `TRIAL_STARTED`, `TRIAL_CONVERTED`, `TRIAL_AUTO_RENEW_DISABLED`, `MEMBERSHIP_STARTED`, `RENEWAL_SUCCEEDED`, `AUTO_RENEW_DISABLED`, `AUTO_RENEW_ENABLED`, `MEMBERSHIP_ENDED`, `BILLING_ISSUE_STARTED`, `BILLING_RECOVERED` and `REFUND_GRANTED` (`BillingEvent::ALL` lists them). The [Node SDK page](/sdks/node/#how-do-i-send-billing-events-from-node) lists the properties of each event and the closed value lists.

Register these event names for your app; [setup with your AI agent](/agents/) registers the events your app sends. Whisperr stores an event with an unregistered name but does not map it to your app’s events.

## How does the PHP SDK deliver events?

- Events buffer in memory and go out in batches on `flush()`: after the Laravel response, when `flush_at` events are buffered, or at shutdown.
- Events go to `POST /v1/events/batch` and identify calls to `POST /v1/identify`, in order, with the `X-API-Key` header.
- `429`, `5xx` and network errors are retried up to `max_retries` times. The wait between tries is at most about 2 seconds, so request teardown does not hang. The PHP SDK does not read `Retry-After`.
- On `401`, `403` or exhausted retries, the batch stays buffered for the next `flush()` in the same process. Other `4xx` responses drop the batch and report `dropped`.
- The buffer lives only as long as the PHP process or request.

See [Delivery, retries, idempotency](/api/delivery/).

## Options (plain PHP constructor)

| Key               | Default                    | Notes                                                                         |
| ----------------- | -------------------------- | ----------------------------------------------------------------------------- |
| `api_key`         | —                          | Server key (`wrk_…`). Required unless `disabled`.                             |
| `base_url`        | `https://api.whisperr.net` | Ingestion base URL.                                                           |
| `app_version`     | —                          | Your backend release. Sent as `context.app_version`.                          |
| `flush_at`        | `100`                      | Flush when this many events are buffered.                                     |
| `max_batch_size`  | `500`                      | Events per batch request. 500 is the maximum.                                 |
| `max_retries`     | `3`                        | Retries per batch in one flush.                                               |
| `request_timeout` | `10.0`                     | Per-request timeout (seconds).                                                |
| `disabled`        | `false`                    | Captures and sends nothing. Useful in tests.                                  |
| `debug`           | `false`                    | Logs warnings with `error_log`.                                               |
| `on_error`        | —                          | `callable(WhisperrError): void`. Types: `auth`, `dropped`, `retry_exhausted`. |

## Common questions

### Does the PHP SDK slow down my Laravel responses?

No. In Laravel the SDK buffers events during the request and sends them in the app's terminating hook, after the response is sent.

### How do I stop a retried webhook from recording the same event twice?

Pass a stable messageId built from your own data, and the original occurredAt, to track(). Whisperr keeps one event per message ID for each app.

### Which PHP and Laravel versions does whisperr/php support?

PHP 8.0 or later with ext-curl and ext-json. Laravel is optional; CI tests Illuminate 8.83 on PHP 8.0 and Illuminate 10 and 11 on PHP 8.1 to 8.3.

### Which API key does the PHP SDK use?

The server key (wrk_…), shown as a Server key in Settings → API keys. Keep it in your environment, never in front-end code.

Source: https://docs.whisperr.net/sdks/php/
