Filum plugin screenshot
Dark mode ready
Multilingual support
Supports v5.x

Filum

Community

Filum is a Filament plugin that adds persistent, real-time messaging between panel users - direct conversations and group threads joined by invitation, with presence, unread counts, and entries in Filament's own notification bell - delivered over whatever broadcaster your application already configures, or over polling when it configures none.

Supported versions:
5.x 4.x
Rati Rukhadze avatar Author: Rati Rukhadze

Package health

Beta

Automated checks of this plugin's Composer package

82 / 100
Security 68
Maintenance 100
Ecosystem 100
15 checks
  • Failed: GitHub Actions pinned to SHA View details on Plumb
  • Skipped: GitLab CI includes pinned to SHA
  • Passed: Open security advisories
  • Passed: Dependabot PR responsiveness — No open Dependabot PRs.
  • Skipped: Renovate MR responsiveness
  • Passed: Dependabot or Renovate configured
  • Failed: Dependency update cooldown configured No cooldown configured in .github/dependabot.yml. View details on Plumb
  • Passed: Provides a security policy
  • Passed: Abandoned or archived — No consulted source marks the package abandoned (packagist, github).
  • Passed: Commit and release recency — Active: last commit 0 days ago; last release 0 days ago.
  • Passed: composer.lock not committed by library composer.lock is absent from the released dist archive.
  • Passed: Dist archive is lean
  • Passed: Current Laravel version supported — Package dependencies resolve together with current Laravel 13.0.
  • Passed: Current PHP version supported — Constraint ^8.3 supports current PHP 8.5.
  • Skipped: Current Symfony version supported
Third-party plugin. This is built by the community, not the Filament team. Filament does not review, endorse, or vet the security of plugins outside the filament/ namespace. Review the source and install at your own risk. Found malware or an unresolved security issue the author won't address? Report it .
Powered by Plumb Last scanned 18 hours ago

Documentation

Latest version Tests Downloads License

Real-time chat between admin users, inside your Filament panel.

Click a colleague in the sidebar, the conversation opens, history persists, and messages arrive as they are sent. Available as a full page and as a slide-over overlay on every panel page.

Broadcasting-agnostic, and free. Filum works in an application that has never configured broadcasting, and it never requires a paid service. If you have a broadcaster — Reverb, soketi, Pusher, Ably — Filum uses it. If you don't, it falls back to polling and the chat still works. Nothing in require pulls a hosted WebSocket vendor.

Dark-first, following your panel's light/dark state. English and Georgian included.

#Requirements

  • PHP 8.3+
  • Laravel 12 or 13
  • Filament 4.11.5+ or 5.6.5+

The Filament floor is a security minimum, not a preference: earlier 4.x and 5.x releases carry published advisories, so Filum will not resolve against them.

#Installation

composer require heyosseus/filum
php artisan filum:install
php artisan migrate

Then add the plugin to your panel provider:

use Heyosseus\Filum\FilumPlugin;

public function panel(Panel $panel): Panel
{
    return $panel->plugin(FilumPlugin::make());
}

That's it. You now have a Chat page in the navigation and a chat trigger on every page. No broadcaster required.

#Upgrading an existing install

Each release that changes the schema publishes its migration under a tag of its own. Publish only that tag:

composer update heyosseus/filum
php artisan vendor:publish --tag=filum-migrations-groups   # 0.1.x → 0.2.0
php artisan migrate

Do not publish filum-migrations on an install that already has Filum's tables. That tag covers the whole directory and is for a fresh install. Laravel stamps a published migration with the moment it was published, so your copy of create_filum_tables is named after your original install rather than after Filum's source file; vendor:publish looks for the source name, does not find it, and copies the file a second time under a fresh stamp. migrate then runs the duplicate first, hits table already exists, aborts, and never reaches the new migration — so the new code queries columns that were never added, and all chat fails, including 1:1. If it has already happened, delete the newer duplicate of *_create_filum_tables.php before migrating.

Run the migration in the same release step as the code, not after it. New code on an old schema is a hard outage rather than a degradation: opening a direct conversation writes kind, state and joined_at, and every membership check reads state.

#Real-time

