Search by

plaurent / laravel-vouchers

plaurent

Moteur de codes promo pour Laravel : campagnes, codes publics ou individuels, remises fixes ou en pourcentage, limites cumulatives et suivi commercial.

Package info

github.com/plaurent75/LaravelVouchers

pkg:composer/plaurent/laravel-vouchers

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-09-10 14:16 UTC

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 Amountnot 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_required rather 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 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 Cart from 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.