├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .php-cs-fixer.php ├── README.md ├── composer.json ├── config └── vote.php ├── migrations └── 2021_05_05_000000_create_votes_table.php └── src ├── Events ├── Event.php ├── VoteCancelled.php └── Voted.php ├── Traits ├── Votable.php └── Voter.php ├── Vote.php └── VoteServiceProvider.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/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.0, 8.1] 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 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | setRules([ 5 | '@PSR12' => true, 6 | 'binary_operator_spaces' => true, 7 | 'blank_line_after_opening_tag' => true, 8 | 'compact_nullable_typehint' => true, 9 | 'declare_equal_normalize' => true, 10 | 'lowercase_cast' => true, 11 | 'lowercase_static_reference' => true, 12 | 'new_with_braces' => true, 13 | 'no_blank_lines_after_class_opening' => true, 14 | 'no_leading_import_slash' => true, 15 | 'no_whitespace_in_blank_line' => true, 16 | 'no_unused_imports' => true, 17 | 'ordered_class_elements' => [ 18 | 'order' => [ 19 | 'use_trait', 20 | ], 21 | ], 22 | 'ordered_imports' => [ 23 | 'imports_order' => [ 24 | 'class', 25 | 'function', 26 | 'const', 27 | ], 28 | 'sort_algorithm' => 'none', 29 | ], 30 | 'return_type_declaration' => true, 31 | 'short_scalar_cast' => true, 32 | 'single_blank_line_before_namespace' => true, 33 | 'single_trait_insert_per_statement' => true, 34 | 'ternary_operator_spaces' => true, 35 | 'unary_operator_spaces' => true, 36 | 'visibility_required' => [ 37 | 'elements' => [ 38 | 'const', 39 | 'method', 40 | 'property', 41 | ], 42 | ], 43 | ]) 44 | ->setFinder( 45 | PhpCsFixer\Finder::create() 46 | ->exclude('vendor') 47 | ->in([__DIR__.'/src/', __DIR__.'/tests/']) 48 | ) 49 | ; 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Laravel Vote 2 | --- 3 | 4 | ⬆️ ⬇️ User vote system for Laravel Application. 5 | 6 | [![CI](https://github.com/overtrue/laravel-vote/workflows/CI/badge.svg)](https://github.com/overtrue/laravel-vote/actions/workflows/ci.yml) 7 | 8 | [![Sponsor me](https://github.com/overtrue/overtrue/blob/master/sponsor-me-button-s.svg?raw=true)](https://github.com/sponsors/overtrue) 9 | 10 | 11 | ## Installing 12 | 13 | ```shell 14 | composer require overtrue/laravel-vote -vvv 15 | ``` 16 | 17 | ### Configuration & Migrations 18 | 19 | 20 | ```shell 21 | php artisan vendor:publish 22 | ``` 23 | 24 | then create tables: 25 | 26 | ```bash 27 | php artisan migrate 28 | ``` 29 | 30 | ## Usage 31 | 32 | ### Traits 33 | 34 | #### `Overtrue\LaravelVote\Traits\Voter` 35 | 36 | ```php 37 | 38 | use Illuminate\Notifications\Notifiable; 39 | use Illuminate\Contracts\Auth\MustVerifyEmail; 40 | use Illuminate\Foundation\Auth\User as Authenticatable; 41 | use Overtrue\LaravelVote\Traits\Voter; 42 | 43 | class User extends Authenticatable 44 | { 45 | use Voter; 46 | 47 | <...> 48 | } 49 | ``` 50 | 51 | #### `Overtrue\LaravelVote\Traits\Voteable` 52 | 53 | ```php 54 | use Illuminate\Database\Eloquent\Model; 55 | use Overtrue\LaravelVote\Traits\Votable; 56 | 57 | class Idea extends Model 58 | { 59 | use Votable; 60 | 61 | <...> 62 | } 63 | ``` 64 | 65 | ### API 66 | 67 | ```php 68 | $user = User::find(1); 69 | $idea = Idea::find(2); 70 | 71 | $user->vote($idea, 1); // upvote 72 | $user->vote($idea, -1); // downvote 73 | $user->upvote($idea); 74 | $user->downvote($idea); 75 | 76 | // with custom number of votes 77 | $user->upvote($idea, 3); 78 | $user->downvote($idea, 3); 79 | 80 | // cancel vote 81 | $user->cancelVote($idea); 82 | 83 | // get my voted items 84 | $user->getVotedItems(Idea::class) // Illuminate\Database\Eloquent\Builder 85 | 86 | // state 87 | $user->hasVoted($idea); 88 | $idea->hasBeenVotedBy($user); 89 | ``` 90 | 91 | #### Get model voters: 92 | 93 | ```php 94 | foreach($idea->voters as $user) { 95 | // echo $user->name; 96 | } 97 | ``` 98 | 99 | #### Get user voted items. 100 | 101 | User can easy to get Votable models to do what you want. 102 | 103 | *note: this method will return a `Illuminate\Database\Eloquent\Builder` * 104 | 105 | ```php 106 | $votedItemsQuery = $user->getVotedItems(); 107 | 108 | // filter votable_type 109 | $votedIdeasQuery = $user->getVotedItems(Idea::class); 110 | 111 | // fetch results 112 | $votedIdeas = $user->getVoteItems(Idea::class)->get(); 113 | $votedIdeas = $user->getVoteItems(Idea::class)->paginate(); 114 | $votedIdeas = $user->getVoteItems(Idea::class)->where('title', 'Laravel-Vote')->get(); 115 | ``` 116 | 117 | ### Aggregations 118 | 119 | ### count relations 120 | ```php 121 | // all 122 | $user->votes()->count(); 123 | 124 | // filter votable_type 125 | $user->votes()->ofType(Idea::class)->count(); 126 | 127 | // voters count 128 | $idea->voters()->count(); 129 | ``` 130 | 131 | List with `*_count` attribute: 132 | 133 | ```php 134 | // for Voter models: 135 | $users = User::withCount('votes')->get(); 136 | // or 137 | $users = User::withCount('upvotes')->get(); 138 | // or 139 | $users = User::withCount('downvotes')->get(); 140 | // or 141 | $users = User::withCount(['votes', 'upvotes', 'downvotes'])->get(); 142 | 143 | foreach($users as $user) { 144 | echo $user->votes_count; 145 | echo $user->upvotes_count; 146 | echo $user->downvotes_count; 147 | } 148 | 149 | // for Votable models: 150 | $ideas = Idea::withCount('voters')->get(); 151 | // or 152 | $ideas = Idea::withCount('upvoters')->get(); 153 | $ideas = Idea::withCount('downvoters')->get(); 154 | 155 | // or 156 | $ideas = Idea::withCount(['voters', 'upvoters', 'downvoters'])->get(); 157 | 158 | foreach($ideas as $idea) { 159 | echo $idea->voters_count; 160 | echo $idea->upvoters_count; 161 | echo $idea->downvoters_count; 162 | } 163 | ``` 164 | 165 | ### Votable sum votes 166 | 167 | ```php 168 | $user1->upvote($idea); // 1 (up) 169 | $user2->upvote($idea); // 2 (up) 170 | $user3->upvote($idea); // 3 (up) 171 | $user4->downvote($idea); // -1 (down) 172 | 173 | // sum(votes) 174 | $idea->totalVotes(); // 2(3 - 1) 175 | 176 | // sum(votes) where votes > 0 177 | $idea->totalUpvotes(); // 3 178 | 179 | // abs(sum(votes)) where votes < 0 180 | $idea->totalDownvotes(); // 1 181 | 182 | // appends aggregations attributes 183 | $idea->appendsVotesAttributes(); 184 | $idea->toArray(); 185 | // result 186 | [ 187 | "id" => 1 188 | "title" => "Add socialite login support." 189 | "created_at" => "2021-05-20T03:26:16.000000Z" 190 | "updated_at" => "2021-05-20T03:26:16.000000Z" 191 | 192 | // these aggregations attributes will be appends. 193 | "total_votes" => 2 194 | "total_upvotes" => 3 195 | "total_downvotes" => 1 196 | ], 197 | ``` 198 | 199 | ### Attach voter vote status to votable collection 200 | 201 | You can use `Voter::attachVoteStatus(Collection $votables)` to attach the voter vote status, it will set `has_voted`,`has_upvoted` and `has_downvoted` attributes to each model of `$votables`: 202 | 203 | #### For model 204 | ```php 205 | $idea = Idea::find(1); 206 | 207 | $user->attachVoteStatus($idea); 208 | 209 | // result 210 | [ 211 | "id" => 1 212 | "title" => "Add socialite login support." 213 | "created_at" => "2021-05-20T03:26:16.000000Z" 214 | "updated_at" => "2021-05-20T03:26:16.000000Z" 215 | "has_voted" => true 216 | "has_upvoted" => true 217 | "has_downvoted" => false 218 | ], 219 | ``` 220 | 221 | #### For `Collection | Paginator | LengthAwarePaginator | array`: 222 | 223 | ```php 224 | $ideas = Idea::oldest('id')->get(); 225 | 226 | $user->attachVoteStatus($ideas); 227 | 228 | $ideas = $ideas->toArray(); 229 | 230 | // result 231 | [ 232 | [ 233 | "id" => 1 234 | "title" => "Add socialite login support." 235 | "created_at" => "2021-05-20T03:26:16.000000Z" 236 | "updated_at" => "2021-05-20T03:26:16.000000Z" 237 | "has_voted" => true 238 | "has_upvoted" => true 239 | "has_downvoted" => false 240 | ], 241 | [ 242 | "id" => 2 243 | "title" => "Add php8 support." 244 | "created_at" => "2021-05-20T03:26:16.000000Z" 245 | "updated_at" => "2021-05-20T03:26:16.000000Z" 246 | "has_voted" => true 247 | "has_upvoted" => false 248 | "has_downvoted" => true 249 | ], 250 | [ 251 | "id" => 3 252 | "title" => "Add qrcode support." 253 | "created_at" => "2021-05-20T03:26:16.000000Z" 254 | "updated_at" => "2021-05-20T03:26:16.000000Z" 255 | "has_voted" => false 256 | "has_upvoted" => false 257 | "has_downvoted" => false 258 | ], 259 | ] 260 | ``` 261 | 262 | #### For pagination 263 | 264 | ```php 265 | $ideas = Idea::paginate(20); 266 | 267 | $user->attachVoteStatus($ideas->getCollection()); 268 | ``` 269 | 270 | ### N+1 issue 271 | 272 | 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: 273 | 274 | ```php 275 | // Voter 276 | use Tests\Idea;$users = User::with('votes')->get(); 277 | 278 | foreach($users as $user) { 279 | $user->hasVoted($idea); 280 | } 281 | 282 | // Votable 283 | $ideas = Idea::with('voters')->get(); 284 | 285 | foreach($ideas as $idea) { 286 | $idea->hasBeenVotedBy($user); 287 | } 288 | 289 | // Votable votes 290 | $ideas = Idea::withTotalVotes() // total_votes 291 | ->withTotalUpvotes() // total_upvotes 292 | ->withTotalDownvotes() // total_downvotes 293 | ->get(); 294 | 295 | // same as 296 | // withVotesAttributes() = withTotalVotes() + withTotalUpvotes() + withTotalDownvotes() 297 | $ideas = Idea::withVotesAttributes()->get(); 298 | 299 | // result 300 | [ 301 | [ 302 | "id" => 1 303 | "title" => "Add socialite login support." 304 | "created_at" => "2021-05-19T07:01:10.000000Z" 305 | "updated_at" => "2021-05-19T07:01:10.000000Z" 306 | "total_votes" => 2 307 | "total_upvotes" => 3 308 | "total_downvotes" => 1 309 | ], 310 | [ 311 | "id" => 2 312 | "title" => "Add PHP8 support." 313 | "created_at" => "2021-05-20T07:01:10.000000Z" 314 | "updated_at" => "2021-05-20T07:01:10.000000Z" 315 | "total_votes" => 1 316 | "total_upvotes" => 2 317 | "total_downvotes" => 1 318 | ] 319 | ] 320 | ``` 321 | 322 | ### Events 323 | 324 | | **Event** | **Description** | 325 | | --- | --- | 326 | | `Overtrue\LaravelVote\Events\Voted` | Triggered when the relationship is created. | 327 | | `Overtrue\LaravelVote\Events\VoteCancelled` | Triggered when the relationship is deleted. | 328 | 329 | ## Related packages 330 | 331 | - Follow: [overtrue/laravel-follow](https://github.com/overtrue/laravel-follow) 332 | - Like: [overtrue/laravel-like](https://github.com/overtrue/laravel-like) 333 | - Vote: [overtrue/laravel-vote](https://github.com/overtrue/laravel-Vote) 334 | - Subscribe: [overtrue/laravel-subscribe](https://github.com/overtrue/laravel-subscribe) 335 | - Bookmark: overtrue/laravel-bookmark (working in progress) 336 | 337 | ## :heart: Sponsor me 338 | 339 | [![Sponsor me](https://github.com/overtrue/overtrue/blob/master/sponsor-me.svg?raw=true)](https://github.com/sponsors/overtrue) 340 | 341 | 如果你喜欢我的项目并想支持它,[点击这里 :heart:](https://github.com/sponsors/overtrue) 342 | 343 | 344 | ## Project supported by JetBrains 345 | 346 | Many thanks to Jetbrains for kindly providing a license for me to work on this and other open-source projects. 347 | 348 | [![](https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.svg)](https://www.jetbrains.com/?from=https://github.com/overtrue) 349 | 350 | ## Contributing 351 | 352 | You can contribute in one of three ways: 353 | 354 | 1. File bug reports using the [issue tracker](https://github.com/overtrue/laravel-Votes/issues). 355 | 2. Answer questions or fix bugs on the [issue tracker](https://github.com/overtrue/laravel-Votes/issues). 356 | 3. Contribute new features or update the wiki. 357 | 358 | _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._ 359 | 360 | ## PHP 扩展包开发 361 | 362 | > 想知道如何从零开始构建 PHP 扩展包? 363 | > 364 | > 请关注我的实战课程,我会在此课程中分享一些扩展开发经验 —— [《PHP 扩展包实战教程 - 从入门到发布》](https://learnku.com/courses/creating-package) 365 | 366 | ## License 367 | 368 | MIT 369 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "overtrue/laravel-vote", 3 | "description": "User Vote 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\\LaravelVote\\": "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", 27 | "orchestra/testbench": "^8.0", 28 | "friendsofphp/php-cs-fixer": "^3.6", 29 | "brainmaestro/composer-git-hooks": "dev-master", 30 | "laravel/pint": "^1.5" 31 | }, 32 | "extra": { 33 | "laravel": { 34 | "providers": [ 35 | "Overtrue\\LaravelVote\\VoteServiceProvider" 36 | ] 37 | }, 38 | "hooks": { 39 | "pre-commit": [ 40 | "composer fix-style" 41 | ], 42 | "pre-push": [ 43 | "composer test" 44 | ] 45 | } 46 | }, 47 | "scripts": { 48 | "post-update-cmd": [ 49 | "cghooks update" 50 | ], 51 | "post-merge": "composer install", 52 | "post-install-cmd": [ 53 | "cghooks add --ignore-lock", 54 | "cghooks update" 55 | ], 56 | "cghooks": "vendor/bin/cghooks", 57 | "check-style": "vendor/bin/pint --test", 58 | "fix-style": "vendor/bin/pint", 59 | "test": "vendor/bin/phpunit --colors=always" 60 | }, 61 | "scripts-descriptions": { 62 | "test": "Run all tests.", 63 | "check-style": "Run style checks (only dry run - no fixing!).", 64 | "fix-style": "Run style checks and fix violations." 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /config/vote.php: -------------------------------------------------------------------------------- 1 | false, 8 | 9 | /* 10 | * User tables foreign key name. 11 | */ 12 | 'user_foreign_key' => 'user_id', 13 | 14 | /** 15 | * If uses table use uuid as primary, please set to true. 16 | */ 17 | 'users_use_uuids' => false, 18 | 19 | /* 20 | * Table name for vote records. 21 | */ 22 | 'votes_table' => 'votes', 23 | 24 | /* 25 | * Model name for Vote record. 26 | */ 27 | 'vote_model' => \Overtrue\LaravelVote\Vote::class, 28 | ]; 29 | -------------------------------------------------------------------------------- /migrations/2021_05_05_000000_create_votes_table.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 16 | $table->unsignedBigInteger(config('vote.user_foreign_key'))->index()->comment('user_id'); 17 | $table->integer('votes'); 18 | $table->morphs('votable'); 19 | $table->timestamps(); 20 | }); 21 | } 22 | 23 | /** 24 | * Reverse the migrations. 25 | */ 26 | public function down() 27 | { 28 | Schema::dropIfExists(config('vote.votes_table')); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Events/Event.php: -------------------------------------------------------------------------------- 1 | vote = $vote; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Events/VoteCancelled.php: -------------------------------------------------------------------------------- 1 | relationLoaded('voters')) { 24 | return $this->voters->contains($user); 25 | } 26 | 27 | return ($this->relationLoaded('votes') ? $this->votes : $this->votes()) 28 | ->where(\config('vote.user_foreign_key'), $user->getKey())->count() > 0; 29 | } 30 | 31 | return false; 32 | } 33 | 34 | public function hasBeenUpvotedBy(Model $user): bool 35 | { 36 | if (\is_a($user, config('auth.providers.users.model'))) { 37 | if ($this->relationLoaded('upvoters')) { 38 | return $this->upvoters->contains($user); 39 | } 40 | 41 | return ($this->relationLoaded('upvotes') ? $this->upvotes : $this->upvotes()) 42 | ->where(\config('vote.user_foreign_key'), $user->getKey())->count() > 0; 43 | } 44 | 45 | return false; 46 | } 47 | 48 | public function hasBeenDownvotedBy(Model $user): bool 49 | { 50 | if (\is_a($user, config('auth.providers.users.model'))) { 51 | if ($this->relationLoaded('down voters')) { 52 | return $this->downvoters->contains($user); 53 | } 54 | 55 | return ($this->relationLoaded('downvotes') ? $this->downvotes : $this->downvotes()) 56 | ->where(\config('vote.user_foreign_key'), $user->getKey())->count() > 0; 57 | } 58 | 59 | return false; 60 | } 61 | 62 | public function votes(): \Illuminate\Database\Eloquent\Relations\MorphMany 63 | { 64 | return $this->morphMany(config('vote.vote_model'), 'votable'); 65 | } 66 | 67 | public function upvotes(): \Illuminate\Database\Eloquent\Relations\MorphMany 68 | { 69 | return $this->votes()->where('votes', '>', 0); 70 | } 71 | 72 | public function downvotes(): \Illuminate\Database\Eloquent\Relations\MorphMany 73 | { 74 | return $this->votes()->where('votes', '<', 0); 75 | } 76 | 77 | public function voters() 78 | { 79 | return $this->belongsToMany( 80 | config('auth.providers.users.model'), 81 | config('vote.votes_table'), 82 | 'votable_id', 83 | config('vote.user_foreign_key') 84 | )->where('votable_type', $this->getMorphClass())->withPivot(['votes']); 85 | } 86 | 87 | public function upvoters(): \Illuminate\Database\Eloquent\Relations\BelongsToMany 88 | { 89 | return $this->voters()->where('votes', '>', 0); 90 | } 91 | 92 | public function downvoters(): \Illuminate\Database\Eloquent\Relations\BelongsToMany 93 | { 94 | return $this->voters()->where('votes', '<', 0); 95 | } 96 | 97 | public function appendsVotesAttributes($attributes = ['total_votes', 'total_upvotes', 'total_downvotes']) 98 | { 99 | $this->append($attributes); 100 | 101 | return $this; 102 | } 103 | 104 | public function getTotalVotesAttribute() 105 | { 106 | return (int) ($this->attributes['total_votes'] ?? $this->totalVotes()); 107 | } 108 | 109 | public function getTotalUpvotesAttribute() 110 | { 111 | return abs($this->attributes['total_upvotes'] ?? $this->totalUpvotes()); 112 | } 113 | 114 | public function getTotalDownvotesAttribute() 115 | { 116 | return abs($this->attributes['total_downvotes'] ?? $this->totalDownvotes()); 117 | } 118 | 119 | public function totalVotes() 120 | { 121 | return $this->votes()->sum('votes'); 122 | } 123 | 124 | public function totalUpvotes() 125 | { 126 | return $this->votes()->where('votes', '>', 0)->sum('votes'); 127 | } 128 | 129 | public function totalDownvotes() 130 | { 131 | return $this->votes()->where('votes', '<', 0)->sum('votes'); 132 | } 133 | 134 | public function scopeWithTotalVotes(Builder $builder): Builder 135 | { 136 | return $builder->withSum(['votes as total_votes' => 137 | fn ($q) => $q->select(\DB::raw('COALESCE(SUM(votes), 0)')) 138 | ], 'votes'); 139 | } 140 | 141 | public function scopeWithTotalUpvotes(Builder $builder): Builder 142 | { 143 | return $builder->withSum(['votes as total_upvotes' => 144 | fn ($q) => $q->where('votes', '>', 0)->select(\DB::raw('COALESCE(SUM(votes), 0)')) 145 | ], 'votes'); 146 | } 147 | 148 | public function scopeWithTotalDownvotes(Builder $builder): Builder 149 | { 150 | return $builder->withSum(['votes as total_downvotes' => 151 | fn ($q) => $q->where('votes', '<', 0)->select(\DB::raw('COALESCE(SUM(votes), 0)')) 152 | ], 'votes'); 153 | } 154 | 155 | public function scopeWithVotesAttributes(Builder $builder) 156 | { 157 | $this->scopeWithTotalVotes($builder); 158 | $this->scopeWithTotalUpvotes($builder); 159 | $this->scopeWithTotalDownvotes($builder); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/Traits/Voter.php: -------------------------------------------------------------------------------- 1 | 0 ? $this->upvote($object, $votes) : $this->downvote($object, $votes); 19 | } 20 | 21 | public function toggleVote(Model $object, int $votes = 1) 22 | { 23 | if ($this->hasVoted($object, $votes)) { 24 | $this->cancelVote($object); 25 | } else { 26 | $this->vote($object, $votes); 27 | } 28 | } 29 | 30 | public function upvote(Model $object, int $votes = 1) 31 | { 32 | /* @var Votable|Model $object */ 33 | if ($this->hasVoted($object)) { 34 | $this->cancelVote($object); 35 | } 36 | 37 | $vote = app(config('vote.vote_model')); 38 | $vote->{config('vote.user_foreign_key')} = $this->getKey(); 39 | $vote->votes = abs($votes); 40 | $object->votes()->save($vote); 41 | 42 | return $vote; 43 | } 44 | 45 | public function downvote(Model $object, int $votes = 1) 46 | { 47 | /* @var Votable|Model $object */ 48 | if ($this->hasVoted($object)) { 49 | $this->cancelVote($object); 50 | } 51 | 52 | $vote = app(config('vote.vote_model')); 53 | $vote->{config('vote.user_foreign_key')} = $this->getKey(); 54 | $vote->votes = abs($votes) * -1; 55 | $object->votes()->save($vote); 56 | 57 | return $vote; 58 | } 59 | 60 | public function attachVoteStatus(Model|Collection|Paginator|LengthAwarePaginator|array $votables): Collection|Model 61 | { 62 | $returnFirst = false; 63 | 64 | switch (true) { 65 | case $votables instanceof Model: 66 | $returnFirst = true; 67 | $votables = \collect([$votables]); 68 | break; 69 | case $votables instanceof LengthAwarePaginator: 70 | $votables = $votables->getCollection(); 71 | break; 72 | case $votables instanceof Paginator: 73 | $votables = \collect($votables->items()); 74 | break; 75 | case \is_array($votables): 76 | $votables = \collect($votables); 77 | break; 78 | } 79 | 80 | $voterVoted = $this->votes()->get()->keyBy(function ($item) { 81 | return \sprintf('%s-%s', $item->votable_type, $item->votable_id); 82 | }); 83 | 84 | $votables->map(function (Model $votable) use ($voterVoted) { 85 | if (\in_array(Votable::class, \class_uses($votable))) { 86 | $key = \sprintf('%s-%s', $votable->getMorphClass(), $votable->getKey()); 87 | $votable->setAttribute('has_voted', $voterVoted->has($key)); 88 | $votable->setAttribute('has_upvoted', $voterVoted->has($key) && $voterVoted->get($key)->is_up_vote); 89 | $votable->setAttribute('has_downvoted', $voterVoted->has($key) && $voterVoted->get($key)->is_down_vote); 90 | } 91 | }); 92 | 93 | return $returnFirst ? $votables->first() : $votables; 94 | } 95 | 96 | public function cancelVote(Model $object): bool 97 | { 98 | /* @var Votable|Model $object */ 99 | $relation = $object->votes() 100 | ->where('votable_id', $object->getKey()) 101 | ->where('votable_type', $object->getMorphClass()) 102 | ->where(config('vote.user_foreign_key'), $this->getKey()) 103 | ->first(); 104 | 105 | if ($relation) { 106 | $relation->delete(); 107 | } 108 | 109 | return true; 110 | } 111 | 112 | public function hasVoted(Model $object, ?int $votes = null): bool 113 | { 114 | return ($this->relationLoaded('votes') ? $this->votes : $this->votes()) 115 | ->where('votable_id', $object->getKey()) 116 | ->where('votable_type', $object->getMorphClass()) 117 | ->when($votes, fn($q) => $q->where('votes', $votes)) 118 | ->count() > 0; 119 | } 120 | 121 | public function hasUpvoted(Model $object): bool 122 | { 123 | return $this->hasVoted($object, 1); 124 | } 125 | 126 | public function hasDownvoted(Model $object): bool 127 | { 128 | return $this->hasVoted($object, -1); 129 | } 130 | 131 | public function votes(): \Illuminate\Database\Eloquent\Relations\HasMany 132 | { 133 | return $this->hasMany(config('vote.vote_model'), config('vote.user_foreign_key'), $this->getKeyName()); 134 | } 135 | 136 | public function upvotes(): \Illuminate\Database\Eloquent\Relations\HasMany 137 | { 138 | return $this->votes()->where('votes', '>', 0); 139 | } 140 | 141 | public function downvotes(): \Illuminate\Database\Eloquent\Relations\HasMany 142 | { 143 | return $this->votes()->where('votes', '<', 0); 144 | } 145 | 146 | public function getVotedItems(string $model) 147 | { 148 | return app($model)->whereHas( 149 | 'voters', 150 | function ($q) { 151 | return $q->where(config('vote.user_foreign_key'), $this->getKey()); 152 | } 153 | ); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/Vote.php: -------------------------------------------------------------------------------- 1 | Voted::class, 31 | 'deleted' => VoteCancelled::class, 32 | ]; 33 | 34 | protected $appends = [ 35 | 'is_up_vote', 36 | 'is_down_vote', 37 | ]; 38 | 39 | protected $casts = [ 40 | 'votes' => 'int', 41 | ]; 42 | 43 | public function __construct(array $attributes = []) 44 | { 45 | $this->table = \config('vote.votes_table'); 46 | 47 | parent::__construct($attributes); 48 | } 49 | 50 | protected static function boot() 51 | { 52 | parent::boot(); 53 | 54 | self::saving(function ($vote) { 55 | $userForeignKey = \config('vote.user_foreign_key'); 56 | $vote->{$userForeignKey} = $vote->{$userForeignKey} ?: auth()->id(); 57 | 58 | if (\config('vote.uuids')) { 59 | $vote->{$vote->getKeyName()} = $vote->{$vote->getKeyName()} ?: (string) Str::orderedUuid(); 60 | } 61 | }); 62 | } 63 | 64 | public function votable(): \Illuminate\Database\Eloquent\Relations\MorphTo 65 | { 66 | return $this->morphTo(); 67 | } 68 | 69 | public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo 70 | { 71 | return $this->belongsTo(\config('auth.providers.users.model'), \config('vote.user_foreign_key')); 72 | } 73 | 74 | public function voter(): \Illuminate\Database\Eloquent\Relations\BelongsTo 75 | { 76 | return $this->user(); 77 | } 78 | 79 | public function isUpVote(): bool 80 | { 81 | return $this->votes > 0; 82 | } 83 | 84 | public function isDownVote(): bool 85 | { 86 | return $this->votes < 0; 87 | } 88 | 89 | public function getIsUpVoteAttribute(): bool 90 | { 91 | return $this->isUpVote(); 92 | } 93 | 94 | public function getIsDownVoteAttribute(): bool 95 | { 96 | return $this->isDownVote(); 97 | } 98 | 99 | public function scopeOfType(Builder $query, string $type): Builder 100 | { 101 | return $query->where('votable_type', app($type)->getMorphClass()); 102 | } 103 | 104 | public function scopeOfVotable(Builder $query, string $type): Builder 105 | { 106 | return $this->scopeOfType(...\func_get_args()); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/VoteServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 15 | \dirname(__DIR__).'/config/vote.php' => config_path('vote.php'), 16 | ], 'laravel-vote-config'); 17 | 18 | $this->publishes([ 19 | \dirname(__DIR__).'/migrations/' => database_path('migrations'), 20 | ], 'laravel-vote-migrations'); 21 | } 22 | 23 | /** 24 | * Register bindings in the container. 25 | */ 26 | public function register() 27 | { 28 | $this->mergeConfigFrom( 29 | \dirname(__DIR__).'/config/vote.php', 30 | 'vote' 31 | ); 32 | } 33 | } 34 | --------------------------------------------------------------------------------