← Home

#Laravel

There's a complete example Laravel application at github.com/bootdesk/chat-sdk/tree/main/examples/hello-world-laravel. Refer to it for a working setup with middleware, handlers, file upload converter, and tenant adapter resolver.

#Installation

composer require bootdesk/chat-sdk-laravel

#Configuration

Publish the config file:

php artisan vendor:publish --tag=chat-config

This creates config/chat.php with these options:

Option Default Description
user_name 'Bot' Bot display name
adapters [] Per-adapter credentials
state.store 'file' Cache store for state
state.prefix 'chat:' Key prefix for state
handlers [] Handler classes to register
concurrency 'drop' Concurrency strategy (drop/queue/debounce/concurrent)
lock_scope 'thread' Lock scope for concurrency ('thread' or 'channel')
transcripts null Transcript config (requires identity resolver)
return [
    'slack' => [
        'bot_token' => env('SLACK_BOT_TOKEN'),
        'signing_secret' => env('SLACK_SIGNING_SECRET'),
    ],
    'telegram' => [
        'bot_token' => env('TELEGRAM_BOT_TOKEN'),
    ],
    'telnyx' => [
        'api_key' => env('TELNYX_API_KEY'),
        'messaging_profile_id' => env('TELNYX_MESSAGING_PROFILE_ID'),
        'public_key' => env('TELNYX_PUBLIC_KEY'),
        'from_number' => env('TELNYX_FROM_NUMBER'),
        'agent_id' => env('TELNYX_AGENT_ID'),
    ],
    'concurrency' => 'drop',
];

#Instagram Adapter

The Instagram adapter supports two authentication paths:

Path 1 — Facebook Page-linked account (uses graph.facebook.com):

'instagram' => [
    'page_access_token' => env('INSTAGRAM_PAGE_ACCESS_TOKEN'),
    'app_secret' => env('META_APP_SECRET'),
    'verify_token' => env('INSTAGRAM_VERIFY_TOKEN'),
],

Path 2 — Instagram Login (uses graph.instagram.com):

'instagram' => [
    'ig_access_token' => env('INSTAGRAM_ACCESS_TOKEN'),
    'ig_user_id' => env('INSTAGRAM_USER_ID'),
    'app_secret' => env('META_APP_SECRET'),
    'verify_token' => env('INSTAGRAM_VERIFY_TOKEN'),
],

The adapter auto-detects which path to use based on which token is provided. The app_secret is your Meta app secret (required for both paths — used for x-hub-signature-256 webhook verification).

#Web Adapter

The WebAdapter provides browser-based chat UI integration. Configure it with a WebAdapterConfig class:

Basic:

use BootDesk\ChatSDK\Web\WebAdapterConfig;
use Illuminate\Support\Facades\Auth;
use Psr\Http\Message\ServerRequestInterface;

class AppWebAdapterConfig extends WebAdapterConfig
{
    public function getUser(ServerRequestInterface $request): ?array
    {
        return Auth::check()
            ? ['id' => (string) Auth::id(), 'name' => Auth::user()?->name]
            : null;
    }
}

'web' => [
    'user_name' => env('BOT_USERNAME', 'Bot'),
    'config' => App\Chat\AppWebAdapterConfig::class,
],

With signature verification:

class AppWebAdapterConfig extends WebAdapterConfig
{
    public function getUser(ServerRequestInterface $request): ?array
    {
        return Auth::check()
            ? ['id' => (string) Auth::id(), 'name' => Auth::user()?->name]
            : null;
    }

    public function verifySignature(ServerRequestInterface $request): bool|string
    {
        $signature = $request->getHeaderLine('X-Signature');
        $payload = (string) $request->getBody();
        $expected = 'sha256=' . hash_hmac('sha256', $payload, config('app.webhook_secret'));
        return hash_equals($expected, $signature) ? true : 'Invalid signature';
    }
}

'web' => [
    'user_name' => env('BOT_USERNAME', 'Bot'),
    'config' => App\Chat\AppWebAdapterConfig::class,
    'broadcaster' => fn () => app(\BootDesk\ChatSDK\Core\Contracts\BroadcastAdapter::class),
    'async_mode' => env('CHAT_WEB_ASYNC_MODE', false),
],

The getUser() method receives the PSR-7 ServerRequestInterface and must return ['id' => string, 'name' => ?string] or null for unauthenticated.

The verifySignature() method receives the PSR-7 request and must return true for valid signatures, or an error message string for invalid. Called before user authentication.

Each adapter is auto-discovered at runtime via class_exists(). Only configured adapters are registered.

#Usage

#The Chat Facade

use Chat;

Chat::onNewMessage(function (Message $message, Thread $thread) {
    $thread->post("Echo: {$message->text}");
});

Chat::onSlashCommand(function (SlashCommandEvent $event) {
    $event->thread->post("You ran: {$event->command}");
});

#The chat Helper

$chat = chat();  // Returns the Chat singleton

chat()->onNewMessage(function ($message, $thread) {
    // ...
});

#Webhook Route

The package ships with a pre-built WebhookController that handles PSR-7 conversion automatically. Register the route in routes/api.php:

