<?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));
}
}
<?php
namespace App\Support;
class CartTotals
{
public function __construct(
public readonly int $subtotalCents,
public readonly int $discountCents,
public readonly int $taxCents,
public readonly int $totalCents,
public readonly ?string $couponCode = null,
) {
}
public function toArray(): array
{
return [
'subtotal_cents' => $this->subtotalCents,
'discount_cents' => $this->discountCents,
'tax_cents' => $this->taxCents,
'total_cents' => $this->totalCents,
'coupon_code' => $this->couponCode,
];
}
}
<?php
namespace App\Services;
use App\Exceptions\CouponException;
use App\Models\Cart;
use App\Models\Coupon;
use App\Support\CartTotals;
class CartPricingService
{
private const TAX_RATE = 0.0725;
public function priceCart(Cart $cart, ?string $code = null): CartTotals
{
$subtotal = $this->subtotalCents($cart);
$discount = 0;
$appliedCode = null;
if ($code !== null && $code !== '') {
$coupon = $this->applyCoupon($code, $subtotal);
$discount = $coupon->discountFor($subtotal);
$appliedCode = $coupon->code;
}
$taxable = $subtotal - $discount;
$tax = (int) round($taxable * self::TAX_RATE);
return new CartTotals($subtotal, $discount, $tax, $taxable + $tax, $appliedCode);
}
private function subtotalCents(Cart $cart): int
{
return $cart->items->reduce(function (int $carry, $item) {
return $carry + ($item->unit_price_cents * $item->quantity);
}, 0);
}
private function applyCoupon(string $code, int $subtotal): Coupon
{
$coupon = Coupon::whereRaw('LOWER(code) = ?', [strtolower($code)])->first();
if ($coupon === null) {
throw new CouponException("Coupon \"{$code}\" was not found.");
}
if (! $coupon->isValid($subtotal)) {
throw new CouponException("Coupon \"{$code}\" cannot be applied to this cart.");
}
return $coupon;
}
}
<?php
namespace App\Http\Controllers;
use App\Exceptions\CouponException;
use App\Models\Cart;
use App\Services\CartPricingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CartController extends Controller
{
public function applyCoupon(Request $request, CartPricingService $pricing): JsonResponse
{
$data = $request->validate([
'cart_id' => ['required', 'integer'],
'code' => ['required', 'string', 'max:64'],
]);
$cart = Cart::with('items')->findOrFail($data['cart_id']);
try {
$totals = $pricing->priceCart($cart, $data['code']);
} catch (CouponException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return response()->json($totals->toArray());
}
}
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
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
<?php
namespace App\Providers;
use App\Contracts\PaymentGateway;
use App\Services\StripePaymentGateway;
Laravel service container and dependency injection
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
{
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build"
},
Laravel mix/Vite for asset compilation
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
Laravel database migrations for schema management
Share this code
Here's the card — post it anywhere.