Tax rates
Each shop owns many TaxRate rows. A rate carries a tax class, an optional ISO 3166-1 alpha-2 country and a rate in integer basis points (1 bp = 0.01 %, so 1900 = 19.00 % and 850 = 8.5 %):
use RoundlyConsulting\Shops\Contracts\TaxResolver;
$shop->taxRates()->createMany([
['name' => 'VAT', 'tax_class' => 'standard', 'rate' => 2000, 'is_default' => true], // 20.00 %
['name' => 'DE VAT', 'tax_class' => 'standard', 'rate' => 1900, 'country' => 'DE'], // 19.00 %
['name' => 'LU reduced', 'tax_class' => 'reduced', 'rate' => 850, 'country' => 'LU'], // 8.50 %
]);
$value = app(TaxResolver::class)->rateFor($shop, 'reduced', 'LU');
$value->basisPoints; // 850
$value->percentage()->value(); // "8.5" — money's Percentage
$value->toTaxRate(); // money's TaxRate, which does the exact tax math
$value->isZero(); // false
app(TaxResolver::class)->rateFor($shop, 'standard', 'DE'); // 1900 — exact country match
app(TaxResolver::class)->rateFor($shop, 'standard', 'de'); // 1900 — countries match case-insensitively
app(TaxResolver::class)->rateFor($shop, 'standard'); // 2000 — the class default
app(TaxResolver::class)->rateFor(null, 'standard'); // 2000 — the config floor (20 %)Resolution order
The default DatabaseTaxResolver loads the shop’s rates for the class (priority descending, then id) and picks:
- 1. the first rate whose country equals the requested country;
- 2. else the class default — the rate flagged is_default, a country-less one first, else a country-specific one such as your home country’s;
- 3. else the highest-priority rate for the class, whatever its country;
- 4. else the tax_classes config floor — an unknown class falls back to standard, and the floor always has a standard rate (its shipped 20 % when not set).
Only Shop instances (or subclasses) reach steps 1–3; a null shop goes straight to the config floor. Countries match case-insensitively (de = DE), and a rate’s country is stored upper-cased. Cart prices pass no country, so an exact country match (step 1) only happens on orders with a shipping address.
TaxRateValue
use RoundlyConsulting\Money\Money;
use RoundlyConsulting\Shops\Support\Tax\TaxRateValue;
$rate = new TaxRateValue(1900, taxClass: 'standard', country: 'DE', label: 'DE VAT');
TaxRateValue::zero('zero'); // a 0 bp rate
$rate->percentage()->value(); // "19"
$rate->toTaxRate()->taxInGross(Money::ofMinor(1190, 'EUR')); // 1.90 EUR
$taxRate->toValue(); // a TaxRate model as a TaxRateValue (label = its name)Assigning tax classes
// What you sell carries a tax class — snapshotted onto cart and order lines:
$variant->update(['tax_class' => 'reduced']);
// What a class costs on a given order right now (its shop, its shipping country):
$order->taxRateFor('reduced'); // TaxRateValueAn order line snapshots the rate its class resolved to when it was added (order_items.tax_rate in basis points plus tax_label), so editing a rate only affects carts and orders placed afterwards.
Config-only and custom resolvers
ConfigTaxResolver resolves purely from shops.tax_classes, ignoring shop and country. Each configured rate must be a whole number from 0 to 100 (or its integer string) — twenty or 19.5 throws an InvalidConfigurationException naming shops.tax_classes.<class> instead of becoming a 0 % rate:
// config/shops.php — config-only tax, ignoring shop and country
'tax' => [
'resolver' => \RoundlyConsulting\Shops\Support\Tax\ConfigTaxResolver::class,
],For your own jurisdiction logic, implement TaxResolver — it must always return a TaxRateValue, never null:
use RoundlyConsulting\Shops\Contracts\TaxResolver;
use RoundlyConsulting\Shops\Shops\Shop;
use RoundlyConsulting\Shops\Support\Tax\DatabaseTaxResolver;
use RoundlyConsulting\Shops\Support\Tax\TaxRateValue;
final class ExportAwareTaxResolver implements TaxResolver
{
public function rateFor(?Shop $shop, string $taxClass = 'standard', ?string $country = null): TaxRateValue
{
if ($country !== null && ! in_array($country, ['DE', 'AT', 'SK'], true)) {
return TaxRateValue::zero($taxClass); // your own jurisdiction rule
}
return (new DatabaseTaxResolver)->rateFor($shop, $taxClass, $country);
}
}
// config/shops.php → 'tax' => ['resolver' => ExportAwareTaxResolver::class]Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.