Search by

marque / parley

lomsoftware

Polymorphic threaded discussion for Marque — torrent comments and forum-lite from one data model

v4.0.0 2026-10-28 07:01 UTC

This package is auto-updated.

Last update: 2026-10-28 07:27:41 UTC


README

Polymorphic threaded discussion for the Marque tracker platform. Torrent comments and a lightweight forum, from one data model.

  threads
    ├─ threadable set, no title    →  comments on a torrent, or anything else
    ├─ threadable null, has title  →  a forum thread
    └─ pinned + locked, has title  →  an announcement (a forum thread)

Why

Comments and a forum look like two features. They are one: a title-less, subject-attached thread and a titled, categorised thread are the same row shape with different columns set. Building that as a single polymorphic model means a comment box and a forum share every line of posting, replying, editing and moderation logic — nothing is implemented twice, and anything that later wants discussion (requests, uploads, user profiles) gets it by attaching to the same threads table.

Parley does not implement its own text formatting. Post bodies go through marque/squidink — parley stores source text and the name of the parser that wrote it, and renders through squidink's pipeline. A post and a torrent description behave identically, and the site owner's choice of input syntax applies everywhere.

Installation

composer require marque/parley

Publish the config if you want to change anything:

php artisan vendor:publish --tag=parley-config

Requires marque/trove (user model, roles) and marque/deck (Blade components) — both are composer required automatically as dependencies.

Attaching discussion to a model you own

Add the HasThreads trait to any Eloquent model:

use Marque\Parley\Concerns\HasThreads;

class Torrent extends Model
{
    use HasThreads;
}
$torrent->comments($userId);   // the comment thread; creates it if there isn't one
$torrent->commentThread();     // the thread or null, never creates
$torrent->commentCount();
$torrent->hasComments();
$torrent->latestComment();

The thread is created lazily, not alongside the torrent. comments() creates it on first call, so use commentThread(), commentCount(), hasComments() or latestComment() to read without creating a row. A torrent whose page only reads through those holds no thread until someone posts.

Attaching to a model you don't own

If the model belongs to a package that can't take a dependency on parley — the situation guise is in with trove's Torrent, since trove is Marque's one mandatory package — use ThreadServiceInterface::threadFor() instead of the trait. It resolves the same thread by morph class and key, without requiring HasThreads on the model at all. Like comments(), it creates the thread if there isn't one, owned by the user you pass:

use Marque\Parley\Contracts\ThreadServiceInterface;

$thread = app(ThreadServiceInterface::class)->threadFor($torrent, auth()->user());

See docs/integration.md in the monorepo for the full reasoning — it covers this pattern generally, for any optional package attaching to a model it doesn't own.

The comment thread component

One Livewire component serves both presentations — a torrent's comments and a forum thread's replies are the same posting, replying, editing and deleting mechanics on the same models, so there is no second implementation to keep in sync.

{{-- Comments: pass the subject, the thread resolves or is created lazily --}}
<livewire:parley-comment-thread :subject="$torrent" />

{{-- Forum: pass the thread directly, it always already exists --}}
<livewire:parley-comment-thread :thread="$thread" />

Handles the full post tree — arbitrary-depth nested replies, submit, reply, edit, delete, all policy-gated — with an empty state and permission-aware controls. Soft-deleted posts render as [deleted] rather than disappearing, so a reply chain survives its parent's removal.

Browsing is open to guests. A guest who reaches posting (the new-thread page, or a submit sent directly) is redirected to your app's login route and brought back afterwards. If there is no route named login, they get the policy's 403.

Indentation caps at config('parley.nesting.indent_depth'); nesting itself has no limit in storage. Changing the cap is a display decision, not a migration.

The forum

Behind a config toggle — comments and the forum share one set of tables, so switching the forum off is a route/UI choice, not an install choice:

'forum' => [
    'enabled' => env('PARLEY_FORUM', true),
],

When off, the forum's routes are not registered at all — a comments-only deployment 404s the forum rather than merely lacking a link to it.

Four pages, each a full Livewire component:

Component Route name Purpose
CategoryIndex parley.forum.categories.index Every category, thread count
ThreadIndex parley.forum.categories.show Threads in a category, pinned first
ThreadShow parley.forum.threads.show One thread — title, moderation, posts
ThreadCreate parley.forum.threads.create New thread: title + first post

Categories are created directly against the Category model — there's no admin UI in the package; manage them the way you manage any other reference data in your app.

Moderation

Pin, lock and soft-delete, keyed off marque/trove's roles:

'moderation' => [
    'role' => 'moderator',   // the minimum trove role that can moderate
],

Authors can edit and delete their own posts and threads. With moderation.lock_blocks_edits on, nobody can edit or delete a locked thread's posts, moderators included, until it is unlocked; the thread itself can still be deleted. Pin and lock are moderator-only regardless of ownership. Locking a thread is enforced at the service layer (ThreadLockedException), not just hidden in the UI, so it holds against any caller — a REST endpoint, a queued job, a script — not only the component that greys out its submit button.

Moderation is recorded through marque/trove's StaffActionsInterface when the service is given the actor (the components pass the signed-in user): pinning and locking whoever does them, and deleting a thread or editing or deleting a post that isn't the actor's own (parley.thread.pinned, unpinned, locked, unlocked, edited, deleted; parley.post.edited, deleted), with what changed. The services' moderation methods take optional actor and reason. With no store installed, nothing is kept.

Rate limiting

Off by default. Turn this on before any public deployment. An unlimited post/reply surface is fine for a private, trusted-user tracker — it is not fine for anything a stranger can reach.

'rate_limiting' => [
    'enabled' => env('PARLEY_RATE_LIMITING', false),
    'max_attempts' => env('PARLEY_RATE_LIMIT_MAX', 5),
    'decay_seconds' => env('PARLEY_RATE_LIMIT_DECAY', 60),
],

max_attempts posts (or replies — they share one limit) per decay_seconds, per user. The defaults (5 per minute) are a starting point, not a rule — tune to your own traffic and moderation capacity. Enforced at the service layer (PostService) via Laravel's own RateLimiter, so it applies to any caller: the Livewire UI, a REST endpoint, a script — not just whichever component happens to check first.

Breaching the limit surfaces as a normal validation error on the post form ("You're posting too quickly — try again in a moment"), the same as an empty or over-length post — never an unhandled exception.

It ships off by default rather than on, matching every other opt-in toggle in the Marque suite (2FA, passkeys): an upgrade should never silently start rejecting a legitimate user's fast-typing session. That default is only safe because turning it on is a one-line config change you make before going live, not after.

What's out of scope

Private messages, reputation, badges, signatures, polls, rich moderation queues, and search. Parley has no search at all, not even a basic LIKE. The moment any of these is genuinely wanted, a dedicated forum platform (Discourse, etc.) is the better answer — parley is not trying to compete with one.

Testing

composer test

License

MIT. See LICENSE.