mll-lab/laravel-comment
Add comments to any Laravel Eloquent model with a reusable commentable trait, migrations, and a ready-made Comment model. Supports polymorphic associations and straightforward integration into existing apps.
Confirm the floor before anything else. 2.0.0 requires PHP 8.2+ and Laravel 10–13. Run composer why-not mll-lab/laravel-comment against your real lockfile rather than trusting the constraint line — a major bump can tighten transitive pins internally.
Install in three commands:
composer require mll-lab/laravel-comment
php artisan vendor:publish # pick the package's migration + config tags
php artisan migrate
If the provider registers publish tags, be explicit: --tag=comment-migrations and --tag=comment-config are the conventional names — check the service provider rather than guessing.
Read the source before the README. The package is small (trait + model + provider + migration), and the documentation is thinner than the code. Spend ten minutes in vendor/mll-lab/laravel-comment/src:
Comment model tells you the fillable columns (commentable_id, commentable_type, user_id, parent_id, and the body column), which is the real API contract.First use case: comments on a Post.
use MllLab\LaravelComment\...\Commentable; // confirm namespace from the trait file
class Post extends Model
{
use Commentable;
}
Then:
$post->comments()->create([
'user_id' => auth()->id(),
'body' => $request->validated('body'),
]);
Render with $post->comments()->whereNull('parent_id')->with('author')->latest()->paginate(20)->withQueryString(). That is the whole happy path — everything past this point is work the package deliberately does not do for you.
The package is a persistence layer, not a comment system. It gives you a polymorphic relation and a threaded schema. There is no UI, no moderation, no spam protection, no notification, no authorization, and no depth limit. Budget for building each of those; treat the package as saving you the migration and the relationship, nothing more.
Create a CommentPolicy on day one. The package ties comments to an author but almost certainly does not gate edit/delete. Without a policy, any authenticated user can PATCH any comment the moment you expose an endpoint:
public function update(User $user, Comment $comment): bool
{
return $user->id === $comment->user_id;
}
Call $this->authorize('update', $comment) in the controller, not in the view.
Guard threading at write time. When accepting a parent_id, verify the parent belongs to the same commentable and cap the depth:
$parent = Comment::findOrFail($request->parent_id);
abort_unless(
$parent->commentable_type === $post->getMorphClass()
&& $parent->commentable_id === $post->getKey(),
422
);
abort_if($parent->depth() >= 3, 422); // your own helper
Otherwise a crafted request re-parents a comment onto an unrelated model, and the tree becomes unwalkable.
Never eager-load the whole tree. with('children.children.children') is fine for a demo and fatal in production. Load one level per request — top-level comments paginated, then fetch children for the visible IDs — or use a recursive CTE if you need the full thread in one query. Always eager-load author; the N+1 on a 20-comment page is 21 queries.
Return a resource, not a model. If the front end is Livewire, Inertia, or Vue, shape the created comment into a CommentResource so the client can append it without a refetch:
return new CommentResource($comment->load('author'));
Use withCount for badges. Post::withCount('comments') is the cheap path for "12 comments" in an index. If you only want top-level counts, define a second relation on your model scoped to whereNull('parent_id') and count that instead — the two numbers differ and users notice.
Hook notifications through a model observer, not the controller, so comments created from seeders, imports, or an admin panel behave identically. Comment::observe(CommentObserver::class) and notify the commentable's owner on created, skipping self-replies.
For moderation, extend rather than fork. Add a status column in your own migration and a visible() scope or a visibleComments() relation on the trait-using model. Keep the package's migration untouched so vendor:publish upgrades stay clean.
Decide your morph strategy before the first row is written. If you plan to call Relation::enforceMorphMap(), register the aliases now. Adding it after comments exist means rewriting every commentable_type string in the table.
The 2.0.0 changelog does not list breaking changes. It documents only raised floors and Laravel 13 support. A major version with no upgrade guide means the blast radius is unknown — diff the source against your 1.x install, focusing on config keys, published migration columns, and any method signatures on the trait or Comment model. Do this in a branch with tests, not in production.
Deleting a parent probably orphans its children. There is no cascade in the schema unless the migration declares one. Pick a strategy and implement it explicitly: a ON DELETE CASCADE foreign key on parent_id, a soft delete plus a "deleted" tombstone render so the thread stays readable, or reparenting children to the grandparent. Silent orphans — children that exist but are unreachable because their parent is gone — are the classic failure mode here.
Comment::create() bypasses the morph auto-fill. $post->comments()->create([...]) sets commentable_id and commentable_type for you. Creating on the model directly requires both keys, and a mismatched pair produces a comment that exists but never appears in any list.
Check the author column against your auth model. If the package's config or migration assumes App\Models\User and you use a different model or a UUID key, the FK will fail on migrate or on insert. Verify the config key and the migration's foreignId type before running php artisan migrate on anything shared.
Add your own indexes. The default (commentable_type, commentable_id) index is necessary but not sufficient. Add (commentable_type, commentable_id, parent_id, created_at) for thread queries and a plain parent_id index for child fetches once the table passes a few hundred thousand rows.
Config and migration cache. After publishing or editing config, run php artisan config:clear. On deploy, the migration is only published once — new package versions that alter the schema will not re-publish over your existing file, so read the changelog for migration changes rather than assuming vendor:publish --force is safe.
Write your own factory. Packages this size rarely ship one. A CommentFactory with a reply() state (parent_id set to an existing comment on the same commentable) pays for itself the first time you test depth limits.
Pagination interacts badly with trees. Paginating top-level comments is straightforward; paginating replies per-parent is not. Either load all children for the current page's parents, or add a "load more replies" endpoint scoped to a single parent ID.
Version treadmill. 2.0.0 moves the floor to PHP 8.2 / Laravel 10 and tracks Laravel 13. Assign someone to check the package's support window quarterly — if it ships majors quickly and drops prior lines, missing one release compounds into a mandatory upgrade on someone else's schedule rather than yours.
Six stars is a real signal. Read the source before you depend on it, pin the version in composer.json, and keep your rendering layer decoupled enough that swapping the persistence out is a weekend, not a rewrite.
How can I help you explore Laravel packages today?