use BootDesk\ChatSDK\Laravel\Http\Controllers\WebhookController;

Route::match(['get', 'post'], '/chats/{adapter}', [WebhookController::class, 'handle']);

It accepts both GET (for platform verification challenges like Telnyx) and POST (for actual webhooks). The {adapter} parameter matches the adapter name (slack, telegram, telnyx, etc.).

Under the hood, the controller does the PSR-7 conversion for you:

$psrRequest = $psrHttpFactory->createRequest($request);
$psrResponse = $this->chat->handleWebhook($adapter, $psrRequest);

return (new HttpFoundationFactory)->createResponse($psrResponse);

#Chat Handlers

Create handler classes that implement the ChatHandler contract. They're auto-discovered via config/chat.php:

namespace App\Chat;

use BootDesk\ChatSDK\Core\Chat;
use BootDesk\ChatSDK\Core\Message;
use BootDesk\ChatSDK\Core\Thread;
use BootDesk\ChatSDK\Laravel\Contracts\ChatHandler;

class ChatHandlers implements ChatHandler
{
    public function register(Chat $chat): void
    {
        $chat->onNewMessage(function (Message $message, Thread $thread) {
            if ($message->text === 'hello') {
                $thread->post('Hi there!');
            }
        });

        $chat->onSlashCommand(function (SlashCommandEvent $event) {
            $event->thread->post("You ran: {$event->command}");
        });
    }
}

Then register it in config/chat.php:

'handlers' => [
    \App\Chat\ChatHandlers::class,
],

You can have multiple handlers — each receives the Chat instance in register() and can set up its own listeners.

#Middleware

Register receiving and sending middleware via a ChatHandler class. Implement BootDesk\ChatSDK\Laravel\Contracts\ChatHandler and it's auto-discovered by the service provider:

namespace App\Chat;

use BootDesk\ChatSDK\Core\Chat;
use BootDesk\ChatSDK\Laravel\Contracts\ChatHandler;

class ChatMiddlewareHandler implements ChatHandler
{
    public function register(Chat $chat): void
    {
        $chat
            ->addReceivingMiddleware(new Middleware\LogReceivedMessage)
            ->addSendingMiddleware(new Middleware\LogSentMessage);
    }
}

The middleware classes implement ReceivingMiddleware or SendingMiddleware:

namespace App\Chat\Middleware;

use BootDesk\ChatSDK\Core\Contracts\Adapter;
use BootDesk\ChatSDK\Core\Contracts\ReceivingMiddleware;
use BootDesk\ChatSDK\Core\Message;
use Illuminate\Support\Facades\Log;

class LogReceivedMessage implements ReceivingMiddleware
{
    public function handle(Message $message, Adapter $adapter, callable $next): ?Message
    {
        Log::info('chat.received', ['text' => $message->text]);

        return $next($message);
    }
}
namespace App\Chat\Middleware;

use BootDesk\ChatSDK\Core\Contracts\Adapter;
use BootDesk\ChatSDK\Core\Contracts\SendingMiddleware;
use BootDesk\ChatSDK\Core\PostableMessage;
use BootDesk\ChatSDK\Core\SentMessage;

class LogSentMessage implements SendingMiddleware
{
    public function handle(string $threadId, PostableMessage $message, Adapter $adapter, string $operation, callable $next): ?SentMessage
    {
        Log::info('chat.sending', ['text' => $message->getTextContent()]);

        return $next($threadId, $message, $adapter, $operation);
    }
}

The ChatHandler is registered in config/chat.php:

'handlers' => [
    \App\Chat\ChatMiddlewareHandler::class,
    \App\Chat\ChatHandlers::class,
],

#Adapters

The service provider auto-discovers which adapters to register based on your config/chat.php. Each adapter's constructor dependencies are resolved from the container:

'github' => [
    'auth_token' => env('GITHUB_TOKEN'),
    'webhook_secret' => env('GITHUB_WEBHOOK_SECRET'),
],

For multi-tenant systems, implement the AdapterResolver contract. It's checked first — if it returns null, the service provider falls back to config/chat.php. The resolver receives the adapter name and the PSR-7 request, allowing you to return different adapter instances per request:

namespace App\Chat\Helpers;

use BootDesk\ChatSDK\Core\Contracts\Adapter;
use BootDesk\ChatSDK\Core\Contracts\AdapterResolver;
use Psr\Http\Message\ServerRequestInterface;

class TenantAdapterResolver implements AdapterResolver
{
    public function resolve(string $name, ?ServerRequestInterface $request = null): ?Adapter
    {
        // When $request is null (e.g. queued job), fall back to another strategy
        $token = $request !== null
            ? $request->getHeaderLine('X-Tenant-Token')
            : $this->resolveTenantFromQueue();

        return match ($name) {
            'slack' => app()->make(SlackAdapter::class, [
                'botToken' => $token,
            ]),
            default => null, // falls back to config/chat.php
        };
    }

    private function resolveTenantFromQueue(): string
    {
        // e.g. read from serialized job context, database, or cache
        return 'default_tenant_token';
    }
}

