Laravel Favorites
Laravel Favorites lets any Eloquent model favorite any other. Add CanFavorite to the model that favorites, such as a user, and HasFavorites to the model that is favorited, such as a post. Each favorite is one row in a favorites table, and a unique index on that table keeps every favoriter and favoritable pair to one row, even when two requests try to create it at the same moment. favorite() and unfavorite() tell you whether they changed anything, and the favorites are native Eloquent relations, so counts, filters and eager loading run in SQL.
Results That Say What Changed
favorite and unfavorite return true only when the call created or removed the favorite.
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 favorite posts, comments, products or each other, and one model can favorite and be favorited.
Native Relations
Count, filter and eager load favorites 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 favorites, while a soft delete keeps them for a restore.
Send the state the user wants (favorite() or unfavorite()) from your UI instead of toggleFavorite(): a double click then can't undo itself.
#Installation
Install the package with Composer and create its table.
#Requirements
- PHP 8.0
- Laravel 7
- MySQL, MariaDB, PostgreSQL or SQLite
#Composer Installation
composer require pharaonic/laravel-favoritesThe service provider is registered automatically.
#Run Migrations
The package loads its migration automatically, so the favorites 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=favorites-migrationsThe published file keeps the package's file name, 2026_11_12_000001_create_pharaonic_favorites_table.php, so Laravel runs your copy instead of the package's, never both.
The migration stops with an error when a favorites table already exists, for example one that another package or an earlier migration created. Set pharaonic.favorites.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:
php artisan vendor:publish --tag=favorites-configThe file is published to config/pharaonic/favorites.php.
You're all set! Add CanFavorite and HasFavorites to your models, as Basic Usage shows.
#Configuration
Both settings are optional. Publish the file with php artisan vendor:publish --tag=favorites-config to change them.
1return [2 'table' => 'favorites',3 'cleanup_on_delete' => true,4];| Key | Default | Description |
|---|---|---|
pharaonic.favorites.table | favorites | The table the migration creates and the Favorite model uses. |
pharaonic.favorites.cleanup_on_delete | true | Delete a model's favorites when the model is deleted. See Deletes and Soft Deletes. |
#Changing the table name
The migration reads pharaonic.favorites.table when it runs, so set the name before the first php artisan migrate:
1'table' => 'post_favorites',Index names follow the table name (post_favorites_unique_pair, post_favorites_favoritable_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('favorites', 'post_favorites')) 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 CanFavorite to the model that favorites and HasFavorites to the model that is favorited.
1namespace App;2 3use Illuminate\Foundation\Auth\User as Authenticatable;4use Pharaonic\Laravel\Favorites\Concerns\CanFavorite;5 6class User extends Authenticatable7{8 use CanFavorite;9}1namespace App;2 3use Illuminate\Database\Eloquent\Model;4use Pharaonic\Laravel\Favorites\Concerns\HasFavorites;5 6class Post extends Model7{8 use HasFavorites;9}The favoriter 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 CanFavorite. A model can be favorited without HasFavorites too; the trait adds the favorites() relation and the favoriteBy(), unfavoriteBy(), favoritedBy() and whereFavoritedBy() helpers.
#Favoriting and unfavoriting
favorite() and unfavorite() return whether this call changed the table, not the current state:
1$user->favorite($post); // true: the favorite was created2$user->favorite($post); // false: it already existed, nothing changed3 4$user->unfavorite($post); // true: the favorite was removed5$user->unfavorite($post); // false: there was no favorite, nothing changed6 7$user->hasFavorited($post); // falseBoth are idempotent: calling them again leaves the same state. Use hasFavorited() when you need the current state.
#Toggling
toggleFavorite() returns the new state, true when the pair is favorited after the call:
1$user->toggleFavorite($post); // true: now favorited2$user->toggleFavorite($post); // false: now not favoritedA 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 favorite() and unfavorite() for endpoints that a user can click twice.
#From the favorited model
HasFavorites adds the same operations seen from the other side. They work on the same row as the CanFavorite methods:
1$post->favoriteBy($user); // true when created, like $user->favorite($post)2$post->favoritedBy($user); // true, like $user->hasFavorited($post)3$post->unfavoriteBy($user); // true when removed, like $user->unfavorite($post)4 5$user->favorite($post);6$post->unfavoriteBy($user); // true7$user->hasFavorited($post); // false#A model that favorites and is favorited
A model can use both traits. givenFavorites() holds the favorites it gave and favorites() the favorites it received:
1class User extends Authenticatable 2{ 3 use CanFavorite, HasFavorites; 4} 5 6$alice->favorite($bob); 7 8$alice->givenFavorites()->count(); // 1 9$alice->favorites()->count(); // 010$bob->favorites()->count(); // 111$bob->favoritedBy($alice); // true#Unsaved models
The writes need both models saved, because a favorite stores their keys. favorite(), unfavorite(), toggleFavorite(), favoriteBy() and unfavoriteBy() throw an InvalidArgumentException before touching the database when either model is unsaved. The reads hasFavorited() and favoritedBy() return false, and whereFavoritedBy() matches nothing.
1$user->favorite(new Post); // InvalidArgumentException: The favoritable [App\Post] is not saved: ...2$user->hasFavorited(new Post); // false#Relationships
All relations are native Eloquent relations, so everything Eloquent offers works on them.
| Relation | On | Type | Holds |
|---|---|---|---|
givenFavorites() | CanFavorite models | MorphMany of Favorite | The favorites the model gave. |
favorites() | HasFavorites models | MorphMany of Favorite | The favorites the model received. |
favoriter() | Favorite | MorphTo | The model that gave the favorite. |
favoritable() | Favorite | MorphTo | The model that received the favorite. |
#Why givenFavorites() and not favorites()
The favoriter side's relation is called givenFavorites() so it never clashes with favorites() on the favorited side. A User can use both traits without a trait conflict, and $user->favorites always means the favorites the user received.
#Reading through the relations
1use Pharaonic\Laravel\Favorites\Models\Favorite; 2 3$favorite = $post->favorites()->latest()->first(); 4 5$favorite->favoriter; // App\User 6$favorite->favoritable; // App\Post 7$favorite->created_at; 8 9$user->givenFavorites()->with('favoritable')->latest()->get()10 ->map(fn (Favorite $favorite) => $favorite->favoritable); // the posts, comments, ... the user favorited#The Favorite model
Pharaonic\Laravel\Favorites\Models\Favorite reads the table from pharaonic.favorites.table and has standard timestamps. It has two scopes for filtering favorites by one end:
1Favorite::whereFavoriter($user)->count(); // favorites the user gave2Favorite::whereFavoritable($post)->count(); // favorites the post received3 4User::whereHas('givenFavorites', fn ($query) => $query->whereFavoritable($post))->get(); // who favorited the postThe favorites 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 favorites into PHP.
#Counting
1$post->favorites()->count(); // select count(*) ...2 3$posts = Post::withCount('favorites')->get();4$posts->first()->favorites_count; // 125 6$post->loadCount('favorites');$post->favorites->count() (property, not method) loads every favorite of the post first. Use favorites()->count() or withCount('favorites'). The package has no favorites_count accessor, so it never runs a hidden query.
#Filtering by favoriter
whereFavoritedBy() keeps the models the favoriter favorites, through whereHas():
1Post::whereFavoritedBy($user)->latest()->paginate();#"Did I favorite this?" in a list
Calling $user->hasFavorited($post) for every post of a page runs one query per post. Count the viewer's favorite in the same query instead:
1$posts = Post::withCount([2 'favorites',3 'favorites as favorited_by_me' => fn ($query) => $query->whereFavoriter($request->user()),4])->latest()->paginate();5 6$post->favorites_count; // 127(bool) $post->favorited_by_me; // true when the viewer favorited it#Eager loading
Load favorites and both ends with a fixed number of queries:
1Post::with('favorites.favoriter')->get(); // posts, favorites, then one query per favoriter type2$user->givenFavorites()->with('favoritable')->latest()->get();#Indexes
The migration creates:
| Index | Columns | Serves |
|---|---|---|
favorites_unique_pair (unique) | favoriter_type, favoriter_id, favoritable_type, favoritable_id | Uniqueness, hasFavorited(), favoritedBy(), whereFavoritedBy(), givenFavorites() |
favorites_favoritable_index | favoritable_type, favoritable_id | favorites(), withCount('favorites') |
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 favoriter_type and favoritable_type columns store $model->getMorphClass(): the class name, or its alias when you register a morph map.
1use Illuminate\Database\Eloquent\Relations\Relation;2 3public function boot()4{5 Relation::morphMap([6 'user' => \App\User::class,7 'post' => \App\Post::class,8 ]);9}With this map a favorite stores user and post, and $favorite->favoriter resolves back to App\User. Register the map before you store favorites: 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\Post.
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
favoriter_id and favoritable_id are varchar(64), so models with different key types share the table:
1$user->favorite($document); // integer favoriter, UUID favoritable2$document->favorite($comment); // UUID favoriter, integer favoritableKeys 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 favorites 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:
| Event | Dispatched by | Not dispatched |
|---|---|---|
Pharaonic\Laravel\Favorites\Events\FavoriteCreated | favorite(), favoriteBy(), toggleFavorite() when they insert the favorite | When the favorite already existed |
Pharaonic\Laravel\Favorites\Events\FavoriteRemoved | unfavorite(), unfavoriteBy(), toggleFavorite() when they delete the favorite | When there was no favorite, and for the cleanup on delete |
Both have three public properties: $favorite (the Favorite row), $favoriter, and $favoritable. In FavoriteRemoved, $favorite is the deleted row, with exists false.
1namespace App\Listeners; 2 3use App\Notifications\PostFavorited; 4use Pharaonic\Laravel\Favorites\Events\FavoriteCreated; 5 6class NotifyAuthor 7{ 8 public function handle(FavoriteCreated $event) 9 {10 if ($event->favoritable instanceof \App\Post) {11 $event->favoritable->author->notify(new PostFavorited($event->favoriter));12 }13 }14}1protected $listen = [2 \Pharaonic\Laravel\Favorites\Events\FavoriteCreated::class => [3 \App\Listeners\NotifyAuthor::class,4 ],5];#When listeners run
The events are dispatched right after the insert or delete. When you favorite 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 favorites a queued listener dispatched after your transaction, or call favorite() outside it.
#Model events
FavoriteCreated comes with the Favorite model's own creating and created events, because the favorite is saved through Eloquent; a creating listener that returns false cancels the favorite, and favorite() then returns false. The removal deletes the row with a query, so the Favorite model's deleting and deleted events do not fire. Listen to FavoriteRemoved instead.
#Deletes and Soft Deletes
When a model that uses CanFavorite or HasFavorites is deleted through Eloquent, its favorites are deleted with it:
| Action | Favorites it gave (CanFavorite) | Favorites it received (HasFavorites) |
|---|---|---|
$model->delete() | deleted | deleted |
$model->delete() on a SoftDeletes model | kept | kept |
$model->restore() | still there | still there |
$model->forceDelete() | deleted | deleted |
A model with both traits loses both directions. Keeping the favorites on a soft delete means a restored model has exactly the favorites it had.
1$post->delete(); // SoftDeletes: the favorites stay2$post->restore(); // $post->favoritedBy($user) is still true3$post->forceDelete(); // the favorites are deletedThe cleanup deletes the rows with one query and dispatches no FavoriteRemoved events.
#Turning cleanup off
1'cleanup_on_delete' => false,Deleting a model then leaves its favorites in the table. whereFavoritedBy() and withCount() ignore them for models that no longer exist, but givenFavorites() of a favoriter still lists favorites of deleted posts, whose favoritable is null.
#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;ON DELETE CASCADEforeign keys of your own tables;truncate().
After such a delete, remove the favorites yourself, for example in the same job:
1use Pharaonic\Laravel\Favorites\Models\Favorite;2 3$ids = Post::where('created_at', '<', now()->subYear())->pluck('id');4 5Post::whereIn('id', $ids)->delete();6Favorite::where('favoritable_type', (new Post)->getMorphClass())7 ->whereIn('favoritable_id', $ids->map(fn ($id) => (string) $id))8 ->delete();To clean up after the fact, delete the favorites whose model is gone:
1Favorite::where('favoritable_type', (new Post)->getMorphClass())2 ->whereNotIn('favoritable_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 favoriter_type the same way.
#Integrity and Concurrency
#The unique index
The favorites_unique_pair index on (favoriter_type, favoriter_id, favoritable_type, favoritable_id) is what keeps a pair to one row. The package never reads first and writes second: favorite() inserts the row, and when the index refuses it because the pair already has one, favorite() returns false. Two requests that favorite 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 favorited", 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. The pair's row is then read with a locking read, so a favorite another request committed after your transaction started is still found: on MySQL, whose transactions read from a snapshot, a plain read would miss it.
#Deadlocks
On MySQL, a favorite and an unfavorite (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->favorite($post);3 // ...4}, 3);#Removing
unfavorite() reads the pair's favorite and deletes it by id. The call that deleted the row returns true and dispatches FavoriteRemoved; a simultaneous unfavorite() deletes nothing and returns false.
#Toggling under concurrency
toggleFavorite() inserts first: when the insert works, the pair is now favorited and the call returns true. When the pair already has a favorite, the call deletes it and returns false. Nothing serializes 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 "favorited", both may delete the same favorite, so the pair ends up not favorited instead of favorited again. When a user's intent matters, as in a favorite button, send the state the user wants and call favorite() or unfavorite().
#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, favoriting, unfavoriting 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
#CanFavorite
Pharaonic\Laravel\Favorites\Concerns\CanFavorite, for the model that favorites.
| Method | Returns | Description |
|---|---|---|
favorite(Model $favoritable) | bool | Favorites the model. True when this call created the favorite, false when it already existed. |
unfavorite(Model $favoritable) | bool | Removes the favorite. True when this call removed it, false when there was none. |
hasFavorited(Model $favoritable) | bool | Whether this model favorites the model. False when either is unsaved. |
toggleFavorite(Model $favoritable) | bool | Favorites the model, or removes the favorite. Returns the new state: true when favorited. |
givenFavorites() | MorphMany | The favorites this model gave. |
#HasFavorites
Pharaonic\Laravel\Favorites\Concerns\HasFavorites, for the model that is favorited.
| Method | Returns | Description |
|---|---|---|
favorites() | MorphMany | The favorites this model received. |
favoriteBy(Model $favoriter) | bool | Same as $favoriter->favorite($this), for any favoriter model. |
unfavoriteBy(Model $favoriter) | bool | Same as $favoriter->unfavorite($this). |
favoritedBy(Model $favoriter) | bool | Same as $favoriter->hasFavorited($this). |
whereFavoritedBy(Model $favoriter) | scope | Keeps the models the favoriter favorites: Post::whereFavoritedBy($user). |
#Favorite
Pharaonic\Laravel\Favorites\Models\Favorite.
| Member | Type | Description |
|---|---|---|
favoriter() | MorphTo | The model that gave the favorite. |
favoritable() | MorphTo | The model that received the favorite. |
whereFavoriter(Model $favoriter) | scope | Favorites given by the model. Matches nothing for an unsaved model. |
whereFavoritable(Model $favoritable) | scope | Favorites received by the model. Matches nothing for an unsaved model. |
id | int | Primary key. |
favoriter_type, favoritable_type | string | getMorphClass() of each end, up to 191 characters. |
favoriter_id, favoritable_id | string | The key of each end as a string, up to 64 characters. |
created_at, updated_at | Carbon | Timestamps. |
#Events
| Event | Properties | Dispatched |
|---|---|---|
Pharaonic\Laravel\Favorites\Events\FavoriteCreated | Favorite $favorite, Model $favoriter, Model $favoritable | After a call inserted a favorite. |
Pharaonic\Laravel\Favorites\Events\FavoriteRemoved | Favorite $favorite, Model $favoriter, Model $favoritable | After a call deleted a favorite (not on cleanup). |
#Exceptions
| Exception | Thrown by | When |
|---|---|---|
InvalidArgumentException | favorite(), unfavorite(), toggleFavorite(), favoriteBy(), unfavoriteBy() | 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\QueryException | every method | Any database error other than the pair's own unique index refusing a duplicate. |
#Configuration and publish tags
| Key | Default |
|---|---|
pharaonic.favorites.table | favorites |
pharaonic.favorites.cleanup_on_delete | true |
| Tag | Publishes |
|---|---|
favorites-config, pharaonic-config | config/pharaonic/favorites.php |
favorites-migrations, pharaonic-migrations | database/migrations/2026_11_12_000001_create_pharaonic_favorites_table.php |
laravel-favorites, pharaonic | both |
#Examples
#1. A favorite button
Send the state the user wants, so a double click can't undo itself, and return the count for the button.
1Route::middleware('auth')->group(function () {2 Route::post('posts/{post}/favorite', 'PostFavoriteController@store');3 Route::delete('posts/{post}/favorite', 'PostFavoriteController@destroy');4}); 1namespace App\Http\Controllers; 2 3use App\Post; 4use Illuminate\Http\Request; 5 6class PostFavoriteController extends Controller 7{ 8 public function store(Request $request, Post $post) 9 {10 $request->user()->favorite($post);11 12 return ['favorited' => true, 'favorites' => $post->favorites()->count()];13 }14 15 public function destroy(Request $request, Post $post)16 {17 $request->user()->unfavorite($post);18 19 return ['favorited' => false, 'favorites' => $post->favorites()->count()];20 }21}#2. A feed with counts and the viewer's favorites
One query for the posts, their counts and whether the viewer favorited each one.
1public function index(Request $request)2{3 $posts = Post::withCount([4 'favorites',5 'favorites as favorited_by_me' => fn ($query) => $query->whereFavoriter($request->user()),6 ])->latest()->paginate(20);7 8 return view('feed', compact('posts'));9}#3. A "favorited posts" page
1$posts = Post::whereFavoritedBy($request->user())->latest()->paginate(20);Or newest favorite first, with every type the user favorited:
1$favorites = $request->user()->givenFavorites()->with('favoritable')->latest()->paginate(20);#4. Users who favorite each other
User uses both traits, so users can favorite users.
1class User extends Authenticatable2{3 use CanFavorite, HasFavorites;4}1$alice->favorite($bob);2 3$bob->favorites()->with('favoriter')->get(); // who favorited Bob4$alice->givenFavorites()->count(); // how many favorites Alice gave5User::whereFavoritedBy($alice)->get(); // the users Alice favorites#5. Notifying the author
1public function handle(FavoriteCreated $event)2{3 if ($event->favoritable instanceof Post && ! $event->favoritable->author->is($event->favoriter)) {4 $event->favoritable->author->notify(new PostFavorited($event->favoriter));5 }6}Register it for Pharaonic\Laravel\Favorites\Events\FavoriteCreated in your EventServiceProvider.
#Troubleshooting
#The [favorites] table already exists when migrating
Another package or an earlier migration created a favorites table. Set pharaonic.favorites.table to another name before migrating, or move that table out of the way first.
#favorite() returns false the first time
The pair was already favorited: favorite() returns whether this call created the favorite. Use hasFavorited() for the current state. A creating listener on the Favorite model that returns false also makes favorite() return false.
#The favoriter [App\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 favorites 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 favoritable_id column directly, for example a hand-written join. Go through the package relations (favorites(), givenFavorites(), whereFavoritedBy(), withCount('favorites')), which compare the key as a string, or cast it yourself: cast(posts.id as varchar) = favorites.favoritable_id.
#Favorites 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.
#Favorites 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 favorites SET favoritable_type = 'post' WHERE favoritable_type = 'App\\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 Favorites!
Want to Contribute?
We welcome contributions from everyone! Whether it's bug fixes, new features, or documentation improvements - every contribution counts.