DEV3 Technologies home

Atomic donation goal progress

Update a campaign's raised total with a single SQL increment and fire a goal-reached event exactly once, even under concurrent donations.

LaravelDatabasedonationsatomicprogressevents

Code

<?php

namespace App\Fundraising;

use App\Events\GoalReached;
use App\Models\Campaign;
use Illuminate\Support\Facades\DB;

final class GoalProgress
{
    public static function add(Campaign $campaign, int $cents): Campaign
    {
        return DB::transaction(function () use ($campaign, $cents) {
            // The database applies each addition to the current value: no lost updates.
            Campaign::whereKey($campaign->id)->update([
                'raised_cents' => DB::raw('raised_cents + '.(int) $cents),
            ]);

            // Only the request that flips the null timestamp announces the goal.
            $claimed = Campaign::whereKey($campaign->id)
                ->whereNull('goal_reached_at')
                ->whereColumn('raised_cents', '>=', 'goal_cents')
                ->update(['goal_reached_at' => now()]);

            $fresh = $campaign->fresh();

            if ($claimed === 1) {
                GoalReached::dispatch($fresh); // fires after commit
            }

            return $fresh;
        });
    }

    public static function percent(Campaign $campaign): int
    {
        return $campaign->goal_cents > 0
            ? min(100, intdiv($campaign->raised_cents * 100, $campaign->goal_cents))
            : 0;
    }
}

How it works

What it solves

A progress bar looks harmless until two donations land at the same moment. With $campaign->raised += $amount; $campaign->save(); both requests read the old total and one donation vanishes from the display. A second problem is celebratory: the "goal reached" email should go out once, not once per donation that follows.

How it works

GoalProgress::add increments raised_cents with a SQL expression, raised_cents = raised_cents + ?, so the database applies each addition against the current value no matter how requests interleave. It runs in a transaction with the donation insert, so the total and the donation rows can never disagree.

Reaching the goal is claimed with a second conditional update: set goal_reached_at where it is still null and raised_cents >= goal_cents. Only one request gets an affected-row count of 1, and only that request dispatches the GoalReached event, which listeners turn into emails or social posts. The campaign is then re-read so callers get fresh numbers.

percent() caps the display at 100 while the stored total keeps growing, which is what you want for stretch goals.

Adapting it

Pass refunds as negative amounts and decide whether goal_reached_at should clear. Add milestone thresholds by looping the claim over several columns.

Pitfalls

  • Dispatch the event after the transaction commits, which DB::afterCommit handles.
  • Store money as integer minor units.

How to use it

  1. Add goal_cents, raised_cents (default 0) and nullable goal_reached_at to campaigns.
  2. Copy the service and the event; register listeners in your app.
  3. When a donation succeeds: GoalProgress::add($campaign, $donation->amount_cents) inside the transaction that saves the donation.
  4. Render the bar with GoalProgress::percent($campaign).
  5. Listen for GoalReached to send a thank-you update.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets