Every Laravel SaaS boilerplate I've ever used makes the same quiet assumption: your customers pay with Stripe.
That assumption holds right up until you try to sell to a business in Cairo, Riyadh, Dubai, Doha, Kuwait City, or Kuala Lumpur — where the cards, wallets, and kiosk-payment habits people actually use
run through Paymob, Fawry, HyperPay, MyFatoorah, Telr, HitPay, and a dozen others. Suddenly the boilerplate that was going to save you a month of work can't take a single payment from your real market.
I kept hitting this on client projects, so I built the thing I wanted to fork from. It's called Quartz, it's MIT-licensed, and it ships with 13 payment gateways behind one interface:
- Global — Stripe, PayPal
- Egypt — Paymob, Fawry, PayTabs, Geidea
- GCC — Amazon Payment Services, Telr, HyperPay, MyFatoorah
- Malaysia — HitPay, Billplz, iPay88
→ github.com/Quartz-Solutions/saas
But the interesting part isn't the list. It's that these gateways don't agree on anything about how checkout works — and making them look uniform to the rest of the app was the actual engineering
problem.
The problem: 13 gateways, 5 completely different checkout flows
When you call "start a payment" on these gateways, here's what they hand you back:
- Stripe / PayPal / PayTabs / MyFatoorah → a hosted URL you redirect the browser to.
- Paymob → an iframe you embed in your page.
- HyperPay → a JavaScript widget (COPYandPAY) you script into the DOM.
- Amazon Payment Services / iPay88 → a set of signed fields you render as a self-submitting form POST.
- Fawry → a kiosk reference number the customer takes to a physical kiosk and pays in cash, days later.
A naive boilerplate leaks this everywhere — the controller ends up with a match ($gateway) for every decision, and adding a gateway means touching the UI in ten places. That's not polymorphism; it's a
tagged union wearing a trench coat.
The fix: a discriminated CheckoutResult
Every gateway implements one interface:
interface CheckoutGateway
{
public function initiateCheckout(CheckoutSession $session): CheckoutResult;
/** @return array<int, string> ISO 4217 codes this gateway can settle */
public function supportedCurrencies(): array;
public function supportsSubscriptions(): bool;
}
initiateCheckout() returns one of five known result kinds — a discriminated union the front end knows how to render:
return new RedirectCheckout($sessionId, url: $stripeSession->url);
// or
return new IframeCheckout($sessionId, iframeUrl: $paymobUrl, ['height' => '700']);
// or
return new KioskReferenceCheckout($sessionId, reference: '1234567890');
// …plus FormPostCheckout and WidgetCheckout
The result is persisted on a CheckoutSession row (result_kind + result_payload jsonb), and a single React component switches on it:
switch (session.result_kind) {
case 'redirect': return <RedirectTo url={payload.url} />;
case 'iframe': return <CheckoutIframe {...payload} />;
case 'form_post': return <AutoSubmittingForm {...payload} />;
case 'widget': return <ScriptedWidget {...payload} />;
case 'kiosk_ref': return <KioskReferenceCard {...payload} />;
}
Now adding a 14th gateway is one driver class returning the right CheckoutResult — zero controller or UI changes. Webhooks reconcile back to the CheckoutSession by (gateway, gateway_session_id),
idempotently, so a re-delivered event never double-creates a subscription.
This also fixed a subtler bug: with a single "Subscribe" button silently posting a default gateway, disabling Stripe used to 500 the page. Now picking a plan creates a checkout intent, the user picks an
enabled gateway that supports the plan's currency, and only then does any gateway API get touched.
enabled gateway that supports the plan's currency, and only then does any gateway API get touched.
What else is in the box
The gateways are the headline, but a SaaS needs the boring parts done too. Quartz ships:
- Multi-tenancy — path-based (/t/{slug}/…) with a TenantResolver seam so subdomain/custom-domain is a swap, not a rewrite. Roles per tenant via Spatie teams, invitations, owner transfer, soft-delete + 30-day GDPR purge.
- Auth — Fortify (2FA, email verification, password reset), magic links, social login, session management, login history.
- Admin scope — super-admin with impersonation, audit log, webhook event replay, feature flags, and a real metrics dashboard (MRR, churn).
- Public REST API — Sanctum tokens with a scoped ability catalog, per-category rate limits, idempotency keys, outbound webhooks (HMAC-signed, retried), and Scribe-generated docs.
- A block-based CMS for the marketing site, with versioning and preview.
- 674 feature tests, green on both SQLite and real Postgres 16.
Stack: Laravel 13 · Inertia · React 19 · TypeScript · Tailwind 4 · Postgres 16 · Redis, with Docker dev + prod stacks.
It's MIT — fork it and ship
Quartz is meant to be cloned per project (git clone … my-saas) and made your own. No license fee, no attribution required, commercial use fine.
git clone https://github.com/Quartz-Solutions/saas.git my-saas
cd my-saas && docker compose up -d
If you're building a SaaS that needs to take money in MENA, the GCC, or Southeast Asia — or you just want a head start that isn't Stripe-only — I'd love for you to try it and tell me where it falls
short.
⭐ https://github.com/Quartz-Solutions/saas
Built by Quartz Solutions (https://github.com/Quartz-Solutions) — we build multi-tenant SaaS products and wire up the regional payment gateways most shops can't. If that's a problem you have, say hi
(https://github.com/Quartz-Solutions).
Top comments (1)
This is the boring-20%-that's-actually-critical in its purest form, and the Cairo framing nails why it bites: Stripe-only is invisible until you have a real customer in a market Stripe doesn't serve, and then it's a hard wall, not a polish issue. Payments are the canonical example of the part everyone defers (it's "just integrate Stripe") right up until geography, currency, or local rails make it the thing blocking revenue. 13 gateways behind one abstraction is exactly the right response - the SaaS boilerplate's value isn't the CRUD, it's having the genuinely-hard, genuinely-boring parts (multi-gateway payments, auth, the stuff that's a wall in production) already solved.
This is the whole thesis I build on - the boring 20% (auth, billing, deploy) is where projects stall, so it has to be handled as a solved default, not a TODO. It's literally why Moonshift exists, the thing I work on - a multi-agent pipeline that takes a prompt to a deployed SaaS with those boring-critical parts wired in (your project tackles the same gap on the Laravel-boilerplate side). Multi-model routing keeps a build ~$3 flat, first run free no card. Great open-source contribution - payment-gateway fragmentation outside the US/EU is hugely underserved. Which gateway was the gnarliest to abstract behind a common interface? The webhook/refund semantics never line up, in my experience.