featured image

Building a Gamification System with Observer Pattern and Scheduled Jobs

Learn how to build a scalable gamification system in Laravel using observers for real-time updates and scheduled jobs for batch processing, inspired by MyKpopLists.

Published

Sun Oct 12 2025

Technologies Used

Laravel PHP PostgreSQL SQL Redis Vue.js Inertia.js
Advanced 45 minutes

Recalculating scores on every page load works fine for 50 users. At 5,000 users, it starts to drag. At 50,000 users, loading all of them into memory and running five queries per user per page load is a disaster — 250,000 database queries just to render a leaderboard.

MyKpopLists’ gamification system avoids this with two complementary mechanisms: observers that increment scores atomically the moment a like or vote happens, and a nightly scheduled job that recalculates everything from scratch to catch any drift. Real-time updates keep the UI responsive; batch recalculation ensures accuracy over time.

What You Need

  • Advanced Laravel: observers, events, commands, scheduled tasks
  • Database optimization: indexes, updateOrCreate, increment
  • Some familiarity with race conditions in concurrent web environments

Stack: Laravel 12, PHP 8.2+, PostgreSQL (for window functions), Redis for queuing

php artisan make:model UserScore -m
php artisan make:observer UserScoreObserver
php artisan make:command CalculateUserScores
php artisan migrate

The Architecture: Bank Account Reconciliation

Think of the scoring system like bank account management. Real-time updates (observers) are instant transactions — someone likes your post, your balance goes up by 3 points immediately. Batch processing (the scheduled job) is the monthly statement reconciliation — the bank recalculates everything from the source of truth, catches any rounding errors, and updates your official balance.

Both are necessary. Real-time updates give instant feedback. Batch recalculation fixes anything that drifted: deleted content that wasn’t reflected, failed observer executions, race conditions.

The Denormalized Score Table

The first decision was to store pre-calculated scores rather than computing them on demand. A denormalized user_scores table means the leaderboard query is a single indexed SELECT rather than an aggregation across four tables.

// database/migrations/xxxx_create_user_scores_table.php
Schema::create('user_scores', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->unique()->constrained()->onDelete('cascade');
    
    $table->integer('total_score')->default(0);
    $table->integer('monthly_increase')->default(0);
    
    // Breakdown columns for transparency
    $table->integer('post_likes_score')->default(0);
    $table->integer('review_votes_score')->default(0);
    $table->integer('comment_likes_score')->default(0);
    $table->integer('content_creation_score')->default(0);
    
    $table->timestamp('last_calculated_at')->nullable();
    $table->timestamp('monthly_snapshot_at')->nullable();
    $table->integer('monthly_snapshot_score')->default(0);
    
    $table->timestamps();
    
    $table->index('total_score');
    $table->index('monthly_increase');
    $table->index(['total_score', 'monthly_increase']);
});

The tradeoffs of denormalization: reads are fast and simple (this is what we optimize for — leaderboards get read thousands of times more than scores get updated), but updates are more complex and there’s potential for temporary inconsistency. The scheduled reconciliation job handles the latter.

The Scoring Service

// app/Services/ScoringService.php
class ScoringService
{
    const POST_LIKE_POINTS       = 3;
    const REVIEW_UPVOTE_POINTS   = 2;
    const REVIEW_DOWNVOTE_POINTS = -1;
    const COMMENT_LIKE_POINTS    = 1;
    const COMMENT_DISLIKE_POINTS = -0.5;
    
    const POST_CREATION_POINTS    = 0.2;
    const REVIEW_CREATION_POINTS  = 0.3;
    const COMMENT_CREATION_POINTS = 0.1;

