A like and dislike system in Laravel 13 needs one table: votes, with a user, a post and a value of 1 (like) or -1 (dislike). A unique constraint on user and post guarantees one vote per user, and a single controller method handles liking, disliking and removing a vote. This tutorial builds it with Eloquent and loads the counts efficiently with withCount.
Targets: Laravel 13 (PHP 8.3 to 8.5), Eloquent and Blade. The example votes on Post models and requires a logged-in user.
Step 1: Create the votes table
php artisan make:model Vote -mSchema::create('votes', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
$table->smallInteger('value');
$table->timestamps();
$table->unique(['user_id', 'post_id']);
});The unique index is your real protection. Even if two requests arrive at the same moment, the database refuses a second vote row for the same user and post. Run php artisan migrate.
Step 2: Define the models and relationships
// app/Models/Vote.php
protected $fillable = ['user_id', 'post_id', 'value'];
public function post(): BelongsTo
{
return $this->belongsTo(Post::class);
}// app/Models/Post.php
public function votes(): HasMany
{
return $this->hasMany(Vote::class);
}
public function userVote(): HasOne
{
return $this->hasOne(Vote::class)->where('user_id', auth()->id());
}Import BelongsTo, HasMany and HasOne from Illuminate\Database\Eloquent\Relations. The userVote relationship loads the current user's vote, which lets you highlight the pressed button.
Step 3: Write the vote controller
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use App\Models\Vote;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class VoteController extends Controller
{
public function __invoke(Request $request, Post $post): RedirectResponse
{
$data = $request->validate([
'value' => ['required', 'in:1,-1'],
]);
$value = (int) $data['value'];
$vote = Vote::where('user_id', $request->user()->id)
->where('post_id', $post->id)
->first();
if ($vote && $vote->value === $value) {
$vote->delete(); // pressing the same button again removes the vote
} else {
Vote::updateOrCreate(
['user_id' => $request->user()->id, 'post_id' => $post->id],
['value' => $value]
);
}
return back();
}
}use App\Http\Controllers\VoteController;
use Illuminate\Support\Facades\Route;
Route::post('/posts/{post}/vote', VoteController::class)
->middleware('auth')
->name('posts.vote');The logic has three outcomes. A new vote creates a row. A different value changes the existing row, for example from like to dislike. The same value removes the vote, which gives you the toggle behavior users expect.
Step 4: Load counts without N+1 queries
$posts = Post::query()
->withCount([
'votes as likes_count' => fn ($query) => $query->where('value', 1),
'votes as dislikes_count' => fn ($query) => $query->where('value', -1),
])
->with('userVote')
->latest()
->paginate(10);withCount adds likes_count and dislikes_count to every post with subqueries, so there is no query per post. The with('userVote') part loads the current user's vote in one extra query.
Step 5: Show the buttons in Blade
@foreach ($posts as $post)
<h2>{{ $post->title }}</h2>
<form method="POST" action="{{ route('posts.vote', $post) }}" style="display:inline">
@csrf
<input type="hidden" name="value" value="1">
<button type="submit" @class(['active' => $post->userVote?->value === 1])>
Like ({{ $post->likes_count }})
</button>
</form>
<form method="POST" action="{{ route('posts.vote', $post) }}" style="display:inline">
@csrf
<input type="hidden" name="value" value="-1">
<button type="submit" @class(['active' => $post->userVote?->value === -1])>
Dislike ({{ $post->dislikes_count }})
</button>
</form>
@endforeachStyle the .active class yourself. Guests see the buttons too but are redirected to log in when they click, because of the auth middleware.
How do I make it work without a page reload?
Submit the forms with the Fetch API and return JSON with the new counts from the controller, or build the buttons as a Livewire component. The database design and the toggle logic stay the same.
How do I check that it works?
- Log in and press Like. The like count should become 1 and the button active.
- Press Dislike. The like count should drop to 0 and the dislike count rise to 1.
- Press Dislike again to remove the vote. Both counts should be 0.
- Check the
votestable: you should never see two rows for the same user and post.
Common mistakes
- No unique index: fast double clicks create duplicate votes.
- Counting in a loop: calling
$post->votes()->count()per post creates an N+1 problem. UsewithCount. - Trusting the value: validate that it is 1 or -1 only.
- Using GET links: voting changes data, so use POST with CSRF.
- Counting guests: require authentication, or tie guest votes to something reliable, which is harder to do safely.
FAQ
Can I use a polymorphic relation to vote on comments too?
Yes. Replace post_id with votable_id and votable_type (use morphs('votable') in the migration) and define a morphMany relation on each votable model.
Should I store a likes counter on the posts table?
Only if you need to sort by it on very large tables. Then update the counter inside a transaction when votes change, and keep the votes table as the source of truth.
Why store 1 and -1 instead of a boolean?
A signed value makes it easy to add up a score with sum('value') and keeps the table simple.



