mrsuner / laravel-coupon
Coupon lifecycle management (generation, validation, redemption) for Laravel, decoupled from billing via a single event. Works standalone and auto-wires into mrsuner/laravel-api-boilerplate.
Requires
- php: ^8.2
- illuminate/database: ^12.0|^13.0
- illuminate/events: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- laravel/sanctum: ^4.0
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.5.50|^12.5.8
Suggests
- laravel/sanctum: Required for the default admin-API authentication (auth:sanctum / ability:admin).
This package is auto-updated.
Last update: 2026-08-30 10:52:13 UTC
README
Coupon lifecycle management for Laravel — generation, validation, and redemption
recording. An official companion package for
mrsuner/laravel-api-boilerplate.
Design philosophy
This package owns the coupon lifecycle only. It has zero knowledge of billing systems, subscription engines, or application-specific business logic. The boundary is enforced by a single rule:
The package fires
CouponRedeemedand stops. The host application listens and acts.
This means:
- No dependency on
laravel/cashier,laravel/cashier-paddle, or any billing package. - No dependency on your
Order,Subscription, orCreditmodels. - Any SaaS built on the boilerplate can install it regardless of its billing stack.
What connects the package to your business logic is one event class
(CouponRedeemed) and one public service (CouponService). Everything else is
internal.
Works in any Laravel application. When installed on top of
mrsuner/laravel-api-boilerplate
it auto-wires into the boilerplate's admin stack; on a plain Laravel app it
falls back to a Sanctum admin-ability API (see Configuration).
Requirements
- PHP
^8.2 - Laravel
^12.0 | ^13.0 laravel/sanctumfor the default admin-API authentication.
Installation
composer require mrsuner/laravel-coupon "^1.0"
php artisan vendor:publish --tag=coupon-config
php artisan migrate
The service provider is auto-discovered. It registers the migrations, the
CouponService singleton, and the admin API routes.
Configuration
config/coupon.php:
| Key | Description |
|---|---|
generation.length / prefix / suffix / charset |
Auto-generated code format. Default: 8 unambiguous uppercase chars. |
table_names.* |
Override table names if they collide with your schema. |
route.enabled |
Master switch for the admin API (default true). |
route.prefix / name |
Where the admin API mounts and its route-name prefix. |
route.middleware |
null = auto-detect (see below); or an explicit array. |
Admin middleware auto-detection. When route.middleware is null (the
default), the package picks a stack at boot:
- If the boilerplate's
App\Http\Middleware\InternalIpWhitelistclass exists, it uses the full boilerplate admin stack (throttle:60,1+InternalIpWhitelist+auth:sanctum+ability:admin, plusEnsureAdminAccesswhen available). - Otherwise (a plain Laravel app) it falls back to
['auth:sanctum', 'ability:admin'].
Set an explicit array to take full control. The package always appends the framework's route-model-binding middleware automatically.
Route gate. Admin routes are skipped (every endpoint 404s) when
config('coupon.route.enabled') is false, or when the host defines
config('boilerplate.admin.enabled') and sets it to false.
Redeemable morph maps. The package follows Laravel's native polymorphic
type handling. If you want redemption rows to store aliases instead of fully
qualified model class names, register them in the host app with
Relation::morphMap() or Relation::enforceMorphMap().
Public API — CouponService
Resolve it from the container: app(\Mrsuner\Coupon\Services\CouponService::class).
use Mrsuner\Coupon\Services\CouponService; $coupons = app(CouponService::class); // Create a single code (auto-generated if "code" is omitted). $coupon = $coupons->generate([ 'type' => 'percent_off', 'value' => ['percent' => 20], 'restrictions' => ['max_uses' => 500, 'per_user' => 1, 'expires_at' => '2026-12-31T23:59:59Z'], ]); // Bulk-generate; "code" becomes a prefix (LAUNCH-A3F9K2). $codes = $coupons->generateBulk(50, ['code' => 'LAUNCH', 'type' => 'free_months', 'value' => ['months' => 1]]); // Validate without recording (returns a ValidationResult value object). // Pass amount_cents when the coupon has restrictions.min_amount. $result = $coupons->validate('LAUNCH20', $user, ['amount_cents' => 15_000]); if (! $result->valid) { // $result->error is one of ValidationResult::NOT_FOUND|INACTIVE|EXPIRED|EXHAUSTED|USER_LIMIT|MIN_AMOUNT } // Validate, record, and fire CouponRedeemed (atomic). $redemption = $coupons->redeem('LAUNCH20', $user, ['ip' => request()->ip(), 'amount_cents' => 15_000]);
redeem() throws CouponNotRedeemableException when validation fails; the
ValidationResult is available via $e->getValidationResult().
Providing a redeem endpoint
The package does not ship a user-facing redeem endpoint. Wire your own:
// routes/api.php Route::post('/redeem-coupon', RedeemCouponController::class)->middleware('auth:sanctum'); // app/Http/Controllers/RedeemCouponController.php public function __invoke(Request $request, CouponService $coupons): JsonResponse { $request->validate([ 'code' => ['required', 'string'], 'amount_cents' => ['nullable', 'integer', 'min:0'], ]); $result = $coupons->validate($request->code, $request->user(), [ 'amount_cents' => $request->integer('amount_cents'), ]); if (! $result->valid) { return response()->json(['message' => __('coupon.'.$result->error)], 422); } $redemption = $coupons->redeem($request->code, $request->user(), [ 'ip' => $request->ip(), 'amount_cents' => $request->integer('amount_cents'), ]); return response()->json(['message' => 'Coupon applied.', 'redemption' => $redemption->id]); }
Reacting to redemptions
// app/Providers/AppServiceProvider.php use Mrsuner\Coupon\Events\CouponRedeemed; use App\Listeners\ApplyCouponEffect; public function boot(): void { Event::listen(CouponRedeemed::class, ApplyCouponEffect::class); }
// app/Listeners/ApplyCouponEffect.php public function handle(CouponRedeemed $event): void { match ($event->coupon->type) { 'percent_off' => $this->applyStripePromo($event->redeemable, $event->coupon->value), 'free_months' => $this->extendTrial($event->redeemable, $event->coupon->value['months']), 'amount_off' => $this->applyCredit($event->redeemable, $event->coupon->value['amount']), default => Log::info('Unhandled coupon type', ['type' => $event->coupon->type]), }; audit_log('coupon.redeemed', $event->coupon, [ 'user' => $event->redeemable, 'metadata' => ['redemption_id' => $event->redemption->id], ]); }
The package deliberately leaves Stripe/Paddle integration to your listener so the audit trail can carry billing context (subscription id, order id, etc.).
Admin API
All endpoints mount under internal/admin/v1 behind the boilerplate admin stack.
| Method | URI | Name |
|---|---|---|
| GET | /coupons |
admin.coupons.index |
| POST | /coupons |
admin.coupons.store |
| POST | /coupons/bulk |
admin.coupons.bulk |
| GET | /coupons/{coupon} |
admin.coupons.show |
| PATCH | /coupons/{coupon} |
admin.coupons.update |
| DELETE | /coupons/{coupon} |
admin.coupons.destroy |
| PATCH | /coupons/{coupon}/activate |
admin.coupons.activate |
| PATCH | /coupons/{coupon}/deactivate |
admin.coupons.deactivate |
| GET | /coupons/{coupon}/redemptions |
admin.coupons.redemptions |
| GET | /coupon-redemptions |
admin.coupon-redemptions.index |
Notes:
typeandvalueare immutable after creation (to preserve redemption snapshot integrity). Onlyname,is_active, andrestrictionsare updatable.- Deleting a coupon with redemptions requires
?force=true; otherwise it returns422. Deletes are soft — redemption history is always preserved.
Exceptions
Map them in bootstrap/app.php if you want custom HTTP responses:
->withExceptions(function (Exceptions $exceptions) { $exceptions->render(function (CouponNotRedeemableException $e) { return response()->json(['message' => __('coupon.'.$e->getMessage())], 422); }); })
Testing
composer install
composer test
The suite runs against orchestra/testbench with an in-memory SQLite database.
License
MIT.