DEV3 Technologies home

Monthly usage metering with automatic reset

Count usage per team and metric in one row per month, consuming units with a single conditional update so a limit can never be overshot.

LaravelDatabasemeteringquotausageatomic

Code

<?php

namespace App\Billing;

use Illuminate\Support\Facades\DB;

final class UsageMeter
{
    /** Period key; a new month means a fresh row, so there is nothing to reset. */
    public static function period(): string
    {
        return now()->utc()->format('Y-m');
    }

    /** Take $units if the limit allows it. A null limit means unlimited. */
    public static function consume(int $teamId, string $metric, int $units = 1, ?int $limit = null): bool
    {
        $key = ['team_id' => $teamId, 'metric' => $metric, 'period' => self::period()];

        // The unique index makes this safe when two requests start a month together.
        DB::table('usage_counters')->insertOrIgnore($key + ['used' => 0, 'updated_at' => now()]);

        $query = DB::table('usage_counters')->where($key);

        if ($limit !== null) {
            $query->whereRaw('used + ? <= ?', [$units, $limit]);
        }

        // Check and increment are one statement, so the last unit goes to exactly one caller.
        return $query->update(['used' => DB::raw('used + '.(int) $units), 'updated_at' => now()]) > 0;
    }

    public static function used(int $teamId, string $metric): int
    {
        return (int) DB::table('usage_counters')
            ->where(['team_id' => $teamId, 'metric' => $metric, 'period' => self::period()])
            ->value('used');
    }
}

How it works

What it solves

Metered plans need a counter that resets each month, survives concurrent requests, and never lets two callers both take the last unit. A read-then-write counter races; a cron that zeroes everything on the first of the month is another moving part that can fail.

How it works

Usage is stored as one row per team, metric and period, where the period is a string such as 2025-06. A new month simply means a new row, so the reset happens by itself with no scheduled job and the old rows double as billing history.

consume() first makes sure the row exists with insertOrIgnore, relying on the unique index. Then it runs a single UPDATE ... SET used = used + n WHERE used + n <= limit. The database serialises that statement per row, so the check and the increment are one atomic step. The affected-rows count tells you whether the units were granted: 1 means yes, 0 means the limit would have been exceeded.

A null limit means unlimited and drops the where clause. used() reads the current total for dashboards.

Adapting it

Pass the limit from your plan config. Use a different period format, such as Y-m-d, for daily quotas. Call consume right before the action it pays for, inside the same transaction when you want rollback on failure.

Pitfalls

  • MySQL reports changed rows; the update always changes used, so the count is reliable.
  • Use the same timezone for the period everywhere, UTC is safest.

How to use it

  1. Run the migration to create usage_counters.
  2. Copy UsageMeter into app/Billing.
  3. Before a metered action: if (! UsageMeter::consume($team->id, 'links', 1, $team->planLimit('links'))) { abort(402); }.
  4. Show remaining quota with UsageMeter::used($team->id, 'links').
  5. Nothing to schedule: the next month starts a fresh row on its own.

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