Filum picks a transport automatically:

Your application What Filum does
No broadcaster configured Polls every 5 seconds
A real broadcaster configured Pushes over it, and reconciles by polling every 30 seconds

The reconciliation poll stays on under a broadcaster on purpose: a dropped socket, a missed event, or a laptop waking from sleep heals itself instead of leaving someone reading a stale thread.

To go real-time with no third party, install Reverb:

composer require laravel/reverb
php artisan reverb:install   # also installs Echo and sets BROADCAST_CONNECTION
php artisan reverb:start
npm run build                # Echo has to reach the browser

Filum needs no further configuration — it reads whatever config/broadcasting.php already says and switches by itself.

Both halves have to be in place. Filum starts pushing as soon as a real broadcaster is configured, and the browser starts listening as soon as window.Echo exists in the panel. If you configure a broadcaster but never build an Echo client into your assets, the socket half is missing and the reconciliation poll is all that is left — which is slower than the no-broadcaster default, because it is 30 seconds rather than 5. reverb:install sets up both; if you wire a broadcaster up by hand, make sure Echo is actually loaded on the page.

To decide for yourself:

// config/filum.php
'transport' => [
    'driver' => 'polling', // 'auto' (default), 'broadcast', or 'polling'
],

#Who can use it

By default, anyone the panel has already authenticated. To narrow it, register a gate in a service provider:

use Heyosseus\Filum\Filum;

Filum::auth(fn ($user) => $user->hasRole('admin'));

One gate governs everything — the page, the overlay, and the broadcast channels — so nobody can subscribe to a conversation the page would not have shown them.

#Notifications

A message to someone who is not looking rings Filament's own notification bell, writing into the application's existing notifications table. No broadcaster needed — Filament polls the bell.

It rings once per conversation, on the transition from caught up to behind. A forty-message burst is one bell entry, not forty; catching up and falling behind again earns a fresh one.

It also rings only for a recipient who is not currently present. Someone with the panel open already sees the unread counter change on the colleague, on the group, and on the overlay tab, so a bell as well would only tell them what they can already see. "Present" is exactly the set the board shows as HERE NOW, so the bell and the board never disagree — tune it with presence.ttl, below. This applies to invitations too: inviting someone who is at their desk right now grows the invitations section for them on the next tick and never rings.

'notifications' => [
    'enabled' => env('FILUM_NOTIFICATIONS', true),
],

Where the notifications table has never been migrated, or your user model is not Notifiable, this quietly does nothing — the message still sends. To deliver somewhere else entirely (mail, Slack, a pager), implement Heyosseus\Filum\Contracts\Notifier and bind it:

$this->app->singleton(Heyosseus\Filum\Contracts\Notifier::class, MyNotifier::class);

#Groups

Beyond 1:1 chat, colleagues can form a group. Joining is always by invitation: creating a group only seats the creator, who becomes its owner; everyone else appears in the invitations section of their own board and has to accept before the thread, the send path or the broadcast channel will admit them. Declining and leaving land on the same state, so a colleague who said no and a colleague who walked away look identical to the group afterwards.

Permissions are flat with one exception for the owner:

  • Any member may invite a colleague.
  • Anyone may leave.
  • Only the owner may remove someone else, rename the group, or delete it.

An owner does not remove themselves — they leave, like anyone else. When they do, ownership passes automatically to whoever has been joined the longest (ties broken by who joined first, i.e. the lower participant id). If nobody is left joined, the group is deleted rather than left ownerless.

The group's thread header carries an invite picker: a disclosure listing colleagues who are neither invited nor already joined, so growing the group is a click from the thread itself. Someone who has left reappears in that list — leaving does not blacklist a person, it just returns them to "not yet invited."

The owner additionally sees a rename field and a roster of everyone else in the group, each row with a Remove button; a row still marked Pending has not accepted yet, and removing it withdraws the invitation. No row is ever offered against the owner themselves — an owner leaves, which hands the group on. All of it is plain fields and buttons rather than modals, on purpose: a Filament modal inside a Livewire component inside the drawer is the one place Filament 4 and 5 behave differently.

'groups' => [
    'enabled' => env('FILUM_GROUPS', true),
],

