Laravel Packagev11.0.1MIT License

Laravel Likes

Laravel Likes lets any Eloquent model like any other. Add CanLike to the model that likes, such as a user, and HasLikes to the model that is liked, such as a post. Each like is one row in a likes table, and a unique index on that table keeps every liker and likeable pair to one row, even when two requests try to create it at the same moment. like() and unlike() tell you whether they changed anything, and the likes are native Eloquent relations, so counts, filters and eager loading run in SQL.

Results That Say What Changed

like and unlike return true only when the call created or removed the like.

Unique by the Database

A unique index, not a read before the write, keeps each pair to one row.

Any Model, Both Roles

Users, bots or teams like posts, comments, products or each other, and one model can like and be liked.

Native Relations

Count, filter and eager load likes with withCount, whereHas and with.

Mixed Key Types

Integer, UUID and ULID models share one table, and morph map aliases are respected.

Clean Deletes

Deleting a model deletes its likes, while a soft delete keeps them for a restore.

Quick Tip

Send the state the user wants (like() or unlike()) from your UI instead of toggleLike(): a double click then can't undo itself.

#Installation

Install the package with Composer and create its table.

#Requirements

  • PHP 8.2, 8.3 or 8.4
  • Laravel 11
  • MySQL, MariaDB, PostgreSQL or SQLite

#Composer Installation

Terminal
composer require pharaonic/laravel-likes

The service provider is registered automatically.

#Run Migrations

The package loads its migration automatically, so the likes 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=likes-migrations

The published file keeps the package's file name, 2026_11_04_000001_create_pharaonic_likes_table.php, so Laravel runs your copy instead of the package's, never both.

Existing likes table

The migration stops with an error when a likes table already exists, for example one created by pharaonic/laravel-users. Set pharaonic.likes.table to another name before migrating, or move that table out of the way first.

#Publish Configuration

Publish the configuration only when you need another table name or want to turn off cleanup on delete:

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

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

Installation Complete

You're all set! Add CanLike and HasLikes to your models, as Basic Usage shows.

#Configuration

Both settings are optional. Publish the file with php artisan vendor:publish --tag=likes-config to change them.

config/pharaonic/likes.php
1return [
2 'table' => 'likes',
3 'cleanup_on_delete' => true,
4];
KeyDefaultDescription
pharaonic.likes.tablelikesThe table the migration creates and the Like model uses.
pharaonic.likes.cleanup_on_deletetrueDelete a model's likes when the model is deleted. See Deletes and Soft Deletes.

#Changing the table name

The migration reads pharaonic.likes.table when it runs, so set the name before the first php artisan migrate:

config/pharaonic/likes.php
1'table' => 'post_likes',

Index names follow the table name (post_likes_unique_pair, post_likes_liker_index).

Changing the setting after the migration ran does not rename the table: the package then reads and writes a table that doesn't exist. Rename the table in a migration of your own (Schema::rename('likes', 'post_likes')) at the same time as you change the setting. Rolling back the package migration drops the table the setting names at that moment.

#Basic Usage

Add CanLike to the model that likes and HasLikes to the model that is liked.

app/Models/User.php
1namespace App\Models;
2 
3use Illuminate\Foundation\Auth\User as Authenticatable;
4use Pharaonic\Laravel\Likes\Concerns\CanLike;
5 
6class User extends Authenticatable
7{
8 use CanLike;
9}
app/Models/Post.php
1namespace App\Models;
2 
3use Illuminate\Database\Eloquent\Model;
4use Pharaonic\Laravel\Likes\Concerns\HasLikes;
5 
6class Post extends Model
7{
8 use HasLikes;
9}

The liker doesn't have to be a user, and nothing in the package reads the authenticated user: a Team, a Bot or any other model can use CanLike. A model can be liked without HasLikes too; the trait adds the likes() relation and the likeBy(), unlikeBy(), likedBy() and whereLikedBy() helpers.

#Liking and unliking

like() and unlike() return whether this call changed the table, not the current state:

1$user->like($post); // true: the like was created
2$user->like($post); // false: it already existed, nothing changed
3 
4$user->unlike($post); // true: the like was removed
5$user->unlike($post); // false: there was no like, nothing changed
6 
7$user->hasLiked($post); // false

Both are idempotent: calling them again leaves the same state. Use hasLiked() when you need the current state.

#Toggling

toggleLike() returns the new state, true when the pair is liked after the call:

1$user->toggleLike($post); // true: now liked
2$user->toggleLike($post); // false: now not liked

A toggle is not idempotent: two calls cancel each other. When two toggles of the same pair arrive at the same moment, the result is not guaranteed to be what two toggles in a row would give (see Integrity and Concurrency). Prefer like() and unlike() for endpoints that a user can click twice.

#From the liked model

HasLikes adds the same operations seen from the other side. They work on the same row as the CanLike methods:

1$post->likeBy($user); // true when created, like $user->like($post)
2$post->likedBy($user); // true, like $user->hasLiked($post)
3$post->unlikeBy($user); // true when removed, like $user->unlike($post)
4 
5$user->like($post);
6$post->unlikeBy($user); // true
7$user->hasLiked($post); // false

#A model that likes and is liked

A model can use both traits. givenLikes() holds the likes it gave and likes() the likes it received:

1class User extends Authenticatable
2{
3 use CanLike, HasLikes;
4}
5 
6$alice->like($bob);
7 
8$alice->givenLikes()->count(); // 1
9$alice->likes()->count(); // 0
10$bob->likes()->count(); // 1
11$bob->likedBy($alice); // true

#Unsaved models

The writes need both models saved, because a like stores their keys. like(), unlike(), toggleLike(), likeBy() and unlikeBy() throw an InvalidArgumentException before touching the database when either model is unsaved. The reads hasLiked() and likedBy() return false, and whereLikedBy() matches nothing.

1$user->like(new Post); // InvalidArgumentException: The likeable [App\Models\Post] is not saved: ...
2$user->hasLiked(new Post); // false

#Relationships

All relations are native Eloquent relations, so everything Eloquent offers works on them.

RelationOnTypeHolds
givenLikes()CanLike modelsMorphMany of LikeThe likes the model gave.
likes()HasLikes modelsMorphMany of LikeThe likes the model received.
liker()LikeMorphToThe model that gave the like.
likeable()LikeMorphToThe model that received the like.

#Why givenLikes() and not likes()

The liker side's relation is called givenLikes() so it never clashes with likes() on the liked side. A User can use both traits without a trait conflict, and $user->likes always means the likes the user received.

#Reading through the relations

1use Pharaonic\Laravel\Likes\Models\Like;
2 
3$like = $post->likes()->latest()->first();
4 
5$like->liker; // App\Models\User
6$like->likeable; // App\Models\Post
7$like->created_at;
8 
9$user->givenLikes()->with('likeable')->latest()->get()
10 ->map(fn (Like $like) => $like->likeable); // the posts, comments, ... the user liked

#The Like model

Pharaonic\Laravel\Likes\Models\Like reads the table from pharaonic.likes.table and has standard timestamps. It has two scopes for filtering likes by one end:

1Like::whereLiker($user)->count(); // likes the user gave
2Like::whereLikeable($post)->count(); // likes the post received
3 
4User::whereHas('givenLikes', fn ($query) => $query->whereLikeable($post))->get(); // who liked the post

The likes table lives on the default database connection. Counts, filters and eager loading need it in the same database as your models.

#Querying and Counts

Counting and filtering run in SQL. None of them loads the likes into PHP.

#Counting

1$post->likes()->count(); // select count(*) ...
2 
3$posts = Post::withCount('likes')->get();
4$posts->first()->likes_count; // 12
5 
6$post->loadCount('likes');

$post->likes->count() (property, not method) loads every like of the post first. Use likes()->count() or withCount('likes'). The package has no likes_count accessor, so it never runs a hidden query.

#Filtering by liker

whereLikedBy() keeps the models the liker likes, through whereHas():

