Muhammad Yasir Amin

Loading websiteโ€ฆ

More from Muhammad Yasir Amin

Build Along With Me

Practical walkthroughs on Laravel, React, APIs and deployment workflows.

Subscribe on YouTube

Laravel 13 Like and Dislike System: Votes with Eloquent and a Unique Constraint

๐Ÿ› ๏ธ Laravel Tutorials4 min read

Laravel 13 Like and Dislike System: Votes with Eloquent and a Unique Constraint

Muhammad Yasir Amin

Muhammad Yasir Amin

Senior Full Stack Engineerโ€ขNov 12, 2026
Laravel 13 Like and Dislike System: Votes with Eloquent and a Unique Constraint

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

Code
php artisan make:model Vote -m
Shell
Schema::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

JavaScript
// app/Models/Vote.php
protected $fillable = ['user_id', 'post_id', 'value'];

public function post(): BelongsTo
{
    return $this->belongsTo(Post::class);
}
JavaScript
// 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

JavaScript
<?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();
    }
}
CSS
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

JavaScript
$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

JavaScript
@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>
@endforeach

Style 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?

  1. Log in and press Like. The like count should become 1 and the button active.
  2. Press Dislike. The like count should drop to 0 and the dislike count rise to 1.
  3. Press Dislike again to remove the vote. Both counts should be 0.
  4. Check the votes table: 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. Use withCount.
  • 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.

References

Article Index & Topic Tags
LaravelEloquentRelationshipsVoting

YouTube Channel

Build Along With Me

Practical walkthroughs on Laravel, React, APIs and deployment workflows.

Muhammad Yasir Amin
Available for Contracts
Author & Solutions Architect

Senior Full Stack Engineer

Independent Senior Full Stack Engineer working on Laravel, React and Next.js applications, APIs and delivery workflows.

Laravel & PHP ApplicationsReact & Next.js InterfacesREST APIs & IntegrationsDatabase Queries & DebuggingDocker Development WorkflowsClear Scope & Handover
Discuss Your Next Step

What Does Your Application Need Next?

Share your current system, the problem you want to solve and what a useful result looks like. We can identify the right starting point and agree on a practical scope.

Technical Audit

Find the Next Useful Fix

Application Development

Scoped, Reviewable Milestones

Ongoing Support

Agreed Capacity and Priorities

Share your goals, current stack and requirements before the call.