jeffersongoncalves / laravel-knowledge-base
A Laravel package for building knowledge bases with articles, categories, versioning, and feedback
Package info
github.com/jeffersongoncalves/laravel-knowledge-base
pkg:composer/jeffersongoncalves/laravel-knowledge-base
Requires
- php: ^8.2
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/events: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
- spatie/laravel-package-tools: ^1.15
Requires (Dev)
- larastan/larastan: ^2.9|^3.0
- laravel/pint: ^1.0
- nunomaduro/collision: ^8.0
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-11 16:03:38 UTC
README
Laravel Knowledge Base
A Laravel package for building knowledge bases with articles, categories, versioning, feedback, and search.
Features
- Article Management — Create, update, publish, archive, and soft-delete articles with UUID and slug routing
- Hierarchical Categories — Nested parent/child categories with ordering and activation control
- Article Versioning — Automatic version history tracking with editor attribution and change notes
- User Feedback — Helpful/not helpful voting with optional comments, supports authenticated and anonymous users
- Related Articles — Many-to-many article relationships with sort ordering
- Full-Text Search — Database-powered, language-aware search across publicly visible articles by title and content
- SEO Fields — Built-in SEO title, description, and keywords per article
- Customizable Models — Override any model via config while maintaining contract compliance
- Table Prefix — Configurable table prefix to avoid naming collisions (default:
kb_) - Translations — 19 languages out of the box: ar, az, de, en, es, fa, fr, hi, it, ja, nl, pl, pt, pt_BR, ru, tr, uk, uz, zh_CN
- Public Visibility — One rule for what customers may see: published, public, and in a public, active category with public ancestors
- Content Localization — One version per configured language with fallback, per-language slugs and an authoritative language
- Collections (FAQ) — Curated, ordered, translatable lists of articles such as an FAQ
- Help Center API — One public-only, language-aware read API for building help pages: category tree, category, article, collection and search
Requirements
- PHP 8.2+
- Laravel 11+
Installation
composer require jeffersongoncalves/laravel-knowledge-base
Publish and run migrations:
php artisan vendor:publish --tag="knowledge-base-migrations"
php artisan migrate
Upgrading an existing knowledge base: before running
migrate, setknowledge-base.default_localeto the language your existing content is written in (defaulten). The localization migration tags every existing article and category with that language; a wrong value mislabels all content.
Publish the config (optional):
php artisan vendor:publish --tag="knowledge-base-config"
Configuration
The config file (config/knowledge-base.php) covers:
Table Prefix
'table_prefix' => 'kb_',
Set to null to use table names without a prefix.
Custom Models
Override any model to extend the default behavior. Custom models must implement the corresponding contract:
'models' => [ 'article' => \App\Models\Article::class, 'category' => \App\Models\Category::class, 'article_version' => \App\Models\ArticleVersion::class, 'article_feedback' => \App\Models\ArticleFeedback::class, 'article_relation' => \App\Models\ArticleRelation::class, 'article_translation' => \App\Models\ArticleTranslation::class, 'category_translation' => \App\Models\CategoryTranslation::class, 'collection' => \App\Models\Collection::class, 'collection_translation' => \App\Models\CollectionTranslation::class, ],
Knowledge Base Settings
'default_visibility' => 'public', // default visibility for new articles 'versioning_enabled' => true, // track article version history 'feedback_enabled' => true, // allow user feedback 'track_views' => true, // track article view counts
Languages
'locales' => ['en'], // languages the knowledge base serves 'default_locale' => 'en', // authoritative language; must be in locales 'missing_translation' => 'fallback', // 'fallback' or 'hide' 'search_languages' => ['en' => 'english', 'de' => 'german'], // PostgreSQL text-search config per language
See Localization for details.
Search
'search_engine' => 'database', // search engine type 'search_results_limit' => 20, // max results per search
Usage
Using the Service
The KnowledgeBaseService is the recommended way to interact with the knowledge base:
use JeffersonGoncalves\KnowledgeBase\Services\KnowledgeBaseService; $service = app(KnowledgeBaseService::class);
Creating Categories
// Auto-generates slug from name $category = $service->createCategory([ 'name' => 'Getting Started', ]); // With custom slug and parent $child = $service->createCategory([ 'name' => 'Installation', 'slug' => 'installation-guide', 'parent_id' => $category->id, 'description' => 'How to install the application', 'icon' => 'heroicon-o-wrench', 'sort_order' => 1, ]); // Update $service->updateCategory($category, ['name' => 'Quick Start']); // Delete (soft delete) $service->deleteCategory($category);
Creating Articles
$article = $service->createArticle([ 'category_id' => $category->id, 'title' => 'How to Install', 'slug' => 'how-to-install', 'content' => 'Step 1: Run composer require...', 'excerpt' => 'Quick installation guide.', 'visibility' => 'public', 'seo_title' => 'Installation Guide - My App', 'seo_description' => 'Learn how to install My App step by step.', 'seo_keywords' => 'install, setup, getting started', 'metadata' => ['difficulty' => 'beginner'], ], $author); // $author is any Eloquent Model (User, Admin, etc.)
This automatically:
- Generates a UUID
- Creates version 1 (when versioning enabled)
- Dispatches
ArticleCreatedevent
Updating Articles
$updated = $service->updateArticle($article, [ 'title' => 'How to Install (Updated)', 'content' => 'Updated installation steps...', ], $editor, 'Fixed outdated instructions');
When versioning is enabled, this creates a new version entry with the editor and change notes.
Publishing & Archiving
// Publish — sets status to Published, sets published_at, dispatches ArticlePublished $service->publishArticle($article); // Archive — sets status to Archived $service->archiveArticle($article); // Delete — soft deletes $service->deleteArticle($article);
Feedback
// Authenticated helpful feedback $service->addFeedback($article, true, $user, 'Very clear, thank you!', '127.0.0.1'); // Anonymous not-helpful feedback $service->addFeedback($article, false, null, 'Needs more examples'); // Anonymous without comment $service->addFeedback($article, true);
Feedback automatically increments helpful_count or not_helpful_count on the article and dispatches ArticleFeedbackReceived.
Search
// Basic search (only articles customers may see, see "Public vs Internal Content") $results = $service->search('installation'); // With filters $results = $service->search('installation', [ 'category_id' => 1, 'visibility' => 'public', 'limit' => 10, ]); // Staff search $all = $service->search('installation', ['include_internal' => true]); // every published article $staffOnly = $service->search('installation', ['visibility' => 'internal']); // only staff-only articles
Results are ordered by view_count descending (most popular first).
Searching in a language. Pass locale to search that language's versions. Under the fallback setting, an article whose requested-language version is missing or not readable is matched through its authoritative version instead; hide returns nothing for it. Each result reports the language it was served in and carries that language version in served_translation; the article's own columns stay the authoritative version.
$results = $service->search('Passwort', ['locale' => 'de']); foreach ($results as $article) { $article->served_locale; // 'de', or 'en' when it fell back $article->served_translation->title; // the title in that language $article->served_translation->slug; // and its slug, for the link }
On PostgreSQL the German version is searched with German text-search rules (search_languages); MySQL, MariaDB and SQLite use the same LIKE search as above.
Using Models Directly
For queries outside the service, use ModelResolver:
use JeffersonGoncalves\KnowledgeBase\Support\ModelResolver; $articleClass = ModelResolver::article(); $categoryClass = ModelResolver::category(); // Article scopes $published = $articleClass::published()->get(); // staff only, see below $public = $articleClass::publiclyVisible()->get(); $staffOnly = $articleClass::staffOnly()->get(); $drafts = $articleClass::draft()->get(); $archived = $articleClass::archived()->get(); $internal = $articleClass::byVisibility(ArticleVisibility::Internal)->get(); // Category scopes $public = $categoryClass::publiclyVisible()->get(); $active = $categoryClass::active()->get(); $roots = $categoryClass::root()->get(); $ordered = $categoryClass::ordered()->get(); $tree = $categoryClass::active()->root()->ordered()->get(); // Relationships $article->category; $article->author; $article->versions; $article->feedback; $article->relatedArticles; $category->parent; $category->children; $category->articles; // View tracking $article->incrementViewCount();
Public vs Internal Content
Both articles and categories have a visibility of public or internal. An article may be shown to customers only when all of these hold:
- the article is published,
- the article's visibility is
public, - its category, and every parent category above it, is
public, active and not deleted.
Every other published article is staff-only. A public article inside an internal category is staff-only too, and a category whose parent is missing counts as hidden.
| Query | Returns | Use for |
|---|---|---|
publiclyVisible() |
Articles that pass the rule above | Help pages, FAQ, anything a customer can see |
staffOnly() |
Published articles that fail it | Staff-only lists |
published() |
Every published article, whatever its visibility | Admin and staff tools only |
publiclyVisible() and staffOnly() split the published articles: each one is returned by exactly one of them. Category::publiclyVisible() applies the same category rule to category menus. All of them combine with other constraints:
$article = $articleClass::publiclyVisible()->where('slug', $slug)->firstOrFail(); $articles = $category->articles()->publiclyVisible()->get(); $related = $article->relatedArticles()->publiclyVisible()->get();
Content meant for customers and staff alike is simply public: staff tools use published() and see it as well.
Note:
published()filters on status only. Do not use it on customer-facing pages, or internal content will show up there.
Localization
The knowledge base can serve its articles and categories in several languages. Configure them once:
// config/knowledge-base.php 'locales' => ['en', 'de'], // languages the knowledge base serves 'default_locale' => 'en', // authoritative language for new content; must be in locales 'missing_translation' => 'fallback', // 'fallback' (default) or 'hide' 'search_languages' => [ // PostgreSQL text-search configuration per language 'en' => 'english', 'de' => 'german', ],
With a single configured language the knowledge base behaves exactly as before this change.
One version per language. Each article and category has at most one version per configured language, with its own title, slug, excerpt, content and SEO fields (articles) or name, slug and description (categories). Category, visibility, related articles, author and counters are shared by every version.
$german = $article->translations()->create([ 'locale' => 'de', 'title' => 'Passwort zurücksetzen', 'slug' => 'passwort-zuruecksetzen', 'content' => 'So setzt du dein Passwort zurück.', ]);
The authoritative language. Every article has exactly one authoritative language, defaulting to default_locale, and always has a version in it. Its version is mirrored onto the article's own title, slug, content, excerpt and SEO columns, in both directions: writing those columns updates the authoritative version, and editing the authoritative version updates those columns. This is what keeps existing code that reads $article->title working.
$article->source_locale; // 'en' $article->authoritativeTranslation()->title; // the mirrored title $article->changeSourceLocale('de'); // only to a language it has a version in
Categories always have a version in default_locale; their main row mirrors it.
Slugs. A slug belongs to one article or category: the same article may use the same slug in several of its languages, but two different articles may never share a slug in any language. Saving a conflicting slug or a second version in the same language is rejected.
Editing a language. updateArticle() edits the authoritative version; updateArticleTranslation() edits one other language. Both record a history entry tagged with the edited language.
$service->updateArticleTranslation($article, 'de', ['title' => 'Passwort ändern'], $editor, 'Wording'); $article->versions()->forLocale('de')->get(); // the history of one language only
Outdated versions. Saving the authoritative version can mark every other version as outdated (off by default, so typo fixes flag nothing); readers still see outdated versions.
$service->updateArticle($article, $data, $editor, 'Rewrote step 2', flagOtherLocalesOutdated: true); $article->flagOtherTranslationsOutdated(); // or mark the other languages directly $translation->markUpToDate(); // editing or confirming a version clears it $service->outdatedTranslations('de'); // the outdated versions of one language
Run php artisan kb:verify-translations to report any drift between a main row and its authoritative version.
Serving a language. TranslationResolver is the one place that decides what a reader gets, applying the article's status, the visibility rules and missing_translation (fallback serves the authoritative version, hide serves nothing). It reports which language was actually served, plus that version's slug, so a page can redirect.
use JeffersonGoncalves\KnowledgeBase\Support\TranslationResolver; $resolver = app(TranslationResolver::class); $resolved = $resolver->resolveArticleBySlug('reset', 'de'); // $resolved->locale // 'de', or 'en' when it fell back // $resolved->slug // the served version's slug (may differ from 'reset') // $resolved->translation // the translation that was served $resolved = $resolver->resolveArticle($article, 'de'); $resolved = $resolver->resolveCategory($category, 'de'); $resolved = $resolver->resolveCategoryBySlug($categorySlug, 'de');
Pass publicOnly: false for staff lookups that may see internal content.
Before migrating an existing knowledge base, set
default_localeto the language the existing content is written in. The upgrade tags every existing article and category with that language, so a wrong value mislabels all existing content. The migration stops before writing anything whendefault_localeis not one oflocales.
Collections
A collection is a named, ordered, translatable list of articles, such as an FAQ. The FAQ is one collection; other lists ("Getting started", …) need no schema change.
use JeffersonGoncalves\KnowledgeBase\Models\Collection; $faq = Collection::create([ 'name' => 'Frequently asked questions', 'slug' => 'frequently-asked-questions', ]); $faq->translations()->create([ 'locale' => 'de', 'name' => 'Häufige Fragen', 'slug' => 'haeufige-fragen', ]); $faq->addArticle($article); // appended at the end $faq->addArticle($other, 0); // or at a given position $faq->reorderArticles([$other, $article]); $faq->removeArticle($article); $faq->articleIds(); // the ids, in order
Like categories, a collection always has a version in default_locale (its main row mirrors it), and a slug belongs to one collection in any language. Adding an article that is already in the collection is rejected; removing it, or deleting the collection, never touches the article itself.
Editors may put any article in a collection, public or not: the public view filters at read time, so an article whose visibility (or whose category's) changes later is handled without re-saving the collection. Collection::publicArticles($locale) returns only the publicly visible articles, in order, each resolved for the language (see Localization above): under hide, an article without a readable version in that language is left out; under fallback it is served in its authoritative language, at its position. An inactive or deleted collection is not found publicly.
Help Center API
Support\HelpCenter composes the visibility and language rules for public pages, so a help page is a few lines. It is public-only by construction: every result goes through Visibility and TranslationResolver, and anything not publicly visible is reported as not found.
use JeffersonGoncalves\KnowledgeBase\Support\HelpCenter; $help = app(HelpCenter::class); $tree = $help->categories('de'); // list<CategoryNode>, with article counts $category = $help->category('abrechnung', 'de'); // ?CategoryPage $article = $help->article($slug, 'de'); // ?ArticlePage $collection = $help->collection('haeufige-fragen', 'de'); // ?CollectionPage $results = $help->search('reset', 'de', 20); // public, language-aware search results
- An
ArticlePagecarries the served version (translation), the served language (locale),isFallback(served ≠ requested),redirectSlug(set when the requested slug is not the served version's slug),alternates(locale ⇒ slug of every language the article can actually be read in),breadcrumbandrelated. - A
CategoryPagecarries the resolved category, itschildrenand itsarticles. - A
CollectionPagecarries the resolved collection and its publicarticlesin order. - A
CategoryNodecarries the resolved category, a slug and name in the served language, its public article count (articlesCount, counting only the articles a reader of that language is served) and itschildren.
A minimal public article page built on the API:
Route::get('/{locale}/help/articles/{slug}', function (string $locale, string $slug) { abort_unless(in_array($locale, config('knowledge-base.locales'), true), 404); $page = app(HelpCenter::class)->article($slug, $locale); if ($page === null) { abort(404); } if ($page->redirectSlug !== null) { return redirect("/{$page->locale}/help/articles/{$page->redirectSlug}", 301); } return view('help.article', ['page' => $page]); });
The package registers no routes or views (it stays headless); the app renders the pages, including redirects, a fallback notice when isFallback is true, hreflang alternates from alternates, and 404s.
Extending Models
Create custom models that implement the required contract:
namespace App\Models; use JeffersonGoncalves\KnowledgeBase\Models\Article as BaseArticle; use JeffersonGoncalves\KnowledgeBase\Models\Contracts\ArticleContract; class Article extends BaseArticle implements ArticleContract { // Add custom relationships, scopes, methods... public function comments() { return $this->hasMany(Comment::class); } }
Then update the config:
// config/knowledge-base.php 'models' => [ 'article' => \App\Models\Article::class, // ... ],
Events
| Event | Payload | When |
|---|---|---|
ArticleCreated |
$article |
After article creation via service |
ArticlePublished |
$article |
After publishing via service |
ArticleFeedbackReceived |
$article, $feedback |
After feedback submission |
Listen to events in your EventServiceProvider or with listeners:
use JeffersonGoncalves\KnowledgeBase\Events\ArticlePublished; class SendArticleNotification { public function handle(ArticlePublished $event): void { // Notify subscribers about the new article $article = $event->article; } }
Enums
ArticleStatus
| Value | Label (en) | Label (pt_BR) |
|---|---|---|
draft |
Draft | Rascunho |
published |
Published | Publicado |
archived |
Archived | Arquivado |
ArticleVisibility
| Value | Label (en) | Label (pt_BR) |
|---|---|---|
public |
Public | Público |
internal |
Internal | Interno |
TranslationStatus
| Value | Label (en) | Label (pt_BR) |
|---|---|---|
draft |
Draft | Rascunho |
published |
Published | Publicado |
use JeffersonGoncalves\KnowledgeBase\Enums\ArticleStatus; use JeffersonGoncalves\KnowledgeBase\Enums\ArticleVisibility; $status = ArticleStatus::Published; $status->label(); // 'Published' or 'Publicado' $visibility = ArticleVisibility::Internal; $visibility->label(); // 'Internal' or 'Interno'
Database Tables
All tables use the configured prefix (default: kb_):
| Table | Description |
|---|---|
kb_categories |
Hierarchical article categories |
kb_category_translations |
Category name/slug/description per language |
kb_articles |
Knowledge base articles |
kb_article_translations |
Article title/slug/content per language |
kb_article_versions |
Article version history, per language |
kb_article_feedback |
User feedback on articles |
kb_article_relations |
Related articles (pivot) |
kb_collections |
Curated, ordered lists of articles (e.g. an FAQ) |
kb_collection_translations |
Collection name/slug/description per language |
kb_collection_article |
Ordered membership of articles in collections (pivot) |
Testing
composer test
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
License
The MIT License (MIT). Please see License File for more information.
