Skip to main content
Shopper calculates taxes per line item, based on where an order is being shipped. You define geographic zones, assign percentage rates to each zone, and optionally override those rates for specific products, product types, or categories. The cart pipeline applies all of this automatically during checkout. All tax amounts are stored in cents as unsigned integers, consistent with the rest of the monetary system.

How It Works

The tax system has four main pieces:
  1. TaxZone: a geographic area (country, or country + province) with a tax inclusion policy and an optional custom provider
  2. TaxRate: a percentage rate attached to a zone; one rate per zone is marked as the default
  3. TaxRateRule: a targeting rule on a rate; when a cart line matches a rule, its rate takes priority over the zone’s default
  4. TaxCalculationProvider: the service responsible for receiving a taxable item and a context, and returning TaxLine value objects
When the cart is being calculated, the 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 is is_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).
When the calculator resolves a zone for a shipping address, it first tries to find a zone matching both the country and province. If none exists, it falls back to a country-level zone with no province_code. The display_name accessor produces a human-readable label by combining country and zone name (for example, France — Standard VAT).

Zone Schema

The combination of 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

Tax Rates

Each zone has one or more rates. One rate should be marked as is_default it applies to every item in the zone that does not match a more specific override rule.
France has a reduced VAT rate of 5.5% on food. You can define it as a separate non-default rate on the same zone, then target it at the right product category using a rule:

Tax Rate Schema

The TaxRate model is configurable via config/shopper/models.php.

Rate Relationships

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.
This rule tells the calculator: “whenever a cart line belongs to the food category, use the 5.5% rate instead of the default 20%.” You can also target a specific product:
Or an entire product type, which is useful for applying different rules to virtual goods versus physical ones:

Rule Reference Types

Rule Schema

The combination of 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

The is_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:
A 100productwitha10100 product with a 10% exclusive tax costs **110** at checkout. Tax-inclusive (VAT): tax is already embedded in the listed price. The customer pays exactly the listed price, but the tax authority receives a portion:
A €100 product with 20% VAT inclusive: tax = €100 - (€100 / 1.2) = €16.67, amount before tax = €83.33. Shopper handles both formulas automatically in 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 call Cart::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:
  1. Reads the cart’s shipping address country and province
  2. Builds a TaxCalculationContext from those values
  3. Passes a CartLineTaxAdapter for the line to TaxCalculator::calculate()
  4. Deletes any existing CartLineTaxLine records for that line
  5. Creates new CartLineTaxLine records with the resulting tax lines
  6. Accumulates the tax total on the pipeline context
If no matching tax zone exists for the shipping address, the 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 using CreateOrderTaxLinesAction. This snapshot preserves the exact tax breakdown at the moment of purchase future changes to zones or rates have no impact on historical orders.
The action resolves the shipping address country, runs 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

Accessing Tax Lines on an Order

Tax Providers

A TaxProvider 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

Custom Tax Providers

The built-in SystemTaxProvider 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:
Here is a complete implementation that calls a fictional external tax API:
Register the provider in your service provider:
Then create a TaxProvider record and link it to the zone that should use it:
When the calculator resolves a zone that has an enabled provider, it instantiates that provider from the container instead of falling back to the system default. Each unique country/province combination is cached in memory for the duration of the request, so the provider is only resolved once per zone.

The TaxCalculationContext

The TaxCalculationContext is a read-only value object that carries the geographic information needed to resolve the correct zone and provider:

The TaxableItem Contract

Any object you pass to TaxCalculator::calculate() must implement Shopper\Core\Contracts\TaxableItem: The contract exposes a single getTaxableTotal() rather than a separate unit amount and quantity. The provider taxes that total in one pass, so a line whose total is not evenly divisible by its quantity is never rounded per unit. This keeps exclusive zones from overcharging by a cent and keeps inclusive VAT breakdowns balanced (net plus tax always equals gross). Shopper ships two ready-made adapters:
  • CartLineTaxAdapter wraps a CartLine for use during cart calculation; the taxable total is the post-discount line total in cents
  • OrderItemTaxAdapter wraps an OrderItem for use in CreateOrderTaxLinesAction; the taxable total is unit_price_amount times quantity in cents
If you are building a custom checkout flow or need to calculate taxes outside of a cart, you can implement TaxableItem directly on any of your objects and call TaxCalculator::calculate() with a manually constructed TaxCalculationContext.