Bind it in a service provider:

$this->app->bind(AdapterResolver::class, TenantAdapterResolver::class);

See the example app for a full implementation.

The HTTP client is auto-bound as Psr\Http\Client\ClientInterface (uses Guzzle by default). Override by rebinding in your service provider:

$this->app->bind(ClientInterface::class, function () {
    return new CustomClient;
});

#State

Laravel uses CacheStateAdapter backed by your configured cache driver (Redis, database, file). This provides conversation state, deduplication, modal context, and rate limiting across multiple processes.

#File Upload Converter

For adapters without native file upload support, register a FileUploadConverter in your service provider:

use BootDesk\ChatSDK\Core\Contracts\FileUploadConverter;

$this->app->bind(FileUploadConverter::class, function () {
    return new PublicFilesystemToAttachment;
});

#Messaging Window

Platforms like WhatsApp enforce a 24-hour messaging window. The Laravel package provides two middleware classes to handle this:

1. Track — record the last incoming message timestamp:

use BootDesk\ChatSDK\Laravel\Middleware\TrackMessagingWindow;

$chat->addReceivingMiddleware(new TrackMessagingWindow($state));

2. Enforce — block or convert messages when the window expires:

use BootDesk\ChatSDK\Laravel\Middleware\EnforceMessagingWindow;

$chat->addSendingMiddleware(new EnforceMessagingWindow(
    state: $state,
    templateFallback: fn (PostableMessage $msg) => PostableMessage::text(
        'You have a new message waiting. Open the app to reply.'
    ),
));

When the window has expired and no templateFallback is set, the message is silently dropped. When templateFallback is provided, the original message is replaced with the fallback template.

The adapter must implement AdapterHasMessagingWindow (WhatsApp does by default with a 86400s window). See the architecture guide for the full contract.

#Broadcasting

The Laravel package includes LaravelBroadcastAdapter for real-time event broadcasting via Pusher, Redis, or Laravel's broadcast drivers.

#Configuration

Publish the broadcasting config:

php artisan vendor:publish --tag=chat-broadcasting

Config file: config/chat-broadcasting.php

return [
    'enabled' => env('CHAT_BROADCASTING_ENABLED', true),
    'default' => env('CHAT_BROADCASTING_DEFAULT', 'pusher'),
    'channel_prefix' => env('CHAT_BROADCASTING_CHANNEL_PREFIX', 'chat'),
    'thread_channel_type' => env('CHAT_BROADCASTING_THREAD_CHANNEL_TYPE', 'public'),
    'user_channel_type' => env('CHAT_BROADCASTING_USER_CHANNEL_TYPE', 'private'),
];
Option Default Description
enabled true Enable/disable broadcasting globally
default 'pusher' Broadcaster driver (pusher/redis/log/null)
channel_prefix 'chat' Prefix for all channel names
thread_channel_type 'public' Thread channel type (public/private/presence)
user_channel_type 'private' User channel type (private/presence)

#Channel Types

Both thread channels and user channels can be configured as public, private, or presence:

  • Public channels (Channel): Open to anyone with the channel name
  • Private channels (PrivateChannel): Require authentication via Laravel's channel routes
  • Presence channels (PresenceChannel): Private + presence features (who's online)

Thread broadcasts (messages posted, edited, deleted, reactions): configured via thread_channel_type (default: public)

User broadcasts (DMs, typing in DMs, streaming): configured via user_channel_type (default: private)

Set in .env:

CHAT_BROADCASTING_THREAD_CHANNEL_TYPE=presence  # or public/private
CHAT_BROADCASTING_USER_CHANNEL_TYPE=presence   # or private

#Usage with WebAdapter

use BootDesk\ChatSDK\Web\WebAdapter;
use BootDesk\ChatSDK\Laravel\Broadcasting\LaravelBroadcastAdapter;

$broadcaster = app(LaravelBroadcastAdapter::class);

$adapter = new WebAdapter(
    userName: 'Bot',
    config: new App\Chat\AppWebAdapterConfig,
    broadcaster: $broadcaster,
    asyncMode: true,  // Broadcast events immediately
);

The broadcaster is automatically connected/disconnected via Chat::initialize() and Chat::shutdown().

#Broadcast Events

Event Target Channel Type
MessagePostedEvent Thread Public
MessageEditedEvent Thread Public
MessageDeletedEvent Thread Public
ReactionAddedEvent Thread Public
ReactionRemovedEvent Thread Public
TypingStartedEvent User (DM) Private
StreamingChunkEvent User (DM) Private
DirectMessageRequestedEvent User Private

#Client-Side Example (Pusher)

const pusher = new Pusher('your-app-key', {
    cluster: 'your-cluster',
});

// Thread channel
const threadChannel = pusher.subscribe('chat.web:user123:conv456');
threadChannel.bind('chat.message.posted', (data) => {
    console.log('New message:', data.text);
});

// Private user channel
const userChannel = pusher.subscribe('private-chat.web:user123:conv456.user123');
userChannel.bind('chat.typing.started', (data) => {
    console.log('User is typing...');
});