The starter ships with Stripe through Laravel Cashier, and only one provider is ever installed at a time. Cashier Stripe and Cashier Paddle both put a Billable trait on the User model, so they can't coexist even if you wanted them to.
That means the goal here isn't runtime provider switching, it's keeping the Stripe-specific code in a small number of obvious places. Someone moving to Paddle should be rewriting four files and a migration, not hunting through controllers.
Stripe is the default because it's the most widely used, the best documented, and it suits the common case of selling domestically or to businesses. Cashier's Stripe support is also considerably deeper than its Paddle support, which makes it the better thing to build patterns against.
The catch is that Stripe is a payment processor, so you're the seller of record and the tax registrations are yours. That's fine at home and largely fine for B2B, where cross-border sales are usually reverse-charged. It stops being fine when you're selling digital goods to consumers internationally, because tax follows the customer's location and some places expect a foreign seller to register from the very first sale. A merchant of record like Paddle or Lemon Squeezy sells as the legal seller instead, so the registrations and the filings are theirs, which costs a couple of extra points of margin and buys the whole problem back. If international consumer sales are the plan, start there rather than migrating later. Worth an accountant's read either way.
Less than you'd expect, but more than zero.
The three services under App\Services\Stripe are where every API call lives. The webhook listener under App\Listeners\Stripe is the other half, since webhook payload shapes are entirely a provider concern. Cashier ships its own subscriptions table with its own columns, so the migration goes too. And the client loads a provider-specific JS SDK, which is outside this repo but worth knowing about when you plan the work.
Everything else survives. Routes, controllers, requests, resources, the plans and prices tables, the plan feature limits, the subscription gate middleware, and the notifications all stay exactly as they are.
Three interfaces in App\Contracts describe what the app needs from a billing provider.
SubscriptionProvider covers the subscription lifecycle, intent, sync, swap, cancel, resume, and the billing address write. PromotionCodeProvider resolves a coupon code. PlanSyncProvider pulls prices down from the provider so amounts can be displayed without an API call on every request.
The important detail is what's absent from those signatures. No Stripe objects, no Stripe exceptions, no payment intents. Anything that can fail returns a ServiceResult, which is the same pattern the rest of the starter's services use, and the controller decides what that failure means in HTTP terms. If a signature ever needs a vendor type to express itself, the abstraction has sprung a leak.
Implementations live in a namespace named for the provider, so App\Services\Stripe\SubscriptionService today and App\Services\Paddle\SubscriptionService alongside it later. The namespace is the whole convention.
SubscriptionService is the one worth reading before you write another. Two of its methods carry the design.
intent() creates the subscription up front in an incomplete state and hands back the secret the client mounts a payment form against. That ordering is the interesting part. The subscription exists before anyone has paid, which means an abandoned attempt leaves a real object behind, and a page refresh would happily create a second one. So intent() is written to be called repeatedly for the same attempt. It reconciles the local status with the provider first, since the last webhook may be stale, then either hands back the secret from the attempt already in flight or tears that attempt down and builds a fresh one if the price moved. Trials come back as a setup secret rather than a payment one, because there is nothing to charge yet, and the response says which so the client knows what to confirm.
sync() is the only place a subscription becomes an entitlement. It reads the live state from the provider, updates the local status, promotes the card, recomputes the cached plan, and sends the subscribed notification once.
The webhook is the whole commit path, and it's the piece of a swap most likely to bite. Payload shapes are entirely a provider concern, so the listeners under App\Listeners\Stripe get rewritten rather than adapted. Cashier's WebhookHandled event is the hook point in both packages, which at least means the shape of the listener survives even when its contents don't.
The part that carries over conceptually is the reliance on a diff of what changed. Stripe hands that over as previous_attributes and Paddle expresses the same idea differently, but either way the event type alone isn't enough to tell a renewal from a cancellation, and any provider's listener has to answer that question somehow.
Full detail on what's listened to and what happens when a webhook doesn't land is in Webhooks.
Cashier publishes its own tables, so a provider swap replaces that migration wholesale. Nothing is added to it, which is the useful part, since a swap is a straight replacement rather than a replacement plus a patch.
The tables that don't move are the ones that matter, plans, prices, and the plan columns on users. The price columns are named for Stripe today, which is a rename rather than a redesign.
The bindings sit in AppServiceProvider::register(), one line per contract. Changing providers means pointing those three lines at the new implementations.
The work in order:
stripe_* columns on the prices table and the references to them.Steps two and seven are the real work. The rest is mechanical, and nothing in app/Http should need touching at all. If it does, something leaked and it's worth fixing in the contract rather than working around it.