qalainau/filament-team-chat
Self-hosted Slack-like team chat for Filament v5. Channels, DMs, threads, reactions, mentions, file sharing, search, and unread tracking work out of the box. No external services needed—just Livewire polling and your existing auth and users.
The package is self-contained — its own tc_ prefixed tables, its own Livewire components, its own polling layer. That means the fastest way to build confidence is to prove the whole install pipeline works for two real users before you touch a single config value.
Install and migrate:
composer require qalainau/filament-team-chat
php artisan vendor:publish --tag=team-chat-migrations
php artisan migrate
# only needed for @mention / DM database notifications
php artisan make:notifications-table && php artisan migrate
Register the plugin in the panel provider where chat should live:
use Filament\TeamChat\FilamentTeamChatPlugin;
public function panel(Panel $panel): Panel
{
return $panel
// ...
->plugin(FilamentTeamChatPlugin::make());
}
Add the trait to User. This is not optional — DM helpers, mention resolution, unread counts and presence all hang off it:
use Filament\TeamChat\Concerns\HasTeamChat;
class User extends Authenticatable
{
use HasTeamChat;
}
Compile the Tailwind sources. This is the number one first-run failure. The package ships utility classes in its own views; Filament v5 only compiles what your theme's @source globs include. If you have no custom theme yet, run php artisan filament:theme first, then:
/* resources/css/filament/admin/theme.css */
@source '../../../../vendor/qalainau/filament-team-chat/resources/views/**/*';
Then npm run build.
Publish the config immediately, even if you change nothing. Having config/team-chat.php in your repo makes future upgrades diffable instead of a guessing game about defaults:
php artisan vendor:publish --tag=team-chat-config
First use case to validate: two browsers, two users, open /admin/team-chat, click a public channel (clicking auto-joins), send a message. It should appear in the second window within ~3 seconds. If it doesn't, suspect Livewire polling or your theme build — not the chat layer.
Where to look first when something is off:
config/team-chat.php — polling intervals, uploads, tenancy, feature toggles.tc_ tables — missing data is almost always an unpublished migration, not a problem with your users table.Sidebar (5s), MessageFeed (3s), MessageComposer, ThreadPanel (3s), ChannelHeader. These are the things that poll and dispatch events; when the UI "feels stale," the interval lives there.The same classes powering the UI are your integration surface. Prefer them over writing to models directly, because they handle membership, mentions, notifications and receipts consistently.
use Filament\TeamChat\Actions\SendMessage;
$message = app(SendMessage::class)->execute(
messageable: $channel, // Channel or Conversation
userId: $user->id,
body: 'Deploy finished: **v2.4.1** is live.',
parentId: null,
files: [],
);
This is the pattern for: deploy notifications from CI, ticket-created pings from an observer, "your invoice is ready" messages from a queued job. Create a dedicated system/bot user and post as it rather than impersonating a human.
Rather than asking admins to click around, create the default workspace on user registration or in a DatabaseSeeder:
use Filament\TeamChat\Models\Channel;
$channel = Channel::firstOrCreate(
['slug' => 'general'],
[
'name' => 'general',
'type' => 'public',
'created_by' => $user->id,
],
);
$channel->members()->syncWithoutDetaching([$user->id => ['role' => 'owner']]);
Because public channels are visible to everyone and auto-join on click, you mostly need this for private channels (e.g. #leadership, #support-tier-2) where membership must be explicit.
findOrCreateDirectMessage() returns the existing conversation if one exists. That makes it safe to call from a "Contact support" button, an order-detail page, or a user profile card without creating conversation duplicates:
$dm = $user->findOrCreateDirectMessage($otherUser->id);
// group DM
$group = $user->createGroupConversation(
userIds: [$user2->id, $user3->id],
name: 'Incident #4821',
);
A nice day-to-day pattern: an "Open incident channel" action in your own Filament resource that spins up a group DM with the on-call users and posts the incident summary via SendMessage.
SearchMessages already enforces membership rules — it won't leak messages from channels or DMs the user isn't in. Reuse it rather than querying tc_messages yourself:
use Filament\TeamChat\Actions\SearchMessages;
$results = app(SearchMessages::class)->execute($user->id, 'deploy', limit: 20);
You can surface this in a global Filament search provider or a dashboard widget so chat content becomes discoverable outside /team-chat.
use Filament\TeamChat\Actions\MarkAsRead;
$unread = $channel->unreadCountFor($user->id); // => 3
app(MarkAsRead::class)->execute($channel, $user->id);
unreadCountFor() is cheap enough to use in a navigation badge or a "You have N unread messages" widget. Mark-as-read is also what a "Mark all read" bulk action should call — don't update the receipt table by hand.
On multi-audience installs, this is the most valuable hook in the package. It narrows the DM user list, @mention autocomplete and profile cards — and it's enforced server-side, so it isn't just cosmetic:
FilamentTeamChatPlugin::make()
->modifyAvailableUsersQueryUsing(
fn (Builder $query, User $user) => $query->where('tenant_id', $user->tenant_id)
);
Also reach for the features block in the config when a deployment shouldn't have the full Slack surface — a helpdesk-style panel may want DMs and search off, channels on.
Enable tenancy.enabled and let the default resolver pick up Filament::getTenant(). Only reach for a custom resolver when chat scope differs from panel scope:
'tenancy' => [
'enabled' => true,
'model' => \App\Models\Team::class,
'resolver' => fn () => auth()->user()->current_team_id,
],
A class with a resolve() method is also supported — useful if the resolution needs DI or caching.
use Filament\TeamChat\Actions\ToggleReaction;
app(ToggleReaction::class)->execute($message->id, $user->id, '👍'); // true=added, false=removed
The eight built-in emoji are enough for "ack / done / eyes" workflows. Combined with tc_user_statuses, you get a decent lightweight presence layer without adding a broadcast service.
Tailwind is the silent killer. If the chat renders as unstyled markup, it's the missing @source glob 95% of the time. Second most common: adding the glob but forgetting npm run build. Also rebuild after upgrading — v0.5.0 added responsive and RTL utility classes that won't exist in a stale CSS bundle.
Set user_key_type before migrating. If your users table uses UUIDs or ULIDs, the migrations must create matching foreign keys. Publishing, migrating, then discovering you need UUID FKs means a fresh install or hand-written migration surgery. Same applies to tenancy.key_type. Do this on day zero, not day ninety.
Polling is not realtime, and it costs you. 3s on the message feed plus 5s on the sidebar, per open tab. That's fine for a 20-person internal tool and not fine for 500 concurrent users. Raise the intervals in config/team-chat.php for larger installs, and be aware that the thread panel adds another 3s poll on top. Budget accordingly before you promise "instant."
Attachments need a reachable disk. Defaults are disk => public and directory => team-chat-attachments, max_size => 10240 KB. If images don't preview, check php artisan storage:link and that APP_URL is correct — this is a Laravel filesystem issue, not a chat issue. Bump max_size deliberately, since uploads land in a Livewire request.
Feature toggles change the UI, so rebuild the theme. Disabling threads, reactions or search via features updates the component behaviour, but the classes you're hiding still need to be in the compiled bundle for the remaining components to look right.
Don't touch tc_messages directly for anything user-facing. Sending, editing, deleting, mentions, mentions-driven notifications and read receipts are all coordinated by the action classes and Livewire events. A raw Message::create() will produce a message with no mentions parsed and no notifications dispatched — which looks like a bug and is actually your bug.
Soft-deleted messages are still rows. If you build reports or analytics on tc_messages, filter deleted records explicitly. Same for archived channels — archiving hides them from the sidebar but doesn't remove them from queries.
Thread replies are just messages with parent_id. When counting "messages in a channel" for a dashboard metric, decide whether thread replies count. They live in the same table, polymorphically attached to the parent — a naive count() will overcount the main feed.
Multi-tenant migration is not retroactive. Toggling tenancy.enabled on an existing install with a single shared workspace will strand existing channels with a null team_id. Plan a backfill, or accept that tenancy is a fresh-install decision.
Notifications require the table. If @mentions and DMs don't notify, it's usually because notifications was never created. Check with php artisan migrate:status before digging into the package.
Debugging stale UI: open the Livewire devtools and watch which component fires. MessageFeed updating but Sidebar not → the sidebar's 5s poll is the one to look at. Neither updating → check your Livewire asset build and whether a JS error is killing the polling loop.
Testing: the package ships 102 tests using Orchestra Testbench. From your app you can filter with php artisan test --filter=TeamChat to confirm you haven't broken integration during an upgrade. If you're writing your own tests against the action classes, use SendMessage and MarkAsRead rather than fixtures — you get correct state for free.
Version discipline: 15 stars and a README-level maturity signal. Pin the version in composer.json, read the CHANGELOG before bumping (v0.5.0 was a feature release with migration implications), and keep your published config and theme in version control so upgrades are reviewable diffs rather than surprises.
How can I help you explore Laravel packages today?