1Post::whereLikedBy($user)->latest()->paginate();

#"Did I like this?" in a list

Calling $user->hasLiked($post) for every post of a page runs one query per post. Count the viewer's like in the same query instead:

1$posts = Post::withCount([
2 'likes',
3 'likes as liked_by_me' => fn ($query) => $query->whereLiker($request->user()),
4])->latest()->paginate();
5 
6$post->likes_count; // 12
7(bool) $post->liked_by_me; // true when the viewer liked it

#Eager loading

Load likes and both ends with a fixed number of queries:

1Post::with('likes.liker')->get(); // posts, likes, then one query per liker type
2$user->givenLikes()->with('likeable')->latest()->get();

#Indexes

The migration creates:

IndexColumnsServes
likes_unique_pair (unique)likeable_type, likeable_id, liker_type, liker_idUniqueness, likes(), withCount('likes'), likedBy()
likes_liker_indexliker_type, liker_idgivenLikes(), whereLikedBy()

The ids are strings, so the relations compare your model's key as a string too. That keeps both indexes usable, and lets PostgreSQL compare a bigint or uuid key with the varchar column at all.

#Morph Maps and Ids

#Stored types

The liker_type and likeable_type columns store $model->getMorphClass(): the class name, or its alias when you register a morph map.

app/Providers/AppServiceProvider.php
1use Illuminate\Database\Eloquent\Relations\Relation;
2 
3public function boot()
4{
5 Relation::morphMap([
6 'user' => \App\Models\User::class,
7 'post' => \App\Models\Post::class,
8 ]);
9}

With this map a like stores user and post, and $like->liker resolves back to App\Models\User. Register the map before you store likes: rows stored with class names are not rewritten when you add a map later, and lookups for post won't find rows stored as App\Models\Post.

#Enforced morph maps

With Relation::enforceMorphMap(), every model that likes or is liked needs an alias. Liking with an unmapped model throws Laravel's ClassMorphViolationException before anything is written:

1Relation::enforceMorphMap([
2 'user' => \App\Models\User::class,
3 'post' => \App\Models\Post::class,
4]);
5 
6$user->like($product); // ClassMorphViolationException: No morph map defined for model [App\Models\Product].

#Type length

Types are limited to 191 characters. A longer class name or alias throws an InvalidArgumentException before anything is written; register a shorter alias.

#Integer, UUID and ULID keys

liker_id and likeable_id are varchar(64), so models with different key types share the table:

1$user->like($document); // integer liker, UUID likeable
2$document->like($comment); // UUID liker, integer likeable

Keys are stored as strings (42 becomes '42'). A key longer than 64 characters throws an InvalidArgumentException instead of being cut.

#No foreign keys

The table has no FOREIGN KEY constraints. A polymorphic column points at a different table on each row (users, bots, posts), and a foreign key can only reference one table. The package deletes a model's likes when the model is deleted instead (see Deletes and Soft Deletes).

#Events

The package dispatches two events, only when a call actually changed the table:

EventDispatched byNot dispatched
Pharaonic\Laravel\Likes\Events\LikeCreatedlike(), likeBy(), toggleLike() when they insert the likeWhen the like already existed
Pharaonic\Laravel\Likes\Events\LikeRemovedunlike(), unlikeBy(), toggleLike() when they delete the likeWhen there was no like, and for the cleanup on delete

Both have three public properties: $like (the Like row), $liker, and $likeable. In LikeRemoved, $like is the deleted row, with exists false.

app/Listeners/NotifyAuthor.php
1namespace App\Listeners;
2 
3use App\Notifications\PostLiked;
4use Pharaonic\Laravel\Likes\Events\LikeCreated;
5 
6class NotifyAuthor
7{
8 public function handle(LikeCreated $event)
9 {
10 if ($event->likeable instanceof \App\Models\Post) {
11 $event->likeable->author->notify(new PostLiked($event->liker));
12 }
13 }
14}

Laravel discovers the listener in app/Listeners from the LikeCreated type of its handle() method. To register it yourself, add it in a service provider:

