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.6
- 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
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-17 00:24:26 UTC
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 rate number comes from
the register — 80 jurisdictions across eleven regimes,
compiled to local disk by php artisan tax:data:sync and read without a network call.
A missing rate is refused, never assumed 0%, and that includes the first run: until
the register is synced the engine refuses and says so.
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 (general B2C services source at the supplier; goods and electronic services at the customer), intra-EU B2B reverse charge, Art. 59c €10k micro-business relief scoped to the supplies it covers; rates from the register, which reads the Commission's TEDB among its sources |
✅ |
| 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 and intrastate-sourcing gates, with rates, 25-category taxability, nexus thresholds and sourcing rules from the register (all 51 jurisdictions) |
✅ address-exact for 30 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. A category whose rule is conditional, or one
whose rule is conditional on the line amount (the MA/NY/RI clothing thresholds),
refuses rather than defaulting to taxable — over-collecting from a consumer is
a failure too. State rates, per-state taxability (25 categories) and economic-nexus
thresholds come from the register.
Intrastate sourcing is applied, not just supplied: nine states tax an in-state
sale at the seller's location, so give the supply a SupplyRoute(shipFrom: …) and
a Texas in-state sale is charged the seller's rate. Interstate stays
destination-sourced everywhere, and a supply with no route behaves exactly as
before. Address-exact rates are live for 30 states. 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 — fifteen of them go finer still with a street index
(tax:data:sync --streets=KS), and California and New Mexico resolve by point against their own
polygon layers. Florida, Pennsylvania, Hawaii and Virginia need no boundary file at all,
because the county is the only authority that can tax there and a geocoder returns it
for free. The rest fall back to the state share, flagged
(details).
Remote-seller elections close two of those states on request. Alabama's SSUT
(flat 8%) and Texas' Single Local Use Tax Rate (6.25% + 1.75% for 2026) are
statutory schemes a remote seller elects into; give the state registration the
remote-election scheme and the engine prices under them — opt-in, dated, and
refusing rather than guessing when the published figure lapses.
Marketplace sales are not the seller's to collect. Every US state with a sales
tax now makes a qualifying marketplace the liable party — Missouri closed the set on
2023-01-01 — so pass marketplaceFacilitated: true and the engine returns
MarketplaceFacilitated: nothing charged, because the marketplace already charged
it. It is kept apart from Exempt and NotRegistered on purpose. All three are a
zero and they mean opposite things on a return, and most states still expect the
sale reported in gross receipts and then deducted. The rule is checked on the
supply's date, so a backdated Missouri sale from 2022 is still the seller's.
Canada resolves at province level (no local tax). Every regime reads the same
register; to put your own source in front of it, bind TaxRateSource — see
docs/coverage.
EU place of supply follows the Directive rather than a single rule: goods (Art. 33(a)) and electronically-supplied services (Art. 58) are taxed at the customer, while a general B2C service is taxed where the supplier is established (Art. 45) — so a German consultancy invoicing a French consumer owes German VAT. On top of that sits the Art. 59c €10,000 micro-business threshold, scoped to the supplies it actually covers (goods and TBE, not services generally): a below-threshold, non-opted seller charges origin VAT; opted-in or over-threshold charges destination. 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.
Documents, not just single supplies
A real invoice is multi-line. TaxOrder carries the context every line shares plus
SupplyLine[], and OrderTaxCalculator::assessOrder() returns each line's verdict
tied to the id you sent:
$assessment = app(OrderTaxCalculator::class)->assessOrder(new TaxOrder( place: $geo->find(new CountryCode('DK')), customer: CustomerType::Consumer, seller: new SellerRegistrations(new CountryCode('DK')), pricing: Pricing::Exclusive, lines: [ new SupplyLine('subscription', Money::of('100.00', 'DKK'), TaxCategory::DigitalService), new SupplyLine('usage', Money::of('37.50', 'DKK'), TaxCategory::DigitalService), new SupplyLine('onboarding', Money::of('2500.00','DKK'), TaxCategory::ServicesProfessional), ], )); $assessment->tax(); // summed from the rounded lines, never recomputed $assessment->forLine('usage'); // that line's own assessment $assessment->taxByAuthority(); // per-jurisdiction totals for remittance, or null
The order plane adds no tax logic — each line becomes a single-supply query and runs the identical path, so a document cannot reach an outcome a single supply could not. A line may override the document's pricing (VAT-inclusive subscription beside exclusive usage) or carry its own exemption.
Rate breakdown
Where a rate is stacked from several authorities — a US state share plus the
county, city and special-district records a rooftop lookup matched — the
assessment carries a TaxBreakdown splitting the tax across them, so a seller can
remit per jurisdiction. The shares are allocated from the tax actually charged,
never recomputed per authority, so they sum to it exactly and a return reconciles
with the invoices behind it. A null breakdown means the split is unknown, not
that one authority takes everything. See
docs/core-concepts/rate-breakdown.md.
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.