PHP SDK for the OpenSalesTax engine — the open-source, self-hostable US sales tax calculation API.
Status: v0.1 alpha. API surface stable. Tested against engine v0.14 — v0.24.
US sales tax is a mess: ~10,000 jurisdictions, ~50,000 ZIPs, rates change quarterly, taxability rules vary per state per category. The commercial APIs (Avalara, TaxJar, Stripe Tax) charge $0.50–$10+ per transaction or 0.5% of revenue.
OpenSalesTax is the open-source self-hostable engine. This SDK is the PHP wrapper around its v1 HTTP API — composer require, point at your engine, get tax.
$client = new OpenSalesTaxClient(baseUrl: 'http://your-engine:8080');
$result = $client->calculate(
address: new Address(zip5: '55401'),
lineItems: [new LineItem(amount: '100.00', category: 'general')],
);
echo $result->taxTotal; // "8.025"That's it. ~200 LOC of stateless wrapper code — no business logic, no caching, no surprise dependencies. The complexity lives in the engine; this SDK just calls it.
composer require ejosterberg/opensalestaxRequires PHP 8.2+ (uses class-level readonly syntax for DTOs) and a reachable OpenSalesTax engine (self-host via the engine's docker-compose).
use OpenSalesTax\Client;
use OpenSalesTax\Address;
use OpenSalesTax\LineItem;
$client = new Client(baseUrl: 'http://localhost:8080');
$result = $client->calculate(
address: new Address(zip5: '55401'),
lineItems: [
new LineItem(amount: '100.00', category: 'general'),
new LineItem(amount: '50.00', category: 'clothing'),
],
);
echo $result->subtotal; // "150.00"
echo $result->taxTotal; // "8.025"
foreach ($result->lines as $line) {
echo "{$line->category}: \${$line->tax}\n";
if ($line->note !== null) {
echo " → {$line->note}\n"; // e.g. "Clothing is non-taxable in Minnesota..."
}
}$client = new Client(
baseUrl: 'http://your-engine:8080',
apiKey: 'optional-x-api-key', // null if engine doesn't require auth
timeoutSeconds: 10.0,
httpClient: null, // optional PSR-18 override (Guzzle 7 default)
);
$client->health(); // HealthResponse{status, version, databaseConnected}
$client->states(); // StatesResponse{states[StateInfo], total}
$client->rates(zip5: '55401'); // RatesResponse{input, jurisdictions[], combinedRatePct, disclaimer}
$client->calculate($address, $lineItems); // CalculateResponse{subtotal, taxTotal, lines[], disclaimer}Each line in a CalculateResponse carries the per-jurisdiction breakdown:
foreach ($result->lines as $line) {
foreach ($line->jurisdictions as $j) {
echo " {$j->type:9} {$j->name:50} {$j->ratePct}% \${$j->tax}\n";
// state Minnesota 6.875% $6.8750
// county Hennepin County 0.15% $0.1500
// city Minneapolis 0.5% $0.5000
// district Hennepin County Transit Sales Tax 0.5% $0.5000
}
}Sums reconcile exactly: $line->tax === sum($line->jurisdictions[*]->tax). Use the breakdown for accounting (state/county/city splits); use $line->tax for the customer-facing total.
Standard categories the engine recognizes: general (default), clothing, groceries, prescription_drugs, prepared_food, digital_goods. Per-state taxability rules apply (e.g. clothing is non-taxable in Minnesota; groceries in most states).
Amounts are strings, not integers (cents) or floats. Strings preserve the engine's exact precision; the engine quantizes per-jurisdiction in fixed-point. Convert from cents in your own code if you need to:
$cents = 9999;
$amount = number_format($cents / 100, 2, '.', ''); // "99.99"
new LineItem(amount: $amount, category: 'general');Flat hierarchy. All errors extend OpenSalesTax\Exceptions\OpenSalesTaxException:
OpenSalesTaxApiException— non-2xx HTTP from the engine; carriesstatusCode,rawBody,errorBody.OpenSalesTaxNetworkException— transport failure (timeout, DNS); wraps the underlying PSR-18 exception viagetPrevious().OpenSalesTaxValidationException— client-side input rejected before sending (bad ZIP regex, negative amount).
try {
$result = $client->calculate(...);
} catch (OpenSalesTaxApiException $e) {
error_log("Engine returned {$e->statusCode}: {$e->rawBody}");
} catch (OpenSalesTaxNetworkException $e) {
error_log("Cannot reach engine: " . $e->getMessage());
}- PHPStan level=max — zero suppressed errors
- PHP-CS-Fixer with PSR-12 + risky rules — zero violations
- PHPUnit — 21 unit + integration tests, 54 assertions, all passing
- GitHub Actions CI matrix on PHP 8.2 / 8.3 / 8.4
- DCO sign-off required on every commit
This SDK targets the OpenSalesTax v1 HTTP API. Tested against engine v0.14 — v0.24. The v1 API surface has been stable across that range. Pin both in production:
ejosterberg/opensalestax: ^0.1
opensalestax engine: v0.20+ (recommended; older versions had a state-bleed bug fixed in v0.22)
- Not the engine. See open-sales-tax for the calculator itself.
- Not Stripe-aware. For a Stripe Tax replacement, layer opensalestax-stripe-php on top.
- Not a tax-filing service — calculation only. The merchant remits.
- Not a caching layer. Caching is the consumer's job because cache-invalidation policy is platform-specific.
Tax calculations are provided as-is for convenience. The merchant is solely responsible for tax-collection accuracy and remittance to the appropriate jurisdictions. Verify against your state Department of Revenue before remitting.
DCO sign-off (git commit -s) required on every commit. See CONTRIBUTING.md. Apache 2.0 + SPDX header on every source file.