guava/filament-knowledge-base
A Filament plugin that adds a searchable knowledge base to your admin panel. Create articles and categories with familiar Filament resources, write in Markdown, and help users find answers without leaving the dashboard.
Install and publish before you register the plugin on a panel. Order matters — registering the plugin first gives you nav items and routes pointing at tables that don't exist yet, and the resulting 500s look like a broken package rather than a missing migration.
composer require guava/filament-knowledge-base
php artisan vendor:publish --tag="filament-knowledge-base-migrations"
php artisan vendor:publish --tag="filament-knowledge-base-config"
php artisan migrate
Pin tightly in composer.json — this package has essentially no public dependents and a fast-moving minor line, so "guava/filament-knowledge-base": "3.3.*" (not ^3) until you've done an upgrade drill at least once.
Read these four things before writing any code, in this order:
vendor/guava/filament-knowledge-base/src/ and find the class implementing Filament\Contracts\Plugin. Read register() and boot(): they tell you exactly which resources, pages, widgets and routes get attached, and under what IDs. Copy the FQCN from the file, never from a blog post — the namespace is the single most common source of "class not found" in AI-generated snippets for low-adoption packages.config/filament-knowledge-base.php. Navigation group/label/icon, route prefix, resource class overrides, possibly a public-reader toggle. This file answers 80% of "can I change X without forking?" questions.category_id on articles, or a pivot? Are there published_at / is_published / status columns? Does a tenant_id exist? Your content model decisions hang off this.php artisan route:list --name=knowledge (adjust the filter). Get the real route names and prefix now — you'll be generating URLs from them for the rest of the project's life, and the docs' assumed prefix is frequently wrong.Register per panel, in the panel provider:
// app/Providers/Filament/AdminPanelProvider.php
use GuavaCZ\FilamentKnowledgeBase\KnowledgeBasePlugin; // verify against vendor/
public function panel(Panel $panel): Panel
{
return $panel
->plugin(KnowledgeBasePlugin::make());
}
First use case — one end-to-end pass that validates the whole chain. Log into /admin, create a category ("Getting Started") and one article with a body, publish it, then hit the reader route in a private window as a low-privilege user. That single loop exercises migrations, route registration, policy wiring and the renderer. If it passes, the rest of the work is content and ergonomics; if it fails, you've found the problem while the blast radius is still zero.
Treat the resources as scaffolding you own, not a black box. Before forking, check the config for a resources (or similarly named) map. Guava plugins generally let you substitute your own resource subclass:
// config/filament-knowledge-base.php — verify key names in your version
'resources' => [
'article' => \App\Filament\Resources\KnowledgeBase\ArticleResource::class,
'category' => \App\Filament\Resources\KnowledgeBase\CategoryResource::class,
],
Extend, then override form() / table() / getNavigationGroup() in your subclass. That survives a composer update; a patched vendor view does not.
Keep article bodies in version control, seed them on slug. This is the pattern that makes the package feel like part of your codebase rather than an external CMS. Bodies live as markdown in database/kb/*.md, a seeder upserts by slug, and the whole thing reviews in a PR like any other change.
// database/seeders/KnowledgeBaseSeeder.php
foreach (File::glob(database_path('kb/*.md')) as $path) {
$slug = basename($path, '.md');
Article::updateOrCreate(
['slug' => $slug],
[
'title' => Str::headline($slug),
'category_id' => $categories[Str::before($slug, '/')] ?? null,
'body' => File::get($path),
'is_published' => true,
],
);
}
Run it unguarded in local and staging; in production wrap it behind an explicit --class invocation or an env check so a stray db:seed can't clobber an article a support lead hand-edited. If hand-editing in production is the actual workflow, invert it: seed once, then treat the DB as the source of truth and export back to markdown on a schedule.
Deep-link contextual help from the exact field that confuses people. This is the highest-leverage thing the package does and the reason to adopt it over a Notion page. Put a hint action next to the input, not a link in a sidebar:
TextInput::make('webhook_url')
->hintIcon('heroicon-m-question-mark-circle', tooltip: 'Outbound webhook setup')
->hintAction(
Action::make('help')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn () => Help::url('integrations/webhook-setup'))
->openUrlInNewTab()
);
Wrap it in one helper so a missing or unpublished article degrades to a hidden action instead of a 404 in a modal:
final class Help
{
/** @var array<string, string>|null */
private static ?array $published = null;
public static function url(string $slug): ?string
{
self::$published ??= Article::query()
->where('is_published', true)
->pluck('slug')
->flip()
->all();
return isset(self::$published[$slug])
? route('filament.knowledge-base.articles.show', ['article' => $slug]) // verify route name
: null;
}
}
Reference slugs from a HelpArticle enum or a constants class rather than raw strings scattered through resources — that gives you one place to grep when a slug changes, and IDE rename support.
Split authoring from reading across panels with policies. The plugin registers per panel with no global switch, and a read-only factory may not exist. The reliable lever is a policy, which also gives you the audit-shaped answer stakeholders ask for:
public function viewAny(User $user): bool
{
return $user->can('knowledge-base.read');
}
public function create(User $user): bool
{
return $user->can('knowledge-base.author');
}
Register the full plugin on AdminPanelProvider; register it on operator/customer panels only if you want the nav there, and let the policy hide create/edit. If even the nav item is noise for readers, skip the plugin on that panel entirely and just deep-link out to the reader routes.
Decide the body format once, up front. If you use Filament's RichEditor the body is HTML, and RichEditor output from a trusted author is not automatically safe for other users — purify on render (Purifier::clean($body) or equivalent) in a view override. If you use markdown, Str::markdown($body) in your own view gives you control over heading anchors, tables and link handling. Switching later means rewriting every article, so make this call during the spike.
Handle draft/publish as a workflow, not a boolean. If the schema has a published flag, treat "publish" as a deliberate action with a review step: author as draft, editor flips it, and the reader query filters on it. Keep a published_at you can sort by so the KB index can show "recently updated" — users trust docs more when they can see recency.
Test the surface you actually depend on. Two cheap tests pay for themselves:
it('renders every seeded article', function () {
$this->seed(KnowledgeBaseSeeder::class);
Article::where('is_published', true)->pluck('slug')->each(function (string $slug) {
$this->get(route('filament.knowledge-base.articles.show', ['article' => $slug]))
->assertOk();
});
});
it('only links to published articles', function () {
expect(Help::url('integrations/webhook-setup'))->not->toBeNull();
});
The first catches route-prefix and rendering regressions on upgrade; the second catches the "help action points at a deleted article" bug that otherwise surfaces as a support ticket about a broken help link.
php artisan migrate before vendor:publish --tag=...-migrations does nothing and the plugin then fails on missing tables. Publish, migrate, then register.route:list says exists. Check the order in route:list, not just presence.Gate::forUser($user)->allows('viewAny', Article::class) in tinker. The plugin will almost never be the owner of that decision.tenant_id, adding one also means scoping the slug unique index — otherwise two tenants can hold the same slug and route-model binding quietly resolves the wrong article. Use a compound unique (tenant_id, slug) and scoped bindings.hintAction you placed in the app starts 404ing.local/staging, and in production hook Article::saved → Cache::forget("kb.article.{$article->slug}"). Nothing eats an afternoon faster than "my edit didn't save" that was actually a stale cache.resources/views/vendor/... and note in the PR that it must be re-diffed on every upgrade — with a package this young, that's a recurring tax.--force, then git diff. Renamed config keys and new columns show up as a readable diff instead of a staging incident. 3.3.2 was a Polish translation fix, so patch releases are low risk; the minor line is where behaviour moves.php artisan storage:link. Rich-text image uploads write to a disk; if the symlink isn't there, authors see broken images and assume the editor is broken.How can I help you explore Laravel packages today?