Disabled means absent, the same as the panel-wide switch below: no groups section on the board, no new-group action, and existing groups genuinely unreachable — a member who was joined before the switch cannot open one, read it, send into it or leave it while it is off, and every group action is refused. Not a feature that half-works underneath a switched-off UI. Nothing is deleted, so switching it back on restores what was there.

#Reactions

Any message can carry emoji reactions. They are a toggle — tapping the same emoji twice takes it back, so there is no separate remove control to find. Several people can add the same emoji, and one person can add several different ones.

'reactions' => [
    'enabled' => env('FILUM_REACTIONS', true),
    'emoji' => ['👍', '❤️', '😂', '🎉', '👀', '✅'],
],

The set is configuration rather than a picker, and anything outside it is refused rather than stored. Filum ships compiled CSS and runs no build step, so a full picker would mean a JavaScript bundle for something a back office uses six of — name your own six instead. Reactions are ordered by that list rather than by count, so a row of chips never reshuffles under the cursor.

Reactions never ring the notification bell. A thumbs-up is an acknowledgement, not a message, and an entry for each one would bury the entries that matter. They appear for everyone looking at the thread on the next tick, with or without a broadcaster.

#Presence

The sidebar shows who is around. A heartbeat writes last_seen_at on an interval, and that store is the only source of truth. Under a broadcaster, presence-channel events additionally prompt an immediate re-read, so the sidebar updates instantly — but it behaves identically without one, just with up to a minute of latency.

'presence' => [
    'heartbeat_interval' => 60, // seconds between beats
    'ttl' => 180,               // how long a beat counts as "here"
],

ttl is also what decides whether the notification bell rings, above — and it is deliberately the only knob for that decision. It would be easy to add a second, shorter threshold just for notifications ("ring if silent for 30s even if the board still shows them as here"), but that threshold could then disagree with what the board displays: a colleague marked HERE NOW who nonetheless gets a bell, which reads as a bug even though it would be working as configured. One number, shared by both, means the bell and the board can never contradict each other — the honest cost is that ttl's default of 180 seconds means someone who closes their laptop and is written to thirty seconds later still counts as present, and gets no bell for it. Lower ttl if that lag matters more to you than a slightly twitchier "here" indicator; there is no separate way to tune one without the other.

#Your user model

Filum assumes nothing about your schema:

'users' => [
    'model' => App\Models\AdminUser::class,
    'guard' => null,           // null follows the panel's own guard
    'name_column' => 'name',
    'avatar_column' => null,   // e.g. 'avatar_url'
    'provider' => Heyosseus\Filum\Users\ConfiguredUserProvider::class,
],

model is the one thing you must set if your panel does not authenticate App\Models\User. Filum queries it to list colleagues and derives the migration's foreign key from it, and neither can be guessed — a panel on its own guard has its own user model. Set it before you migrate:

FILUM_USER_MODEL=App\Models\AdminUser

Unquoted, and note it goes in every environment's .env, not just your local one. Leaving it wrong is not a quiet failure: Filum is asked for the current user while the navigation is being built, so an unresolvable model surfaces as an error on every page in the panel rather than only on the chat.

guard needs no value — Filum follows whichever guard the panel uses. Name one only to override that.

Integer, UUID and ULID keys all work — migrations derive the foreign key from the model you name. For anything more involved (a name assembled from two columns, an avatar from a media library, a restricted set of chattable colleagues), implement Heyosseus\Filum\Contracts\UserProvider and name it as the provider.

#Language

English and Georgian ship complete. Publish to change or add:

php artisan vendor:publish --tag=filum-translations

#Switching it off

'enabled' => false,

Disabled means absent: no page, no overlay, no channels, no commands. Not a chat that turns people away.

To keep the page but drop the overlay:

'overlay' => ['enabled' => false],

#Testing

composer test          # rector, pint, phpstan, type coverage, line coverage
composer test:unit     # tests with coverage
composer test:tia      # tests via Pest's Test Impact Analysis (local, PHP 8.4+)

Both transports are covered by the suite, including the case with no broadcaster bound at all — the install Filum promises to work in.

#Credits

#License

MIT. See LICENSE.md.