# Laravel

Source: https://kirora.io/docs/sdks/laravel

Use Kirora from Laravel: a configured client with a facade, and webhooks verified and dispatched as Laravel events.

Use Kirora from Laravel with a configured API client and verified webhook events. The `kirora/kirora-laravel` package puts the client in Laravel's container, with a facade, and turns Kirora's webhooks into Laravel events, verified before they're dispatched. Everything else, from services and models to pagination, retries and errors, comes from the [PHP library](https://kirora.io/docs/sdks/php), which this page links to rather than repeats. The [README](https://github.com/kiroratech/kirora-laravel#readme) is the full reference.

## Install

Add the package with Composer:

```bash
composer require kirora/kirora-laravel
```

It supports Laravel 12, on PHP 8.2 or later, and Laravel 13, on PHP 8.3 or later. Laravel discovers its service provider, so there's nothing to register.

Create an API key under **Developer → Server keys** in the dashboard, with the scopes your integration needs, and add it to your app's `.env`:

```ini
KIRORA_API_KEY=your_api_key
```

To check it works, open `php artisan tinker` and call `Kirora\Laravel\Facades\Kirora::me()`, which returns the key's scopes and workspace without changing anything.

## Configure

The package reads its settings from your environment, so publishing its configuration is optional:

| Variable | Default | Description |
| --- | --- | --- |
| `KIRORA_API_KEY` | None | Your server key. Required. |
| `KIRORA_BASE_URL` | `https://api.kirora.io` | The API host, without `/v1`. For tests and mocks. |
| `KIRORA_TIMEOUT` | 30 | Seconds each attempt may take. |
| `KIRORA_MAX_RETRIES` | 3 | Attempts after the first, from 0 to 10. |
| `KIRORA_MAX_RETRY_DELAY` | 30 | The longest wait between attempts, in seconds. |
| `KIRORA_WORKSPACE` | None | Partner keys only: the tenant workspace every call acts in. |
| `KIRORA_WEBHOOK_SECRET` | None | Your webhook endpoint's signing secret. Required for webhooks. |
| `KIRORA_WEBHOOK_TOLERANCE` | 300 | How far a webhook's timestamp may be from now, in seconds. |
| `KIRORA_WEBHOOK_PATH` | `kirora/webhook` | The webhook route's path. |
| `KIRORA_WEBHOOK_DOMAIN` | Any | Limits the webhook route to one domain. |
| `KIRORA_WEBHOOK_ROUTE` | `true` | `false` to register the webhook route yourself. |

To add middleware to the webhook route, publish `config/kirora.php`:

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

The package fills in any webhook setting your file leaves out from its defaults, so a partial `webhook` array keeps the default path, tolerance and route.

The client checks its settings when it's first resolved, and a missing key throws an `InvalidArgumentException`. If you cache configuration, run `php artisan config:cache` after changing a setting, and restart queue workers and Octane so they pick it up.

## Use the client

Inject `Kirora\Client`, or use the `Kirora` facade: both give you the same client, created once. With injection, each service is a property, as in the PHP library:

```php
use App\Models\Order;
use Kirora\Client;

class CheckoutController
{
    public function __invoke(Client $kirora, Order $order)
    {
        $kirora->events->track([
            'externalId' => (string) auth()->id(),
            'name' => 'Order Completed',
            'properties' => ['order_id' => (string) $order->id, 'total' => $order->total, 'currency' => $order->currency],
        ], ['idempotencyKey' => "order-{$order->id}-completed"]);
    }
}
```

On the facade, each service is a method, so `$kirora->contacts` is `Kirora::contacts()`:

```php
use Kirora\Laravel\Facades\Kirora;

$contact = Kirora::contacts()->identify([
    'externalId' => 'cust_1042',
    'email' => 'amaka@example.com',
]);
```

The client's own methods, such as `me()`, `forWorkspace()` and `request()`, work as they are. For the services, models, pagination, errors, retries and per-call options, see the [PHP library](https://kirora.io/docs/sdks/php#create-a-client).

Import the facade with `use Kirora\Laravel\Facades\Kirora;`, and don't add an alias called `Kirora` in `config/app.php`. The PHP library declares a global `Kirora` class, the one with `Kirora::client()`, so such an alias never takes effect, and `\Kirora::contacts()` fails. To use the facade in Blade, alias it under another name, such as `KiroraApi`.

## Partner workspaces

With a partner key, `Kirora::forWorkspace($externalId)` returns a client that acts in one of your tenants' workspaces. It's a new client, and the shared one is unchanged, so make one for each tenant rather than changing configuration between requests or jobs:

```php
$tenant = Kirora::forWorkspace('tenant-42');
$contact = $tenant->contacts->get('cust_1042', ['idType' => 'externalId']);
```

