SIGNALS Documentation
API Reference

Tax Resolver Registry

Contract for registering keyed tax resolver implementations and resolving the configured active resolver.

Overview

App\Services\Tax\TaxResolverRegistry is the composition root for the App\Contracts\Tax\TaxResolver seam.

Document tax code never calls a concrete calculator. It depends on the TaxResolver interface, and AppServiceProvider binds that interface to whatever this registry resolves. Core seeds one key — zone, backed by ZoneAwareTaxResolver — from the tax.resolvers config map, and tax.resolver names the key that is active.

The registry replaces what was previously a bare container binding. A bare binding can be replaced silently by any package that boots later; a keyed registry rejects collisions, records which package registered each key, and keeps the selection of the active implementation an explicit configuration decision rather than a side effect of load order.

Public surface

Method Purpose
register() Warehouse one resolver class under a new key, optionally attributed to a package
resolve() Resolve one TaxResolver implementation through the container
activeKey() Return the configured active key
has() Check whether a key is registered
keys() Return every registered key in registration order
packageFor() Return the plugin package that registered a key, or null for core

Accepted contract

The registered value is a class-string for an App\Contracts\Tax\TaxResolver implementation. That contract has two methods, both operating on integer minor units:

  • calculateExclusive(int $netMinorUnits, string $currencyCode, ?int $organisationTaxTypeId, ?int $catalogueItemTaxTypeId, ?DateTimeInterface $effectiveAt, ?int $catalogueItemTaxCategoryId): TaxResult
  • calculateInclusive(int $grossMinorUnits, string $currencyCode, ?int $organisationTaxTypeId, ?int $catalogueItemTaxTypeId, ?DateTimeInterface $effectiveAt, ?int $catalogueItemTaxCategoryId): TaxResult

Exclusive calculation adds tax to a net amount. Inclusive calculation extracts embedded tax from a gross amount. Both return a TaxResult, which carries the rate name, the percentage, and the net, tax, and gross amounts in minor units. Money never crosses this boundary as a float.

Identity, collisions, and construction

Keys are stable registry identities. register() rejects a blank key, a key that is already registered, a class that does not implement TaxResolver, and a class that is not instantiable. Registration is therefore additive only: no registration can take over the core zone key, and later registrations cannot silently shadow earlier ones.

resolve() does not store resolver instances. It asks Laravel's container each time, so resolver dependencies stay injectable and test doubles can replace concrete classes through the container. Core registers ZoneAwareTaxResolver as a singleton, so the default path resolves the same shared instance the bare binding used to return.

The third register() argument records the plugin package that supplied a key; packageFor() reads it back and returns null for core registrations.

Selecting the active resolver

resolve() called with no argument uses activeKey(), which reads tax.resolver on every call and falls back to the DEFAULT_KEY constant (zone) when unset. Runtime configuration changes therefore take effect on the next resolution rather than at boot.

Registering a resolver changes nothing on its own. Until tax.resolver names a different key, every document keeps resolving tax through zone, and every calculated total is unchanged.

Failure behaviour

resolve($key) throws InvalidArgumentException with Unknown tax resolver [{key}]. when the key is not registered — including when tax.resolver names a key nothing registered, which fails loudly at resolution rather than falling back to a default that the operator did not choose.

register() throws InvalidArgumentException for each rejected registration, with a message naming the blank key, the duplicate key, or the offending class.

Current core registrations

Key Resolver Behaviour
zone ZoneAwareTaxResolver Matches cross-border zone rules first, then falls back to the organisation × product tax-class matrix, then to the seeded Standard / Reduced / Zero rates

zone is the only shipped resolver and the default value of tax.resolver.

Worked example

Resolving the active resolver and calculating tax on a net amount:

$resolver = app(TaxResolverRegistry::class)->resolve();

$result = $resolver->calculateExclusive(
    netMinorUnits: 12550,
    currencyCode: 'GBP',
    organisationTaxTypeId: $account->tax_type_id,
    productTaxTypeId: $catalogueItem->tax_type_id,
);

Because AppServiceProvider binds TaxResolver to $registry->resolve(), injecting the interface gives the same object without naming the registry at all — the registry is only consulted directly when code needs a specific key or the catalogue.