    public function calculateUserScore(User $user): array
    {
        $postLikesScore = $user->posts()
            ->withCount([
                'likesRelation as likes'    => fn($q) => $q->where('is_like', true),
                'likesRelation as dislikes' => fn($q) => $q->where('is_like', false),
            ])
            ->get()
            ->sum(fn($post) =>
                ($post->likes    * self::POST_LIKE_POINTS) +
                ($post->dislikes * self::REVIEW_DOWNVOTE_POINTS)
            );

        $reviewVotesScore = $user->reviews()
            ->withCount([
                'votes as upvotes'   => fn($q) => $q->where('vote_type', 'up'),
                'votes as downvotes' => fn($q) => $q->where('vote_type', 'down'),
            ])
            ->get()
            ->sum(fn($review) =>
                ($review->upvotes   * self::REVIEW_UPVOTE_POINTS) +
                ($review->downvotes * self::REVIEW_DOWNVOTE_POINTS)
            );

        $commentLikesScore = $user->comments()
            ->withCount([
                'likes as likes_count'    => fn($q) => $q->where('is_like', true),
                'likes as dislikes_count' => fn($q) => $q->where('is_like', false),
            ])
            ->get()
            ->sum(fn($comment) =>
                ($comment->likes_count    * self::COMMENT_LIKE_POINTS) +
                ($comment->dislikes_count * self::COMMENT_DISLIKE_POINTS)
            );

        $contentCreationScore =
            ($user->posts()->count()    * self::POST_CREATION_POINTS) +
            ($user->reviews()->count()  * self::REVIEW_CREATION_POINTS) +
            ($user->comments()->count() * self::COMMENT_CREATION_POINTS);

        return [
            'post_likes_score'        => (int) $postLikesScore,
            'review_votes_score'      => (int) $reviewVotesScore,
            'comment_likes_score'     => (int) $commentLikesScore,
            'content_creation_score'  => (int) $contentCreationScore,
            'total_score'             => (int) ($postLikesScore + $reviewVotesScore + $commentLikesScore + $contentCreationScore),
        ];
    }

    public function incrementScore(User $user, int $points, string $component): void
    {
        $userScore = UserScore::firstOrCreate(['user_id' => $user->id], ['total_score' => 0]);

        // Atomic increment — no read-modify-write race condition
        $userScore->increment($component, $points);
        $userScore->increment('total_score', $points);
        $userScore->updateMonthlyIncrease();
    }
}

The increment() call translates to UPDATE user_scores SET total_score = total_score + 3 at the database level. This is atomic — no read-modify-write cycle, no race condition when two requests try to add points simultaneously.

Observers for Real-Time Updates

Observers fire automatically when Eloquent events occur. Register them once in the service provider and they run on every like, vote, or post creation without any call sites in your business logic:

// app/Observers/UserScoreObserver.php
class UserScoreObserver
{
    public function __construct(protected ScoringService $scoringService) {}

    public function postLikeCreated(Like $like): void
    {
        if ($like->likeable_type !== Post::class) return;

        $points = $like->is_like
            ? ScoringService::POST_LIKE_POINTS
            : ScoringService::REVIEW_DOWNVOTE_POINTS;

        $this->scoringService->incrementScore($like->likeable->user, $points, 'post_likes_score');
    }

    public function postLikeDeleted(Like $like): void
    {
        if ($like->likeable_type !== Post::class) return;

        $points = $like->is_like
            ? -ScoringService::POST_LIKE_POINTS
            : -ScoringService::REVIEW_DOWNVOTE_POINTS;

        $this->scoringService->incrementScore($like->likeable->user, $points, 'post_likes_score');
    }

    public function reviewVoteCreated($vote): void
    {
        $points = $vote->vote_type === 'up'
            ? ScoringService::REVIEW_UPVOTE_POINTS
            : ScoringService::REVIEW_DOWNVOTE_POINTS;

        $this->scoringService->incrementScore($vote->review->user, $points, 'review_votes_score');
    }

    public function postCreated(Post $post): void
    {
        $this->scoringService->incrementScore(
            $post->user,
            (int) (ScoringService::POST_CREATION_POINTS * 100) / 100,
            'content_creation_score'
        );
    }
}
// app/Providers/AppServiceProvider.php
public function boot(): void
{
    Like::observe(UserScoreObserver::class);
    Post::observe(UserScoreObserver::class);
    Review::observe(UserScoreObserver::class);
}

The Nightly Recalculation Command

The batch job processes all users in chunks to avoid loading 50,000 models into memory at once, skips bot accounts, and handles monthly snapshots for the “rising stars” leaderboard:

class CalculateUserScores extends Command
{
    protected $signature = 'scores:calculate 
                           {--user-id= : Calculate score for specific user}
                           {--chunk=100 : Number of users to process at once}';

    public function handle(): int
    {
        if ($userId = $this->option('user-id')) {
            $user = User::findOrFail($userId);
            $userScore = $this->scoringService->updateUserScore($user);
            $this->info("Score: {$userScore->total_score}");
            return Command::SUCCESS;
        }

        $chunkSize  = (int) $this->option('chunk');
        $totalUsers = User::count();
        $bar        = $this->output->createProgressBar($totalUsers);
        $bar->start();

        User::chunk($chunkSize, function ($users) use ($bar) {
            foreach ($users as $user) {
                if (in_array($user->username, ['reddit_bot', 'posts_bot', 'admin'])) {
                    $bar->advance();
                    continue;
                }
                try {
                    $this->scoringService->updateUserScore($user);
                } catch (\Exception $e) {
                    $this->error("Error processing user {$user->id}: {$e->getMessage()}");
                }
                $bar->advance();
            }
        });

        $bar->finish();
        $this->updateMonthlySnapshots();
        return Command::SUCCESS;
    }

