LaravelMenu
On this page

Laravel

Updated

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, which this page links to rather than repeats. The README is the full reference.

Install

Add the package with Composer:

Terminal
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:

Terminal
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' => '[email protected]',
]);

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.

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 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.
  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 Mailables; 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 covers the result, including channels and copies that weren’t sent.

Sign tokens for the web SDK

The web SDK 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 covers token lifetimes, and the web SDK’s setup 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 has a complete test.