A scoped client reaches its services through properties. See [Partner workspaces](https://kirora.io/docs/sdks/php#partner-workspaces) for managing workspaces and embedding Kirora's builders.

## Receive webhooks

The package registers `POST /kirora/webhook`, named `kirora.webhook`, outside the `web` middleware group, so no session or CSRF check applies to it. To use it:

1. In Kirora, add a webhook endpoint at `https://your-app.example/kirora/webhook`. See [Webhooks](https://kirora.io/docs/api/webhooks).
2. Set `KIRORA_WEBHOOK_SECRET` to that endpoint's signing secret.
3. Check the route is there with `php artisan route:list --name=kirora.webhook`.
4. Listen for the events you need.

Each webhook is verified against the raw request body before anything else runs. One with a missing or wrong signature, a timestamp outside the tolerance, or event headers that don't match the signed body gets an empty `403`; the reason is logged at `debug` level. With no secret set, every webhook is refused with a `500`.

A verified webhook dispatches two events from `Kirora\Laravel\Events`: `WebhookReceived` for every webhook, then the event for its type:

| Webhook | Event |
| --- | --- |
| `contact.created` | `ContactCreated` |
| `contact.unsubscribed` | `ContactUnsubscribed` |
| `contact.consent_changed` | `ContactConsentChanged` |
| `form.submitted` | `FormSubmitted` |
| `campaign.sent` | `CampaignSent` |
| `journey.enrolled` | `JourneyEnrolled` |
| `journey.action_reached` | `JourneyActionReached` |
| `message.sent` | `MessageSent` |
| `message.delivered` | `MessageDelivered` |
| `message.opened` | `MessageOpened` |
| `message.clicked` | `MessageClicked` |
| `message.bounced` | `MessageBounced` |
| `message.complained` | `MessageComplained` |
| `message.failed` | `MessageFailed` |
| `message.skipped` | `MessageSkipped` |
| `webhook.test` | `WebhookTest` |

A type added after your version of the package dispatches only `WebhookReceived`. Each event has the decoded body in `$payload` and Kirora's headers in `$headers`, with helpers for `eventId()`, `eventType()`, `type()`, `occurredAt()`, `data()` and `attempt()`:

```php
namespace App\Listeners;

use Illuminate\Contracts\Queue\ShouldQueue;
use Kirora\Laravel\Events\MessageBounced;

class LogMessageBounce implements ShouldQueue
{
    public function handle(MessageBounced $event): void
    {
        logger()->warning('Kirora message bounced', [
            'event_id' => $event->eventId(),
            'message_id' => $event->data()['message']['id'] ?? null,
        ]);
    }
}
```

Laravel discovers listeners in `app/Listeners` by the event their `handle()` takes; elsewhere, register them with `Event::listen()`. Listeners match the exact class, so to receive every webhook, listen for `WebhookReceived`.

The route answers `200` once synchronous listeners have finished and queued ones have been queued. Delivery is at-least-once, so deduplicate on `eventId()`; your app owns storing and deduplicating events. A queued listener that fails after the `200` isn't redelivered by Kirora: retry it through Laravel's queue, and watch for failed jobs.

To move the route, set `KIRORA_WEBHOOK_PATH` or `KIRORA_WEBHOOK_DOMAIN`. To register it yourself, set `KIRORA_WEBHOOK_ROUTE=false` and point your route at the package's controller, which verifies the signature itself. Keep it outside the `web` group, or exclude it from CSRF protection:

```php
use Illuminate\Support\Facades\Route;
use Kirora\Laravel\Http\Controllers\WebhookController;

Route::post('hooks/kirora', WebhookController::class);
```

## Send transactional email

Send through the API with `Kirora::transactional()->send()`, using a template from your workspace. The package doesn't add a `Mail` transport or work with `Mailable`s; each send goes to one recipient.

```php
use Kirora\Laravel\Facades\Kirora;

$sent = Kirora::transactional()->send([
    'to' => ['email' => $user->email],
    'data' => ['order_id' => $order->id],
    'email' => ['template' => 'order-receipt'], // its alias or its id
], ['idempotencyKey' => "receipt-{$order->id}"]);
```

Pass your own idempotency key, as here, so a retried job replays the first send rather than sending twice. To send your own content instead of a template, pass `subject`, `from`, and `html` or `text` in `email`; `html` can be a rendered Blade view. [Send a transactional message](https://kirora.io/docs/sdks/php#send-a-transactional-message) covers the result, including channels and copies that weren't sent.

## Sign tokens for the web SDK

The [web SDK](https://kirora.io/docs/sdks/web) signs users in with a token from your server. Add the workspace's SDK token secret to `config/services.php`, so it's still read once configuration is cached:

```php
'kirora' => [
    'sdk_token_secret' => env('KIRORA_SDK_TOKEN_SECRET'),
],
```

Then sign a token with the PHP library's `UserToken`, from a route only a signed-in user can reach:

```php
use Illuminate\Support\Facades\Route;
use Kirora\UserToken;

Route::middleware('auth')->get('/kirora/token', fn () => UserToken::create(
    config('services.kirora.sdk_token_secret'),
    (string) auth()->id(),
));
```

The [PHP page](https://kirora.io/docs/sdks/php#sign-tokens-for-the-web-sdk) covers token lifetimes, and the web SDK's [setup](https://kirora.io/docs/sdks/web#2-add-a-token-route-to-your-server) the browser side.

## Test your code

The package doesn't use Laravel's `Http` client, so `Http::fake()` doesn't reach it. Instead, bind a PSR-18 client under `KiroraServiceProvider::HTTP_CLIENT` before the client is first used, such as a Guzzle client with a `MockHandler`:

```php
use GuzzleHttp\Client as Guzzle;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Kirora\Laravel\Facades\Kirora;
use Kirora\Laravel\KiroraServiceProvider;

it('reads a list', function () {
    config(['kirora.api_key' => 'test-key', 'kirora.max_retries' => 0]);

    $this->app->instance(KiroraServiceProvider::HTTP_CLIENT, new Guzzle([
        'handler' => HandlerStack::create(new MockHandler([
            new Response(200, ['Content-Type' => 'application/json'], '{"data":{"id":"beta","name":"Beta testers"}}'),
        ])),
    ]));

    expect(Kirora::lists()->get('beta')->name)->toBe('Beta testers');
});
```

To test a webhook from end to end, set `kirora.webhook.secret` in the test, post a body signed with it to `/kirora/webhook`, and assert the event with `Event::fake()`. The signature is `sha256=` followed by the hex HMAC-SHA256 of `{timestamp}.{body}`; the [README](https://github.com/kiroratech/kirora-laravel#webhook-tests) has a complete test.