ozankurt / laravel-modules-loyalty
Digital loyalty / stamp-card system for Laravel apps (KurtModules).
Requires
- php: ^8.3
- bacon/bacon-qr-code: ^3.1
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/database: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
- ozankurt/laravel-modules-core: ^1.0
- spatie/laravel-package-tools: ^1.92
- spatie/laravel-translatable: ^6.11
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^9.0 || ^10.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
- rector/rector: ^2.0
Suggests
- ext-openssl: Required to sign Apple Wallet (.pkpass) passes.
- ext-zip: Required to package Apple Wallet (.pkpass) passes.
README
Digital loyalty / stamp-card system for Laravel apps — collect stamps, earn rewards, redeem them, with a public card page, QR-based granting, and Apple/Google Wallet passes.
Part of the KurtModules family. Requires ozankurt/laravel-modules-core.
A deliberate departure from the family
Every other KurtModules package is headless + Filament-only with no public views. Loyalty is the exception by design: it is a customer-facing surface, so it ships public routes, Blade views, and compiled JS/CSS assets, and no Filament panel. It still follows every other family convention (Core-based provider, anonymous publishable migrations, Pest + Testbench, SemVer).
Architecture
- Headless behavior, swappable skin. One vanilla-JS behavior layer keyed to a stable
data-*attribute contract; the entire stylesheet is replaceable without touching behavior. Shipscoffee,hotdog,restaurant, andminimalthemes (light + dark), all overridable. - Unified voucher primitive. Every stamp is granted by redeeming a single-use
Voucher— whether issued by a staff terminal, printed on a receipt, or shown as a till QR. - Configurable identity. Cards can be anonymous (Mars-style), user-bound, or anonymous-then-claimable — chosen per install.
- Wallet via an adapter seam. Apple
.pkpass+ Google Wallet behind oneWalletProviderinterface; live-updating passes are opt-in (certs/service account are the consuming app's to supply). - Built on the shared Core API kit. The JSON controllers extend Core's
ApiController, and the staff terminal throttles through Core's sharedApiRateLimiter(throttle:loyalty-api), so Loyalty rides the same HTTP foundation as every other module. Theloyalty.http.modecontract (headless/api/ui) maps directly onto Core'sHttpMode— same string values, same semantics. Customer-facing bodies (the card/stateJSON, terminal stamp/redeem, wallet/PassKit payloads) keep their exact historic shape; the Core success/error envelope ({ "data": … }/{ "message": …, "errors": … }) is adopted on the staff analytics endpoint (GET /loyalty/stats).
Status — all milestones landed ✅
- M1 — package foundation, schema, models/factories, domain services (Card, Voucher, Stamp, Redemption).
- M2 — HTTP surface: customer card routes,
/stateJSON, voucher redemption, staff terminal behind theloyalty:staffgate, rate limiting. - M3 — frontend: vanilla-JS behavior layer (data-attribute contract), Vite build with committed
dist, contract stylesheet +coffee/hotdog/restaurant/minimalthemes (light + dark). QR codes render server-side as inline SVG (bacon/bacon-qr-code), so they work without JavaScript. - M4 — Apple
.pkpass+ Google Wallet providers behind aWalletProviderseam, add-to-wallet endpoints + buttons, live push (Apple Wallet web service + APNs, Google object PATCH) via a queued job on card events, and artisan commands (loyalty:install,loyalty:demo,loyalty:prune-vouchers,loyalty:wallet-check).
Quick start
composer require ozankurt/laravel-modules-loyalty php artisan loyalty:install # publish config, migrations, views, assets php artisan migrate # creates loyalty_* tables incl. loyalty_program_tiers + the expiry columns php artisan loyalty:demo # seed a program + card, prints the card URL
loyalty:install publishes every migration, including the tier table (loyalty_program_tiers) and the expiry columns (stamp_expiry_days / reward_expiry_days on programs, rewards_expired on cards) — so a single php artisan migrate sets up the full schema.
Define who staff are (the terminal is deny-all until you do):
// AuthServiceProvider Gate::define('loyalty:staff', fn ($user) => $user->is_staff);
Wallet passes are opt-in — set the Apple certificate / Google service-account env vars, then php artisan loyalty:wallet-check. Live pass updates need LOYALTY_WALLET_PUSH=true plus a queue worker.
HTTP modes
Pick how much of the HTTP surface the package registers with LOYALTY_HTTP_MODE (config loyalty.http.mode). The domain services + events are always available regardless — the controllers are a thin adapter over them, never the source of truth.
| Mode | Registers | Use when |
|---|---|---|
ui (default) |
JSON/resource endpoints + shipped HTML card page & terminal + views/assets | You want the full Mars-style experience out of the box. |
api |
JSON + resource endpoints only (state, create, claim, voucher redeem, terminal stamp/redeem, wallet passes). No Blade, no assets. | You're building your own frontend but want the HTTP layer. |
headless |
Nothing. | You want to wire your own routes/controllers and call CardService / StampService / VoucherService / RedemptionService directly. |
ui ⊇ api. Even in ui, the card page route honours Accept: application/json and returns state instead of HTML.
loyalty.http.mode resolves through Core's Kurt\Modules\Core\Http\HttpMode enum (identical headless/api/ui string values). The staff terminal's throttle budget lives in loyalty.http.rate_limit (maxAttempts,decayMinutes, default 60,1), registered as the shared throttle:loyalty-api limiter; the public write endpoints (create / claim / voucher redeem) keep their own tighter loyalty.routes.rate_limit budget (default 30,1).
Frontend
The behavior layer is framework-free and keyed to a stable data-* attribute contract, so the entire stylesheet is swappable without touching JS. Rebuild/extend it with your own pipeline:
npm install npm run build # -> resources/dist (loyalty.js, loyalty.css, themes/*) npm run test # Vitest
Data-attribute contract (rewrite the Blade freely as long as these survive):
| Attribute | Role |
|---|---|
data-loyalty-card / data-loyalty-token / data-loyalty-state-url |
card root + poll target |
data-loyalty-stamps → data-loyalty-stamp[data-state] |
the stamp grid (JS toggles data-state) |
data-loyalty-progress / data-loyalty-count / data-loyalty-required |
progress (live region) |
data-loyalty-qr / data-loyalty-wallet="apple|google" |
QR + wallet buttons |
data-loyalty-terminal + data-loyalty-{scanner,card-input,stamp-btn,redeem-btn,terminal-result} |
staff terminal |
Wallet setup
Both providers are opt-in and only render buttons once configured. Env vars:
- Apple:
LOYALTY_APPLE_WALLET=true,LOYALTY_APPLE_PASS_TYPE_ID,LOYALTY_APPLE_TEAM_ID,LOYALTY_APPLE_CERTIFICATE(+_PASSWORD),LOYALTY_APPLE_WWDR_CERTIFICATE. - Google:
LOYALTY_GOOGLE_WALLET=true,LOYALTY_GOOGLE_ISSUER_ID,LOYALTY_GOOGLE_CLASS_ID,LOYALTY_GOOGLE_SERVICE_ACCOUNT(path to the JSON key). - Live push:
LOYALTY_WALLET_PUSH=true+ a queue worker (Apple via APNs using the pass cert, Google via object PATCH). Runphp artisan loyalty:wallet-checkto verify.
Multi-tier rewards
A program can define a ladder of ProgramTier rows (loyalty_program_tiers) instead of a single repeating goal. Each tier has an absolute threshold (cumulative stamp count), a reward label, an optional reward_payload JSON (voucher config / metadata), and a position for ordering:
$program->tiers()->createMany([ ['threshold' => 3, 'reward' => ['en' => 'Free cookie'], 'position' => 1], ['threshold' => 6, 'reward' => ['en' => 'Free coffee'], 'position' => 2], ['threshold' => 10, 'reward' => ['en' => 'Free tumbler'], 'position' => 3], ]);
When a stamp crossing meets the next unmet tier threshold, that tier's reward is credited to rewards_earned and a TierReached event (carrying the Card and ProgramTier) fires, alongside the existing CardCompleted event. A single multi-stamp voucher that vaults past several thresholds credits each crossed tier.
Fully backward compatible: a program with no tier rows behaves exactly as before — the single stamps_required threshold, repeating, with the same rollover crediting for reset_on_reward = false.
Analytics
LoyaltyStatsService::overview(?int $programId, ?Carbon $since, ?Carbon $until) returns cards issued, active cards, stamps granted, rewards earned/redeemed, and the redemption rate — overall (totals) and per program (programs) — from a handful of grouped aggregate queries (no N+1). The optional program filter and date range narrow the population.
- Command:
php artisan loyalty:stats [--program=id|slug] [--since=date] [--until=date]prints the breakdown as a table. - Endpoint:
GET /loyalty/statsreturns the same data as JSON for a consumer dashboard, wrapped in the Core envelope under a top-leveldatakey ({ "data": { "range": …, "totals": …, "programs": … } }). It is behind theloyalty:staffgate and registered only inapianduimodes (neverheadless). Acceptsprogram,since,untilquery params.
Stamp / reward expiry
Stamps and unredeemed earned rewards can expire after a configurable window. Defaults live in config/loyalty.php under expiry (LOYALTY_STAMP_EXPIRY_DAYS / LOYALTY_REWARD_EXPIRY_DAYS), and each program can override them via its stamp_expiry_days / reward_expiry_days columns. null everywhere = never expires (today's behaviour).
php artisan loyalty:expire # schedulable; safe to run repeatedly
The command voids (zeroes) stamps on cards whose last activity is older than stamp_expiry_days, and voids unredeemed earned rewards (tracked in the new rewards_expired counter, kept separate from redemptions so analytics stay accurate) older than reward_expiry_days. Each runs inside a locked transaction and fires StampsExpired / RewardExpired. It is idempotent — a second run is a no-op. Schedule it in the consuming app:
// routes/console.php or the console kernel Schedule::command('loyalty:expire')->daily();
See docs/superpowers/specs for the full design and docs/superpowers/plans for the build plan.
License
MIT © Ozan Kurt