Laravel Reacts
Laravel Reacts lets any Eloquent model react to any other. A reaction is either a registered type you define in the configuration, such as like or love, or, in groups that allow it, one free-form emoji such as 🚀. Add CanReact to the model that reacts, such as a user, and HasReactions to the model that receives reactions, such as a post or a chat message. Every reaction is one row in a single reactions table; everything else, the types, their labels and emoji, and which kind of content offers which types, lives in config/pharaonic/reacts.php.
Groups of Types
Posts, comments and chat messages each offer their own list of reaction types.
Free-Form Emoji
Where a group allows it, users react with exactly one emoji of their choice.
Single or Multiple
Keep one reaction per user and post, or let users add several types.
Unique by the Database
Unique indexes stop duplicates, even when requests arrive at the same time.
Lists Without N+1
One call returns counts, choices and the viewer's selection for a whole page.
Policy and Events
Decide who may react, and listen to every reaction that was added, replaced or removed.
The package has no routes, controllers or views. Authorize the request in your application, call react() or toggleReaction(), and render the array that ReactionView returns.
#Installation
Install the package with Composer and create its table.
#Requirements
- PHP 8.3, 8.4 or 8.5
- Laravel 13
- MySQL, MariaDB, PostgreSQL or SQLite
- PCRE2 10.40 or later for free-form emoji (PHP's bundled PCRE on most current builds)
#Composer Installation
composer require pharaonic/laravel-reactsThe service provider is registered automatically.
#Run Migrations
The package loads its migration automatically, so the reactions table is created with your other migrations:
php artisan migrateTo change the migration before it runs, publish it into database/migrations:
php artisan vendor:publish --tag=reacts-migrationsThe published file keeps the package's file name, 2026_11_13_000001_create_pharaonic_reactions_table.php, so Laravel runs your copy instead of the package's, never both.
The migration stops with an error when the table already exists. Set pharaonic.reacts.table to another name before migrating, or move that table out of the way first.
#Publish Configuration
Publish the configuration to define your own reaction types and groups:
php artisan vendor:publish --tag=reacts-configThe file is published to config/pharaonic/reacts.php.
You're all set! Add CanReact and HasReactions to your models, as Basic Usage shows.
#Configuration
Every setting lives in config/pharaonic/reacts.php and is read as pharaonic.reacts.*. The file holds only arrays, strings and booleans, so php artisan config:cache works.
1return [ 2 'model' => \Pharaonic\Laravel\Reacts\Models\Reaction::class, 3 'table' => 'reactions', 4 'connection' => null, 5 6 'definitions' => [ 7 'like' => ['label' => 'Like', 'emoji' => '👍'], 8 'love' => ['label' => 'Love', 'emoji' => '❤️'], 9 'laugh' => ['label' => 'Laugh', 'emoji' => '😂'],10 'wow' => ['label' => 'Wow', 'emoji' => '😮'],11 'sad' => ['label' => 'Sad', 'emoji' => '😢'],12 'angry' => ['label' => 'Angry', 'emoji' => '😡'],13 'fire' => ['label' => 'Fire', 'emoji' => '🔥'],14 ],15 16 'default_group' => 'default',17 18 'groups' => [19 'default' => ['types' => ['like', 'love']],20 'posts' => ['extends' => 'default', 'types' => ['laugh', 'wow', 'sad', 'angry']],21 'chat' => ['types' => ['love', 'laugh', 'fire'], 'free_form' => true],22 ],23 24 'mode' => 'multiple',25 'cleanup_on_delete' => true,26 'preserve_on_soft_delete' => true,27 'policy' => \Pharaonic\Laravel\Reacts\Support\DefaultReactionPolicy::class,28];#Settings
| Key | Default | Description |
|---|---|---|
model |
Models\Reaction |
The reaction model: the package's or a subclass of it. See Custom Reaction Model. |
table |
reactions |
The table the migration creates and the model uses. Set it before migrating. |
connection |
null |
A connection in database.connections, or null for the default one. |
definitions |
7 types | Every registered type, keyed by its key. See Reaction Types and Groups. |
default_group |
default |
The group of a reactable that does not implement ResolvesReactionGroup. |
groups |
default, posts, chat |
Which types each group offers, and whether it accepts free-form emoji. |
mode |
multiple |
multiple or single. See Single and Multiple Mode. |
cleanup_on_delete |
true |
Delete the reactions a model gave or received when it is deleted. |
preserve_on_soft_delete |
true |
Keep the reactions when a model is only soft deleted. |
policy |
DefaultReactionPolicy |
A class implementing ReactionPolicy. See Authorization and Policies. |
#Validation
The package validates the configuration the first time it needs it and throws InvalidReactionConfiguration with the setting's name for an unknown group, an inheritance cycle, a type a group lists but no definition defines, a key that is not a valid type key, a definition without a label, an unsafe icon, an unknown field, a mode other than single or multiple, a model class that is not a Reaction, or a connection that does not exist.
Laravel merges the package configuration one level deep. When you publish the file, keep every key in it, and edit definitions and groups as whole arrays.
#Basic Usage
#Add the traits
Add CanReact to the model that reacts. It does not have to be a user: a team, a bot or any other model works.
1namespace App\Models;2 3use Illuminate\Foundation\Auth\User as Authenticatable;4use Pharaonic\Laravel\Reacts\Concerns\CanReact;5 6class User extends Authenticatable7{8 use CanReact;9}Add HasReactions to the model that receives reactions. It gives the model its reactions() relation, its counts and its query scope.
1namespace App\Models;2 3use Illuminate\Database\Eloquent\Model;4use Pharaonic\Laravel\Reacts\Concerns\HasReactions;5 6class Post extends Model7{8 use HasReactions;9}A model can use both traits, for example users who react to each other.
#React
react() returns whether the stored state changed:
1$user->react($post, 'like'); // true: added2$user->react($post, 'love'); // true: a second type, in multiple mode3$user->react($post, 'love'); // false: already there, nothing written4$user->react($post, 'LOVE'); // false: keys are lowercased firstThe type must be offered by the reactable's group, or react() throws InvalidReactionType. A free-form emoji works only in groups with free_form on:
1$user->react($chatMessage, '🚀'); // true2$user->react($post, '🚀'); // InvalidReactionType: the group does not allow free-form emoji#Check and list
1$user->hasReacted($post); // true: any type2$user->hasReacted($post, 'love'); // true: this type3$post->reactedBy($user, 'like'); // the same check, from the reactable4 5$user->reactionsOn($post); // Collection of Reaction models, oldest first6$user->reactionsOn($post)->pluck('type_key'); // ['like', 'love']Checks with an unsaved model return false and an empty collection.
#Remove and toggle
1$user->unreact($post, 'love'); // true: removed2$user->unreact($post, 'love'); // false: nothing to remove3 4$user->toggleReaction($post, 'wow'); // true: added5$user->toggleReaction($post, 'wow'); // false: removed6 7$user->clearReactions($post); // 2: the number of reactions removedtoggleReaction() returns the new state of that type. unreact() and toggleReaction() also remove a type the group no longer offers, so old reactions never get stuck.
#From the reactable
HasReactions has the same writes from the other side:
1$post->reactBy($user, 'like'); // the same as $user->react($post, 'like')2$post->unreactBy($user, 'like'); // the same as $user->unreact($post, 'like')#Counts
1$post->reactionCounts(); // ['like' => 10, 'love' => 5, '🚀' => 2]: most first, then by key2$post->topReaction(); // 'like', or null when there are noneCounts come from one grouped query, or from the loaded reactions relation when it is loaded. They include every stored row, also types a group no longer offers.
react(), unreact(), toggleReaction() and clearReactions() throw InvalidArgumentException when either model is not saved yet.
#Reaction Types and Groups
A reaction is stored as its key: a registered key such as love, or one free-form emoji such as 🚀. Labels, emoji and icons only describe how a type looks, so you can change them at any time without touching stored reactions.
#Definitions
definitions lists every registered type. A key is a lowercase letter followed by up to 63 lowercase letters, digits, _ or -.
1'definitions' => [2 'like' => ['label' => 'Like', 'emoji' => '👍'],3 'insightful' => ['label' => 'Insightful', 'emoji' => '💡', 'meta' => ['color' => 'amber']],4 'party_cat' => ['label' => 'Party Cat', 'icon' => '/images/reactions/party-cat.webp'],5],| Field | Required | Description |
|---|---|---|
label |
Yes | Plain text, 1 to 100 characters, no control characters. |
emoji |
No | Exactly one emoji, stored in canonical form. |
icon |
No | A relative path or an https URL of up to 1024 characters. javascript:, data:, http: and //host are refused. |
meta |
No | An array with string keys, for your own presentation data. |
The package stores and returns the icon string; it never uploads, resizes or checks the file. Serve trusted files, and keep Blade's escaping on when you render labels and icons.
#Groups
A group is the list of types one kind of content offers. A group may extend another: it offers the parent's types first, then its own.
1'groups' => [2 'default' => ['types' => ['like', 'love']],3 'posts' => ['extends' => 'default', 'types' => ['laugh', 'wow', 'sad', 'angry']],4 'chat' => ['types' => ['love', 'laugh', 'fire'], 'free_form' => true],5],Here posts offer like, love, laugh, wow, sad and angry, in that order.
A reactable uses default_group unless it implements ResolvesReactionGroup:
1namespace App\Models; 2 3use Illuminate\Database\Eloquent\Model; 4use Pharaonic\Laravel\Reacts\Concerns\HasReactions; 5use Pharaonic\Laravel\Reacts\Contracts\ResolvesReactionGroup; 6 7class ChatMessage extends Model implements ResolvesReactionGroup 8{ 9 use HasReactions;10 11 public function reactionGroup(): string12 {13 return 'chat';14 }15}reactionGroup() may depend on the model, for example return $this->is_announcement ? 'posts' : 'chat';.
#Free-form emoji
In a group with 'free_form' => true, a reactor may also react with one emoji of their choice. A group without the setting takes it from the group it extends; a group that sets nothing accepts no free-form emoji.
1$user->react($chatMessage, '🚀'); // true2$user->react($chatMessage, '👍🏽'); // true: skin tones, flags, keycaps and ZWJ sequences count as one emoji3$user->react($chatMessage, '🚀🔥'); // InvalidReactionType: two emoji4$user->react($chatMessage, 'nice'); // InvalidReactionType: text is never an emoji reactionFree-form reactions do not need a definition: they are stored in the reactions table like any other reaction, with the emoji as the key.
Every spelling of an emoji is stored the same way: an emoji that shows as an emoji by default never keeps U+FE0F, and one that shows as text by default always gets it. So ❤ and ❤️ are one reaction, and so are 🚀 and 🚀 followed by U+FE0F.
The package accepts one emoji sequence as Unicode's emoji grammar describes it, using the Unicode emoji properties of PHP's PCRE library. That needs PCRE2 10.40 or later (PCRE_VERSION). PHP builds that bundle an older PCRE, such as PHP 8.0 and 8.1 on Windows or in the official Docker images, throw InvalidReactionConfiguration for free-form emoji; registered types keep working, and the emoji of a definition gets a simpler check.
#Removing a type
Take a type out of a group, or out of definitions, and new reactions with it are refused. Existing ones stay: they are still counted, hasReacted() still sees them, and unreact() and toggleReaction() still remove them. Listing Reactions shows them under used_other.
#Single and Multiple Mode
The mode decides how many reactions one reactor keeps on one reactable.
| Mode | A reactor keeps | react($post, 'love') after like |
|---|---|---|
multiple (default) |
one reaction per type | adds love; both stay |
single |
one reaction | replaces like with love |
Repeating the same type is a no-op in both modes and returns false.
#Set the mode
Set the default for the whole application:
1'mode' => 'single',Or let a reactable decide with ResolvesReactionMode. The mode belongs to the reactable, never to the reactor:
1namespace App\Models; 2 3use Illuminate\Database\Eloquent\Model; 4use Pharaonic\Laravel\Reacts\Concerns\HasReactions; 5use Pharaonic\Laravel\Reacts\Contracts\ResolvesReactionMode; 6 7class Post extends Model implements ResolvesReactionMode 8{ 9 use HasReactions;10 11 public function reactionMode(): string12 {13 return 'single';14 }15}reactionMode() must return single or multiple, and the same answer for the same stored model on every server. Never take it from the request.
#Single mode
1$user->react($post, 'like'); // true: created2$user->react($post, 'love'); // true: like replaced by love, one row3$user->react($post, 'love'); // false: unchanged4$user->hasReacted($post, 'like'); // false5$user->toggleReaction($post, 'wow'); // true: love replaced by wow6$user->toggleReaction($post, 'wow'); // false: removed7$user->unreact($post, 'like'); // false: unreact never removes another typeA replacement is one change: the stored row takes the new type, and one ReactionReplaced event is dispatched, not a removal and a creation.
#Changing the mode later
Changing the mode does not rewrite stored rows, and reads never change them: counts and reactionsOn() show what is stored. The next write on a pair brings it in line:
- after a switch to
single, the nextreact()keeps one reaction and removes the others in the same transaction, with oneReactionReplaced; - after a switch to
multiple, the next write simply allows a second type.
To clean up every pair at once, see Integrity and Concurrency.
#Listing Reactions
ReactionView turns the reactions of a reactable into an array you can pass to Blade or return as JSON. It never writes, and holds only strings, numbers and booleans.
1use Pharaonic\Laravel\Reacts\Support\ReactionView;2 3$view = ReactionView::for($post, auth()->user())->toArray();The viewer may be null: then nothing is selected.
#The array
1{ 2 "mode": "single", 3 "free_form_enabled": false, 4 "total": 15, 5 "choices": [ 6 {"kind": "registered", "key": "like", "label": "Like", "emoji": "👍", "icon": null, "count": 10, "selected": false, "selectable": true}, 7 {"kind": "registered", "key": "love", "label": "Love", "emoji": "❤️", "icon": null, "count": 5, "selected": true, "selectable": true}, 8 {"kind": "registered", "key": "wow", "label": "Wow", "emoji": "😮", "icon": null, "count": 0, "selected": false, "selectable": true} 9 ],10 "used_other": []11}| Key | Description |
|---|---|
mode |
The reactable's mode: single or multiple. |
free_form_enabled |
Whether the reactable's group accepts free-form emoji. |
total |
Every stored reaction row, also of types the group no longer offers. In multiple mode it can be more than the number of users. |
choices |
Every type the group offers, in configuration order, also with a count of 0. |
used_other |
Stored reactions that are not choices: free-form emoji, and types the group no longer offers. Most used first. |
Each item has kind (registered or emoji), key, label, emoji, icon, count, selected (the viewer has this reaction) and selectable. selectable means the group offers the type, or, for an emoji, that the group accepts free-form emoji; it is not an authorization decision. A type with no definition left gets a label made from its key (party_cat becomes "Party cat") and no emoji or icon.
#In Blade
The package has no routes; these are your application's.
1use App\Http\Controllers\PostReactionsController;2 3Route::get('posts/{post}', [PostReactionsController::class, 'show'])->name('posts.show');4Route::post('posts/{post}/reactions/toggle', [PostReactionsController::class, 'toggle'])5 ->middleware('auth')->name('posts.reactions.toggle'); 1namespace App\Http\Controllers; 2 3use App\Models\Post; 4use Illuminate\Http\Request; 5use Illuminate\Support\Facades\Gate; 6use Pharaonic\Laravel\Reacts\Support\ReactionView; 7 8class PostReactionsController extends Controller 9{10 public function show(Post $post, Request $request)11 {12 return view('posts.show', [13 'post' => $post,14 'reactionView' => ReactionView::for($post, $request->user())->toArray(),15 ]);16 }17 18 public function toggle(Post $post, Request $request)19 {20 Gate::authorize('react', $post); // your Gate or Policy21 $input = $request->validate(['type' => ['required', 'string', 'max:128']]);22 $request->user()->toggleReaction($post, $input['type']);23 24 return back();25 }26} 1<p>Total reactions: {{ $reactionView['total'] }}</p> 2 3@foreach ($reactionView['choices'] as $reaction) 4 <form action="{{ route('posts.reactions.toggle', $post) }}" method="POST"> 5 @csrf 6 <input type="hidden" name="type" value="{{ $reaction['key'] }}"> 7 <button type="submit" 8 aria-pressed="{{ $reaction['selected'] ? 'true' : 'false' }}" 9 {{ ! auth()->check() || ! $reaction['selectable'] ? 'disabled' : '' }}>10 @if ($reaction['icon'])11 <img src="{{ $reaction['icon'] }}" alt="" width="20" height="20">12 @elseif ($reaction['emoji'])13 <span aria-hidden="true">{{ $reaction['emoji'] }}</span>14 @endif15 {{ $reaction['label'] }} ({{ $reaction['count'] }})16 </button>17 </form>18@endforeach19 20@foreach ($reactionView['used_other'] as $reaction)21 <span>{{ $reaction['emoji'] ?: $reaction['label'] }} ({{ $reaction['count'] }})</span>22@endforeach23 24@if ($reactionView['free_form_enabled'] && auth()->check())25 <form method="POST" action="{{ route('posts.reactions.toggle', $post) }}">26 @csrf27 <input name="type" placeholder="🚀" required>28 <button type="submit">React</button>29 </form>30@endifClicking a selected reaction removes it; clicking another adds it, or replaces the previous one in single mode. Disabled buttons are presentation only: the controller still authorizes every request.
#As JSON
1use App\Models\Post; 2use Illuminate\Http\Request; 3use Illuminate\Support\Facades\Gate; 4use Pharaonic\Laravel\Reacts\Support\ReactionView; 5 6Route::get('posts/{post}/reactions', function (Post $post, Request $request) { 7 return response()->json(['data' => ReactionView::for($post, $request->user())->toArray()]); 8}); 9 10Route::post('posts/{post}/reactions', function (Post $post, Request $request) {11 Gate::authorize('react', $post);12 $request->user()->toggleReaction($post, $request->validate(['type' => 'required|string|max:128'])['type']);13 14 return response()->json(['data' => ReactionView::for($post, $request->user())->toArray()]);15})->middleware('auth:api');The array holds counts and the viewer's own selection only, never other reactors' ids. Icons are strings, never files or HTML.
#A paginated feed
ReactionView::forMany() builds the views of many reactables in two queries, whatever the page size: one for every count, one for the viewer's reactions. Pass the paginator's items, not the paginator; models of different classes can be mixed.
1$posts = Post::query()->latest()->paginate(15);2$reactionViews = ReactionView::forMany($posts->items(), $request->user());3 4return view('posts.index', compact('posts', 'reactionViews'));1@foreach ($posts as $post)2 @php($reactions = $reactionViews->get($post))3 <article>4 <h3>{{ $post->title }}</h3>5 @foreach ($reactions['choices'] as $reaction)6 <span>{{ $reaction['emoji'] ?: $reaction['label'] }}: {{ $reaction['count'] }}</span>7 @endforeach8 </article>9@endforeachget() finds a model by its morph type and key, and throws InvalidArgumentException for a model that was not in the batch; has() checks first.
#Relationships and Queries
#Relations
reactions() (from HasReactions) and givenReactions() (from CanReact) are plain MorphMany relations, so eager loading and counting work as usual:
1$posts = Post::query()->withCount('reactions')->with('reactions.reactor')->latest()->get();2 3$posts[0]->reactions_count; // 154$posts[0]->reactions[0]->reactor; // the User who reacted5 6$user->givenReactions()->with('reactable')->latest()->get();Each Reaction has reactor() and reactable() MorphTo relations, and the type_key attribute: the registered key or the emoji.
#Filtering reactables
whereReactedBy() keeps the reactables a reactor reacted to, with any type or with one:
1Post::whereReactedBy($user)->latest()->paginate();2Post::whereReactedBy($user, 'love')->count();#Filtering reactions
The Reaction model has three scopes:
1use Pharaonic\Laravel\Reacts\Models\Reaction;2 3Reaction::query()->whereReactor($user)->count();4Reaction::query()->whereReactable($post)->whereType('love')->count();5Reaction::query()->whereType('🚀')->with('reactable')->get();An unsaved model matches nothing.
#Counts on many reactables
For one reactable, reactionCounts() runs one grouped query. For a list, use ReactionView::forMany(), or a grouped query of your own:
1Reaction::query()2 ->whereReactable($post)3 ->groupBy('type_key')4 ->selectRaw('type_key, count(*) as total')5 ->pluck('total', 'type_key');#Morph maps and ids
The table stores the morph type from getMorphClass(), so aliases from Relation::morphMap() are used, and keys as strings of up to 64 characters, so integer, UUID and ULID models share the table.
Stored rows keep the morph type and key they were written with. Changing a morph alias, or a model's key type, needs a data migration, as with any Laravel polymorphic relation.
The package's own reads and writes ignore global scopes on a custom reaction model. The reactions() and givenReactions() relations are plain Eloquent and apply them.
#Events
The package dispatches an event for every change it makes, and none when nothing changed. Each one is dispatched right after the write's own transaction commits.
| Event | When | Properties |
|---|---|---|
ReactionCreated |
A reaction was added without replacing another | $reaction, $reactor, $reactable |
ReactionReplaced |
In single mode, a new type replaced the previous one | $current, $previous (the replaced reactions as they were), $reactor, $reactable |
ReactionRemoved |
unreact(), toggleReaction() or clearReactions() removed a reaction |
$reaction (with exists false), $reactor, $reactable |
All three live in Pharaonic\Laravel\Reacts\Events.
1namespace App\Listeners; 2 3use Pharaonic\Laravel\Reacts\Events\ReactionCreated; 4 5class NotifyAuthor 6{ 7 public function handle(ReactionCreated $event): void 8 { 9 $event->reactable->author->notify(new \App\Notifications\NewReaction($event->reactor, $event->reaction->type_key));10 }11}$previous usually holds one reaction: the row before the change, with exists false. The replacement updates that row in place, so $current has the same id. $previous holds several reactions when single mode replaces rows left from multiple mode; the others were deleted.
No event is dispatched when react() finds the reaction already there, when unreact() has nothing to remove, or when a model's deletion removes its reactions.
When you call the package inside DB::transaction(), the events are dispatched before your transaction commits, and a rollback does not take them back. When that matters, do the follow-up work after your transaction returns.
#Custom Reaction Model
Use your own model, for example to add relations or accessors. It must extend the package's Reaction:
1namespace App\Models; 2 3use Pharaonic\Laravel\Reacts\Models\Reaction as BaseReaction; 4 5class Reaction extends BaseReaction 6{ 7 public function isEmoji(): bool 8 { 9 return \Pharaonic\Laravel\Reacts\ValueObjects\ReactionDefinition::kindOf($this->type_key) === 'emoji';10 }11}1'model' => \App\Models\Reaction::class,2'table' => 'social_reactions',3'connection' => 'mysql',Every query, relation and returned reaction uses your class: reactionsOn(), reactions(), givenReactions(), events and the scopes.
#Table and connection
The base model takes its table from table and its connection from connection. A subclass that sets $table or $connection itself wins over both, and the migration creates the table with that name, on that connection.
The migration creates the table it finds in the configuration when it runs. Renaming table afterwards does not rename the existing table.
#Deletes and Soft Deletes
When a model with CanReact is deleted, the reactions it gave are deleted. When a model with HasReactions is deleted, the reactions it received are deleted. A model with both traits loses both.
| Setting | Default | Effect |
|---|---|---|
cleanup_on_delete |
true |
false keeps reactions when a model is deleted. |
preserve_on_soft_delete |
true |
A soft delete keeps the reactions, so a restored model has them back. false deletes them on a soft delete too. |
A force delete always removes them while cleanup_on_delete is on. Cleanup runs without the reaction policy and dispatches no ReactionRemoved.
#Mass deletes
Query deletes such as Post::where('draft', true)->delete() do not fire model events, so they leave the reactions behind. Delete them with the same query on the reactions table:
1use Pharaonic\Laravel\Reacts\Models\Reaction; 2 3$ids = Post::where('draft', true)->pluck('id'); 4 5Reaction::query() 6 ->where('reactable_type', (new Post)->getMorphClass()) 7 ->whereIn('reactable_id', $ids->map(function ($id) { 8 return (string) $id; 9 }))10 ->delete();11 12Post::whereKey($ids)->delete();#Integrity and Concurrency
The rules live in the reactions table, not only in PHP, so two requests at the same moment cannot break them.
| Column | Unique | Keeps |
|---|---|---|
identity_hash |
Yes | One row per reactor, reactable and type. |
single_slot_hash |
Yes, when set | One row per reactor and reactable in single mode. It is NULL on multiple-mode rows. |
pair_hash |
No (indexed) | Finds a reactor's reactions on a reactable. |
The hashes are SHA-256 of the morph types, keys and type, so the indexes stay small on every database. A collision is theoretically possible but practically never happens.
#How a write runs
react() and toggleReaction() run in one transaction and lock the pair's existing rows first, so writes on the same pair wait for each other. In single mode a replacement updates the locked row in place rather than deleting and inserting, so the pair is never empty in between. Only on a pair with no reaction yet can two requests insert at once: the unique index refuses the second insert, and that write reads the pair again and decides anew, so a duplicate becomes a no-op and a different type in single mode becomes a replacement. Outside your own transaction, Laravel retries a deadlock or a busy SQLite database up to five times.
| Situation | Result |
|---|---|
Eight requests react like at once |
One row, one true, one ReactionCreated |
| Eight different types at once, multiple mode | One row per type |
| Eight different types at once, single mode | One row, one ReactionCreated, then a ReactionReplaced for each request that changed the type |
#Reconciling after a mode change
Switching a reactable to single mode leaves pairs with several rows until their next write. To reconcile everything at once, keep the newest row of each pair and mark it as the single-mode row, for example for posts:
1use Illuminate\Support\Facades\DB; 2use Pharaonic\Laravel\Reacts\Models\Reaction; 3 4$type = (new Post)->getMorphClass(); 5 6DB::transaction(function () use ($type) { 7 $newest = Reaction::query()->where('reactable_type', $type) 8 ->groupBy('pair_hash')->selectRaw('max(id) as id')->pluck('id'); 9 10 Reaction::query()->where('reactable_type', $type)->whereNotIn('id', $newest)->delete();11 Reaction::query()->whereIn('id', $newest)->update(['single_slot_hash' => DB::raw('pair_hash')]);12});After a switch back to multiple mode nothing needs to change; to tidy up, set single_slot_hash to NULL for that reactable type.
Rows you insert yourself must carry the same hashes the package writes. Use react() for imports when you can.
#API Reference
#CanReact
For the model that reacts.
| Method | Description | Returns |
|---|---|---|
react(Model $reactable, string $type) |
Add a reaction; in single mode it replaces the previous one. | bool: the state changed |
unreact(Model $reactable, string $type) |
Remove one type, also one no group offers any more. | bool: a row was removed |
toggleReaction(Model $reactable, string $type) |
Remove the type when it exists, add it otherwise. | bool: the type's new state |
clearReactions(Model $reactable) |
Remove every reaction on the reactable. | int: rows removed |
hasReacted(Model $reactable, ?string $type = null) |
Any reaction, or this type. false for unsaved models. |
bool |
reactionsOn(Model $reactable) |
This model's reactions on the reactable, oldest first. | Collection of Reaction |
givenReactions() |
The reactions this model gave. | MorphMany |
#HasReactions
For the model that receives reactions.
| Method | Description | Returns |
|---|---|---|
reactBy(Model $reactor, string $type) |
The same as $reactor->react($this, $type). |
bool |
unreactBy(Model $reactor, string $type) |
The same as $reactor->unreact($this, $type). |
bool |
reactedBy(Model $reactor, ?string $type = null) |
The same as $reactor->hasReacted($this, $type). |
bool |
reactionCounts() |
Stored reactions per key, most first, then by key. | array<string, int> |
topReaction() |
The key with the most reactions; ties go to the smaller key. | ?string |
reactions() |
The reactions this model received. | MorphMany |
scopeWhereReactedBy(Builder $query, Model $reactor, ?string $type = null) |
Post::whereReactedBy($user, 'love') |
void |
#Reaction model
Pharaonic\Laravel\Reacts\Models\Reaction
| Member | Description |
|---|---|
reactor(), reactable() |
MorphTo relations. |
scopeWhereReactor(Model), scopeWhereReactable(Model), scopeWhereType(string) |
Query scopes. |
reactor_type, reactor_id, reactable_type, reactable_id |
The two models, ids as strings. |
type_key |
The registered key, or the canonical emoji. |
identity_hash, pair_hash, single_slot_hash |
See Integrity and Concurrency. |
#ReactionView
Pharaonic\Laravel\Reacts\Support\ReactionView and ReactionViewBatch
| Method | Returns |
|---|---|
ReactionView::for(Model $reactable, ?Model $viewer = null) |
ReactionView |
ReactionView::forMany(iterable $reactables, ?Model $viewer = null) |
ReactionViewBatch |
ReactionView::toArray() |
The array described in Listing Reactions |
ReactionViewBatch::get(Model $reactable) |
The same array for one model of the batch |
ReactionViewBatch::has(Model $reactable) |
bool |
#Contracts
| Interface | Method |
|---|---|
ResolvesReactionGroup |
reactionGroup(): string, a group in groups |
ResolvesReactionMode |
reactionMode(): string, single or multiple |
ReactionPolicy |
allows(Model $reactor, Model $reactable, ReactionDefinition $definition, ReactionContext $context): bool |
#Exceptions
| Exception | Extends | Thrown when |
|---|---|---|
InvalidReactionType |
InvalidArgumentException |
The type is not a valid key or one emoji, the group does not offer it, or the group accepts no free-form emoji. |
ReactionNotAllowed |
RuntimeException |
The reaction policy refused a valid type. |
InvalidReactionConfiguration |
UnexpectedValueException |
A setting, reactionGroup() or reactionMode() is invalid. |
InvalidArgumentException |
A write got an unsaved model. |
All package exceptions live in Pharaonic\Laravel\Reacts\Exceptions.
#Examples
#1. Post reactions, one per user
Posts keep one reaction per user, while chat messages keep the default multiple mode and accept any emoji.
1namespace App\Models; 2 3use Illuminate\Database\Eloquent\Model; 4use Pharaonic\Laravel\Reacts\Concerns\HasReactions; 5use Pharaonic\Laravel\Reacts\Contracts\ResolvesReactionGroup; 6use Pharaonic\Laravel\Reacts\Contracts\ResolvesReactionMode; 7 8class Post extends Model implements ResolvesReactionGroup, ResolvesReactionMode 9{10 use HasReactions;11 12 public function reactionGroup(): string13 {14 return 'posts';15 }16 17 public function reactionMode(): string18 {19 return 'single';20 }21} 1namespace App\Models; 2 3use Illuminate\Database\Eloquent\Model; 4use Pharaonic\Laravel\Reacts\Concerns\HasReactions; 5use Pharaonic\Laravel\Reacts\Contracts\ResolvesReactionGroup; 6 7class ChatMessage extends Model implements ResolvesReactionGroup 8{ 9 use HasReactions;10 11 public function reactionGroup(): string12 {13 return 'chat';14 }15}1$user->react($post, 'like'); // true2$user->react($post, 'love'); // true: like replaced3$user->react($message, 'fire'); // true4$user->react($message, '🚀'); // true: free-form emoji, alongside fire#2. A custom reaction with an icon
1'definitions' => [2 // ...3 'party_cat' => ['label' => 'Party Cat', 'icon' => '/images/reactions/party-cat.webp'],4],5 6'groups' => [7 // ...8 'chat' => ['types' => ['love', 'laugh', 'fire', 'party_cat'], 'free_form' => true],9],1$user->react($message, 'party_cat');2ReactionView::for($message)->toArray()['choices'][3]['icon']; // "/images/reactions/party-cat.webp"#3. "Who reacted with what"
1$reactions = $post->reactions()->with('reactor')->latest()->limit(20)->get();2 3foreach ($reactions as $reaction) {4 echo $reaction->reactor->name.' '.$reaction->type_key;5}#4. The posts a user loved
1$loved = Post::whereReactedBy($user, 'love')->latest()->paginate(15);#5. Notify the author of a new reaction
1protected $listen = [2 \Pharaonic\Laravel\Reacts\Events\ReactionCreated::class => [\App\Listeners\NotifyAuthor::class],3 \Pharaonic\Laravel\Reacts\Events\ReactionReplaced::class => [\App\Listeners\NotifyAuthor::class],4];#Troubleshooting
#"The reaction type [x] is not selectable in the [default] group"
The reactable's group does not offer that type. Add the key to the group's types in config/pharaonic/reacts.php, or return the right group from reactionGroup(). A model without ResolvesReactionGroup uses default_group.
#"does not allow free-form emoji"
The group has no 'free_form' => true, and none of the groups it extends sets it. Turn it on for the group that should accept any emoji.
#An emoji is refused although it looks like one
A free-form reaction must be exactly one emoji: no text, spaces or second emoji. Check for a trailing space from a form field. If every emoji is refused with InvalidReactionConfiguration, PHP's PCRE is older than 10.40; check PCRE_VERSION and update PHP.
#Two rows for the same post in single mode
The post was in multiple mode when they were stored. Reads never rewrite rows; the next react() keeps one, or reconcile all of them as Integrity and Concurrency shows.
#Reactions stay after deleting models
The models were deleted with a query (Post::where(...)->delete()), which fires no model events, or cleanup_on_delete is off, or the model was only soft deleted. See Deletes and Soft Deletes.
#Counts merge different emoji on MySQL
The migration gives type_key a binary collation (utf8mb4_bin) on MySQL and MariaDB, because the default collations treat many emoji as equal. A table created another way needs that collation too.
#"The [reactions] table already exists"
Another package or migration already created a reactions table. Set pharaonic.reacts.table to another name before migrating.
Contributors
Amazing people who made this package possible
This package wouldn't exist without the contributions of our amazing community. A huge thank you to everyone who has helped improve Reacts!
Want to Contribute?
We welcome contributions from everyone! Whether it's bug fixes, new features, or documentation improvements - every contribution counts.