DEV Community

Cover image for When Stripe Tax isn't enough: Custom Tax Rates in a Multi-Tenant Laravel App
yebor974 for Filament Mastery

Posted on Originally published at filamentmastery.com on

When Stripe Tax isn't enough: Custom Tax Rates in a Multi-Tenant Laravel App

Prerequisites: this assumes Laravel Cashier is already installed and configured, with a Billable trait on the tenant model (Team), a Stripe account with Stripe Tax enabled, and general familiarity with Stripe Checkout and subscriptions. It doesn't re-explain Cashier setup from scratch.

The problem

Like most people using Cashier, I assumed enabling Stripe Tax meant I could stop thinking about tax rates altogether.

Stripe Tax is supposed to solve tax rates automatically: give it a customer's address, and it calculates the right rate. It's generally reliable for standard jurisdictions, but it can get special or reduced-rate zones wrong, the kind of local exception that exists in plenty of countries and that a generic tax engine has no reason to know about by default.

One concrete case I ran into: a buyer in La Réunion, a French overseas territory (DROM). Legally, it's treated as an export territory relative to mainland France, with its own reduced rate of 8.5% instead of the standard 20%. Stripe Tax came back with 0% for exactly this case. From what I've observed, Stripe Tax doesn't handle French overseas departments (DROM) correctly for VAT, it's not documented as a known limitation anywhere I could find, just a gap that shows up in practice. One plausible explanation: Stripe Tax's automatic calculation depends on having an active tax registration for a given jurisdiction (visible under Settings → Tax → Registrations in the Dashboard), and DROM territories may not have a registration distinct from mainland France by default. That's a hypothesis based on how Stripe Tax is documented to work, not a confirmed root cause.

That's not a rounding error a client can shrug off. It's a compliance problem: undercharging (or not charging) VAT on a subscription is the kind of thing that surfaces months later, at the worst possible time, and it's not specific to France, any special tax zone can hit the same gap.

The fix ends up being a mix of two systems that don't naturally talk to each other: Stripe's automatic tax, and a table of custom tax rates you control yourself. Deciding which one applies, per customer, per checkout, is the actual architecture problem here.

The rest of this guide (the StripeTaxRate model synced to Stripe through an observer, the session-based bridge to pass the customer's country into Cashier's Checkout payload, the full redirect round-trip back from Stripe, including a subtle bug where successRedirectUrl ends up doing two different jobs at two different times) is part of Filament Mastery's Full Access membership.

Full guide here: When Stripe Tax Isn't Enough: Custom Tax Rates in a Multi-Tenant Laravel App

Filament Mastery is a production decisions library for Laravel & Filament: architecture calls, tradeoffs, and fixes from real client projects. Some guides are free, this one's part of Full Access.

Top comments (0)