zgabievi/laravel-promocodes
A Laravel package for creating and managing promo codes: generate unique codes, attach them to any Eloquent model, set fixed or percentage discounts, apply expiration dates and usage limits, then validate or redeem codes through a simple, expressive API.
The 9.1.0 release is additive — nothing here is breaking — but it meaningfully changes the day-to-day workflow: you can now validate a promocode at the edge of your application (in a FormRequest) and list/query promocodes without hand-rolling Eloquent queries. Start by reading the new retrieval methods and the validation rule before writing any new checkout code.
Minimal setup:
composer require <vendor>/<package> # upgrade to ^9.1
php artisan vendor:publish --tag=<package>-config
php artisan vendor:publish --tag=<package>-migrations
php artisan migrate
Where to look first, in this order:
App\Models\User (or whatever you actually authenticate with) rather than the default.code, usages_left, expires_at and user-bound columns so you know what the retrieval methods can filter on.First use case to implement: a promocode field on your checkout/cart form. Validate it with the new rule in a FormRequest, then redeem it inside the controller action. That's the whole loop, and it's now much shorter than before.
Validate at the request layer, redeem in the action. The new validation rule is designed for exactly this, so move promocode checks out of controllers and into FormRequests:
class ApplyPromocodeRequest extends FormRequest
{
public function rules(): array
{
return [
'code' => ['required', 'string', 'max:64', new PromocodeRule()],
];
}
}
Treat the rule as a pre-flight check only. It validates that the promocode exists and is applicable for the current context; it does not reserve or consume it. Always redeem inside the action, and inside a transaction (see Gotchas).
Use the new retrieval methods instead of local scopes. Prior to 9.1.0 most projects accumulated a Promocode::where('active', true)->where(...) scope in the app layer. Migrate those to the package methods so your eligibility logic matches the package's own rules:
Keep a thin app-level PromocodeService that wraps these calls; that's your seam for caching and for swapping logic later.
Wrap apply/redeem in a transaction and make it idempotent. The classic pattern:
return DB::transaction(function () use ($code, $user, $cart) {
$promocode = Promocodes::redeem($code, $user); // or the bound-user API
$cart->applyDiscount($promocode);
return $promocode;
});
Pass the user explicitly where the API allows it — this is more reliable than relying on auth() resolution, especially in queued jobs, API tokens, and impersonation flows.
Use the validation rule in API and Livewire contexts too. Because it's a standard Laravel rule object, it composes with Rule::when, bail, and sometimes, and it works identically in Livewire validate() calls and JSON:API style endpoints. Centralising it kills the "different eligibility logic per entry point" bug class.
Test with the rule, not with raw DB assertions. Feature tests can now assert assertSessionHasErrors('code') against the rule instead of manually constructing promocodes and asserting on a service exception. For unit tests, seed promocodes via the model factory (or the package's create method) and drive the rule directly.
lockForUpdate), or rely on the package's atomic redeem path. This is the number-one source of oversubscription bugs with promocodes.php artisan config:clear — a stale cached config is the most common reason "the fix doesn't work." Also check for morph-map conflicts if you use Relation::morphMap() and your user model is aliased.expires_at is almost always compared against now() in the app timezone. If your app timezone differs from UTC storage, an expiry "today" can behave unexpectedly near midnight. Add an explicit test at the boundary.usages_left semantics. Confirm whether null means "unlimited" or "zero" in your version, and whether validation treats exhausted codes as invalid or merely "not applicable." Assert both in tests — this is where package upgrades typically introduce subtle behaviour changes.php artisan config:clear to your test bootstrap if you've been bitten before.->where(...)) over reimplementing eligibility. If they return a collection rather than a builder, filter in PHP — don't reach into the model and duplicate the rules, or you'll drift on the next release.usages_left. Nine times out of ten the failure is in eligibility (user binding, expiry, exhausted), not in the write itself.How can I help you explore Laravel packages today?