app/Providers/AppServiceProvider.php
1use App\Listeners\NotifyAuthor;
2use Illuminate\Support\Facades\Event;
3use Pharaonic\Laravel\Likes\Events\LikeCreated;
4 
5public function boot(): void
6{
7 Event::listen(LikeCreated::class, NotifyAuthor::class);
8}

#When listeners run

The events are dispatched right after the insert or delete. When you like inside a database transaction, the listeners run before that transaction commits, and a rollback afterwards does not take the event back. Make a listener that must only see committed likes a queued listener dispatched after your transaction, or call like() outside it.

#Model events

LikeCreated comes with the Like model's own creating and created events, because the like is saved through Eloquent; a creating listener that returns false cancels the like, and like() then returns false. The removal deletes the row with a query, so the Like model's deleting and deleted events do not fire. Listen to LikeRemoved instead.

#Deletes and Soft Deletes

When a model that uses CanLike or HasLikes is deleted through Eloquent, its likes are deleted with it:

ActionLikes it gave (CanLike)Likes it received (HasLikes)
$model->delete()deleteddeleted
$model->delete() on a SoftDeletes modelkeptkept
$model->restore()still therestill there
$model->forceDelete()deleteddeleted

A model with both traits loses both directions. Keeping the likes on a soft delete means a restored model has exactly the likes it had.

1$post->delete(); // SoftDeletes: the likes stay
2$post->restore(); // $post->likedBy($user) is still true
3$post->forceDelete(); // the likes are deleted

The cleanup deletes the rows with one query and dispatches no LikeRemoved events.

#Turning cleanup off

config/pharaonic/likes.php
1'cleanup_on_delete' => false,

Deleting a model then leaves its likes in the table. whereLikedBy() and withCount() ignore them for models that no longer exist, but givenLikes() of a liker still lists likes of deleted posts.

#Deletes that skip the cleanup

The cleanup runs in the model's deleted event, so it only happens when a model instance is deleted. These skip it:

  • Post::where(...)->delete() and other query deletes;
  • DB::table('posts')->delete() and raw SQL;
  • truncate().

After such a delete, remove the likes yourself, for example in the same job:

1use Pharaonic\Laravel\Likes\Models\Like;
2 
3$ids = Post::where('created_at', '<', now()->subYear())->pluck('id');
4 
5Post::whereIn('id', $ids)->delete();
6Like::where('likeable_type', (new Post)->getMorphClass())
7 ->whereIn('likeable_id', $ids->map(fn ($id) => (string) $id))
8 ->delete();

To clean up after the fact, delete the likes whose model is gone:

1Like::where('likeable_type', (new Post)->getMorphClass())
2 ->whereNotIn('likeable_id', Post::withTrashed()->pluck('id')->map(fn ($id) => (string) $id))
3 ->delete();

Leave out withTrashed() for models without SoftDeletes. Run it per type, and for liker_type the same way.

#Integrity and Concurrency

#The unique index

The likes_unique_pair index on (likeable_type, likeable_id, liker_type, liker_id) is what keeps a pair to one row. The package never reads first and writes second: like() inserts the row, and when the index refuses it because the pair already has one, like() returns false. Two requests that like the same post at the same moment therefore get true and false, and the table holds one row.

Only a refusal by a unique index counts as "already liked", and only when the pair's row can then be read. Every other database error is thrown: a missing table, a lost connection, a NOT NULL failure, or a unique index of your own that refused the row.

Inside a transaction the insert runs in a savepoint, so a refused insert doesn't abort your transaction on PostgreSQL.

#Deadlocks

On MySQL, a like and an unlike (or two toggles) of the same pair at the same moment can make InnoDB roll back one of the statements to break a deadlock. Outside a transaction, the package runs that statement again, up to three times, so the call still returns its normal result. Inside your own transaction, the database has already rolled the whole transaction back, so the deadlock reaches you; run the transaction with retries:

1DB::transaction(function () use ($user, $post) {
2 $user->like($post);
3 // ...
4}, 3);

