Usage-based billing with Stripe Meters and Laravel Cashier

5 min read

Flat-rate subscriptions are simple until customers start asking why they're paying the same as your biggest user when they consume one-tenth of the resources. Stripe Meters solve this cleanly: define a unit of work, emit events as usage happens, and Stripe handles aggregation and billing. Here's how to wire up stripe usage-based billing with Laravel Cashier from scratch.

Flat-rate vs usage-based: what changes with Stripe Meters

With a flat subscription, customers pay a fixed amount per billing period regardless of usage. With usage-based billing, they pay for what they consume.

Stripe has two generations of usage billing. The old approach used SubscriptionItem::reportUsage() against a price with recurring[usage_type]=metered. That still works, but Stripe's current approach is Meters: a dedicated object you create once, attach to a price, and emit events against from anywhere in your codebase.

The key differences:

  • Events are tied to a customer, not a subscription item — simpler to instrument
  • Meters support multiple aggregation modes: sum, count, or last
  • You get a full event log in the Stripe dashboard
  • The Stripe Meters API is a first-class billing primitive, not a workaround

Setting up a Stripe Meter

This step happens once, in the Stripe dashboard or via the API. Create the meter first, then create a price linked to it.

In the Stripe Dashboard:

  1. Go to Billing → Meters → Create meter
  2. Set a display name — e.g. "API calls"
  3. Set an event name — this is what you'll reference in your code, e.g. api_call
  4. Choose aggregation: Sum if you're passing a quantity, Count if every event equals one unit

Or via the API:

curl https://api.stripe.com/v1/billing/meters \
  -u sk_test_xxxx: \
  -d display_name="API calls" \
  -d event_name=api_call \
  -d "default_aggregation[formula]"=sum

Note the meter id returned (format: mtr_xxx) — you'll need it when creating the price.

Now create a price linked to that meter. This example charges £0.01 per API call, billed monthly:

curl https://api.stripe.com/v1/prices \
  -u sk_test_xxxx: \
  -d currency=gbp \
  -d unit_amount=1 \
  -d "recurring[interval]"=month \
  -d "recurring[usage_type]"=metered \
  -d "recurring[meter]"=mtr_xxx \
  -d product=prod_xxx

Store the resulting price_id — that's what goes into your Cashier subscription.

Installing Laravel Cashier for Stripe usage-based billing

composer require laravel/cashier

Cashier v16 (current: v16.5.0) supports Laravel 12 and PHP 8.1+. Install and run the migrations:

php artisan cashier:install
php artisan migrate

Add the Billable trait to your User model:

use Laravel\Cashier\Billable;

class User extends Authenticatable
{
    use Billable;
}

Add your Stripe keys to .env:

STRIPE_KEY=pk_test_xxxx
STRIPE_SECRET=sk_test_xxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxx

Creating a metered subscription

Nothing changes here compared to a standard Cashier subscription — you just pass the metered price ID:

$user->newSubscription('default', 'price_xxx')
    ->create($paymentMethodId);

Stripe creates the subscription with a $0 first invoice. Usage accumulates during the billing period and is charged at the end.

One thing to do before any billing calls: ensure the user has a Stripe customer record. Call this during signup or before first use:

$user->createOrGetStripeCustomer();

Cashier won't let you report meter events without a Stripe customer ID — more on that in the gotchas below.

Reporting usage from your Laravel app

Anywhere you want to track a unit of work — a controller, middleware, queued job — call reportMeterEvent():

// Report a single unit
$user->reportMeterEvent('api_call');

// Report multiple units (e.g. tokens consumed)
$user->reportMeterEvent('api_call', 5);

Cashier passes your user's stripe_customer_id in the payload automatically. You don't need to handle Stripe customer IDs directly.

A clean real-world example — middleware that tracks usage per API request:

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class TrackApiUsage
{
    public function handle(Request $request, Closure $next): mixed
    {
        $response = $next($request);

        // Only track authenticated requests that completed successfully
        if ($user = $request->user()) {
            $user->reportMeterEvent('api_call');
        }

        return $response;
    }
}

Register it in bootstrap/app.php against your API routes:

->withMiddleware(function (Middleware $middleware) {
    $middleware->appendToGroup('api', \App\Http\Middleware\TrackApiUsage::class);
})

If the tracking itself is slow (e.g. you're doing additional processing), push it to a deferred closure so the HTTP response isn't blocked:

dispatch(function () use ($user, $tokensUsed) {
    $user->reportMeterEvent('ai_tokens', $tokensUsed);
});

Displaying current usage to users

Pull meter event summaries via Cashier to show customers what they've used this billing period:

use Carbon\Carbon;

$summaries = $user->meterEventSummaries(
    meterId: 'mtr_xxx',
    startTime: Carbon::now()->startOfMonth()->timestamp,
    endTime: Carbon::now()->timestamp,
);

$totalUsage = $summaries->sum('aggregated_value');

Expose this from a controller for a usage dashboard:

public function usage(): JsonResponse
{
    $user = request()->user();

    $summaries = $user->meterEventSummaries(
        'mtr_xxx',
        now()->startOfMonth()->timestamp,
        now()->timestamp,
    );

    return response()->json([
        'usage'               => $summaries->sum('aggregated_value'),
        'billing_period_end'  => now()->endOfMonth()->toDateString(),
    ]);
}

Or keep it simple in a Blade view:

<p>API calls this month: {{ number_format($totalUsage) }}</p>

Gotchas and edge cases

Events are async. Stripe processes meter events asynchronously. Usage doesn't appear in summaries or on upcoming invoices immediately — there's a short delay. Don't build real-time displays that depend on instant consistency, and add a note to your UI that figures may be a few minutes behind.

The Stripe customer must exist before you report. reportMeterEvent() calls assertCustomerExists() internally and throws if the user has no Stripe customer ID. Call $user->createOrGetStripeCustomer() during signup or before the first metered action.

Event names must match exactly. The string you pass to reportMeterEvent() must be identical to the event_name on the Stripe Meter object. A typo means events are silently dropped — no error, they just don't count. I prefer storing the event name as a constant or enum value:

enum MeterEvent: string
{
    case ApiCall  = 'api_call';
    case AiTokens = 'ai_tokens';
}

// Usage
$user->reportMeterEvent(MeterEvent::ApiCall->value);

Don't mix old-style usage reporting with Meters. If you previously used $subscriptionItem->reportUsage() (the legacy per-item approach), that's a different system entirely. You can't mix legacy metered items and Stripe Meters on the same price.

Test with the Stripe CLI. Run stripe listen to forward webhooks locally and verify events are flowing through the dashboard:

stripe listen --forward-to localhost:8000/stripe/webhook

Check the Stripe dashboard under Billing → Meters to confirm events are being recorded against the correct meter and customer.

Wrapping up

Stripe Meters are a significant improvement over the old per-item usage reporting approach. Install Cashier v16, create your meter and metered price in the Stripe dashboard, call reportMeterEvent() wherever usage happens, and use meterEventSummaries() to show customers what they've consumed. The async event processing is the main thing to plan around — everything else falls into place cleanly.

stripe
billing
laravel
cashier
payments
Steven Richardson

Steven is a software engineer with a passion for building scalable web applications. He enjoys sharing his knowledge through articles and tutorials.