    protected function updateMonthlySnapshots(): void
    {
        UserScore::where(function ($query) {
            $query->whereNull('monthly_snapshot_at')
                  ->orWhere('monthly_snapshot_at', '<', now()->subDays(30));
        })->each(fn($userScore) => $userScore->takeMonthlySnapshot());
    }
}
// routes/console.php
Schedule::command('scores:calculate')->dailyAt('03:00');
Schedule::command('scores:calculate')->monthlyOn(1, '04:00');

User::chunk(100, ...) loads 100 users at a time and frees the memory after each chunk. User::all() for 10,000 users would load 10 MB of model objects at once; chunked, the peak memory is 100 KB.

The Leaderboard Controller

With pre-calculated scores, the leaderboard query is trivial:

class LeaderboardController extends Controller
{
    public function index(Request $request)
    {
        $allTimeLeaders = UserScore::with('user:id,username,name,profile_picture')
            ->whereHas('user', fn($q) => $q->whereNotIn('username', ['reddit_bot', 'posts_bot', 'admin']))
            ->orderBy('total_score', 'desc')
            ->limit(50)
            ->get()
            ->map(fn($userScore, $index) => [
                'rank'      => $index + 1,
                'user'      => $userScore->user->only(['id', 'username', 'name', 'profile_picture']),
                'total_score' => $userScore->total_score,
                'breakdown' => $userScore->only(['post_likes_score', 'review_votes_score', 'comment_likes_score', 'content_creation_score']),
            ]);

        $monthlyRisers = UserScore::with('user:id,username,name,profile_picture')
            ->whereHas('user', fn($q) => $q->whereNotIn('username', ['reddit_bot', 'posts_bot', 'admin']))
            ->where('monthly_increase', '>', 0)
            ->orderBy('monthly_increase', 'desc')
            ->limit(20)
            ->get();

        $currentUserRank = null;
        if ($request->user()) {
            $userScore = UserScore::where('user_id', $request->user()->id)->first();
            if ($userScore) {
                $rank = UserScore::where('total_score', '>', $userScore->total_score)->count() + 1;
                $currentUserRank = [
                    'rank'             => $rank,
                    'total_score'      => $userScore->total_score,
                    'monthly_increase' => $userScore->monthly_increase,
                ];
            }
        }

        return Inertia::render('Leaderboard', compact('allTimeLeaders', 'monthlyRisers', 'currentUserRank'));
    }
}

Before this optimization, the leaderboard ran 50,000+ queries and took 5+ seconds. Now it’s one indexed query taking under 50ms. The tradeoff is that scores are slightly stale — potentially by up to 24 hours for the full recalculation, but the observer-based real-time updates keep individual scores current within milliseconds of each action.

Testing the Score Logic

Because the scoring service is a plain class with no framework coupling, it’s straightforward to test:

public function test_post_like_increases_author_score()
{
    $author = User::factory()->create();
    $post   = Post::factory()->create(['user_id' => $author->id]);
    
    $initialScore = $author->userScore->total_score ?? 0;
    
    $liker = User::factory()->create();
    Like::create(['user_id' => $liker->id, 'likeable_type' => Post::class,
                  'likeable_id' => $post->id, 'is_like' => true]);
    
    $author->refresh();
    $this->assertEquals($initialScore + 3, $author->userScore->total_score);
}

public function test_score_calculation_matches_real_time_updates()
{
    $user = User::factory()->create();
    $post = Post::factory()->create(['user_id' => $user->id]);
    Like::factory()->count(5)->create(['likeable_type' => Post::class,
                                       'likeable_id' => $post->id, 'is_like' => true]);
    
    $realtimeScore = $user->userScore->total_score;
    $calculated    = app(ScoringService::class)->calculateUserScore($user);
    
    $this->assertEquals($realtimeScore, $calculated['total_score']);
}

The second test is the most important one — it verifies that the real-time incremental updates match what a full recalculation produces. If this test ever fails, you have a bug in the observer logic that the nightly job would eventually catch.

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!