How It Works
The tax system has four main pieces:- TaxZone: a geographic area (country, or country + province) with a tax inclusion policy and an optional custom provider
- TaxRate: a percentage rate attached to a zone; one rate per zone is marked as the default
- TaxRateRule: a targeting rule on a rate; when a cart line matches a rule, its rate takes priority over the zone’s default
- TaxCalculationProvider: the service responsible for receiving a taxable item and a context, and returning
TaxLinevalue objects
CalculateTax pipeline step reads the shipping address, resolves the matching TaxZone, picks the applicable TaxRate, and persists a CartLineTaxLine record for every cart line with a non-zero tax amount.
Tax Zones
A tax zone defines where a tax configuration applies. It maps to a country and optionally to a specific province or state within that country. The most important setting on a zone isis_tax_inclusive, which determines whether prices already include tax (VAT, common in Europe) or whether tax is added on top (sales tax, common in the US).
province_code.
The display_name accessor produces a human-readable label by combining country and zone name: France — Standard VAT.
Zone Schema
| Column | Type | Description |
|---|---|---|
country_id | unsignedBigInteger | The country this zone applies to |
province_code | nullable string | ISO province/state code (CA, NY, IDF) |
name | nullable string | Optional label to identify the zone in the admin |
is_tax_inclusive | boolean | Whether prices already include tax (VAT) or not (sales tax) |
provider_id | nullable unsignedBigInteger | Custom tax provider; falls back to the system default when null |
parent_id | nullable unsignedBigInteger | Parent zone for hierarchical configurations |
metadata | nullable jsonb | Arbitrary data for custom providers |
country_id and province_code is unique, you cannot have two zones for the same country/province pair. The TaxZone model is configurable via config/shopper/models.php.
Zone Relationships
| Relationship | Type | Description |
|---|---|---|
country() | BelongsTo | The country this zone is scoped to |
rates() | HasMany | All tax rates defined for this zone |
provider() | BelongsTo | The custom tax provider assigned to this zone |
parent() | BelongsTo | Parent zone |
children() | HasMany | Child zones |
Tax Rates
Each zone has one or more rates. One rate should be marked asis_default it applies to every item in the zone that does not match a more specific override rule.
Tax Rate Schema
| Column | Type | Description |
|---|---|---|
tax_zone_id | unsignedBigInteger | The zone this rate belongs to |
name | string | Display name shown on invoices (TVA 20%) |
code | nullable string | Machine-readable identifier (FR_VAT_STANDARD) |
rate | decimal(8,4) | Percentage value 20.0 means 20% |
is_default | boolean | Applied when no override rule matches the item |
is_combinable | boolean | Whether this rate stacks with rates from a parent zone |
metadata | nullable jsonb | Arbitrary data for external providers |
config/shopper/models.php.
Rate Relationships
| Relationship | Type | Description |
|---|---|---|
taxZone() | BelongsTo | The zone this rate is attached to |
rules() | HasMany | Override rules that activate this rate for specific items |
Tax Rate Rules
Rules let you override the default rate for specific items. The calculator checks all non-default rates with rules first. The first rule that matches the cart line wins; if none matches, the zone’s default rate is used. A rule has two fields:reference_type identifies what kind of entity to match, and reference_id holds the specific value to match against.
Rule Reference Types
reference_type | reference_id | Matches when |
|---|---|---|
product_type | A ProductType enum value (standard, virtual, external) | The item’s product type equals the value |
product | A product primary key, as a string | The item’s exact product ID matches |
category | A category primary key, as a string | The item belongs to that category |
Rule Schema
| Column | Type | Description |
|---|---|---|
id | bigint | Primary key |
tax_rate_id | bigint | FK to tax rate |
reference_type | string | Entity type to match (product_type, product, category) |
reference_id | string | Entity value or ID to match against |
tax_rate_id, reference_type, and reference_id is unique. You cannot attach the same rule twice to the same rate.
Inclusive vs Exclusive Taxes
Theis_tax_inclusive flag on the zone controls the math behind the tax amount.
Tax-exclusive (sales tax): tax is added on top of the price. The customer pays more than the listed price:
SystemTaxProvider. The zone’s is_tax_inclusive value is also propagated through the pipeline context so storefronts can present prices correctly showing “VAT included” or displaying tax as an addition at checkout.
Cart Integration
Tax calculation is part of the cart pipeline and runs automatically whenever you callCart::calculate(). The CalculateTax step runs after discounts are applied, ensuring taxes are computed on the post-discount amount.
The step does the following for each cart line:
- Reads the cart’s shipping address country and province
- Builds a
TaxCalculationContextfrom those values - Passes a
CartLineTaxAdapterfor the line toTaxCalculator::calculate() - Deletes any existing
CartLineTaxLinerecords for that line - Creates new
CartLineTaxLinerecords with the resulting tax lines - Accumulates the tax total on the pipeline context
CalculateTax step skips silently and no tax lines are created.
Persisting Tax Lines on Orders
When a cart is converted into an order, tax lines are snapshotted onto the order usingCreateOrderTaxLinesAction. This snapshot preserves the exact tax breakdown at the moment of purchase future changes to zones or rates have no impact on historical orders.
TaxCalculator::calculate() for each order item, creates an OrderTaxLine record for each result, and updates tax_amount on both the order item and the order itself.
OrderTaxLine Schema
| Column | Type | Description |
|---|---|---|
taxable_type | string | Polymorphic type (Order or OrderItem) |
taxable_id | unsignedBigInteger | Polymorphic ID |
tax_rate_id | nullable unsignedBigInteger | Reference to the rate used at time of purchase |
code | string | Rate code snapshot |
name | string | Rate name snapshot |
rate | decimal(8,4) | Rate percentage snapshot |
amount | unsignedInteger | Tax amount in cents |
Accessing Tax Lines on an Order
Tax Providers
ATaxProvider record links a zone to a custom tax calculation service. When a zone has an enabled provider, the calculator uses that provider instead of the built-in system.
Provider Schema
| Column | Type | Description |
|---|---|---|
id | bigint | Primary key |
identifier | string | Unique provider code (e.g., avalara, taxjar) |
is_enabled | boolean | Whether the provider is active |
Custom Tax Providers
The built-inSystemTaxProvider handles most use cases. For regions that require an external API like Avalara, TaxJar, or a homegrown tax service, you can implement the TaxCalculationProvider contract and assign it to specific zones.
The contract is simple:
TaxProvider record and link it to the zone that should use it:
The TaxCalculationContext
TheTaxCalculationContext is a read-only value object that carries the geographic information needed to resolve the correct zone and provider:
| Property | Type | Description |
|---|---|---|
countryCode | string | ISO 3166-1 alpha-2 country code (FR, US, DE) |
provinceCode | ?string | ISO province/state code (CA, NY) |
customerId | ?int | The authenticated customer’s ID, for future customer-level tax rules |
resolvedZone | ?TaxZone | Pre-resolved zone; set internally by the calculator to avoid redundant queries |
The TaxableItem Contract
Any object you pass toTaxCalculator::calculate() must implement Shopper\Core\Contracts\TaxableItem:
| Method | Return | Description |
|---|---|---|
getTaxableAmount() | int | Unit price in cents, after any item-level adjustments |
getQuantity() | int | Item quantity |
getProductType() | ?string | Product type value (standard, virtual, external) |
getProductId() | ?int | The product’s primary key |
getCategoryIds() | array<int, int> | Primary keys of all categories the product belongs to |
CartLineTaxAdapterwraps aCartLinefor use during cart calculation; taxable amount is the post-discount line total divided by quantityOrderItemTaxAdapterwraps anOrderItemfor use inCreateOrderTaxLinesAction; taxable amount is the rawunit_price_amountin cents
TaxableItem directly on any of your objects and call TaxCalculator::calculate() with a manually constructed TaxCalculationContext.