php 150 lines · 4 tabs

Apply and Validate Coupon Codes with a Cart Totals Service in Laravel

Shared by codesnips Aug 2026
4 tabs
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Coupon extends Model
{
    protected $casts = [
        'expires_at' => 'datetime',
        'percent_off' => 'integer',
        'fixed_off_cents' => 'integer',
        'min_subtotal_cents' => 'integer',
        'usage_limit' => 'integer',
        'times_used' => 'integer',
    ];

    public function isValid(int $subtotalCents): bool
    {
        if ($this->expires_at !== null && $this->expires_at->isPast()) {
            return false;
        }

        if ($this->usage_limit !== null && $this->times_used >= $this->usage_limit) {
            return false;
        }

        return $subtotalCents >= (int) $this->min_subtotal_cents;
    }

    public function discountFor(int $subtotalCents): int
    {
        if ($this->type === 'percent') {
            $raw = (int) floor($subtotalCents * $this->percent_off / 100);
        } else {
            $raw = (int) $this->fixed_off_cents;
        }

        return max(0, min($raw, $subtotalCents));
    }
}
4 files · php Explain with highlit

This snippet shows how a cart total recalculation is isolated in a service class so the coupon rules live in one place rather than being smeared across a controller. The core idea is that money math and discount validation are error-prone, so the calculation is made deterministic and testable: given a cart and an optional coupon, the service returns an immutable totals object that the caller renders.

In Coupon model, an Eloquent model carries the discount definition and a small amount of behaviour. isValid() centralises the temporal and usage-limit checks — expiry via expires_at, an optional per-coupon usage_limit, and a min_subtotal_cents threshold — so no caller has to remember every guard. discountFor() computes the discount in integer cents, branching on percent vs fixed types and clamping the result so a discount can never exceed the subtotal, which prevents negative totals. Working in cents avoids floating-point rounding drift that plagues currency arithmetic.

In CartTotals DTO, a readonly value object holds the computed subtotal, discount, tax, and total, plus the applied coupon code. Because it is immutable, it can be passed around and cached without fear of a later mutation silently changing a displayed total. toArray() gives the controller a clean JSON shape.

In CartPricingService, priceCart() orchestrates the flow: it sums line items with subtotalCents(), resolves the discount when a code is present, applies tax to the post-discount amount, and assembles the DTO. The applyCoupon() helper looks up the coupon case-insensitively, throws a domain CouponException when it is missing or isValid() fails, and otherwise delegates the amount to the model. Throwing a typed exception keeps the happy path linear and lets the controller translate failures into HTTP responses.

In CartController, applyCoupon() wires everything to the request: it loads the cart, calls the service inside a try/catch, and returns a 422 with the exception message on failure or the fresh totals on success. This separation means the pricing rules can be unit-tested without touching HTTP, and the controller stays a thin adapter. The trade-off is a little extra indirection, which pays off as coupon logic and tax rules inevitably grow.


Related snips

Share this code

Here's the card — post it anywhere.

Apply and Validate Coupon Codes with a Cart Totals Service in Laravel — share card
Link copied