---
title: "Tax rates — Shops for Laravel | Roundly"
description: "Per-shop, per-country tax rates in basis points, the resolver fallback chain, TaxRateValue, config-only tax and custom resolvers."
url: https://roundly-consulting.com/open-source/docs/shops-for-laravel/tax
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Shops for Laravel](https://roundly-consulting.com/open-source/docs/shops-for-laravel.md)

# 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 %):

```php
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

```php
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

```php
// 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');   // TaxRateValue
```

An 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:

```php
// 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:

```php
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 crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/shops-for-laravel.md)
