plaurent / laravel-vouchers
Moteur de codes promo pour Laravel : campagnes, codes publics ou individuels, remises fixes ou en pourcentage, limites cumulatives et suivi commercial.
Requires
- php: ^8.2
- illuminate/contracts: ^10.0|^11.0|^12.0|^13.0
- illuminate/database: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0
- pestphp/pest: ^2.0|^3.0|^4.0
- pestphp/pest-plugin-laravel: ^2.0|^3.0|^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-10 14:34:51 UTC
README
Promo code engine for Laravel: campaigns, public or individual codes, fixed or percentage discounts, cumulative limits, and commercial reporting.
The package owns the rules. Your project keeps its own routes, controllers, admin screens, authorisation and payment flow — the package registers none of those and never will.
- PHP 8.2+ · Laravel 10, 11, 12, 13
- Integer money throughout: no floats anywhere in the calculation
- Atomic, idempotent redemption
- Country restrictions, per campaign or per code
- Refusal messages translated into nine languages out of the box
- One code per order (v1)
Contents
Install · Declaring what a campaign can discount · Traits · A campaign and its codes · Checking a code · Confirming and cancelling · Money · Limits · Geographic restrictions · Translations · Marketing attribution · Reporting · Events · Configuration · Data model · Concurrency · Security · Upgrading · Not in v1
Install
composer require plaurent/laravel-vouchers
Config, migrations and translations are publishable:
php artisan vendor:publish --tag=vouchers-config
php artisan vendor:publish --tag=vouchers-migrations
php artisan vendor:publish --tag=vouchers-lang
Migrations and translations are loaded automatically, so publishing is only
needed to change them.
If your models are keyed by UUID or ULID, set morph_key_type in the config
before migrating.
Declaring what a campaign can discount
Nothing is targetable until you say so. Each model gets a readable alias, which is what gets stored — so moving the class later doesn't break existing rules.
// config/vouchers.php 'targetables' => [ 'product' => App\Models\Product::class, 'plan' => App\Models\Plan::class, ],
A model absent from this list is never eligible, not even under an "all items" campaign. "All" means all of what you opted in.
Traits
use Plaurent\Vouchers\Concerns\HasVouchers; // your User use Plaurent\Vouchers\Concerns\HasVoucherRedemption; // your Order use Plaurent\Vouchers\Concerns\Targetable; // your Product, Plan…
A campaign and its codes
use Plaurent\Vouchers\Facades\Vouchers; $campaign = Vouchers::createCampaign([ 'title' => 'Coupon pour DjToto', 'description' => 'Instagram campaign, 28 August 2026', 'percentage' => 20, // or 'fixed' => 1000 (10.00) 'targets' => ['plan' => true, 'product' => [$giftCard, $training]], 'min_amount' => 5000, // optional, 50.00 of eligible goods 'max_redemptions' => 1000, // optional, null = unlimited 'max_per_beneficiary' => 1, // the default; null = unlimited 'partner' => $partner, // optional ]); $code = Vouchers::createCode($campaign, ['code' => 'DJTOTO']); $batch = Vouchers::generateCodes($campaign, 500, ['prefix' => 'DJ']);
targets accepts whatever is convenient: 'all', a model, a class name, an
alias, ['product' => [1, 2]], ['type' => 'plan'], or a list mixing them.
An empty selection is an error. It is never silently read as "everything" —
saying 'targets' => 'all' has to be deliberate.
Expiry is mandatory
A campaign cannot run forever. Omit expires_at and it is derived from
starts_at (or now) plus campaigns.default_duration, which defaults to one
month. A campaign is valid while starts_at <= now < expires_at.
Checking a code
Checking consumes nothing, so it is safe on every keystroke.
use Plaurent\Vouchers\Support\Cart; $cart = Cart::make('EUR') ->addItem($plan, 5000) // 50.00 ->addItem($giftCard, 3000) // 30.00 ->addItem($mug, 2000) // 20.00, not eligible ->shipping(990, 198); // optional $result = Vouchers::validate('DJTOTO', $cart, $user); // or an email, or null if ($result->valid) { $result->discountAmount; // 1600 $result->eligibleSubtotal; // 8000 $result->totalAfterDiscount; // 8400 $result->lineDiscounts; // per line, summing exactly to discountAmount } else { __("vouchers.rejections.{$result->reason->value}"); }
$result->toArray() gives you a JSON-ready shape for an API endpoint.
Rejection reasons
Stable keys — treat them as contract:
not_found · not_started · expired · disabled · limit_reached ·
beneficiary_limit_reached · beneficiary_required · not_assigned ·
minimum_not_reached · no_eligible_item · country_not_allowed ·
country_required
Each one is translated for you — see Translations.
Confirming and cancelling
// On payment confirmation. Atomic and idempotent. $redemption = Vouchers::confirm('DJTOTO', $cart, $user, $order, [ 'amount_paid' => $charge->amount, 'meta' => ['gateway' => 'stripe'], ]); // On refund or cancellation. Keeps the history, releases the quotas. Vouchers::cancel($order);
Repeated webhooks for one order produce one redemption. Confirming re-reads the campaign and code under a row lock and re-checks every limit, so it never trusts what validation saw a moment earlier. Cancelling twice does not hand back a second unit of quota.
If the code no longer applies at confirmation time, confirm() throws
RedemptionException carrying the reason.
Money
Every amount is an integer in the currency's smallest unit. There is no Money object and no float. Bring your own library and convert at the boundary:
$cart->addItem($product, $money->getAmount()); // moneyphp/money
Coming from decimal prices
If your prices are 15.99 rather than 1599, convert them with Amount —
not with (int) ($price * 100), which silently loses a cent:
19.99 * 100 is 1998.9999999999998 in binary floating point, and the cast
truncates it to 1998.
use Plaurent\Vouchers\Support\Amount; $cart->addItem($plan, Amount::fromDecimal($plan->price)); // "15.99" -> 1599 Amount::toDecimalString(1599); // "15.99", for display
It parses by string, never by cast, and accepts what a French locale produces
("15,99", "1 234,56 €"). Pass $decimals for other currencies: 0 for JPY,
3 for KWD. A string is safest; a float is accepted but is only as precise as
PHP's serialisation of it.
A currency annotation is stripped where it stands apart from the digits — a
symbol ("$15.99", "15,99 €") or a three-letter code as its own word
("15.99 EUR"). Anything else that is not a number is refused, not
repaired: "1O.00" typed with a letter O throws rather than quietly becoming
1.00. A rejected amount costs you a validation error; a repaired one costs the
difference.
Better still, store minor units in the database — an integer column removes the conversion, and the rounding question, entirely.
A fixed discount carries no currency of its own: 1000 is 10.00 in whatever
the order is denominated in. Percentages are stored in basis points
(2000 = 20%), and 'percentage' => '12.5' is parsed by string, never by cast.
Discounts round once, at the end, with the configured strategy — HalfUp by
default, so 4.994 → 4.99 and 4.995 → 5.00. Swap it for HalfDown, HalfEven,
RoundUp, RoundDown or your own RoundingStrategy. When a discount is split
across lines, the parts sum to exactly the whole.
What the discount applies to
The eligible goods subtotal, always. Tax and shipping are two independent
opt-ins (includes_taxes, includes_shipping), giving all four combinations.
A discount never turns its base negative and never reaches ineligible items to
spend the remainder: 80.00 off against 70.00 of eligible goods is worth 70.00.
The package computes the discount. Final tax calculation, its apportionment and invoicing remain yours.
Limits
Four independent, cumulative limits — campaign total, code total, per-beneficiary on the campaign, per-beneficiary on the code. Every one that applies must hold; a code limit cannot lift a campaign's ceiling.
Identify the buyer whenever a per-beneficiary limit applies. A limit "per beneficiary" cannot be enforced against someone unnamed, so an anonymous checkout is refused with
beneficiary_requiredrather than letting a once-per-person code become endlessly reusable. Pass a user or an email.
That refusal is deliberately not beneficiary_limit_reached, which means
something else: this person has spent their allowance. A first-time visitor
told they have already used the code abandons the basket; one told to sign in
signs in.
| Reason | What actually happened |
|---|---|
beneficiary_required |
Nobody was named, so the limit cannot be checked |
beneficiary_limit_reached |
This person has used their allowance |
Individual codes
Vouchers::createCode($campaign, ['assignee' => $user]); Vouchers::createCode($campaign, ['assignee_email' => 'carol@example.com']);
Someone invited by email can redeem before signing up. Once they create an account with that address, the code is recognised as theirs and their earlier redemptions count against their personal limit — one person, one allowance.
Assignment restricts who, not how often: an individual code keeps its own configurable limits and is not implicitly single-use.
Email comparison folds case and surrounding whitespace, and nothing else. Dots
and +tags are left alone: those are Gmail's routing rules, not email's.
Geographic restrictions
Reserve a campaign or a code for certain countries — ISO 3166-1 alpha-2, allow-list only. Absent means worldwide.
Vouchers::createCampaign([ 'title' => 'Rebajas', 'percentage' => 20, 'targets' => 'all', 'countries' => ['ES', 'PT'], ]); Vouchers::createCode($campaign, ['code' => 'HOLA', 'countries' => ['ES']]);
Cumulative like every other limit: a code narrows its campaign, never widens it.
| Campaign | Code | Usable from |
|---|---|---|
| — | — | everywhere |
ES |
— | Spain |
| — | ES |
Spain |
ES, PT |
ES |
Spain — the code narrows |
ES |
PT |
refused at creation |
That last row would leave an empty intersection, making the code unusable by
anyone. Unlike a tight numeric limit that has legitimate uses, disjoint country
lists are always a typo, so createCode() throws InvalidCampaignException
rather than letting it surface as a customer complaint weeks later.
The package does no IP geolocation — you supply the country, however you resolve it.
Provisional while checking, binding at payment
A buyer's country is usually only settled at the billing step, well after the code was applied. Refusing early would turn away a legitimate customer who has simply not typed their address yet, so:
validate() |
confirm() |
|
|---|---|---|
| Country known, allowed | valid | proceeds |
| Country known, not allowed | country_not_allowed |
refuses |
| Country unknown, code restricted | valid, countryRequired true |
refuses (country_required) |
| Country unknown, code unrestricted | valid | proceeds |
// 1. Cart — country unknown. $result = Vouchers::validate($code, $cart, Beneficiary::forUser($user)); $result->countryRequired; // true -> show "Spain only" // 2. Billing address entered — now reliable. $result = Vouchers::validate($code, $cart, Beneficiary::forUser($user)->inCountry($billing->country)); // -> valid, or country_not_allowed: drop the code and say why // 3. Payment. Vouchers::confirm($code, $cart, Beneficiary::forUser($user)->inCountry($billing->country), $order);
inCountry() returns a new instance — Beneficiary is immutable — which is what
lets a checkout build one early and learn the country late.
A project that wants to be strict from the start simply checks
$result->countryRequired itself.
Beneficiary::forUser() picks up a country attribute off the model when there is
one, the same way it already picks up email; pass country: to override it.
An IP-derived country is fine for display and for a first pass, but hand
confirm()the billing country. An IP is a VPN away from being wrong; a billing address commits the buyer.
Redemptions snapshot beneficiary_country, so you can report on where sales were
made: Redemption::query()->inCountry('ES'). To list the codes usable from a
country, Code::query()->forCountry('ES') — unrestricted codes included.
Translations
Every refusal reason ships translated, so a checkout shows a customer-ready sentence without you writing a lang file.
🇬🇧 en English |
🇫🇷 fr French |
🇩🇪 de German |
🇪🇸 es Spanish |
🇳🇱 nl Dutch |
🇵🇹 pt Portuguese |
🇮🇹 it Italian |
🇯🇵 ja Japanese |
🇵🇱 pl Polish |
$result->reasonMessage(); // follows the application locale $result->reasonMessage('de'); // or an explicit one $result->reason->message(); // straight off the enum
toArray() includes the translated line as message, so an Inertia or API front
end needs no lookup of its own:
return response()->json([ 'valid' => $result->valid, 'message' => $result->reasonMessage(), // null when the code applies 'preview' => $result->valid ? $result->toArray() : null, ]);
Choosing the locale
This is the one part that is yours: the package follows app()->getLocale() and
Laravel does not guess a visitor's language. A single-language site sets
config/app.php; a multilingual one sets the locale in middleware, from the
user's stored preference or the Accept-Language header.
An unshipped locale falls back to English rather than leaking a raw key.
Note that the message is resolved when you call it. In a queued job or a payment
webhook there is no request, so no middleware has run — pass the locale
explicitly there: $result->reasonMessage($user->locale).
Rewording, and adding a language
php artisan vendor:publish --tag=vouchers-lang
Files land in lang/vendor/vouchers/{locale}/rejections.php and take precedence
over the package's. Add a locale the package does not ship by creating that
directory yourself with the same keys.
The shipped wording is deliberately generic — no amounts, no code names — because formatting a currency belongs to your project. Override the lines where you want something richer.
Spanish uses the informal tú and Portuguese is European (pt-PT); both are
worth a glance if your audience expects otherwise.
Marketing attribution
Vouchers::attribute('DJTOTO', $newUser); // consumes no quota
Separate from commercial redemption, so crediting a sign-up never spends a code the person still has to use.
Reporting
Vouchers::stats()->forCampaign($campaign); Vouchers::stats()->forCode($code); Vouchers::stats()->forPartner($partner);
Figures come from the snapshot frozen on each redemption, never recomputed from the campaign — editing a discount in March must not rewrite what February's orders were worth. Results are broken down by currency and never summed across them; converting is your call, with your rates.
Events
Before and after each important operation:
CampaignCreated · CreatingCode / CodeCreated · ValidatingCode /
CodeValidated · ConfirmingRedemption / RedemptionConfirmed ·
CancellingRedemption / RedemptionCancelled · SignupAttributed
The "before" events on validation and confirmation let you add rules the package cannot know about:
Event::listen(ValidatingCode::class, function (ValidatingCode $event) { if (! $event->beneficiary->user?->isStaff()) { $event->reject(RejectionReason::NotAssigned); } });
CreatingCode exposes mutable $attributes for shaping a code before it is
written.
Configuration
Table names, connection, model classes, targetable models and their aliases,
default duration, default per-beneficiary limit, code generation (alphabet,
length, mask, prefix, suffix, separator, attempts, batch cap), rounding
strategy, morph key type, and contract implementations — all in
config/vouchers.php.
A model swapped under models has to subclass the one it replaces, and a class
named under rounding or implementations has to honour its contract;
either way you get an InvalidConfigurationException naming the key, rather
than a failure somewhere further downstream.
codes.max_batch caps a single generateCodes() call at 10 000 so an amount
arriving from a form cannot exhaust the connection. Set it to 0 for a
deliberate bulk job.
Every model can be replaced by a subclass; every table can be renamed.
Data model
| Table | Holds |
|---|---|
promotion_campaigns |
rules and campaign information |
promotion_codes |
public or individual codes |
promotion_targets |
eligible models and objects |
promotion_redemptions |
redemptions tied to payments and orders |
promotion_partners |
reusable partners |
promotion_attributions |
sign-ups credited to a campaign or code |
Campaigns and codes each carry an optional allowed_countries allow-list, and a
redemption snapshots the beneficiary_country it was made from.
Orders, beneficiaries and targeted items are polymorphic, so the package depends on none of your models.
Redemptions are foreign-keyed with restrictOnDelete: they are accounting
history. Archive a campaign rather than deleting it.
Concurrency
Confirmation locks the campaign row, then the code row, in that fixed order, and re-checks every limit before writing. Simultaneous payments therefore cannot exceed a quota — at the cost of serialising confirmations within one campaign.
The unique index on (order_type, order_id) is both the one-code-per-order rule
and the idempotency key.
Row locks are a no-op on SQLite. The suite runs on SQLite and covers the logic — that confirmation re-reads under lock and refuses stale state, that a vetoed confirmation rolls back whole, that the unique index holds. Genuine parallel load should be exercised on MySQL or PostgreSQL.
Security
The package owns the rules. Everything facing the outside world — routes, throttling, authentication, and the price data itself — stays yours, so five things it cannot enforce on your behalf are worth stating plainly.
Build the cart from your own prices
validate() and confirm() compute the discount from the Cart you hand them.
Neither cross-checks it against $order, and amount_paid is recorded exactly
as passed: the package has no view of your order model and deliberately does not
guess at one.
Never build a
Cartfrom request input. Read quantities and unit prices from your own catalogue and order rows. A cart assembled from what the browser sent hands the discount to the customer.
Pass only verified addresses
A code assigned by email is redeemable by whoever asserts that address — the
package cannot verify one. Assigning by model copies the account's own address
onto assignee_email as well, so a code attached to an account is equally
claimable by anyone who knows that account's address.
// Redeems a code assigned to Carol, having proved nothing. Vouchers::confirm($code, $cart, 'carol@example.com', $order);
Pass a Beneficiary built from an authenticated user, or an address your own
flow has already verified. The same applies to attribute().
Throttle the endpoint that checks codes
A promo code is a bearer token. validate() answers not_found for a code that
does not exist and something else for one that does, which is an existence
oracle — unavoidable, and harmless only while guessing stays expensive.
The package registers no routes, so rate limiting is yours to add: Laravel's throttle middleware on the checking endpoint, keyed per session or per IP, and logging for repeated failures.
Entropy is a configuration decision. The default — 8 characters over a
32-character alphabet — is roughly 40 bits. Raise length for campaigns with
real money behind them; the generator will produce a one-character code if you
configure one.
'codes' => [ 'characters' => 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789', 'length' => 12, // ~60 bits ],
Codes you name yourself (['code' => 'SUMMER24']) are guessable by design. That
is the point for a public campaign, and wrong for anything individual.
Email aliasing defeats a per-beneficiary limit
Comparison folds case and surrounding whitespace, and nothing else — dots and
+tags are left alone, because those are Gmail's routing rules rather than
email's. So a@gmail.com and a+1@gmail.com are two beneficiaries holding two
separate allowances, and a once-per-person campaign is unlimited to anyone with
a Gmail account.
Where that matters, canonicalise in a ValidatingCode listener, which is where
your own definition of "the same person" belongs:
Event::listen(ValidatingCode::class, function (ValidatingCode $event) { if ($event->beneficiary->email === null) { return; } if (Claim::alreadyMade($event->campaign, canonical($event->beneficiary->email))) { $event->reject(RejectionReason::BeneficiaryLimitReached); } });
Enforce a morph map
order_type, beneficiary_type and assignee_type hold class names and are
mass-assignable. The package always writes them itself through getMorphClass(),
but Relation::enforceMorphMap() closes those columns to anything you have not
declared — and keeps the stored rows readable after a class is renamed or moved.
Relation::enforceMorphMap([ 'order' => App\Models\Order::class, 'user' => App\Models\User::class, ]);
Reporting a vulnerability
Privately, through GitHub's advisory form rather than a public issue. SECURITY.md has the details, including what falls outside the package's own responsibility.
Upgrading
Migrations are additive: an existing install upgrades with
php artisan migrate
New config keys of the top level are merged in automatically. A key added inside an array you have already published is not — Laravel merges package config shallowly, so your published array replaces the package's wholesale. Add such keys by hand, or re-publish.
Translations behave the other way round: package language files are merged key by
key, so a lang/vendor/vouchers you published before a reason existed still
resolves that reason from the package rather than falling back to English.
An unidentified buyer now has its own rejection reason
An anonymous checkout under a per-beneficiary limit is refused with
beneficiary_required where it used to say beneficiary_limit_reached. The
refusal itself is unchanged; what changed is that it no longer tells a
first-time visitor they have already used the code.
Nothing to do unless your project reads the reason. If it does, the two cases are now separable — which is the point:
match ($result->reason) { RejectionReason::BeneficiaryRequired => $this->promptSignIn(), RejectionReason::BeneficiaryLimitReached => $this->sayAlreadyUsed(), // ... };
beneficiary_limit_reached keeps its meaning for a beneficiary who really has
spent their allowance, and attribute() uses beneficiary_required for the
same reason: it cannot credit a sign-up to nobody.
Not in v1
Multiple codes on one order · tax calculation or filing · payment processing · partial refunds · partner commission calculation · routes, controllers and UI.
Tests
composer install
vendor/bin/pest
228 tests. docs/acceptance-criteria.md maps every acceptance criterion in the specification to the test that proves it, including its worked numeric examples.
License
MIT. See LICENSE.md.