#Removing

unlike() reads the pair's like and deletes it by id. The call that deleted the row returns true and dispatches LikeRemoved; a simultaneous unlike() deletes nothing and returns false.

#Toggling under concurrency

toggleLike() inserts first: when the insert works, the pair is now liked and the call returns true. When the pair already has a like, the call deletes it and returns false. No lock is held between the two steps.

What is guaranteed:

  • the table never holds two rows for a pair;
  • every call returns the state it left the pair in;
  • events are dispatched only for changes the call made.

What is not: two toggles of the same pair at the same moment are not serialized. Starting from "liked", both may delete the same like, so the pair ends up not liked instead of liked again. When a user's intent matters, as in a like button, send the state the user wants and call like() or unlike().

#Tested databases

The test suite runs on SQLite, MySQL 8.4 and PostgreSQL 16, including tests that start several PHP processes, each with its own connection, liking, unliking or toggling the same pair at the same moment. On SQLite the database file serializes those writes; MySQL and PostgreSQL run them in parallel.

#API Reference

#CanLike

Pharaonic\Laravel\Likes\Concerns\CanLike, for the model that likes.

MethodReturnsDescription
like(Model $likeable)boolLikes the model. True when this call created the like, false when it already existed.
unlike(Model $likeable)boolRemoves the like. True when this call removed it, false when there was none.
hasLiked(Model $likeable)boolWhether this model likes the model. False when either is unsaved.
toggleLike(Model $likeable)boolLikes the model, or removes the like. Returns the new state: true when liked.
givenLikes()MorphManyThe likes this model gave.

#HasLikes

Pharaonic\Laravel\Likes\Concerns\HasLikes, for the model that is liked.

MethodReturnsDescription
likes()MorphManyThe likes this model received.
likeBy(Model $liker)boolSame as $liker->like($this), for any liker model.
unlikeBy(Model $liker)boolSame as $liker->unlike($this).
likedBy(Model $liker)boolSame as $liker->hasLiked($this).
whereLikedBy(Model $liker)scopeKeeps the models the liker likes: Post::whereLikedBy($user).

#Like

Pharaonic\Laravel\Likes\Models\Like.

MemberTypeDescription
liker()MorphToThe model that gave the like.
likeable()MorphToThe model that received the like.
whereLiker(Model $liker)scopeLikes given by the model. Matches nothing for an unsaved model.
whereLikeable(Model $likeable)scopeLikes received by the model. Matches nothing for an unsaved model.
idintPrimary key.
liker_type, likeable_typestringgetMorphClass() of each end, up to 191 characters.
liker_id, likeable_idstringThe key of each end as a string, up to 64 characters.
created_at, updated_atCarbonTimestamps.

#Events

EventPropertiesDispatched
Pharaonic\Laravel\Likes\Events\LikeCreatedLike $like, Model $liker, Model $likeableAfter a call inserted a like.
Pharaonic\Laravel\Likes\Events\LikeRemovedLike $like, Model $liker, Model $likeableAfter a call deleted a like (not on cleanup).

#Exceptions

ExceptionThrown byWhen
InvalidArgumentExceptionlike(), unlike(), toggleLike(), likeBy(), unlikeBy()A model is unsaved or has no key, a key is longer than 64 characters, or a morph type is longer than 191. Nothing is written.
Illuminate\Database\ClassMorphViolationExceptionevery methodRelation::enforceMorphMap() is on and a model has no alias. Nothing is written.
Illuminate\Database\QueryExceptionevery methodAny database error other than the pair's own unique index refusing a duplicate.

#Configuration and publish tags

KeyDefault
pharaonic.likes.tablelikes
pharaonic.likes.cleanup_on_deletetrue
TagPublishes
likes-config, pharaonic-configconfig/pharaonic/likes.php
likes-migrations, pharaonic-migrationsdatabase/migrations/2026_11_04_000001_create_pharaonic_likes_table.php
laravel-likes, pharaonicboth

#Examples

#1. A like button

