DEV3 Technologies home

Plan-limit gate middleware

Route middleware that blocks a request when the team has used up its plan allowance for a named limit, with a clear upgrade response.

LaravelMiddlewareplanslimitsmiddlewaresaas

Code

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class EnforcePlanLimit
{
    public function handle(Request $request, Closure $next, string $limit): Response
    {
        $team = $request->user()?->team;
        abort_unless($team, 403);

        $allowed = config("plans.{$team->plan}.{$limit}");

        // Unlimited plans skip the count query entirely.
        if ($allowed === null) {
            return $next($request);
        }

        $used = $team->usage($limit);

        if ($used >= $allowed) {
            $payload = [
                'message' => "Your {$team->plan} plan allows {$allowed} {$limit}.",
                'limit' => $limit,
                'used' => $used,
                'allowed' => $allowed,
                'upgrade_url' => route('billing.plans'),
            ];

            return $request->expectsJson()
                ? response()->json($payload, 402)
                : redirect()->route('billing.plans')->with('error', $payload['message']);
        }

        return $next($request);
    }
}

How it works

What it solves

Subscription products sell allowances: contacts, campaigns, seats. Checking them inside every controller drifts quickly and produces inconsistent errors. A middleware declared on the route makes the rule visible next to the URL it protects and gives every limit the same failure shape.

How it works

Plans live in config/plans.php as a simple map of limit names to numbers, where null means unlimited. The middleware takes the limit name and a counter key as parameters: plan.limit:contacts. It looks up the team's plan, counts current usage through a small resolver on the team model, and compares.

When the allowance is spent it returns a 402 for JSON clients, or redirects back with a flash message for browsers, and includes the limit, the usage and the plan name so the client can render an upgrade prompt. Unlimited plans short-circuit before any count query runs, so top-tier customers pay no extra cost per request.

Because the check runs before the controller, it is a gate, not a guarantee: two simultaneous requests can both pass the last slot.

Adapting it

Add usage($limit) to your team model returning an int, for example contacts()->count(). Register the alias in bootstrap/app.php. For hard guarantees pair this with a database constraint or an atomic counter at write time.

Pitfalls

  • Gate only creating routes, not reads, or customers cannot even view their data when over the limit.
  • Cache the count if it is expensive, but expire it on writes.

How to use it

  1. Add config/plans.php and the middleware class.
  2. Register the alias in bootstrap/app.php: $middleware->alias(['plan.limit' => EnforcePlanLimit::class]);.
  3. Give your Team model a plan attribute and a usage(string $limit): int method.
  4. Protect creating routes: Route::post('/contacts', [ContactController::class, 'store'])->middleware('plan.limit:contacts');.
  5. Show response()->json()['upgrade_url'] in your front end when you receive a 402.

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