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 a folder of services, a few listeners, 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 services under App\Services\Stripe are where every API call lives, one small class per action. The webhook listeners under App\Listeners\Stripe are 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.
The interfaces in App\Contracts describe what the app needs from a billing provider, and there's one per action rather than one per resource. A controller depends on exactly the thing it calls, and the call site says what runs instead of what it acts on.
Between them they cover opening a checkout session and syncing it once it completes, swapping, cancelling, and resuming a subscription, writing the billing address, opening a setup intent for a card, syncing and deleting that card, and pulling prices down from the provider so amounts can be displayed without an API call on every request. Promotion codes are absent on purpose, since Stripe validates them against the session and the API never resolves one.
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\CreateSessionService today and App\Services\Paddle\CreateSessionService alongside it later. The namespace is the whole convention.
Two of them carry the design and are worth reading before you write another.
CreateSessionService opens the session and creates nothing else. The subscription doesn't exist until the client confirms, so this does only what needs the user and the server. It checks they can subscribe, makes sure there's a customer, sweeps any older open session, and picks the address parameters. That's why an abandoned signup leaves nothing behind, and the detail is in Subscribing.
SyncSubscriptionService is the only place a signup becomes an entitlement. It reads the completed session from the provider, writes the subscription, makes the card that paid it the default (on a trial, where nothing paid, the one the user entered), and recomputes the cached plan. The client calls it after checkout and the checkout webhook calls it as a backstop, so a swap needs to keep it safe to run twice.
The webhook is 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 and WebhookReceived events are the hook points 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 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.