Send the state the user wants, so a double click can't undo itself, and return the count for the button.

routes/web.php
1use App\Http\Controllers\PostLikeController;
2 
3Route::middleware('auth')->group(function () {
4 Route::post('posts/{post}/like', [PostLikeController::class, 'store']);
5 Route::delete('posts/{post}/like', [PostLikeController::class, 'destroy']);
6});
app/Http/Controllers/PostLikeController.php
1namespace App\Http\Controllers;
2 
3use App\Models\Post;
4use Illuminate\Http\Request;
5 
6class PostLikeController extends Controller
7{
8 public function store(Request $request, Post $post)
9 {
10 $request->user()->like($post);
11 
12 return ['liked' => true, 'likes' => $post->likes()->count()];
13 }
14 
15 public function destroy(Request $request, Post $post)
16 {
17 $request->user()->unlike($post);
18 
19 return ['liked' => false, 'likes' => $post->likes()->count()];
20 }
21}

#2. A feed with counts and the viewer's likes

One query for the posts, their counts and whether the viewer liked each one.

app/Http/Controllers/FeedController.php
1public function index(Request $request)
2{
3 $posts = Post::withCount([
4 'likes',
5 'likes as liked_by_me' => fn ($query) => $query->whereLiker($request->user()),
6 ])->latest()->paginate(20);
7 
8 return view('feed', compact('posts'));
9}

#3. A "liked posts" page

1$posts = Post::whereLikedBy($request->user())->latest()->paginate(20);

Or newest like first, with every type the user liked:

1$likes = $request->user()->givenLikes()->with('likeable')->latest()->paginate(20);

#4. Users who like each other

User uses both traits, so users can like users.

app/Models/User.php
1class User extends Authenticatable
2{
3 use CanLike, HasLikes;
4}
1$alice->like($bob);
2 
3$bob->likes()->with('liker')->get(); // who liked Bob
4$alice->givenLikes()->count(); // how many likes Alice gave
5User::whereLikedBy($alice)->get(); // the users Alice likes

#5. Notifying the author

app/Listeners/NotifyAuthor.php
1public function handle(LikeCreated $event)
2{
3 if ($event->likeable instanceof Post && ! $event->likeable->author->is($event->liker)) {
4 $event->likeable->author->notify(new PostLiked($event->liker));
5 }
6}

Laravel discovers it from the LikeCreated type of handle(); see Events to register it yourself.

#Troubleshooting

#The [likes] table already exists when migrating

Another package, usually pharaonic/laravel-users, created a likes table. Set pharaonic.likes.table to another name before migrating, or move that table out of the way first.

#like() returns false the first time

The pair was already liked: like() returns whether this call created the like. Use hasLiked() for the current state. A creating listener on the Like model that returns false also makes like() return false.

#The liker [App\Models\User] is not saved

A write was called with a model that isn't saved. Save it first, or check $model->exists.

#Register a shorter alias with Relation::morphMap()

The class name is longer than the 191 characters the type columns hold. Add a morph map alias for the model.

#Illegal mix of collations on MySQL

Your model's table and the likes table use different collations, and the model has a string key (UUID, ULID). Create both with the same collation, the collation of your database connection.

#operator does not exist: bigint = character varying on PostgreSQL

A query compares a model key with the string likeable_id column directly, for example a hand-written join. Go through the package relations (likes(), givenLikes(), whereLikedBy(), withCount('likes')), which compare the key as a string, or cast it yourself: cast(posts.id as varchar) = likes.likeable_id.

#Likes stay after deleting models

The models were deleted with a query (Post::where(...)->delete()), which skips model events, or cleanup_on_delete is false. See Deletes and Soft Deletes for the manual cleanup.

#Likes are missing after adding a morph map

Rows stored before the map hold class names, and the package now looks for the alias. Update those rows deliberately, type by type, for example UPDATE likes SET likeable_type = 'post' WHERE likeable_type = 'App\\Models\\Post';.

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 Likes!

Moamen Eltouny

@MoamenEltouny

66 contributions

Want to Contribute?

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