ozankurt / laravel-modules-interactions
Polymorphic social & engagement toolkit for Laravel: emoji reactions, comments, @mentions, likes/votes/ratings/favorites/subscriptions, and a follows/friendships social graph. Headless + optional Filament admin.
Package info
github.com/OzanKurt/laravel-modules-interactions
pkg:composer/ozankurt/laravel-modules-interactions
Requires
- php: ^8.3
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/database: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
- league/commonmark: ^2.4
- ozankurt/laravel-modules-core: ^1.0
- spatie/laravel-package-tools: ^1.92
Requires (Dev)
- filament/filament: ^3.0 || ^4.0 || ^5.0
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- mockery/mockery: ^1.6
- orchestra/testbench: ^10.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
- rector/rector: ^2.0
This package is auto-updated.
Last update: 2026-07-30 00:42:00 UTC
README
A full-featured, polymorphic social & engagement toolkit for Laravel. Any Eloquent model opts in via traits to gain emoji reactions, threaded comments, @mentions, the engagement primitives (like / dislike / vote / rate / favorite / subscribe / follow), and a social graph (friendships + friend groups).
Part of the KurtModules family. Headless by design, with an optional Filament admin (Filament 3, 4 & 5). Core-only hard dependency.
Inspired by laravel-acquaintances, overtrue's social suite, laravel-reactions, and Laravel-Mentions.
Features
- Engagement —
like/dislike,vote(up/down + net score),rate(averaged),favorite,subscribe,follow. Idempotent; denormalized counters with a live-query fallback. - Reactions — any unicode emoji and Discord-style custom emoji
(
:shortcode:), multiple distinct emoji per user, per-emoji summaries. - Comments — polymorphic, unlimited threading, markdown (safe-rendered), edit history, soft-delete + moderation states.
- Mentions —
@handleparsing against a configurable pool, recorded and kept in sync with the content. - Social graph — one-way follows, mutual friendships (request / accept / deny / block), and named friend groups.
- Events + optional notifications — domain events for everything, plus toggleable Notification classes.
- REST API (optional) — an out-of-the-box, safe-by-default JSON API for
reactions and engagement over
{type}/{id}morph-aliased subjects. See API.
Requirements
- PHP 8.3+, Laravel 12/13
ozankurt/laravel-modules-core^2.0
Installation
composer require ozankurt/laravel-modules-interactions
Core is not on Packagist yet — add it as a VCS repository in your app's
composer.json:
"repositories": [ { "type": "vcs", "url": "https://github.com/OzanKurt/KurtModules-Core" } ]
Publish + migrate:
php artisan vendor:publish --tag=modules-interactions-config php artisan vendor:publish --tag=modules-interactions-migrations php artisan migrate
Setup
Add the actor trait to your user model and the target trait to anything that can be interacted with:
use Kurt\Modules\Interactions\Concerns\Interactor; // the user (actor) use Kurt\Modules\Interactions\Concerns\Interactable; // any target (full surface) class User extends Authenticatable { use Interactor; } class Post extends Model { use Interactable; }
Prefer the granular target traits (Likeable, Voteable, Rateable,
Favoritable, Subscribable, Followable, Reactable, Commentable) when a
model only needs a subset.
Usage
Engagement
$user->like($post); $post->likesCount(); $post->isLikedBy($user); $user->dislike($post); // like and dislike are mutually exclusive $user->upvote($post); $post->votesScore(); // net of up/down $user->downvote($post); $post->votesCount(); $user->rate($post, 5); $post->averageRating(); $post->ratingsCount(); $user->favorite($post); $post->favoritesCount(); $user->subscribe($post); $post->subscribersCount(); $user->follow($otherUser); $otherUser->followersCount();
Reactions
$user->reactWith($post, '🎉'); $user->reactWith($post, ':partyblob:'); // custom emoji (registered) $user->toggleReaction($post, '👍'); $post->reactionSummary(); // ['🎉' => 5, ':partyblob:' => 2]
Comments + mentions
$comment = $user->comment($post, 'Great work @jane!'); // records the @jane mention $reply = $user->comment($post, 'Thanks!', $comment); // threaded reply $comment->renderedBody(); // safe HTML from markdown app(\Kurt\Modules\Interactions\Comments\CommentManager::class) ->moderate($comment, \Kurt\Modules\Interactions\Comments\Enums\CommentStatus::Spam);
Social graph
$alice->befriend($bob); $bob->acceptFriendRequest($alice); // now mutual $alice->isFriendWith($bob); // true $alice->blockFriend($carol); $group = $alice->createFriendGroup('Close Friends'); app(\Kurt\Modules\Interactions\Graph\GroupManager::class)->addMember($group, $bob);
Events & notifications
Every verb fires a domain event (Liked, Voted, Reacted, Commented,
CommentReplied, UserMentioned, Followed, FriendRequested, …). Set
interactions.notifications.enabled to also dispatch the bundled Notification
classes (new follower, mention, comment reply, friend request) to Notifiable
recipients.
Facade
use Kurt\Modules\Interactions\Facades\Interactions; Interactions::reactions()->summary($post); Interactions::friendships()->areFriends($alice, $bob);
Filament admin (optional)
Register the version-dispatching plugin on a panel — the same call works on Filament 3, 4, and 5:
use Kurt\Modules\Interactions\Filament\InteractionsPlugin; $panel->plugin(InteractionsPlugin::make());
It adds a Comments resource (moderate: approve / mark-spam / delete, filter by status; create disabled — comments come from the API/manager), a Custom emoji resource (full CRUD), and a read-only Friendships overview.
API
An out-of-the-box JSON REST API, built on the Core API kit. Safe by default:
nothing is registered until you opt in. Set the mode in config/interactions.php
(or via env) to expose the endpoints:
INTERACTIONS_HTTP_MODE=api # headless (default) | api | ui
// config/interactions.php 'http' => [ 'mode' => env('INTERACTIONS_HTTP_MODE', 'headless'), 'prefix' => 'api/interactions', // URL prefix for every route 'middleware' => ['api'], // base middleware (public reads) 'auth_middleware' => ['auth'], // appended to writes + the per-user read 'rate_limit' => '60,1', // maxAttempts,decayMinutes for interactions-api ],
Reads over the denormalized data are public; every write (and the per-user
engagement state) additionally requires auth_middleware. A host restricts
access further — its own guard, auth:sanctum, a policy middleware — purely by
changing auth_middleware; the module ships no policy of its own, so an
authenticated user may react/engage on any resolvable subject.
The {type}/{id} morph-alias contract
Every endpoint addresses a polymorphic subject as {type}/{id}, where
{type} is a morph alias — never a class name. It is resolved through
Laravel's morph map (Relation::enforceMorphMap), so only the types a host has
registered are addressable; an unknown or unmapped alias is a 404, and an
arbitrary class named in the URL is never instantiated. The model is then loaded
by id (404 when no such row exists).
// The host registers the subjects it wants exposed, e.g. in a service provider: use Illuminate\Database\Eloquent\Relations\Relation; Relation::enforceMorphMap([ 'post' => \App\Models\Post::class, 'comment' => \App\Models\Comment::class, ]);
Endpoints
All paths are relative to the configured prefix (default api/interactions).
Writes flow through the ReactionManager / InteractionManager, so the
denormalized counters and reaction summaries stay consistent.
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST |
{type}/{id}/reactions |
✓ | React with an emoji ({ "emoji": "🎉" }). Returns the updated summary. |
DELETE |
{type}/{id}/reactions |
✓ | Remove one of your emoji reactions ({ "emoji": "🎉" }). |
GET |
{type}/{id}/reactions/summary |
— | The denormalized per-emoji tally, e.g. { "🎉": 5, ":party:": 2 }. |
POST |
{type}/{id}/engagement/{kind} |
✓ | Toggle an engagement kind on/off. Returns active + counts. |
GET |
{type}/{id}/engagement |
✓ | The acting user's per-kind state (+ vote value / rating) and counts. |
GET |
{type}/{id}/counts |
— | The public engagement + reaction tallies. |
{kind} is one of like, dislike, vote, favorite, subscribe, follow.
Toggling like/dislike clears its opposite. vote accepts an optional
{ "value": 1 } or { "value": -1 } (default 1) which feeds the net score.
Responses use the Core envelope: { "data": … } on success, and
{ "message": …, "errors": … } on error (401 for a guest on a write, 404
for an unknown type/subject, 422 for a bad emoji, unsupported kind, or
self-interaction). Each group is throttled by the named interactions-api
limiter (keyed by user id, or client IP for guests).
Configuration
See config/interactions.php: mention pool + pattern, reaction rules
(unicode/custom/max), comment nesting/markdown/moderation defaults, graph
toggles, counter driver, and notification channels.
Counters
counters.driver chooses where totals live: table (denormalized
interactions_counters, the default) or none (skip the table and read counts
via a live query).
When the table is used, engagement.counters.driver chooses how it is kept in
step with each write:
-
recompute(default) – re-run a fullCOUNT(*)after each mutation. Self-healing but O(n) per write. -
atomic– O(1) in-transactionincrement/decrementof the stored tally. Run the reconcile command periodically to bound any drift:php artisan interactions:reconcile
It rewrites every counter from live interaction counts, so it is also useful after a bulk import that bypassed the write path.
reactionSummary() is backed by the same machinery: a denormalized
interactions_reaction_counts cache (per reactable + emoji) maintained on
react/unreact using the engagement.counters.driver strategy, so summaries are
served without a live groupBy. It falls back to a live aggregate when
counters.driver is none, and interactions:reconcile rebuilds the reaction
cache alongside the engagement counters.
Cleanup on delete
Models using HasInteractions (or the aggregate Interactable) purge their
interactions, ratings, reactions and counters automatically when the subject is
deleted, so a removed subject leaves no orphaned engagement behind. Soft-deletes
are skipped; the rows are reclaimed only on a real (force) delete.
Testing
composer test # Pest composer stan # PHPStan level 8 composer lint # Pint
License
MIT © Ozan Kurt