├── .editorconfig ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── ci.yml ├── README.md ├── composer.json ├── config └── like.php ├── migrations └── 2018_12_14_000000_create_likes_table.php ├── phpunit.xml.dist.bak └── src ├── Events ├── Event.php ├── Liked.php └── Unliked.php ├── Like.php ├── LikeServiceProvider.php └── Traits ├── Likeable.php └── Liker.php /.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 -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [overtrue] 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: composer 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "21:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: laravel/framework 11 | versions: 12 | - ">= 6.a, < 7" 13 | -------------------------------------------------------------------------------- /.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 | with: 17 | php-version: 8.1 18 | - name: Install dependencies 19 | run: composer install 20 | - name: pint check 21 | run: composer check-style 22 | phpunit: 23 | strategy: 24 | matrix: 25 | php_version: [8.2, 8.3, 8.4] 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v2 29 | - name: Setup PHP environment 30 | uses: shivammathur/setup-php@v2 31 | with: 32 | php-version: ${{ matrix.php_version }} 33 | coverage: xdebug 34 | - name: Install dependencies 35 | run: composer install 36 | - name: PHPUnit check 37 | run: ./vendor/bin/phpunit --coverage-text 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Like 2 | 3 | 👍 User-like features for Laravel Application. 4 | 5 | [![CI](https://github.com/overtrue/laravel-like/actions/workflows/ci.yml/badge.svg)](https://github.com/overtrue/laravel-like/actions/workflows/ci.yml) 6 | 7 | [![Sponsor me](https://github.com/overtrue/overtrue/blob/master/sponsor-me-button-s.svg?raw=true)](https://github.com/sponsors/overtrue) 8 | 9 | ## Installing 10 | 11 | ```shell 12 | composer require overtrue/laravel-like -vvv 13 | ``` 14 | 15 | ### Configuration and Migrations 16 | 17 | ```shell 18 | php artisan vendor:publish --provider="Overtrue\LaravelLike\LikeServiceProvider" 19 | ``` 20 | 21 | ## Usage 22 | 23 | ### Traits 24 | 25 | #### `Overtrue\LaravelLike\Traits\Liker` 26 | 27 | ```php 28 | 29 | use Illuminate\Notifications\Notifiable; 30 | use Illuminate\Contracts\Auth\MustVerifyEmail; 31 | use Illuminate\Foundation\Auth\User as Authenticatable; 32 | use Overtrue\LaravelLike\Traits\Liker; 33 | 34 | class User extends Authenticatable 35 | { 36 | use Liker; 37 | 38 | <...> 39 | } 40 | ``` 41 | 42 | #### `Overtrue\LaravelLike\Traits\Likeable` 43 | 44 | ```php 45 | use Illuminate\Database\Eloquent\Model; 46 | use Overtrue\LaravelLike\Traits\Likeable; 47 | 48 | class Post extends Model 49 | { 50 | use Likeable; 51 | 52 | <...> 53 | } 54 | ``` 55 | 56 | ### API 57 | 58 | ```php 59 | $user = User::find(1); 60 | $post = Post::find(2); 61 | 62 | $user->like($post); 63 | $user->unlike($post); 64 | $user->toggleLike($post); 65 | 66 | $user->hasLiked($post); 67 | $post->isLikedBy($user); 68 | ``` 69 | 70 | Get user likes with pagination: 71 | 72 | ```php 73 | $likes = $user->likes()->with('likeable')->paginate(20); 74 | 75 | foreach ($likes as $like) { 76 | $like->likeable; // App\Post instance 77 | } 78 | ``` 79 | 80 | Get object likers: 81 | 82 | ```php 83 | foreach($post->likers as $user) { 84 | // echo $user->name; 85 | } 86 | ``` 87 | 88 | with pagination: 89 | 90 | ```php 91 | $likers = $post->likers()->paginate(20); 92 | 93 | foreach($likers as $user) { 94 | // echo $user->name; 95 | } 96 | ``` 97 | 98 | ### Aggregations 99 | 100 | ```php 101 | // all 102 | $user->likes()->count(); 103 | 104 | // short way 105 | $user->totalLikes; 106 | 107 | // with type 108 | $user->likes()->withType(Post::class)->count(); 109 | 110 | // likers count 111 | $post->likers()->count(); 112 | 113 | // short way 114 | $post->totalLikers 115 | ``` 116 | 117 | List with `*_count` attribute: 118 | 119 | ```php 120 | // likes_count 121 | $users = User::withCount('likes')->get(); 122 | 123 | foreach($users as $user) { 124 | // $user->likes_count; 125 | } 126 | 127 | // likers_count 128 | $posts = User::withCount('likers')->get(); 129 | 130 | foreach($posts as $post) { 131 | // $post->likes_count; 132 | } 133 | ``` 134 | 135 | ### N+1 issue 136 | 137 | 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: 138 | 139 | ```php 140 | // Liker 141 | $users = App\User::with('likes')->get(); 142 | 143 | foreach($users as $user) { 144 | $user->hasLiked($post); 145 | } 146 | 147 | // Likeable 148 | $posts = App\Post::with('likes')->get(); 149 | // or 150 | $posts = App\Post::with('likers')->get(); 151 | 152 | foreach($posts as $post) { 153 | $post->isLikedBy($user); 154 | } 155 | ``` 156 | 157 | Of course we have a better solution, which can be found in the following section: 158 | 159 | ### Attach user like status to likeable collection 160 | 161 | You can use `Liker::attachLikeStatus($likeables)` to attach the user like status, it will attach `has_liked` attribute to each model of `$likeables`: 162 | 163 | #### For model 164 | ```php 165 | $post = Post::find(1); 166 | 167 | $post = $user->attachLikeStatus($post); 168 | 169 | // result 170 | [ 171 | "id" => 1 172 | "title" => "Add socialite login support." 173 | "created_at" => "2021-05-20T03:26:16.000000Z" 174 | "updated_at" => "2021-05-20T03:26:16.000000Z" 175 | "has_liked" => true 176 | ], 177 | ``` 178 | 179 | #### For `Collection | Paginator | LengthAwarePaginator | array`: 180 | 181 | ```php 182 | $posts = Post::oldest('id')->get(); 183 | 184 | $posts = $user->attachLikeStatus($posts); 185 | 186 | $posts = $posts->toArray(); 187 | 188 | // result 189 | [ 190 | [ 191 | "id" => 1 192 | "title" => "Post title1" 193 | "created_at" => "2021-05-20T03:26:16.000000Z" 194 | "updated_at" => "2021-05-20T03:26:16.000000Z" 195 | "has_liked" => true 196 | ], 197 | [ 198 | "id" => 2 199 | "title" => "Post title2" 200 | "created_at" => "2021-05-20T03:26:16.000000Z" 201 | "updated_at" => "2021-05-20T03:26:16.000000Z" 202 | "has_liked" => fasle 203 | ], 204 | [ 205 | "id" => 3 206 | "title" => "Post title3" 207 | "created_at" => "2021-05-20T03:26:16.000000Z" 208 | "updated_at" => "2021-05-20T03:26:16.000000Z" 209 | "has_liked" => true 210 | ], 211 | ] 212 | ``` 213 | 214 | #### For pagination 215 | 216 | ```php 217 | $posts = Post::paginate(20); 218 | 219 | $user->attachLikeStatus($posts); 220 | ``` 221 | 222 | ### Events 223 | 224 | | **Event** | **Description** | 225 | | ------------------------------------- | ------------------------------------------- | 226 | | `Overtrue\LaravelLike\Events\Liked` | Triggered when the relationship is created. | 227 | | `Overtrue\LaravelLike\Events\Unliked` | Triggered when the relationship is deleted. | 228 | 229 | ## Related packages 230 | 231 | - Follow: [overtrue/laravel-follow](https://github.com/overtrue/laravel-follow) 232 | - Like: [overtrue/laravel-like](https://github.com/overtrue/laravel-like) 233 | - Favorite: [overtrue/laravel-favorite](https://github.com/overtrue/laravel-favorite) 234 | - Subscribe: [overtrue/laravel-subscribe](https://github.com/overtrue/laravel-subscribe) 235 | - Vote: [overtrue/laravel-vote](https://github.com/overtrue/laravel-vote) 236 | - Bookmark: overtrue/laravel-bookmark (working in progress) 237 | 238 | ## :heart: Sponsor me 239 | 240 | [![Sponsor me](https://github.com/overtrue/overtrue/blob/master/sponsor-me.svg?raw=true)](https://github.com/sponsors/overtrue) 241 | 242 | 如果你喜欢我的项目并想支持它,[点击这里 :heart:](https://github.com/sponsors/overtrue) 243 | 244 | ## Project supported by JetBrains 245 | 246 | Many thanks to Jetbrains for kindly providing a license for me to work on this and other open-source projects. 247 | 248 | [![](https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.svg)](https://www.jetbrains.com/?from=https://github.com/overtrue) 249 | 250 | ## Contributing 251 | 252 | You can contribute in one of three ways: 253 | 254 | 1. File bug reports using the [issue tracker](https://github.com/overtrue/laravel-likes/issues). 255 | 2. Answer questions or fix bugs on the [issue tracker](https://github.com/overtrue/laravel-likes/issues). 256 | 3. Contribute new features or update the wiki. 257 | 258 | _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._ 259 | 260 | ## PHP 扩展包开发 261 | 262 | > 想知道如何从零开始构建 PHP 扩展包? 263 | > 264 | > 请关注我的实战课程,我会在此课程中分享一些扩展开发经验 —— [《PHP 扩展包实战教程 - 从入门到发布》](https://learnku.com/courses/creating-package) 265 | 266 | ## License 267 | 268 | MIT 269 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "overtrue/laravel-like", 3 | "description": "👍 User-like features for Laravel Application.", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "overtrue", 8 | "email": "anzhengchao@gmail.com" 9 | } 10 | ], 11 | "require": { 12 | "laravel/framework": "^9.0|^10.0|^11.0|^12.0" 13 | }, 14 | "autoload": { 15 | "psr-4": { 16 | "Overtrue\\LaravelLike\\": "src" 17 | } 18 | }, 19 | "autoload-dev": { 20 | "psr-4": { 21 | "Tests\\": "tests" 22 | } 23 | }, 24 | "require-dev": { 25 | "mockery/mockery": "^1.4", 26 | "phpunit/phpunit": "^10.0.7|^11.5.3", 27 | "orchestra/testbench": "^8.0|^9.0|^10.0", 28 | "friendsofphp/php-cs-fixer": "^3.0.0", 29 | "brainmaestro/composer-git-hooks": "dev-master", 30 | "laravel/pint": "^1.2" 31 | }, 32 | "extra": { 33 | "laravel": { 34 | "providers": [ 35 | "Overtrue\\LaravelLike\\LikeServiceProvider" 36 | ] 37 | }, 38 | "hooks": { 39 | "pre-commit": [ 40 | "composer fix-style", 41 | "composer test" 42 | ], 43 | "post-merge": "composer install" 44 | } 45 | }, 46 | "scripts": { 47 | "post-update-cmd": [ 48 | "cghooks update" 49 | ], 50 | "post-install-cmd": [ 51 | "cghooks add --ignore-lock", 52 | "cghooks update" 53 | ], 54 | "cghooks": "vendor/bin/cghooks", 55 | "check-style": "vendor/bin/pint --test", 56 | "fix-style": "vendor/bin/pint", 57 | "test": "vendor/bin/phpunit --colors=always" 58 | }, 59 | "scripts-descriptions": { 60 | "test": "Run all tests.", 61 | "check-style": "Run style checks (only dry run - no fixing!).", 62 | "fix-style": "Run style checks and fix violations." 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /config/like.php: -------------------------------------------------------------------------------- 1 | false, 8 | 9 | /* 10 | * User tables foreign key name. 11 | */ 12 | 'user_foreign_key' => 'user_id', 13 | 14 | /* 15 | * Table name for likes records. 16 | */ 17 | 'likes_table' => 'likes', 18 | 19 | /* 20 | * Model name for like record. 21 | */ 22 | 'like_model' => \Overtrue\LaravelLike\Like::class, 23 | 24 | /* 25 | * Model name for liker. 26 | */ 27 | 'user_model' => class_exists(\App\Models\User::class) ? \App\Models\User::class : null, 28 | ]; 29 | -------------------------------------------------------------------------------- /migrations/2018_12_14_000000_create_likes_table.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 16 | $table->unsignedBigInteger(config('like.user_foreign_key'))->index()->comment('user_id'); 17 | $table->morphs('likeable'); 18 | $table->timestamps(); 19 | }); 20 | } 21 | 22 | /** 23 | * Reverse the migrations. 24 | */ 25 | public function down() 26 | { 27 | Schema::dropIfExists(config('like.likes_table')); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /phpunit.xml.dist.bak: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | src/ 6 | 7 | 8 | 9 | 10 | ./tests/ 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/Events/Event.php: -------------------------------------------------------------------------------- 1 | like = $like; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Events/Liked.php: -------------------------------------------------------------------------------- 1 | Liked::class, 17 | 'deleted' => Unliked::class, 18 | ]; 19 | 20 | public function __construct(array $attributes = []) 21 | { 22 | $this->table = \config('like.likes_table'); 23 | 24 | parent::__construct($attributes); 25 | } 26 | 27 | protected static function boot() 28 | { 29 | parent::boot(); 30 | 31 | self::saving(function ($like) { 32 | $userForeignKey = \config('like.user_foreign_key'); 33 | $like->{$userForeignKey} = $like->{$userForeignKey} ?: auth()->id(); 34 | 35 | if (\config('like.uuids')) { 36 | $like->{$like->getKeyName()} = $like->{$like->getKeyName()} ?: (string) Str::orderedUuid(); 37 | } 38 | }); 39 | } 40 | 41 | public function likeable(): \Illuminate\Database\Eloquent\Relations\MorphTo 42 | { 43 | return $this->morphTo(); 44 | } 45 | 46 | /** 47 | * @return \Illuminate\Database\Eloquent\Relations\BelongsTo 48 | */ 49 | public function user() 50 | { 51 | $userModel = config('like.user_model') ?? config('auth.providers.users.model'); 52 | 53 | return $this->belongsTo($userModel, \config('like.user_foreign_key')); 54 | } 55 | 56 | /** 57 | * @return \Illuminate\Database\Eloquent\Relations\BelongsTo 58 | */ 59 | public function liker() 60 | { 61 | return $this->user(); 62 | } 63 | 64 | /** 65 | * @return \Illuminate\Database\Eloquent\Builder 66 | */ 67 | public function scopeWithType(Builder $query, string $type) 68 | { 69 | return $query->where('likeable_type', app($type)->getMorphClass()); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/LikeServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 15 | \dirname(__DIR__).'/config/like.php' => config_path('like.php'), 16 | ], 'config'); 17 | 18 | $this->publishes([ 19 | \dirname(__DIR__).'/migrations/' => database_path('migrations'), 20 | ], 'migrations'); 21 | } 22 | 23 | /** 24 | * Register bindings in the container. 25 | */ 26 | public function register() 27 | { 28 | $this->mergeConfigFrom( 29 | \dirname(__DIR__).'/config/like.php', 30 | 'like' 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Traits/Likeable.php: -------------------------------------------------------------------------------- 1 | relationLoaded('likers')) { 14 | return $this->likers->contains($user); 15 | } 16 | 17 | return $this->likers()->where(\config('like.user_foreign_key'), $user->getKey())->exists(); 18 | } 19 | 20 | return false; 21 | } 22 | 23 | /** 24 | * Return followers. 25 | */ 26 | public function likers(): \Illuminate\Database\Eloquent\Relations\BelongsToMany 27 | { 28 | return $this->belongsToMany( 29 | config('like.user_model') ?? config('auth.providers.users.model'), 30 | config('like.likes_table'), 31 | 'likeable_id', 32 | config('like.user_foreign_key') 33 | ) 34 | ->where('likeable_type', $this->getMorphClass()); 35 | } 36 | 37 | protected function totalLikers(): Attribute 38 | { 39 | return Attribute::get(function () { 40 | return $this->likers()->count() ?? 0; 41 | }); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Traits/Liker.php: -------------------------------------------------------------------------------- 1 | $object->getMorphClass(), 21 | 'likeable_id' => $object->getKey(), 22 | config('like.user_foreign_key') => $this->getKey(), 23 | ]; 24 | 25 | /* @var \Illuminate\Database\Eloquent\Model $like */ 26 | $like = \app(config('like.like_model')); 27 | 28 | /* @var \Overtrue\LaravelLike\Traits\Likeable|\Illuminate\Database\Eloquent\Model $object */ 29 | return $like->where($attributes)->firstOr( 30 | function () use ($like, $attributes) { 31 | return $like->unguarded(function () use ($like, $attributes) { 32 | if ($this->relationLoaded('likes')) { 33 | $this->unsetRelation('likes'); 34 | } 35 | 36 | return $like->create($attributes); 37 | }); 38 | } 39 | ); 40 | } 41 | 42 | /** 43 | * @throws \Exception 44 | */ 45 | public function unlike(Model $object): bool 46 | { 47 | /* @var \Overtrue\LaravelLike\Like $relation */ 48 | $relation = \app(config('like.like_model')) 49 | ->where('likeable_id', $object->getKey()) 50 | ->where('likeable_type', $object->getMorphClass()) 51 | ->where(config('like.user_foreign_key'), $this->getKey()) 52 | ->first(); 53 | 54 | if ($relation) { 55 | if ($this->relationLoaded('likes')) { 56 | $this->unsetRelation('likes'); 57 | } 58 | 59 | return $relation->delete(); 60 | } 61 | 62 | return true; 63 | } 64 | 65 | /** 66 | * @return Like|null 67 | * 68 | * @throws \Exception 69 | */ 70 | public function toggleLike(Model $object) 71 | { 72 | return $this->hasLiked($object) ? $this->unlike($object) : $this->like($object); 73 | } 74 | 75 | public function hasLiked(Model $object): bool 76 | { 77 | return ($this->relationLoaded('likes') ? $this->likes : $this->likes()) 78 | ->where('likeable_id', $object->getKey()) 79 | ->where('likeable_type', $object->getMorphClass()) 80 | ->count() > 0; 81 | } 82 | 83 | public function likes(): HasMany 84 | { 85 | return $this->hasMany(config('like.like_model'), config('like.user_foreign_key'), $this->getKeyName()); 86 | } 87 | 88 | /** 89 | * Get Query Builder for likes 90 | * 91 | * @return \Illuminate\Database\Eloquent\Builder 92 | */ 93 | public function getLikedItems(string $model) 94 | { 95 | return app($model)->whereHas( 96 | 'likers', 97 | function ($q) { 98 | return $q->where(config('like.user_foreign_key'), $this->getKey()); 99 | } 100 | ); 101 | } 102 | 103 | public function attachLikeStatus(&$likeables, ?callable $resolver = null) 104 | { 105 | $likes = $this->likes()->get()->keyBy(function ($item) { 106 | return \sprintf('%s:%s', $item->likeable_type, $item->likeable_id); 107 | }); 108 | 109 | $attachStatus = function ($likeable) use ($likes, $resolver) { 110 | $resolver = $resolver ?? fn ($m) => $m; 111 | $likeable = $resolver($likeable); 112 | 113 | if ($likeable && \in_array(Likeable::class, \class_uses_recursive($likeable))) { 114 | $key = \sprintf('%s:%s', $likeable->getMorphClass(), $likeable->getKey()); 115 | $likeable->setAttribute('has_liked', $likes->has($key)); 116 | } 117 | 118 | return $likeable; 119 | }; 120 | 121 | switch (true) { 122 | case $likeables instanceof Model: 123 | return $attachStatus($likeables); 124 | case $likeables instanceof Collection: 125 | return $likeables->each($attachStatus); 126 | case $likeables instanceof LazyCollection: 127 | return $likeables = $likeables->map($attachStatus); 128 | case $likeables instanceof AbstractPaginator: 129 | case $likeables instanceof AbstractCursorPaginator: 130 | return $likeables->through($attachStatus); 131 | case $likeables instanceof Paginator: 132 | // custom paginator will return a collection 133 | return collect($likeables->items())->transform($attachStatus); 134 | case \is_array($likeables): 135 | return \collect($likeables)->transform($attachStatus); 136 | default: 137 | throw new \InvalidArgumentException('Invalid argument type.'); 138 | } 139 | } 140 | 141 | protected function totalLikes(): Attribute 142 | { 143 | return Attribute::make(get: function ($value) { 144 | return $this->likes()->count() ?? 0; 145 | }); 146 | } 147 | } 148 | --------------------------------------------------------------------------------