Search by

jeffersongoncalves / laravel-knowledge-base

jeffersongoncalves

A Laravel package for building knowledge bases with articles, categories, versioning, and feedback

v1.1.0 2026-10-11 14:37 UTC

README

Laravel Knowledge Base

Laravel Knowledge Base

Latest Version on Packagist GitHub Tests Action Status GitHub Code Style Action Status PHPStan Total Downloads Buy Me A Coffee

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, set knowledge-base.default_locale to the language your existing content is written in (default en). 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 ArticleCreated event

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_locale to 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 when default_locale is not one of locales.

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 ArticlePage carries 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), breadcrumb and related.
  • A CategoryPage carries the resolved category, its children and its articles.
  • A CollectionPage carries the resolved collection and its public articles in order.
  • A CategoryNode carries 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 its children.

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.