beyondcode/laravel-vouchers
Beyondcode's Laravel Vouchers package lets you generate, manage, and redeem voucher or coupon codes in Laravel apps. Set expiry dates, usage limits, and custom redemption rules, then attach vouchers to users or models through a simple, flexible API.
Install and publish the migration in one pass, then wire the trait onto whatever model issues codes:
composer require beyondcode/laravel-vouchers
php artisan vendor:publish --provider="BeyondCode\Vouchers\VouchersServiceProvider"
php artisan migrate
use BeyondCode\Vouchers\Traits\HasVouchers;
class User extends Authenticatable
{
use HasVouchers;
}
Where to look first: the published migration. Before writing a line of business logic, open database/migrations/*_create_vouchers_table.php and confirm what you actually got — the code column, the data JSON column, expires_at, redeemed_at, and the morph columns for the issuing model and the redeemer. Names have shifted between 2.x releases, so treat the file as the source of truth, not the README. Add the unique index on code and a composite index on the owner morph + redeemed_at before the first production run; the migration is yours the moment you publish it.
First use case: a goodwill/promo code that a user redeems against their account.
$voucher = $user->createVoucher(['amount' => 100, 'reason' => 'support-credit']);
// Later, in a controller:
$user->redeem(request('code'));
That's the whole demo. Half a day to a working spike. Budget one to two weeks to production-grade — the package is a primitive, and everything around it (authorization, idempotency, expiry enforcement, audit) is yours.
Do this on day one: create app/Vouchers/VoucherIssuer.php and app/Vouchers/VoucherRedeemer.php. No code outside that namespace imports BeyondCode\Vouchers\*. The core is small enough to fork and own in a day if upstream stalls; an isolation boundary is what makes that option real instead of theoretical.
Issue in bulk, redeem in singular. createVouchers($count, $data) is what partner/seat-block/distributor scenarios actually need — 500 codes for a reseller by Friday is a loop, not a scoping conversation. Keep the business payload in the data JSON column:
$codes = $tenant->createVouchers(500, [
'plan' => 'pro',
'seats' => 5,
'campaign' => 'spring-partner-2026',
]);
Wrap redemption in a transaction and a service. Never call redeem() directly from a controller. The load-bearing question with this package is whether redemption is atomic; assume it isn't until you've read the source and proven otherwise.
final class VoucherRedeemer
{
public function redeem(string $code, Model $redeemer): Voucher
{
return DB::transaction(function () use ($code, $redeemer) {
$voucher = Voucher::where('code', $code)->lockForUpdate()->first();
abort_if(! $voucher, 404, 'Invalid code');
abort_if($voucher->redeemed_at, 422, 'Code already used');
abort_if($voucher->expires_at?->isPast(), 422, 'Code expired');
$voucher->redeem($redeemer); // or update redeemed_at/redeemer morph yourself
return $voucher;
});
}
}
Write a failing concurrency test on day one: two processes, one single-use code. If both succeed without the lock, the lock stays.
Validate at the boundary, not just in the model. Expiry checks belong in the redeemer service and in a validation rule, because the model-level check is a convenience, not a guarantee against clock skew, queued retries, or a direct DB write by support.
Bind codes to real domain objects, not to the user. The polymorphic owner is what makes one table serve "unlocks this course," "activates this plan," and "ships this SKU." Issue from the thing being unlocked:
$course->createVoucher(['access' => 'full'], $code = null);
Instrument with observers, not edits. Register Voucher::observe(VoucherObserver::class) and fire your own VoucherIssued / VoucherRedeemed domain events. That keeps audit logging and downstream side effects (email, entitlement grant, metrics) outside the vendor namespace.
Idempotency on the redeem endpoint. A user double-clicking "Redeem" is the most common production failure, not a flash sale. Accept an idempotency key, store it against the voucher row in data, and return the same voucher on replay rather than a 422.
Testing. Use RefreshDatabase. The package does not reliably ship a factory — write VoucherFactory with states (redeemed, expired, withData) before your first feature test and never hand-roll voucher rows in tests.
Admin surface. A minimal internal CRUD for support-driven issuance (goodwill credits, replacement keys, comped access) is worth more day-to-day than any customer-facing feature. Gate it behind a policy; issuance and redemption are two different permissions and conflating them is how promo codes leak.
Concurrency is the whole risk. If redemption is a read-then-write on redeemed_at, two concurrent requests both succeed on a single-use code. Verify in source before you promise single-use semantics. Mitigation: lockForUpdate() in a transaction plus a conditional UPDATE ... WHERE redeemed_at IS NULL as a belt-and-braces check. Do not adopt without deciding this.
Publishing the migration makes the schema yours. Upstream migration changes become manual merge conflicts forever. Small ongoing tax — accept it deliberately, and note the decision in the PR.
Polymorphic owners carry no foreign key. Deleting a user or an order orphans vouchers silently. Decide archival vs. cascade vs. soft deletes explicitly, and add a scheduled consistency check if codes have money-like value.
Code generation needs collision retry and normalization. Unique index on code is mandatory. On redemption, normalize input — trim, uppercase, strip hyphens — before lookup, or support gets "the code doesn't work" tickets that are just a trailing space. If the package's format doesn't fit (length, prefix, ambiguity-free alphabet), set the code yourself via the $code argument rather than fighting the default.
data is JSON and unindexed. No schema guarantees, no query performance, and SQLite/MySQL/Postgres differ on JSON filtering. If you need to report on data->>'campaign', add a generated column or a real column. Don't build analytics on the JSON blob.
Check the illuminate/* constraint against your pinned Laravel version before scoping anything else. This is the single most likely reason a spike fails and it costs thirty seconds to verify. Same for the PHP constraint.
Zero registry dependents against 706 stars. Visible interest, almost no verified production usage. You are early on the edge cases. Everything in the isolation layer is insurance against that, not over-engineering.
This is a redemption primitive, not a promotions engine. Discount math, stacking, currency, tax, balances, and partial redemption are explicitly out of scope — say so to stakeholders early, or "we added vouchers" will be heard as "we shipped promotions" and you'll spend the next quarter retrofitting a ledger.
How can I help you explore Laravel packages today?