├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── src ├── Events │ ├── Favorited.php │ ├── Unfavorited.php │ └── Event.php ├── FavoriteServiceProvider.php ├── Traits │ ├── Favoriteable.php │ └── Favoriter.php └── Favorite.php ├── .editorconfig ├── config └── favorite.php ├── migrations └── 2018_12_14_000000_create_favorites_table.php ├── composer.json └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [overtrue] 2 | -------------------------------------------------------------------------------- /src/Events/Favorited.php: -------------------------------------------------------------------------------- 1 | favorite = $favorite; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = false 10 | 11 | [*.{vue,js,scss}] 12 | charset = utf-8 13 | indent_style = space 14 | indent_size = 2 15 | end_of_line = lf 16 | insert_final_newline = true 17 | trim_trailing_whitespace = true 18 | 19 | [*.md] 20 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /config/favorite.php: -------------------------------------------------------------------------------- 1 | false, 8 | 9 | /* 10 | * User tables foreign key name. 11 | */ 12 | 'user_foreign_key' => 'user_id', 13 | 14 | /* 15 | * Table name for favorites records. 16 | */ 17 | 'favorites_table' => 'favorites', 18 | 19 | /* 20 | * Model name for favorite record. 21 | */ 22 | 'favorite_model' => Overtrue\LaravelFavorite\Favorite::class, 23 | 24 | /* 25 | * Model name for favoriter model. 26 | */ 27 | 'favoriter_model' => App\Models\User::class, 28 | ]; 29 | -------------------------------------------------------------------------------- /src/FavoriteServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 12 | \dirname(__DIR__).'/config/favorite.php' => config_path('favorite.php'), 13 | ], 'favorite-config'); 14 | 15 | $this->publishes([ 16 | \dirname(__DIR__).'/migrations/' => database_path('migrations'), 17 | ], 'favorite-migrations'); 18 | } 19 | 20 | public function register(): void 21 | { 22 | $this->mergeConfigFrom( 23 | \dirname(__DIR__).'/config/favorite.php', 24 | 'favorite' 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /migrations/2018_12_14_000000_create_favorites_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->unsignedBigInteger(config('favorite.user_foreign_key'))->index()->comment('user_id'); 17 | $table->morphs('favoriteable'); 18 | $table->timestamps(); 19 | }); 20 | } 21 | 22 | /** 23 | * Reverse the migrations. 24 | */ 25 | public function down(): void 26 | { 27 | Schema::dropIfExists(config('favorite.favorites_table')); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | phpcs: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Setup PHP environment 15 | uses: shivammathur/setup-php@v2 16 | - name: Install dependencies 17 | run: composer install 18 | - name: PHPCSFixer check 19 | run: composer check-style 20 | phpunit: 21 | strategy: 22 | matrix: 23 | php_version: [8.1, 8.2, 8.3, 8.4, 8.5] 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v2 27 | - name: Setup PHP environment 28 | uses: shivammathur/setup-php@v2 29 | with: 30 | php-version: ${{ matrix.php_version }} 31 | coverage: xdebug 32 | - name: Install dependencies 33 | run: composer install 34 | - name: PHPUnit check 35 | run: ./vendor/bin/phpunit --coverage-text 36 | -------------------------------------------------------------------------------- /src/Traits/Favoriteable.php: -------------------------------------------------------------------------------- 1 | hasBeenFavoritedBy($user); 19 | } 20 | 21 | public function hasFavoriter(Model $user): bool 22 | { 23 | return $this->hasBeenFavoritedBy($user); 24 | } 25 | 26 | public function hasBeenFavoritedBy(Model $user): bool 27 | { 28 | if (! \is_a($user, config('favorite.favoriter_model'))) { 29 | return false; 30 | } 31 | 32 | if ($this->relationLoaded('favoriters')) { 33 | return $this->favoriters->contains($user); 34 | } 35 | 36 | return ($this->relationLoaded('favorites') ? $this->favorites : $this->favorites()) 37 | ->where(\config('favorite.user_foreign_key'), $user->getKey())->count() > 0; 38 | } 39 | 40 | public function favorites(): \Illuminate\Database\Eloquent\Relations\MorphMany 41 | { 42 | return $this->morphMany(config('favorite.favorite_model'), 'favoriteable'); 43 | } 44 | 45 | public function favoriters(): \Illuminate\Database\Eloquent\Relations\BelongsToMany 46 | { 47 | return $this->belongsToMany( 48 | config('favorite.favoriter_model'), 49 | config('favorite.favorites_table'), 50 | 'favoriteable_id', 51 | config('favorite.user_foreign_key') 52 | ) 53 | ->where('favoriteable_type', $this->getMorphClass()); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Favorite.php: -------------------------------------------------------------------------------- 1 | Favorited::class, 22 | 'deleted' => Unfavorited::class, 23 | ]; 24 | 25 | public function __construct(array $attributes = []) 26 | { 27 | $this->table = \config('favorite.favorites_table'); 28 | 29 | parent::__construct($attributes); 30 | } 31 | 32 | protected static function boot() 33 | { 34 | parent::boot(); 35 | 36 | self::saving(function ($favorite) { 37 | $userForeignKey = \config('favorite.user_foreign_key'); 38 | $favorite->{$userForeignKey} = $favorite->{$userForeignKey} ?: auth()->id(); 39 | 40 | if (\config('favorite.uuids')) { 41 | $favorite->{$favorite->getKeyName()} = $favorite->{$favorite->getKeyName()} ?: (string) Str::orderedUuid(); 42 | } 43 | }); 44 | } 45 | 46 | public function favoriteable(): \Illuminate\Database\Eloquent\Relations\MorphTo 47 | { 48 | return $this->morphTo(); 49 | } 50 | 51 | public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo 52 | { 53 | return $this->belongsTo(\config('favorite.favoriter_model'), \config('favorite.user_foreign_key')); 54 | } 55 | 56 | public function favoriter(): \Illuminate\Database\Eloquent\Relations\BelongsTo 57 | { 58 | return $this->user(); 59 | } 60 | 61 | public function scopeWithType(Builder $query, string $type): Builder 62 | { 63 | return $query->where('favoriteable_type', app($type)->getMorphClass()); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "overtrue/laravel-favorite", 3 | "description": "User favorite features for Laravel Application.", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "overtrue", 8 | "email": "anzhengchao@gmail.com" 9 | } 10 | ], 11 | "require": { 12 | "php": "^8.0.2", 13 | "laravel/framework": "^9.0|^10.0|^11.0|^12.0" 14 | }, 15 | "autoload": { 16 | "psr-4": { 17 | "Overtrue\\LaravelFavorite\\": "src" 18 | } 19 | }, 20 | "autoload-dev": { 21 | "psr-4": { 22 | "Overtrue\\LaravelFavorite\\Tests\\": "tests" 23 | } 24 | }, 25 | "require-dev": { 26 | "mockery/mockery": "^1.4.4", 27 | "phpunit/phpunit": "^10.0.0|^11.5.3", 28 | "orchestra/testbench": "^8.0|^9.0|^10.0", 29 | "friendsofphp/php-cs-fixer": "^3.5", 30 | "brainmaestro/composer-git-hooks": "dev-master", 31 | "laravel/pint": "^1.2" 32 | }, 33 | "extra": { 34 | "laravel": { 35 | "providers": [ 36 | "Overtrue\\LaravelFavorite\\FavoriteServiceProvider" 37 | ] 38 | }, 39 | "hooks": { 40 | "pre-commit": [ 41 | "composer fix-style" 42 | ], 43 | "pre-push": [ 44 | "composer test" 45 | ] 46 | } 47 | }, 48 | "scripts": { 49 | "post-update-cmd": [ 50 | "cghooks remove", 51 | "cghooks add --ignore-lock", 52 | "cghooks update" 53 | ], 54 | "post-merge": "composer install", 55 | "post-install-cmd": [ 56 | "cghooks remove", 57 | "cghooks add --ignore-lock", 58 | "cghooks update" 59 | ], 60 | "cghooks": "vendor/bin/cghooks", 61 | "check-style": "vendor/bin/pint --test", 62 | "fix-style": "vendor/bin/pint", 63 | "test": "vendor/bin/phpunit --colors=always" 64 | }, 65 | "scripts-descriptions": { 66 | "test": "Run all tests.", 67 | "check-style": "Run style checks (only dry run - no fixing!).", 68 | "fix-style": "Run style checks and fix violations." 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Traits/Favoriter.php: -------------------------------------------------------------------------------- 1 | hasFavorited($object)) { 21 | $favorite = app(config('favorite.favorite_model')); 22 | $favorite->{config('favorite.user_foreign_key')} = $this->getKey(); 23 | 24 | $object->favorites()->save($favorite); 25 | } 26 | } 27 | 28 | public function unfavorite(Model $object): void 29 | { 30 | /* @var \Overtrue\LaravelFavorite\Traits\Favoriteable $object */ 31 | $relation = $object->favorites() 32 | ->where('favoriteable_id', $object->getKey()) 33 | ->where('favoriteable_type', $object->getMorphClass()) 34 | ->where(config('favorite.user_foreign_key'), $this->getKey()) 35 | ->first(); 36 | 37 | if ($relation) { 38 | $relation->delete(); 39 | } 40 | } 41 | 42 | public function toggleFavorite(Model $object): void 43 | { 44 | $this->hasFavorited($object) ? $this->unfavorite($object) : $this->favorite($object); 45 | } 46 | 47 | public function hasFavorited(Model $object): bool 48 | { 49 | return ($this->relationLoaded('favorites') ? $this->favorites : $this->favorites()) 50 | ->where('favoriteable_id', $object->getKey()) 51 | ->where('favoriteable_type', $object->getMorphClass()) 52 | ->count() > 0; 53 | } 54 | 55 | public function favorites(): \Illuminate\Database\Eloquent\Relations\HasMany 56 | { 57 | return $this->hasMany(config('favorite.favorite_model'), config('favorite.user_foreign_key'), $this->getKeyName()); 58 | } 59 | 60 | public function attachFavoriteStatus(&$favoriteables, ?callable $resolver = null) 61 | { 62 | $favorites = $this->favorites()->get()->keyBy(function ($item) { 63 | return \sprintf('%s-%s', $item->favoriteable_type, $item->favoriteable_id); 64 | }); 65 | 66 | $attachStatus = function ($favoriteable) use ($favorites, $resolver) { 67 | $resolver = $resolver ?? fn ($m) => $m; 68 | $favoriteable = $resolver($favoriteable); 69 | 70 | if (\in_array(Favoriteable::class, \class_uses($favoriteable))) { 71 | $key = \sprintf('%s-%s', $favoriteable->getMorphClass(), $favoriteable->getKey()); 72 | $favoriteable->setAttribute('has_favorited', $favorites->has($key)); 73 | } 74 | 75 | return $favoriteable; 76 | }; 77 | 78 | switch (true) { 79 | case $favoriteables instanceof Model: 80 | return $attachStatus($favoriteables); 81 | case $favoriteables instanceof Collection: 82 | return $favoriteables->each($attachStatus); 83 | case $favoriteables instanceof LazyCollection: 84 | return $favoriteables = $favoriteables->map($attachStatus); 85 | case $favoriteables instanceof AbstractPaginator: 86 | case $favoriteables instanceof AbstractCursorPaginator: 87 | return $favoriteables->through($attachStatus); 88 | case $favoriteables instanceof Paginator: 89 | // custom paginator will return a collection 90 | return collect($favoriteables->items())->transform($attachStatus); 91 | case \is_array($favoriteables): 92 | return \collect($favoriteables)->transform($attachStatus); 93 | default: 94 | throw new \InvalidArgumentException('Invalid argument type.'); 95 | } 96 | } 97 | 98 | public function getFavoriteItems(string $model) 99 | { 100 | return app($model)->whereHas( 101 | 'favoriters', 102 | function ($q) { 103 | return $q->where(config('favorite.user_foreign_key'), $this->getKey()); 104 | } 105 | ); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Laravel Favorite 2 | 3 | ❤️ User favorite feature for Laravel Application. 4 | 5 | [![CI](https://github.com/overtrue/laravel-favorite/workflows/CI/badge.svg)](https://github.com/overtrue/laravel-favorite/actions) 6 | [![Latest Stable Version](https://poser.pugx.org/overtrue/laravel-favorite/v/stable.svg)](https://packagist.org/packages/overtrue/laravel-favorite) 7 | [![Latest Unstable Version](https://poser.pugx.org/overtrue/laravel-favorite/v/unstable.svg)](https://packagist.org/packages/overtrue/laravel-favorite) 8 | [![Total Downloads](https://poser.pugx.org/overtrue/laravel-favorite/downloads)](https://packagist.org/packages/overtrue/laravel-favorite) 9 | [![License](https://poser.pugx.org/overtrue/laravel-favorite/license)](https://packagist.org/packages/overtrue/laravel-favorite) 10 | 11 | [![Sponsor me](https://github.com/overtrue/overtrue/blob/master/sponsor-me-button-s.svg?raw=true)](https://github.com/sponsors/overtrue) 12 | 13 | ## Installing 14 | 15 | ```shell 16 | composer require overtrue/laravel-favorite -vvv 17 | ``` 18 | 19 | ### Configuration & Migrations 20 | 21 | ```php 22 | php artisan vendor:publish --provider="Overtrue\LaravelFavorite\FavoriteServiceProvider" 23 | ``` 24 | 25 | ## Usage 26 | 27 | ### Traits 28 | 29 | #### `Overtrue\LaravelFavorite\Traits\Favoriter` 30 | 31 | ```php 32 | 33 | use Illuminate\Notifications\Notifiable; 34 | use Illuminate\Contracts\Auth\MustVerifyEmail; 35 | use Illuminate\Foundation\Auth\User as Authenticatable; 36 | use Overtrue\LaravelFavorite\Traits\Favoriter; 37 | 38 | class User extends Authenticatable 39 | { 40 | use Favoriter; 41 | 42 | <...> 43 | } 44 | ``` 45 | 46 | #### `Overtrue\LaravelFavorite\Traits\Favoriteable` 47 | 48 | ```php 49 | use Illuminate\Database\Eloquent\Model; 50 | use Overtrue\LaravelFavorite\Traits\Favoriteable; 51 | 52 | class Post extends Model 53 | { 54 | use Favoriteable; 55 | 56 | <...> 57 | } 58 | ``` 59 | 60 | ### API 61 | 62 | ```php 63 | $user = User::find(1); 64 | $post = Post::find(2); 65 | 66 | $user->favorite($post); 67 | $user->unfavorite($post); 68 | $user->toggleFavorite($post); 69 | $user->getFavoriteItems(Post::class) 70 | 71 | $user->hasFavorited($post); 72 | $post->hasBeenFavoritedBy($user); 73 | ``` 74 | 75 | #### Get object favoriters: 76 | 77 | ```php 78 | foreach($post->favoriters as $user) { 79 | // echo $user->name; 80 | } 81 | ``` 82 | 83 | #### Get Favorite Model from User. 84 | 85 | Used Favoriter Trait Model can easy to get Favoriteable Models to do what you want. 86 | _note: this method will return a `Illuminate\Database\Eloquent\Builder` _ 87 | 88 | ```php 89 | $user->getFavoriteItems(Post::class); 90 | 91 | // Do more 92 | $favoritePosts = $user->getFavoriteItems(Post::class)->get(); 93 | $favoritePosts = $user->getFavoriteItems(Post::class)->paginate(); 94 | $favoritePosts = $user->getFavoriteItems(Post::class)->where('title', 'Laravel-Favorite')->get(); 95 | ``` 96 | 97 | ### Aggregations 98 | 99 | ```php 100 | // all 101 | $user->favorites()->count(); 102 | 103 | // with type 104 | $user->favorites()->withType(Post::class)->count(); 105 | 106 | // favoriters count 107 | $post->favoriters()->count(); 108 | ``` 109 | 110 | List with `*_count` attribute: 111 | 112 | ```php 113 | $users = User::withCount('favorites')->get(); 114 | 115 | foreach($users as $user) { 116 | echo $user->favorites_count; 117 | } 118 | 119 | 120 | // for Favoriteable models: 121 | $posts = Post::withCount('favoriters')->get(); 122 | 123 | foreach($posts as $post) { 124 | echo $post->favorites_count; 125 | } 126 | ``` 127 | 128 | ### Attach user favorite status to favoriteable collection 129 | 130 | You can use `Favoriter::attachFavoriteStatus($favoriteables)` to attach the user favorite status, it will set `has_favorited` attribute to each model of `$favoriteables`: 131 | 132 | #### For model 133 | 134 | ```php 135 | $post = Post::find(1); 136 | 137 | $post = $user->attachFavoriteStatus($post); 138 | 139 | // result 140 | [ 141 | "id" => 1 142 | "title" => "Add socialite login support." 143 | "created_at" => "2021-05-20T03:26:16.000000Z" 144 | "updated_at" => "2021-05-20T03:26:16.000000Z" 145 | "has_favorited" => true 146 | ], 147 | ``` 148 | 149 | #### For `Collection | Paginator | CursorPaginator | array`: 150 | 151 | ```php 152 | $posts = Post::oldest('id')->get(); 153 | 154 | $posts = $user->attachFavoriteStatus($posts); 155 | 156 | $posts = $posts->toArray(); 157 | 158 | // result 159 | [ 160 | [ 161 | "id" => 1 162 | "title" => "Post title1" 163 | "created_at" => "2021-05-20T03:26:16.000000Z" 164 | "updated_at" => "2021-05-20T03:26:16.000000Z" 165 | "has_favorited" => true 166 | ], 167 | [ 168 | "id" => 2 169 | "title" => "Post title2" 170 | "created_at" => "2021-05-20T03:26:16.000000Z" 171 | "updated_at" => "2021-05-20T03:26:16.000000Z" 172 | "has_favorited" => false 173 | ], 174 | [ 175 | "id" => 3 176 | "title" => "Post title3" 177 | "created_at" => "2021-05-20T03:26:16.000000Z" 178 | "updated_at" => "2021-05-20T03:26:16.000000Z" 179 | "has_favorited" => true 180 | ], 181 | ] 182 | ``` 183 | 184 | #### For pagination 185 | 186 | ```php 187 | $posts = Post::paginate(20); 188 | 189 | $user->attachFavoriteStatus($posts); 190 | ``` 191 | 192 | ### N+1 issue 193 | 194 | To avoid the N+1 issue, you can use eager loading to reduce this operation to just 2 queries. When querying, you may specify which relationships should be eager loaded using the `with` method: 195 | 196 | ```php 197 | // Favoriter 198 | $users = User::with('favorites')->get(); 199 | 200 | foreach($users as $user) { 201 | $user->hasFavorited($post); 202 | } 203 | 204 | // with favoriteable object 205 | $users = User::with('favorites.favoriteable')->get(); 206 | 207 | foreach($users as $user) { 208 | $user->hasFavorited($post); 209 | } 210 | 211 | // Favoriteable 212 | $posts = Post::with('favorites')->get(); 213 | // or 214 | $posts = Post::with('favoriters')->get(); 215 | 216 | foreach($posts as $post) { 217 | $post->isFavoritedBy($user); 218 | } 219 | ``` 220 | 221 | ### Events 222 | 223 | | **Event** | **Description** | 224 | | --------------------------------------------- | ------------------------------------------- | 225 | | `Overtrue\LaravelFavorite\Events\Favorited` | Triggered when the relationship is created. | 226 | | `Overtrue\LaravelFavorite\Events\Unfavorited` | Triggered when the relationship is deleted. | 227 | 228 | ## Related packages 229 | 230 | - Follow: [overtrue/laravel-follow](https://github.com/overtrue/laravel-follow) 231 | - Like: [overtrue/laravel-like](https://github.com/overtrue/laravel-like) 232 | - Favorite: [overtrue/laravel-favorite](https://github.com/overtrue/laravel-favorite) 233 | - Subscribe: [overtrue/laravel-subscribe](https://github.com/overtrue/laravel-subscribe) 234 | - Vote: [overtrue/laravel-vote](https://github.com/overtrue/laravel-vote) 235 | - Bookmark: overtrue/laravel-bookmark (working in progress) 236 | 237 | ## Contributing 238 | 239 | You can contribute in one of three ways: 240 | 241 | 1. File bug reports using the [issue tracker](https://github.com/overtrue/laravel-favorite/issues). 242 | 2. Answer questions or fix bugs on the [issue tracker](https://github.com/overtrue/laravel-favorite/issues). 243 | 3. Contribute new features or update the wiki. 244 | 245 | _The code contribution process is not very formal. You just need to make sure that you follow the PSR-0, PSR-1, and PSR-2 coding guidelines. Any new code contributions must be accompanied by unit tests where applicable._ 246 | 247 | ## :heart: Sponsor me 248 | 249 | [![Sponsor me](https://github.com/overtrue/overtrue/blob/master/sponsor-me.svg?raw=true)](https://github.com/sponsors/overtrue) 250 | 251 | 如果你喜欢我的项目并想支持它,[点击这里 :heart:](https://github.com/sponsors/overtrue) 252 | 253 | ## Project supported by JetBrains 254 | 255 | Many thanks to Jetbrains for kindly providing a license for me to work on this and other open-source projects. 256 | 257 | [![](https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.svg)](https://www.jetbrains.com/?from=https://github.com/overtrue) 258 | 259 | ## PHP 扩展包开发 260 | 261 | > 想知道如何从零开始构建 PHP 扩展包? 262 | > 263 | > 请关注我的实战课程,我会在此课程中分享一些扩展开发经验 —— [《PHP 扩展包实战教程 - 从入门到发布》](https://learnku.com/courses/creating-package) 264 | 265 | ## License 266 | 267 | MIT 268 | --------------------------------------------------------------------------------