php 119 lines · 4 tabs

Validate an Unredeemed Coupon Code with a Custom Laravel Rule Object

Shared by codesnips Aug 2026
4 tabs
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('coupons', function (Blueprint $table) {
            $table->id();
            $table->string('code')->unique();
            $table->unsignedInteger('discount_cents');
            $table->unsignedInteger('max_redemptions')->default(1);
            $table->unsignedInteger('redeemed_count')->default(0);
            $table->timestamp('expires_at')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('coupons');
    }
};
4 files · php Explain with highlit

This snippet shows how Laravel's custom rule objects encapsulate a non-trivial validation concern — checking that a coupon code both exists and is still redeemable — so the logic lives in one testable place instead of being smeared across controllers.

The create_coupons_table migration defines the durable shape the rule depends on. A code column is marked unique so codes are unambiguous, and redemption state is modeled with max_redemptions, a redeemed_count counter, and an expires_at timestamp. Splitting a hard limit from a running counter (rather than a single boolean flag) lets a coupon be redeemed several times while still being cheap to check.

The CouponIsRedeemable rule is an invokable rule implementing ValidationRule. Its validate method receives the attribute, value, and a $fail closure, and short-circuits with a specific message for each failure mode: the code not existing, being expired, or being fully consumed. Looking the row up once and stashing it on $this->coupon means the same query can be reused rather than repeated. Using distinct $fail messages is deliberate — a generic "invalid coupon" message frustrates users, whereas "this coupon has expired" is actionable. The rule reads state but does not mutate it; redemption belongs in a transaction after validation passes.

The RedeemCouponRequest form request wires the rule into the framework's validation lifecycle. In rules, the code field is required, normalized as a string, and handed a fresh new CouponIsRedeemable instance. Building the rule inside rules (not as a shared singleton) keeps each request's lookup isolated.

The CouponController shows the payoff: because validation already guaranteed a redeemable coupon, redeem can focus on the state change. It wraps the increment in a DB::transaction and uses lockForUpdate to serialize concurrent redemptions of the same code, closing the check-then-act race that pure validation cannot. This is the key trade-off to understand: the rule provides friendly, early feedback, but the authoritative guard against overselling a limited coupon is the row lock at write time. A developer reaches for this pattern whenever a field's validity depends on database state and clear per-case messaging matters.


Related snips

Share this code

Here's the card — post it anywhere.

Validate an Unredeemed Coupon Code with a Custom Laravel Rule Object — share card
Link copied