conduit-ui / mattermost
The Laravel way to build Mattermost bots — Saloon client, WebSocket, bot framework, streaming replies, slash commands, Filament panel
Requires
- php: ^8.4
- illuminate/contracts: ^13.0
- illuminate/support: ^13.0
- ratchet/pawl: ^0.4.1
- react/event-loop: ^1.5
- saloonphp/saloon: ^4.0
Requires (Dev)
- crescat-io/saloon-sdk-generator: ^1.6
- filament/filament: ^5.4
- larastan/larastan: ^3.0
- laravel/framework: ^13.0
- laravel/pint: ^1.0
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- rector/rector: ^2.0
This package is auto-updated.
Last update: 2026-04-27 13:35:42 UTC
README
The Laravel way to build Mattermost bots.
Status: Under active development. Not yet published on Packagist.
Contents
- Features
- Requirements
- Installation
- Configuration
- Quick start
- REST API client
- Sending messages
- Bot framework
- Slash commands
- Interactive messages
- Streaming replies
- Notifications
- Filament panel
- Artisan commands
- Testing
- Multi-server
- Local development
- License
Features
- Saloon-based REST client covering the full Mattermost API v4
- WebSocket client with auto-reconnect, typed events, and signal handling
- Event-driven bot framework — class-based handlers, middleware pipeline, queueable
- Permission guards — role, permission, channel-membership middleware
- Slash command routing
- Interactive messages — buttons, menus, action handlers
- Streaming replies — observer-driven create → edit → finalize flow
- Fluent message builder with attachments, threading, file uploads
- Laravel notification channel + broadcast driver
- Filament admin panel for status, message log, health
- Artisan commands —
mattermost:listen,mattermost:post,mattermost:health - First-class testing —
Mattermost::fake(), fixture helpers, request assertions - Multi-server support via named connections
Requirements
- PHP 8.4+
- Laravel 13+
Installation
composer require conduit-ui/mattermost php artisan vendor:publish --tag=mattermost-config
Set the minimum env in your .env:
MATTERMOST_URL=https://your-mattermost.example.com MATTERMOST_BOT_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxx MATTERMOST_TEAM=your-team-name MATTERMOST_BOT_USER_ID=zzzzzzzzzzzzzzzzzzzzzzzzzz
Configuration
config/mattermost.php defines connections, slash commands, bot middleware, and websocket
settings. Most of it has sensible defaults — only connections typically needs touching.
return [ 'default' => env('MATTERMOST_CONNECTION', 'default'), 'connections' => [ 'default' => [ 'url' => env('MATTERMOST_URL', 'http://localhost:8065'), 'token' => env('MATTERMOST_BOT_TOKEN'), 'bot_user_id' => env('MATTERMOST_BOT_USER_ID'), 'team' => env('MATTERMOST_TEAM'), ], ], 'bot' => [ // Global middleware applied to every handler. Per-handler // middleware stacks on top via the #[Middleware(...)] attribute. 'middleware' => [ ConduitUI\Mattermost\Bot\Middleware\IgnoreBots::class, ConduitUI\Mattermost\Bot\Middleware\Dedup::class, ], 'dedup_ttl' => 60, 'rate_limit_seconds' => 30, 'allowed_channels' => [], ], ];
Quick start
use ConduitUI\Mattermost\Facades\Mattermost; // One-liner post Mattermost::posts()->createPost([ 'channel_id' => $channelId, 'message' => 'Hello from Laravel!', ]); // Or the fluent builder use ConduitUI\Mattermost\Messages\Message; Mattermost::posts()->createPost( Message::make('Deploy started') ->to($channelId) ->thread($rootPostId) ->toArray() );
REST API client
Every Mattermost API v4 endpoint is wrapped in a Saloon resource. Resources are auto-generated from the OpenAPI spec — every parameter and response is typed.
Mattermost::posts()->createPost([...]); Mattermost::posts()->updatePost($postId, ['message' => 'edited']); Mattermost::posts()->deletePost($postId); Mattermost::channels()->getChannelByName($teamId, 'town-square'); Mattermost::channels()->getChannelMembers($channelId); Mattermost::users()->getUserByUsername('alice'); Mattermost::users()->getUserStatus($userId); Mattermost::reactions()->saveReaction([ 'user_id' => $botUserId, 'post_id' => $postId, 'emoji_name' => 'eyes', ]); Mattermost::files()->uploadFile($channelId, $fileResource);
Available resources via the Mattermost facade: posts(), channels(), users(), teams(),
files(), reactions(), bots(), webhooks(), commands(), emoji(), status(), system().
Sending messages
The fluent Message builder produces a payload for POST /api/v4/posts without
making any HTTP call — pass the result to posts()->createPost().
use ConduitUI\Mattermost\Messages\Message; use ConduitUI\Mattermost\Messages\Attachment; $payload = Message::make('Build complete') ->to($channelId) ->thread($parentPostId) ->attachment( Attachment::make() ->color('#36a64f') ->title('main', 'https://github.com/...') ->text('All checks passed in 2m31s.') ->field('Branch', 'feat/foo', short: true) ->field('Commit', 'abc1234', short: true) ) ->toArray(); Mattermost::posts()->createPost($payload);
Bot framework
Mattermost WebSocket events route through a global + per-handler middleware pipeline to handler classes resolved from the container.
use App\Mattermost\HandleNewPost; use ConduitUI\Mattermost\Bot\Events\PostCreated; use ConduitUI\Mattermost\Facades\Mattermost; // Class-based handler Mattermost::on(PostCreated::class, HandleNewPost::class); // By Mattermost event name Mattermost::on('posted', HandleNewPost::class); // Wildcard — every event Mattermost::on('*', LogAllEvents::class);
Handler classes
use ConduitUI\Mattermost\Bot\Attributes\Middleware; use ConduitUI\Mattermost\Bot\Events\PostCreated; use ConduitUI\Mattermost\Bot\Handler; use ConduitUI\Mattermost\Bot\Middleware\IgnoreBots; use ConduitUI\Mattermost\Bot\Middleware\RateLimit; #[Middleware(IgnoreBots::class, RateLimit::class)] class HandleMentions extends Handler { public function handle(PostCreated $event): void { $this->reply($event, 'on it'); $this->react($event, 'eyes'); $this->typing($event); } }
reply(), react(), and typing() come from the base Handler class — they pick up the
correct connection, bot user id, and threading rules automatically.
Middleware
Built-in middleware ships under ConduitUI\Mattermost\Bot\Middleware:
| Middleware | What it does |
|---|---|
IgnoreBots |
Drops events authored by any configured bot user |
Dedup |
Cache-locks channel:post so duplicate event redeliveries are dropped |
RateLimit |
Per-channel cooldown between accepted posts. DMs exempt |
ChannelFilter |
Allowlist by channel id or name |
AdminOnly |
Requires the post author to be a system/team/channel admin |
Custom middleware implements the Middleware interface:
use ConduitUI\Mattermost\Bot\Events\Event; use ConduitUI\Mattermost\Bot\Middleware\Middleware; class StripPrefix implements Middleware { public function handle(Event $event, \Closure $next): mixed { if ($event instanceof PostCreated) { $event->message = ltrim($event->message, '!'); } return $next($event); } }
Guards
Permission-based middleware under ConduitUI\Mattermost\Bot\Middleware\Guards:
use ConduitUI\Mattermost\Bot\Attributes\Middleware; use ConduitUI\Mattermost\Bot\Middleware\Guards\ChannelMember; use ConduitUI\Mattermost\Bot\Middleware\Guards\RequiresPermission; use ConduitUI\Mattermost\Bot\Middleware\Guards\RequiresRole; #[Middleware(RequiresRole::class)] class DeployHandler extends Handler { public static array $roles = ['system_admin']; public function handle(PostCreated $event): void { /* ... */ } }
Queueable handlers
Handlers implementing ShouldQueue are dispatched onto Laravel's queue rather than
running inline:
class HandleHeavyJob extends Handler implements \Illuminate\Contracts\Queue\ShouldQueue { public function handle(PostCreated $event): void { /* ... */ } }
Configure the queue/connection per config/mattermost.php:
'bot' => [ 'queue' => 'mattermost', 'queue_connection' => 'redis', ],
Slash commands
Register slash commands with the same route-style API as event handlers:
use App\Mattermost\DeployHandler; use ConduitUI\Mattermost\Facades\Mattermost; use ConduitUI\Mattermost\SlashCommands\SlashCommand; use ConduitUI\Mattermost\SlashCommands\SlashCommandResponse; Mattermost::slash('deploy', DeployHandler::class); Mattermost::slash('status', fn (SlashCommand $cmd) => SlashCommandResponse::make()->inChannel('All systems go') );
Mattermost POSTs slash command webhooks to /mattermost/slash/{command}. The route is
auto-registered when enable_slash_commands is true in config.
Inside a handler:
class DeployHandler { public function __invoke(SlashCommand $cmd): SlashCommandResponse { return SlashCommandResponse::make() ->ephemeral("Deploying {$cmd->arg(0)}..."); } }
Interactive messages
Buttons and menus on Mattermost messages POST to /mattermost/interactive when clicked.
Register handlers for action ids:
use ConduitUI\Mattermost\Facades\Mattermost; use ConduitUI\Mattermost\Interactive\InteractiveAction; use ConduitUI\Mattermost\Interactive\InteractiveActionResponse; Mattermost::interactive('approve', fn (InteractiveAction $a) => InteractiveActionResponse::make()->update('Approved!') );
The button itself is built into your post's attachments:
Mattermost::posts()->createPost([ 'channel_id' => $channelId, 'message' => 'Ready to deploy?', 'props' => ['attachments' => [[ 'text' => 'Choose:', 'actions' => [ [ 'id' => 'deploy-approve', 'name' => 'Approve', 'type' => 'button', 'integration' => [ 'url' => url('/mattermost/interactive'), 'context' => ['action' => 'approve', 'env' => 'prod'], ], ], ], ]]], ]);
The context you set on the button is what the DTO surfaces as $action->context(),
and context.action is what actionId() returns.
Streaming replies
For bots replying with LLM tokens, the streaming layer creates a placeholder, edits it as chunks arrive, and finalizes:
use ConduitUI\Mattermost\Streaming\MattermostStreamingReply; $reply = MattermostStreamingReply::create($channelId, rootId: $event->postId); foreach ($llm->stream($prompt) as $chunk) { $reply->append($chunk); } $reply->finalize();
Behind the scenes: posts()->createPost() for the placeholder, batched updatePost()
calls debounced to respect rate limits, and a final patch with the full text.
Notifications
Use Mattermost as a Laravel notification channel:
use ConduitUI\Mattermost\Notifications\MattermostMessage; class DeployFinished extends Notification { public function via(mixed $notifiable): array { return ['mattermost']; } public function toMattermost(mixed $notifiable): MattermostMessage { return MattermostMessage::make("Deploy of `{$this->ref}` finished") ->color('#36a64f') ->field('Duration', '2m31s', short: true); } }
Route via routeNotificationFor('mattermost', ...) on the notifiable, or pass
['channel' => 'channel-id', 'connection' => 'staging'] to target a specific server.
A broadcast driver is also registered — set BROADCAST_CONNECTION=mattermost and
broadcast()->channel('mattermost:channel-id') posts to Mattermost.
Filament panel
Auto-discovered Filament pages give you a status dashboard, message log, and health check inside your existing Filament admin:
// In your PanelProvider use ConduitUI\Mattermost\Filament\MattermostPlugin; return $panel->plugin(MattermostPlugin::make());
Pages: Dashboard (connection + recent activity), Health (per-connection ping + latency), Message Log (recent posts).
Artisan commands
# Start the bot — connects WebSocket, dispatches events through the router php artisan mattermost:listen # One-off post from CLI php artisan mattermost:post town-square "Deploy underway" # Connectivity / auth check php artisan mattermost:health
mattermost:listen handles SIGTERM/SIGINT cleanly; running it under supervisord or systemd
is the recommended path.
Testing
Mattermost::fake() swaps the underlying client for an in-memory recorder. No HTTP, no
network — every API call is captured for assertions.
use ConduitUI\Mattermost\Facades\Mattermost; beforeEach(fn () => Mattermost::fake()); it('posts an announcement when deploy finishes', function () { DeployFinished::dispatch($build); Mattermost::assertPosted(fn ($payload) => $payload['channel_id'] === 'town-square' && str_contains($payload['message'], 'Deploy of') ); Mattermost::assertPostCount(1); });
Available assertions: assertSent, assertNotSent, assertNothingSent, assertPosted,
assertNotPosted, assertNothingPosted, assertPostCount, assertUpdated, assertPatched,
assertDeleted, assertReacted, assertNotReacted, assertFileUploaded,
preventStrayPosts, recorded.
Fixture helpers under MattermostFixtures build realistic payloads for tests:
use ConduitUI\Mattermost\Testing\MattermostFixtures; $post = MattermostFixtures::post(['message' => 'hello']); $wsEvent = MattermostFixtures::websocketEvent('posted', ['post' => $post]); $buttonClick = MattermostFixtures::fakeButtonClick('approve', context: ['env' => 'prod']);
Multi-server
Define multiple connections in config/mattermost.php:
'connections' => [ 'default' => [...], 'staging' => [ 'url' => env('MATTERMOST_STAGING_URL'), 'token' => env('MATTERMOST_STAGING_TOKEN'), ], ],
Pick the connection per call:
Mattermost::connection('staging')->posts()->createPost([...]);
Local development
A docker-compose.yml ships a local Mattermost for development:
docker compose up -d # start docker compose down # stop
After the first boot, create an admin via mmctl or the web UI at localhost:8065,
generate a bot token, and drop it into your .env.
The integration test suite runs against the live container:
vendor/bin/pest --testsuite=Integration
License
MIT