cboxdk / laravel-tax
Cbox Tax — a self-hostable consumption-tax engine for Laravel: it owns the calculation logic (place-of-supply, reverse-charge, rate application, inclusive/exclusive) per jurisdiction and sources only rate data behind a pluggable contract.
Requires
- php: ^8.4
- ext-dom: *
- ext-zlib: *
- brick/money: ^0.14
- cboxdk/laravel-geo: ^0.5
- illuminate/contracts: ^13.0
- illuminate/http: ^13.0
- illuminate/support: ^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.0
README
cboxdk/laravel-tax — a self-hostable consumption-tax engine for Laravel. It
owns the calculation logic — place-of-supply, reverse-charge, rate application,
inclusive/exclusive — and sources only the rate data behind a pluggable
contract. No forced third-party calculation SaaS.
Built on
cboxdk/laravel-geo: every supply is assessed against a jurisdiction resolved from canonical ISO data, so tax is a function of(seller registrations, buyer jurisdiction, product type)— never a fuzzy country-name match.
The boundary: own the logic, source the data
use Cbox\Tax\Contracts\TaxCalculator; use Cbox\Tax\ValueObjects\TaxQuery; use Cbox\Tax\ValueObjects\SellerRegistrations; use Cbox\Tax\Enums\{CustomerType, Pricing}; use Cbox\Geo\ValueObjects\CountryCode; use Brick\Money\Money; $assessment = app(TaxCalculator::class)->assess(new TaxQuery( amount: Money::of('100.00', 'EUR'), pricing: Pricing::Exclusive, place: $geo->find(new CountryCode('FR')), // buyer jurisdiction (from laravel-geo) customer: CustomerType::Business, seller: new SellerRegistrations(new CountryCode('DE')), customerTaxIdValidated: true, // VIES-validated )); $assessment->treatment; // TaxTreatment::ReverseCharge — intra-EU B2B, buyer self-accounts $assessment->tax; // Money 0.00 EUR $assessment->reason; // human-readable explanation for the audit trail
The engine decides whether and how to tax; the TaxRateSource contract
supplies the rate number — the EU Commission's TEDB called live (no API key), the
compiled US dataset, or a commercial adapter. A missing rate is refused, never
assumed 0%.
Multi-entity / seller-of-record routing
Tax depends on which selling entity issues the invoice. The same buyer is taxed differently by a German entity vs a French one:
| Selling entity | Buyer (FR business, validated) | Result |
|---|---|---|
| German entity | cross-border intra-EU B2B | reverse charge — no VAT charged |
| French entity | domestic supply | French VAT is charged |
SellerRegistrations (establishment + registrations) is the seller side of the
calculation the billing engine supplies per invoice.
What's covered
| Regime | Status | |
|---|---|---|
| EU VAT | eu-vat — Art. 44/45/58 place-of-supply, intra-EU B2B reverse charge, Art. 59c €10k micro-business origin/destination sourcing; rates live from the Commission's TEDB |
✅ |
| National VAT/GST | UK, CH, NO, AU, NZ, MX, SG, TW, UAE, SA, BH, OM, TR, CL, ID, VN, PH, JP, KR, TH, UA | ✅ |
| India | in-gst — dual GST (IGST vs CGST+SGST), OIDAR destination, B2B reverse charge |
✅ |
| Malaysia | my-sst — SST service tax; charges B2B+B2C, no reverse charge |
✅ |
| US sales tax | us-sales-tax — nexus/taxability/sourcing logic, with rates, 25-category taxability, nexus thresholds and sourcing from the us-tax-data dataset (all 51 jurisdictions, on by default) |
✅ rooftop for 26 states |
| Canada GST/HST | ca-gst — province-level combined rate, cross-border B2B self-assessment |
✅ |
See docs/coverage for the full per-country table with
sources and confidence — and an honest list of jurisdictions we omit until
their rate data is verified (a broad national-VAT batch pending primary-source
confirmation, Pakistan's other provinces, and Brazil). We omit rather than ship a
rate we cannot stand behind.
The US regime gates on three things before applying a rate — the state must be
resolved (via the AddressGeocoder), the seller must have nexus in it, and the
product must be taxable there — otherwise it returns NotRegistered or
Exempt, never a wrong charge. State rates, per-state taxability (25 categories),
economic-nexus thresholds and intrastate sourcing are supplied by the us-tax-data
dataset, enabled by default. Rooftop resolution is live for 26 states with
us_tax_data.rooftop enabled: the 24 Streamlined states resolve by ZIP+4 through the
published boundary index — Kansas City comes out as 6.5% state + 1.0% county + 1.625%
city — while California and New Mexico resolve by point against their own polygon
services. The rest fall back to the state rate
(details).
Canada resolves at province level (no local tax). Rate data plugs in via
TaxRateSource: set TAX_TEDB_LIVE=true to resolve EU rates from the
Commission's own TEDB service (no key, no registration, cached per country), or
bind a commercial adapter — see docs/coverage.
EU additionally applies the Art. 59c €10,000 micro-business threshold: a below-threshold, non-opted seller charges origin VAT; opted-in or over-threshold charges destination (signals supplied on the seller). Rate sources resolve by taxability category, so reduced/zero bands apply when a bound source supplies them (none are fabricated by default).
Unmodelled jurisdictions and missing rates are refused, not guessed.
Buyer exemptions
A query may carry a native buyer exemption (a resale / nonprofit / government
certificate) on TaxQuery::$exemption. Applied deny-by-default over the regime's
verdict, a valid exemption that covers the taxed jurisdiction rewrites a would-be
Standard line to Exempt (net kept, tax 0, gross = net) with the certificate
reference recorded on the assessment; reverse-charge, not-registered and zero-rated
outcomes are left untouched, and an exemption for a different jurisdiction or an
expired one does not exempt. The engine computes the assessment; certificate
capture and verification are the consumer's concern. See
docs/core-concepts/exemptions.md.
Design
- Contracts-first.
TaxCalculator,TaxRegime,TaxRateSource,RegimeRegistry,AddressGeocoder,VatIdValidator,ReturnAggregator— bind and override any of them. Rate sources compose (static · remote · caching · chain). - Deny-by-default. No regime for a jurisdiction, or no rate, → an exception, never a silent zero.
- Money is exact. Amounts are
brick/money; rate maths rounds half-up once.
Requirements
PHP ^8.4 with ext-dom; Laravel ^13. See composer.json.
Development
composer install
composer qa # pint --test, phpstan (level max), pest, license-check, audit
License
MIT.