overtrue/wisteria
Wisteria is a documentation generator for Laravel that builds a clean, searchable site from Markdown files. It offers a responsive default theme and simple configuration, making it easy to publish and maintain docs for Laravel packages and applications.
Verify compatibility before anything else. Wisteria's last release was July 2021 and the repo is archived, so the composer.json constraints are the real gate. Open the repo and check require for illuminate/* / laravel/framework and the PHP floor before you try installing:
composer show overtrue/wisteria --all | head -30
# then attempt on a scratch branch
composer require overtrue/wisteria
If the constraint is ^5.5|^6.0|^7.0|^8.0 and you're on Laravel 11, Composer will refuse — and if you force it with --ignore-platform-reqs you own every breakage in service-provider registration, model booting, and the $dates/casts API. Decide up front whether this is a "reuse the models" decision or a "fork and maintain" decision.
First-run sequence (standard for overtrue packages, which follow the publish-then-migrate convention):
php artisan vendor:publish --provider="Wisteria\WisteriaServiceProvider"
php artisan migrate
Publish tags are usually split, so publish selectively rather than dumping everything into your app:
php artisan vendor:publish --provider="Wisteria\WisteriaServiceProvider" --tag=config
php artisan vendor:publish --provider="Wisteria\WisteriaServiceProvider" --tag=migrations
php artisan vendor:publish --provider="Wisteria\WisteriaServiceProvider" --tag=views
Where to look first, in this order:
config/wisteria.php (after publishing) — table names, model class bindings, middleware, pagination. This is the package's contract with your app; read it before writing any code.boot() — that's where routes, view namespaces, and Blade components get registered, and it tells you whether routes are auto-loaded or must be published.src/Models/ — Category, Thread, Reply. Learn the actual fillable/relationship names here; don't guess from the README.First use case: seed one category and render a board index. Create the category directly through the model, then a thread attributed to an authenticated user:
use Wisteria\Models\Category;
use Wisteria\Models\Thread;
$category = Category::create([
'name' => 'General',
'slug' => 'general',
]);
$thread = $category->threads()->create([
'user_id' => auth()->id(),
'title' => 'Welcome to the board',
'body' => 'Introduce yourself here.',
]);
If that round-trips, the package is wired correctly. Everything after this is presentation and policy.
Route exposure: choose one owner. Either let the package register its routes (fast, less control) or disable them in config and write your own routes against the package's controllers/models (more control, standard for anything shipping to production). The second is almost always right for an existing app, because you need to wrap the forum in your own middleware — auth, verified email, tenant scoping, rate limiting:
// config/wisteria.php
'routes' => ['enabled' => false],
// routes/web.php
Route::middleware(['web', 'auth', 'verified', 'throttle:30,1'])
->prefix('community')
->group(function () {
Route::get('/', [ForumController::class, 'index'])->name('forum.index');
Route::get('/{category:slug}', [ForumController::class, 'category'])->name('forum.category');
Route::get('/{category:slug}/{thread:slug}', [ForumController::class, 'show'])->name('forum.thread');
Route::post('/{category:slug}', [ThreadController::class, 'store'])->name('forum.thread.store');
Route::post('/{category:slug}/{thread:slug}/reply', [ReplyController::class, 'reply'])->name('forum.reply');
});
Route model binding on slug is the thing to get right early — it makes URLs stable and keeps your controllers thin.
Eager-load to survive the index page. A forum index is the canonical N+1 disaster: categories → thread count → latest thread → that thread's author. Load deliberately and cache the counters:
$categories = Category::query()
->withCount('threads')
->with(['threads' => fn ($q) => $q->latest()->limit(1)->with('user:id,name')])
->get();
For the thread listing, page at the query level, not in the view, and eager-load the author — with('user:id,name') with a column list keeps the payload small.
Wrap writes in a transaction when denormalized counters are involved. Forum packages typically keep a replies_count / last_reply_at on the thread for sorting. If you add replies yourself (bulk import, moderation tooling, seeding), update the parent inside a transaction or the counters drift and your "most active" sort silently lies:
DB::transaction(function () use ($thread, $data) {
$reply = $thread->replies()->create([
'user_id' => auth()->id(),
'body' => $data['body'],
]);
$thread->update([
'replies_count' => $thread->replies()->count(),
'last_reply_at' => $reply->created_at,
]);
return $reply;
});
Authorization belongs in your app, not the package. Add a policy and enforce it in your controllers — the package predates the era where this is assumed:
class ThreadPolicy
{
public function update(User $user, Thread $thread): bool
{
return $user->id === $thread->user_id || $user->isModerator();
}
}
Then $this->authorize('update', $thread) on edit/destroy. Do the same for Reply. Without this, any authenticated user can edit anyone's post if the package's controllers only check auth.
Override views rather than editing vendor files. Publish views into resources/views/vendor/wisteria and edit there. Because a forum is 90% presentation, this is where most of your work will actually live. Keep a thin layer: your app's layout, your app's components (<x-card>, <x-avatar>), and the package's data. If the views use Bootstrap markup and your app uses Tailwind, budget for a rewrite rather than a tweak — it's usually cheaper than fighting the original CSS.
Custom models via config binding. Most overtrue packages let you swap the model class in config. Use it to add behavior — soft deletes, an observer that dispatches events, a global scope for tenant isolation:
// config/wisteria.php
'models' => [
'thread' => App\Models\Forum\Thread::class,
],
// app/Models/Forum/Thread.php
class Thread extends \Wisteria\Models\Thread
{
protected static function booted(): void
{
static::addGlobalScope('tenant', fn ($q) => $q->where('tenant_id', tenant()->id));
}
}
Subclass-and-extend beats editing the package, because it survives a composer update — and with an archived package, you'll eventually be maintaining it as a fork anyway.
Bridge to the rest of the app. The useful integration points are events and notifications, both of which you own: listen on your own model events (via the subclass above) to notify thread participants, index threads into Scout, or mirror activity into a feed. Don't expect the package to emit them.
It's archived — treat it as a vendored dependency, not a live one. No security patches, no Laravel compatibility fixes, no issue triage. Concretely:
composer.json ("overtrue/wisteria": "1.0.0", not ^1.0).Config caching bites on deploy. php artisan config:cache is standard in production and will freeze the config values at cache time. Any runtime config([...]) mutation — common in multi-tenant setups — will not take effect. Reset with config:clear locally when you change config/wisteria.php.
Migrations are publish-once. Once published, the file is yours and future package updates won't touch it. Decide the table names and add your columns (tenant_id, is_pinned, is_locked, deleted_at) in a separate app migration rather than editing the published one — cleaner history, and it survives a republish.
Output escaping on user content. Forums are the classic stored-XSS vector. If views use {!! !!} for post bodies, you must sanitize on write or render:
// escape by default; opt into formatting explicitly
{!! nl2br(e($reply->body)) !!}
Never store raw HTML from an unprivileged user and render it with {!! !!}. If you need Markdown, run it through a parser configured to strip HTML, and cache the rendered output.
Missing pagination / unbounded queries. A long thread with thousands of replies will try to render them all if the view does $thread->replies. Always paginate in your controller and pass the paginator to the view. Same for the board index.
Counter drift under concurrent writes. Two simultaneous replies both computing count() inside separate transactions can produce an off-by-N. Either use increment() on the column (atomic) or accept eventually-consistent counters and recompute via a scheduled command. Don't rely on counts for anything with consequences.
Slug collisions and Unicode titles. Auto-generated slugs from user-supplied titles collide and can produce empty slugs for CJK or emoji-only titles. Generate with a uniqueness suffix (Str::slug($title) . '-' . Str::random(6)) and keep a unique index so failures are loud, not silent.
Route/name conflicts. If the package auto-registers routes with generic names like threads.index, you'll collide with your own. Either disable its routes or namespace them. Check php artisan route:list after install — this is the single fastest way to understand what got registered and under what middleware.
Middleware mismatch. Packages often default to ['web', 'auth']. That's fine; what's not fine is discovering the forum accepts unauthenticated posts because auth was overridden but not re-applied when you disabled the package routes. Assert it in a feature test:
public function test_guests_cannot_create_threads(): void
{
$this->post('/community/general', ['title' => 'x', 'body' => 'y'])
->assertRedirect('/login');
}
Testing without factories. The package likely ships no model factories. Write your own before you write tests — forum tests need Category, Thread, and Reply factories, and the forum's authorization and pagination logic is exactly the kind of code that regresses silently. A ten-line ThreadFactory pays for itself immediately.
Extension points, ranked by durability: (1) subclassing models via config, (2) overriding published views, (3) app-side policies and middleware, (4) your own controllers calling the package models directly. Avoid (5) editing files inside vendor/ — it's the one thing a composer install on a deploy box will quietly undo.
How can I help you explore Laravel packages today?