Laravel Packagev8.0.0MIT License

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.

Quick Tip

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.0 or 8.1
  • Laravel 8
  • MySQL, MariaDB, PostgreSQL or SQLite
  • PCRE2 10.40 or later for free-form emoji (PHP's bundled PCRE on most current builds)

#Composer Installation

Terminal
composer require pharaonic/laravel-reacts

The service provider is registered automatically.

#Run Migrations

The package loads its migration automatically, so the reactions table is created with your other migrations:

Terminal
php artisan migrate

To change the migration before it runs, publish it into database/migrations:

Terminal
php artisan vendor:publish --tag=reacts-migrations

The 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.

Existing reactions table

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:

Terminal
php artisan vendor:publish --tag=reacts-config

The file is published to config/pharaonic/reacts.php.

Installation Complete

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.

config/pharaonic/reacts.php
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.

Merged configuration

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.

app/Models/User.php
1namespace App\Models;
2 
3use Illuminate\Foundation\Auth\User as Authenticatable;
4use Pharaonic\Laravel\Reacts\Concerns\CanReact;
5 
6class User extends Authenticatable
7{
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.

app/Models/Post.php
1namespace App\Models;
2 
3use Illuminate\Database\Eloquent\Model;
4use Pharaonic\Laravel\Reacts\Concerns\HasReactions;
5 
6class Post extends Model
7{
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: added
2$user->react($post, 'love'); // true: a second type, in multiple mode
3$user->react($post, 'love'); // false: already there, nothing written
4$user->react($post, 'LOVE'); // false: keys are lowercased first

The 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, '🚀'); // true
2$user->react($post, '🚀'); // InvalidReactionType: the group does not allow free-form emoji

#Check and list

1$user->hasReacted($post); // true: any type
2$user->hasReacted($post, 'love'); // true: this type
3$post->reactedBy($user, 'like'); // the same check, from the reactable
4 
5$user->reactionsOn($post); // Collection of Reaction models, oldest first
6$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: removed
2$user->unreact($post, 'love'); // false: nothing to remove
3 
4$user->toggleReaction($post, 'wow'); // true: added
5$user->toggleReaction($post, 'wow'); // false: removed
6 
7$user->clearReactions($post); // 2: the number of reactions removed

toggleReaction() 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 key
2$post->topReaction(); // 'like', or null when there are none

Counts 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.

Writes need saved models

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 -.

config/pharaonic/reacts.php
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.
Icons are references

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.

config/pharaonic/reacts.php
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:

app/Models/ChatMessage.php
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(): string
12 {
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, '🚀'); // true
2$user->react($chatMessage, '👍🏽'); // true: skin tones, flags, keycaps and ZWJ sequences count as one emoji
3$user->react($chatMessage, '🚀🔥'); // InvalidReactionType: two emoji
4$user->react($chatMessage, 'nice'); // InvalidReactionType: text is never an emoji reaction

Free-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.

How emoji are checked

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:

config/pharaonic/reacts.php
1'mode' => 'single',

Or let a reactable decide with ResolvesReactionMode. The mode belongs to the reactable, never to the reactor:

app/Models/Post.php
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(): string
12 {
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: created
2$user->react($post, 'love'); // true: like replaced by love, one row
3$user->react($post, 'love'); // false: unchanged
4$user->hasReacted($post, 'like'); // false
5$user->toggleReaction($post, 'wow'); // true: love replaced by wow
6$user->toggleReaction($post, 'wow'); // false: removed
7$user->unreact($post, 'like'); // false: unreact never removes another type

A 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 next react() keeps one reaction and removes the others in the same transaction, with one ReactionReplaced;
  • 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.

routes/web.php
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');
app/Http/Controllers/PostReactionsController.php
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 Policy
21 $input = $request->validate(['type' => ['required', 'string', 'max:128']]);
22 $request->user()->toggleReaction($post, $input['type']);
23 
24 return back();
25 }
26}
resources/views/posts/show.blade.php
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 @endif
15 {{ $reaction['label'] }} ({{ $reaction['count'] }})
16 </button>
17 </form>
18@endforeach
19 
20@foreach ($reactionView['used_other'] as $reaction)
21 <span>{{ $reaction['emoji'] ?: $reaction['label'] }} ({{ $reaction['count'] }})</span>
22@endforeach
23 
24@if ($reactionView['free_form_enabled'] && auth()->check())
25 <form method="POST" action="{{ route('posts.reactions.toggle', $post) }}">
26 @csrf
27 <input name="type" placeholder="🚀" required>
28 <button type="submit">React</button>
29 </form>
30@endif

Clicking 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

routes/api.php
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.

app/Http/Controllers/PostController.php
1$posts = Post::query()->latest()->paginate(15);
2$reactionViews = ReactionView::forMany($posts->items(), $request->user());
3 
4return view('posts.index', compact('posts', 'reactionViews'));
resources/views/posts/index.blade.php
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 @endforeach
8 </article>
9@endforeach

get() 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; // 15
4$posts[0]->reactions[0]->reactor; // the User who reacted
5 
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.

Changing morph aliases or keys

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.

Global scopes

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.

#Authorization and Policies

The package does not know who is signed in or who may react to what. Authorize the request in your application first, with a Gate or Policy, before calling react() or toggleReaction():

app/Providers/AuthServiceProvider.php
1Gate::define('react', function (User $user, Post $post) {
2 return ! $user->is_banned && $post->is_published;
3});
1Gate::authorize('react', $post);
2$request->user()->toggleReaction($post, $request->input('type'));

#Reaction policy

For rules that belong to every reaction, whatever the controller, bind a ReactionPolicy. It is asked after the type passed the catalog check, inside the write's transaction, and only when the call would add a reaction: removing one never asks it.

app/Reactions/NoSelfReactions.php
1namespace App\Reactions;
2 
3use Illuminate\Database\Eloquent\Model;
4use Pharaonic\Laravel\Reacts\Contracts\ReactionPolicy;
5use Pharaonic\Laravel\Reacts\ValueObjects\ReactionContext;
6use Pharaonic\Laravel\Reacts\ValueObjects\ReactionDefinition;
7 
8class NoSelfReactions implements ReactionPolicy
9{
10 public function allows(Model $reactor, Model $reactable, ReactionDefinition $definition, ReactionContext $context): bool
11 {
12 return ! $reactor->is($reactable->author);
13 }
14}
config/pharaonic/reacts.php
1'policy' => \App\Reactions\NoSelfReactions::class,

The policy is resolved from the container, so its constructor can take dependencies. It receives:

Argument Description
$definition key(), kind(), label(), emoji(), icon(), meta() of the type being added
$context group() and mode() of the reactable

When it returns false, the call throws ReactionNotAllowed and writes nothing; in single mode the previous reaction stays. The default policy, DefaultReactionPolicy, allows everything, including reacting to yourself.

Limits are not atomic

A policy that counts rows, such as "at most three reactions per post", reads before it decides, so two requests at the same moment can both pass. Take your own lock when such a limit must be exact.

#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.

app/Listeners/NotifyAuthor.php
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.

Inside your own transaction

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:

app/Models/Reaction.php
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}
config/pharaonic/reacts.php
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.

Set them before migrating

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 written around the package

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.

app/Models/Post.php
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(): string
13 {
14 return 'posts';
15 }
16 
17 public function reactionMode(): string
18 {
19 return 'single';
20 }
21}
app/Models/ChatMessage.php
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(): string
12 {
13 return 'chat';
14 }
15}
1$user->react($post, 'like'); // true
2$user->react($post, 'love'); // true: like replaced
3$user->react($message, 'fire'); // true
4$user->react($message, '🚀'); // true: free-form emoji, alongside fire

#2. A custom reaction with an icon

config/pharaonic/reacts.php
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

app/Providers/EventServiceProvider.php
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!

Moamen Eltouny

@MoamenEltouny

50 contributions

Want to Contribute?

We welcome contributions from everyone! Whether it's bug fixes, new features, or documentation improvements - every contribution counts.