Bekzod Erkinov
Listen to Article
Loading...Table of contents · 6 sections
Prevent Overselling in Laravel: Inventory Reservations, Shipping APIs and Tax Rounding (E-Commerce Part 4)
if ($stock > 0) { $stock--; } sells the last T-shirt to two customers at once. This post builds stock reservations that survive a 50-process race against MySQL, shipping and tax providers behind fakeable interfaces, and integer tax rounding.
$ php -v
PHP 8.4.26 (cli) (built: Oct 6 2026 01:25:39) (NTS)
$ php artisan --version
Laravel Framework 12.69.3
$ mysqld --version
/usr/sbin/mysqld Ver 8.4.11 for Linux on x86_64 (MySQL Community Server - GPL)
Set up Laravel 12 with MySQL and pcntl
The race command forks, so you need pcntl as well as pdo_mysql:
FROM php:8.4-cli
RUN apt-get update -qq && apt-get install -y -qq git unzip libzip-dev && docker-php-ext-install pdo_mysql pcntl zip
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
docker network create p4net
docker run -d --name p4mysql --network p4net -e MYSQL_ROOT_PASSWORD=secret -e MYSQL_DATABASE=shop mysql:8.4
docker build -t p4-php .
docker run --rm --network p4net -v "$PWD:/work" -w /work p4-php composer create-project laravel/laravel shop "12.*"
Point .env at p4mysql (root/secret, database shop), create shop_test with docker exec p4mysql mysql -uroot -psecret -e "CREATE DATABASE shop_test", and give phpunit.xml the same credentials with DB_CONNECTION=mysql and DB_DATABASE=shop_test. SQLite won't do: the locking under test is MySQL's. Run every php artisan command below in that container (docker run --rm --network p4net -v "$PWD/shop:/work" -w /work p4-php ...).
Laravel inventory management: stock reservations that prevent overselling
Each SKU has on_hand (units on the shelf) and reserved (units promised to open carts). Available stock is the difference.
Schema::create('stock_levels', function (Blueprint $t) {
$t->id();
$t->string('sku')->unique();
$t->unsignedInteger('on_hand')->default(0);
$t->unsignedInteger('reserved')->default(0);
$t->timestamps();
});
DB::statement('ALTER TABLE stock_levels ADD CONSTRAINT reserved_lte_on_hand CHECK (reserved <= on_hand)');
Schema::create('reservations', function (Blueprint $t) {
$t->id();
$t->string('sku');
$t->unsignedInteger('qty');
$t->string('status', 12)->default('held'); // held | committed | released
$t->timestamp('expires_at');
$t->timestamps();
$t->index(['status', 'expires_at']);
});
MySQL only enforces CHECK since 8.0.16 (the release notes say earlier versions "parsed and ignored it"). It is a backstop against a buggy writer, not the fix. The fix is reserve(): one conditional UPDATE that checks and writes in a single statement. All timestamps come from one PHP now(), so they can't disagree when database and app timezones differ.
namespace App\Inventory;
class InsufficientStock extends \RuntimeException {}
/** Payment succeeded but the hold had expired and the stock is gone: refund or backorder. */
class HoldLost extends \RuntimeException {}
namespace App\Inventory;
use Illuminate\Support\Facades\DB;
class StockReserver
{
/** Hold $qty units for $ttlSeconds. Returns the reservation id. */
public function reserve(string $sku, int $qty, int $ttlSeconds = 900): int
{
return DB::transaction(function () use ($sku, $qty, $ttlSeconds) {
$now = now(); // one clock for every timestamp below
$held = DB::update(
'UPDATE stock_levels SET reserved = reserved + ?, updated_at = ?
WHERE sku = ? AND on_hand - reserved >= ?',
[$qty, $now, $sku, $qty]
);
if ($held === 0) {
throw new InsufficientStock("Not enough {$sku} for {$qty}");
}
return DB::table('reservations')->insertGetId([
'sku' => $sku, 'qty' => $qty, 'status' => 'held',
'expires_at' => $now->copy()->addSeconds($ttlSeconds),
'created_at' => $now, 'updated_at' => $now,
]);
});
}
/**
* All-or-nothing hold for a whole cart. SKUs are locked in sorted order so two carts
* containing the same SKUs can't wait on each other; attempts: 3 retries the rare deadlock
* InnoDB still reports (SQLSTATE 40001).
*
* @param array<string,int> $qtyBySku
* @return array<string,int> reservation id per SKU
*/
public function reserveCart(array $qtyBySku, int $ttlSeconds = 900): array
{
ksort($qtyBySku);
return DB::transaction(function () use ($qtyBySku, $ttlSeconds) {
$ids = [];
foreach ($qtyBySku as $sku => $qty) {
$ids[$sku] = $this->reserve($sku, $qty, $ttlSeconds); // nested: becomes a savepoint
}
return $ids;
}, attempts: 3);
}
// commit(), release(), releaseExpired(): next section
}
Why this is safe under InnoDB. An UPDATE ... WHERE sets an exclusive next-key lock on every record the search encounters, held until commit, and in REPEATABLE READ an UPDATE sees rows another transaction just committed, not its snapshot. So a second worker waits for the first to commit, then tests the condition against the committed row.
The race: naive vs lockForUpdate vs atomic UPDATE
This command forks N processes that wait for a shared start time, then each reserves one unit. lock does a lockForUpdate() read inside a transaction; atomic calls reserve().
namespace App\Console\Commands;
use App\Inventory\InsufficientStock;
use App\Inventory\StockReserver;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class ShopRace extends Command
{
protected $signature = 'shop:race {strategy : naive|lock|atomic} {--workers=50} {--stock=10} {--late-connect}';
public function handle(): int
{
$strategy = $this->argument('strategy');
$workers = (int) $this->option('workers');
$stock = (int) $this->option('stock');
$idFile = tempnam(sys_get_temp_dir(), 'conn');
DB::table('reservations')->delete();
DB::table('stock_levels')->delete();
DB::table('stock_levels')->insert(['sku' => 'TSHIRT-M', 'on_hand' => $stock, 'reserved' => 0,
'created_at' => now(), 'updated_at' => now()]);
DB::purge(); // children must not inherit this socket: each opens its own below
$startAt = microtime(true) + 3.0; // barrier so all children fire together
$pids = [];
for ($i = 0; $i < $workers; $i++) {
$pid = pcntl_fork();
if ($pid === 0) {
if (! $this->option('late-connect')) {
// connect before the barrier, so setup cost is not in the timed part
file_put_contents($idFile, DB::scalar('SELECT CONNECTION_ID()') . "\n", FILE_APPEND | LOCK_EX);
}
while (microtime(true) < $startAt) {
usleep(100);
}
exit($this->attempt($strategy)); // 0 = told "reserved", 1 = rejected
}
$pids[] = $pid;
}
$ok = $fail = 0;
foreach ($pids as $pid) {
pcntl_waitpid($pid, $status);
pcntl_wexitstatus($status) === 0 ? $ok++ : $fail++;
}
$ms = round((microtime(true) - $startAt) * 1000);
$conns = count(array_unique(array_filter(explode("\n", file_get_contents($idFile)))));
unlink($idFile);
$row = DB::table('stock_levels')->first();
$this->line(sprintf('%-6s workers=%d stock=%d succeeded=%d rejected=%d counter=%d %s connections=%d (%d ms after barrier%s)',
$strategy, $workers, $stock, $ok, $fail, $row->reserved,
$ok > $stock ? 'OVERSOLD by ' . ($ok - $stock) : 'ok',
$conns, $ms, $this->option('late-connect') ? ', connecting after it' : ''));
return 0;
}
private function attempt(string $strategy): int
{
try {
if ($strategy === 'naive') {
$row = DB::table('stock_levels')->where('sku', 'TSHIRT-M')->first();
if ($row->on_hand - $row->reserved < 1) {
return 1;
}
DB::table('stock_levels')->where('sku', 'TSHIRT-M')->update(['reserved' => $row->reserved + 1]);
} elseif ($strategy === 'lock') {
DB::transaction(function () {
$row = DB::table('stock_levels')->where('sku', 'TSHIRT-M')->lockForUpdate()->first();
if ($row->on_hand - $row->reserved < 1) {
throw new InsufficientStock('sold out');
}
DB::table('stock_levels')->where('sku', 'TSHIRT-M')->update(['reserved' => $row->reserved + 1]);
});
} else {
app(StockReserver::class)->reserve('TSHIRT-M', 1);
}
return 0;
} catch (\Throwable $e) {
return 1;
}
}
}
Each child has its own connection. DB::purge() drops the parent's socket before forking, so every child opens a fresh one; connections= counts distinct CONNECTION_ID() values. The verdict counts callers told yes, not the counter, and the trailing figure is wall time from the barrier until the last worker exited, not per-request latency. Output of php artisan shop:race <strategy> --workers=50 --stock=10 (naive run three times, since its result is a race and varies):
naive workers=50 stock=10 succeeded=50 rejected=0 counter=2 OVERSOLD by 40 connections=50 (624 ms after barrier)
naive workers=50 stock=10 succeeded=50 rejected=0 counter=1 OVERSOLD by 40 connections=50 (490 ms after barrier)
naive workers=50 stock=10 succeeded=50 rejected=0 counter=2 OVERSOLD by 40 connections=50 (696 ms after barrier)
lock workers=50 stock=10 succeeded=10 rejected=40 counter=10 ok connections=50 (2168 ms after barrier)
atomic workers=50 stock=10 succeeded=10 rejected=40 counter=10 ok connections=50 (2150 ms after barrier)
The naive version also loses count. All 50 workers were told "reserved", yet the counter ended at 1 or 2: most read the same reserved and wrote the same reserved + 1. So the CHECK constraint can't help: the stored number never exceeds 10 while you've promised 50 shirts.
Lock or atomic: what I measured
An earlier revision reported about 11 s for lock and atomic. I could not reproduce that. With connections opened before the barrier both take about 2.2 s; the --late-connect flag opens them after it, to test whether setup was the cause:
lock workers=50 stock=10 succeeded=10 rejected=40 counter=10 ok connections=0 (2094 ms after barrier, connecting after it)
atomic workers=50 stock=10 succeeded=10 rejected=40 counter=10 ok connections=0 (2249 ms after barrier, connecting after it)
(connections=0 is expected: that mode skips the early probe.) Connecting late changed nothing measurable. These are single runs on Docker Desktop for Windows, and the strategies are indistinguishable at this size. I chose atomic for design: one statement to reason about. Use lockForUpdate() when the decision spans several rows or non-SQL logic, such as a bundle's components.
Reserve, commit, release, expire
Checkout start calls reserve(), payment commit(), failure release(), and a job every minute releaseExpired(). State changes lock the reservation row so a retried webhook can't apply twice:
/** Payment succeeded: the held units leave the shelf. */
public function commit(int $reservationId): void
{
DB::transaction(function () use ($reservationId) {
$r = DB::table('reservations')->where('id', $reservationId)->lockForUpdate()->first();
if (! $r || $r->status === 'committed') {
return; // idempotent: webhooks get retried
}
if ($r->status === 'released') {
// The sweeper beat the payment. Try to take the units back with
// the same conditional UPDATE; if someone else got them, say so.
$retaken = DB::update(
'UPDATE stock_levels SET reserved = reserved + ? WHERE sku = ? AND on_hand - reserved >= ?',
[$r->qty, $r->sku, $r->qty]
);
if ($retaken === 0) {
throw new HoldLost("Reservation {$r->id} expired and {$r->sku} is gone");
}
}
DB::update(
'UPDATE stock_levels SET on_hand = on_hand - ?, reserved = reserved - ? WHERE sku = ?',
[$r->qty, $r->qty, $r->sku]
);
DB::table('reservations')->where('id', $r->id)->update(['status' => 'committed', 'updated_at' => now()]);
});
}
/** Release one reservation (payment failed / cart abandoned). */
public function release(int $reservationId): void
{
DB::transaction(function () use ($reservationId) {
$r = DB::table('reservations')->where('id', $reservationId)->lockForUpdate()->first();
if (! $r || $r->status !== 'held') {
return;
}
DB::update('UPDATE stock_levels SET reserved = reserved - ? WHERE sku = ?', [$r->qty, $r->sku]);
DB::table('reservations')->where('id', $r->id)->update(['status' => 'released', 'updated_at' => now()]);
});
}
/** Scheduler entry point: free every hold past its expiry. Returns count. */
public function releaseExpired(): int
{
$ids = DB::table('reservations')
->where('status', 'held')->where('expires_at', '<', now())
->pluck('id');
foreach ($ids as $id) {
$this->release($id);
}
return $ids->count();
}
}
reserveCart() sorts SKUs because two carts locking the same SKUs in opposite order deadlock, and one transaction rolls back the whole cart if any item fails. releaseExpired() finds candidates without a lock; release() re-reads each under a row lock and skips anything no longer held.
When the scheduler lags and the payment still succeeds
A stalled scheduler just hides stock longer, which costs sales but is safe. The dangerous case is the sweeper releasing a hold at minute 15 and the payment webhook landing at minute 16. My first commit() silently returned for any non-held status, leaving a paid order with no stock deducted. Now a late commit() retakes the units if they are free and otherwise throws HoldLost, which your payment handler turns into a refund or a backorder. The tests (tests/Feature/StockReserverTest.php, real MySQL, one SKU with one unit) start with that case:
namespace Tests\Feature;
use App\Inventory\{HoldLost, InsufficientStock, StockReserver};
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class StockReserverTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
DB::table('stock_levels')->insert(['sku' => 'TSHIRT-M', 'on_hand' => 1, 'reserved' => 0,
'created_at' => now(), 'updated_at' => now()]);
}
public function test_payment_after_release_throws_when_stock_was_resold(): void
{
$reserver = new StockReserver();
$first = $reserver->reserve('TSHIRT-M', 1);
$reserver->release($first); // the sweeper expired it
$reserver->reserve('TSHIRT-M', 1); // another customer takes the last unit
$this->expectException(HoldLost::class);
$reserver->commit($first); // the first customer's payment arrives late
}
public function test_cannot_reserve_more_than_available(): void
{
$this->expectException(InsufficientStock::class);
(new StockReserver())->reserve('TSHIRT-M', 2);
}
public function test_commit_moves_units_off_the_shelf_and_is_idempotent(): void
{
$reserver = new StockReserver();
$id = $reserver->reserve('TSHIRT-M', 1);
$reserver->commit($id);
$reserver->commit($id); // retried webhook
$row = DB::table('stock_levels')->first();
$this->assertSame([0, 0], [(int) $row->on_hand, (int) $row->reserved]);
}
public function test_expired_holds_are_released(): void
{
$reserver = new StockReserver();
$id = $reserver->reserve('TSHIRT-M', 1, ttlSeconds: -1);
$this->assertSame(1, $reserver->releaseExpired());
$this->assertSame('released', DB::table('reservations')->find($id)->status);
$this->assertSame(0, (int) DB::table('stock_levels')->value('reserved'));
}
public function test_timestamps_share_one_clock(): void
{
$this->travelTo(now()->startOfSecond());
$id = (new StockReserver())->reserve('TSHIRT-M', 1, 900);
$r = DB::table('reservations')->find($id);
$this->assertSame(900, (int) Carbon::parse($r->created_at)->diffInSeconds($r->expires_at));
$this->assertSame($r->created_at, DB::table('stock_levels')->value('updated_at'));
}
public function test_reserve_cart_is_all_or_nothing(): void
{
DB::table('stock_levels')->insert(['sku' => 'MUG', 'on_hand' => 0, 'reserved' => 0,
'created_at' => now(), 'updated_at' => now()]);
try {
(new StockReserver())->reserveCart(['TSHIRT-M' => 1, 'MUG' => 1]);
$this->fail('expected InsufficientStock');
} catch (InsufficientStock) {
}
$this->assertSame(0, (int) DB::table('stock_levels')->where('sku', 'TSHIRT-M')->value('reserved'));
$this->assertSame(0, DB::table('reservations')->count());
}
public function test_reserve_cart_holds_every_sku(): void
{
DB::table('stock_levels')->insert(['sku' => 'MUG', 'on_hand' => 1, 'reserved' => 0,
'created_at' => now(), 'updated_at' => now()]);
$ids = (new StockReserver())->reserveCart(['TSHIRT-M' => 1, 'MUG' => 1]);
$this->assertSame(['MUG', 'TSHIRT-M'], array_keys($ids));
}
public function test_db_check_constraint_blocks_a_buggy_writer(): void
{
$this->expectException(QueryException::class);
DB::table('stock_levels')->update(['reserved' => 2]); // on_hand is 1
}
public function test_payment_after_release_retakes_stock_when_free(): void
{
$reserver = new StockReserver();
$id = $reserver->reserve('TSHIRT-M', 1);
$reserver->release($id);
$reserver->commit($id);
$this->assertSame(0, (int) DB::table('stock_levels')->value('on_hand'));
}
}
Shipping and tax API integration in Laravel
Shipping rates behind an interface
Value objects in App\Shipping, one file each:
namespace App\Shipping;
final class Address
{
public function __construct(
public readonly string $street1,
public readonly string $city,
public readonly string $state,
public readonly string $zip,
public readonly string $country = 'US',
) {}
}
final class Parcel
{
/** Stored in metric; each provider converts to what its API wants. */
public function __construct(
public readonly int $weightGrams,
public readonly int $lengthMm,
public readonly int $widthMm,
public readonly int $heightMm,
) {}
}
final class Rate
{
public function __construct(
public readonly string $carrier,
public readonly string $service,
public readonly int $amountCents,
public readonly string $currency,
public readonly ?int $deliveryDays,
) {}
}
interface RateProvider
{
/** @return Rate[] cheapest first */
public function quote(Address $from, Address $to, Parcel $parcel): array;
}
EasyPost's parcel docs say "Weights are in OUNCES (OZ) and go to one decimal point", with dimensions in inches, so the provider converts. Creating a shipment returns a rates array whose rate field is a string.
namespace App\Shipping;
use Illuminate\Support\Facades\Http;
final class EasyPostRateProvider implements RateProvider
{
public function __construct(
private readonly string $apiKey,
private readonly string $baseUrl = 'https://api.easypost.com',
) {}
public function quote(Address $from, Address $to, Parcel $parcel): array
{
$response = Http::withBasicAuth($this->apiKey, '')
->timeout(8)
->retry(2, 200, throw: false)
->post($this->baseUrl . '/v2/shipments', ['shipment' => [
'from_address' => $this->address($from),
'to_address' => $this->address($to),
'parcel' => [
'weight' => round($parcel->weightGrams / 28.349523125, 1), // grams -> ounces
'length' => round($parcel->lengthMm / 25.4, 1), // mm -> inches
'width' => round($parcel->widthMm / 25.4, 1),
'height' => round($parcel->heightMm / 25.4, 1),
],
]])
->throw();
$rates = array_map(fn (array $r) => new Rate(
$r['carrier'], $r['service'], $this->toCents($r['rate']),
$r['currency'], $r['delivery_days'] ?? null,
), $response->json('rates', []));
usort($rates, fn (Rate $a, Rate $b) => $a->amountCents <=> $b->amountCents);
return $rates;
}
private function address(Address $a): array
{
return ['street1' => $a->street1, 'city' => $a->city, 'state' => $a->state,
'zip' => $a->zip, 'country' => $a->country];
}
/** "7.58" -> 758 without ever touching a float. */
private function toCents(string $amount): int
{
[$whole, $frac] = array_pad(explode('.', $amount, 2), 2, '0');
return (int) $whole * 100 + (int) str_pad(substr($frac, 0, 2), 2, '0');
}
}
EasyPost: body unverified. I have no EasyPost account, so I could not send a valid request. A made-up key got HTTP 403 {"error":{"code":"APIKEY.INACTIVE",...}}, which shows the host and Basic-auth path are reachable and says nothing about the body, so check it with a free test key. The unit test uses Http::fake() with the documented response shape and asserts the request carried ounces and inches (1000 g is 35.3 oz). It opens tests/Unit/ShippingAndTaxTest.php, whose header has every import this post needs:
namespace Tests\Unit;
use App\Shipping\{Address, EasyPostRateProvider, FakeRateProvider, FallbackRateProvider, Parcel};
use App\Tax\{RateTableTaxCalculator, Rounding, StripeTaxCalculator, TaxLine};
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class ShippingAndTaxTest extends TestCase
{
private function addr(): Address
{
return new Address('920 5th Ave', 'Seattle', 'WA', '98104');
}
public function test_easypost_provider_sends_ounces_and_inches_and_sorts_by_price(): void
{
Http::fake(['api.easypost.com/*' => Http::response(['rates' => [
['carrier' => 'USPS', 'service' => 'Priority', 'rate' => '9.10', 'currency' => 'USD', 'delivery_days' => 2],
['carrier' => 'USPS', 'service' => 'GroundAdvantage', 'rate' => '5.07', 'currency' => 'USD', 'delivery_days' => 4],
]])]);
$rates = (new EasyPostRateProvider('EZTK_test'))
->quote($this->addr(), $this->addr(), new Parcel(1000, 254, 254, 127));
$this->assertSame([507, 910], [$rates[0]->amountCents, $rates[1]->amountCents]);
Http::assertSent(function (Request $r) {
$p = $r['shipment']['parcel'];
return $r->url() === 'https://api.easypost.com/v2/shipments'
&& $p['weight'] === 35.3 && $p['length'] === 10.0 && $p['height'] === 5.0;
});
}
// ...the other tests in this post go here, inside the class.
}
The fake charges $1.00 plus $0.50 per started 500 g:
final class FakeRateProvider implements RateProvider
{
public array $calls = [];
public function quote(Address $from, Address $to, Parcel $parcel): array
{
$this->calls[] = [$from, $to, $parcel];
$cents = 100 + 50 * (int) ceil($parcel->weightGrams / 500);
return [
new Rate('FakeCarrier', 'Ground', $cents, 'USD', 5),
new Rate('FakeCarrier', 'Express', $cents * 2, 'USD', 2),
];
}
}
public function test_fake_provider_prices_by_weight_and_records_calls(): void
{
$fake = new FakeRateProvider();
$rates = $fake->quote($this->addr(), $this->addr(), new Parcel(1000, 100, 100, 100));
$this->assertSame([200, 400], [$rates[0]->amountCents, $rates[1]->amountCents]);
$this->assertCount(1, $fake->calls);
}
Bind one in register() of app/Providers/AppServiceProvider.php (import the App\Shipping classes). Read config(), not env(): once config is cached, env() only returns system-level variables. Add 'shipping_driver' => env('SHIPPING_DRIVER', 'fake') and 'easypost' => ['key' => env('EASYPOST_API_KEY')] to the array returned by config/services.php, then:
$this->app->bind(RateProvider::class, fn () => match (config('services.shipping_driver')) {
'easypost' => new FallbackRateProvider(new EasyPostRateProvider(config('services.easypost.key')), 899),
default => new FakeRateProvider(),
});
If the shipping API is down at checkout
When the API is down, ->throw() raises and checkout fails; FallbackRateProvider reports the exception and quotes a flat rate. The test fakes a connection failure, not a real timeout:
namespace App\Shipping;
use Illuminate\Http\Client\{ConnectionException, RequestException};
final class FallbackRateProvider implements RateProvider
{
public function __construct(private readonly RateProvider $inner, private readonly int $flatCents) {}
public function quote(Address $from, Address $to, Parcel $parcel): array
{
try {
return $this->inner->quote($from, $to, $parcel);
} catch (RequestException|ConnectionException $e) {
report($e);
return [new Rate('Flat rate', 'Standard', $this->flatCents, 'USD', null)];
}
}
}
public function test_fallback_quotes_flat_rate_when_the_provider_is_down(): void
{
Http::fake(fn () => throw new ConnectionException('cURL error 28: timed out'));
$rates = (new FallbackRateProvider(new EasyPostRateProvider('EZTK_test'), 899))
->quote($this->addr(), $this->addr(), new Parcel(1000, 254, 254, 127));
$this->assertSame([899], array_map(fn ($r) => $r->amountCents, $rates));
}
Tax calculation behind an interface
namespace App\Tax;
use App\Shipping\Address;
final class TaxLine
{
public function __construct(
public readonly string $reference,
public readonly int $amountCents,
public readonly string $taxCode = 'txcd_99999999', // set the Stripe tax code for your product
) {}
}
final class TaxResult
{
public function __construct(
public readonly int $taxCents,
public readonly int $totalCents,
public readonly string $source,
) {}
}
interface TaxCalculator
{
/**
* @param TaxLine[] $lines amounts in minor units, already reduced by any discount
* @param int $shippingCents tax-exclusive shipping charge
*/
public function calculate(array $lines, Address $to, int $shippingCents = 0): TaxResult;
}
RateTableTaxCalculator looks up a basis-point rate by state and knows nothing about taxability, local rates or exemptions; use it only for one jurisdiction where you're registered and all products tax alike. Otherwise use a service. StripeTaxCalculator posts form-encoded to /v1/tax/calculations and reads amount_total, tax_amount_exclusive and tax_amount_inclusive. Stripe only calculates tax where you have an active registration, so "0" can mean "not registered".
Cost, per the pricing pages on 2026-10-08: EasyPost lists "Free for up to 3,000 labels (Postage not included; per-label fee for additional labels)" under wallet carriers, without saying whether rate-only requests count, so ask before assuming quotes are free. Stripe Tax pay-as-you-go is "50¢ per transaction" including 10 calculation calls, then "5¢ per calculation API call above 10", so calculate once per cart state, not per keystroke.
namespace App\Tax;
use App\Shipping\Address;
use Illuminate\Support\Facades\Http;
final class StripeTaxCalculator implements TaxCalculator
{
public function __construct(
private readonly string $secretKey,
private readonly string $currency = 'usd',
private readonly string $baseUrl = 'https://api.stripe.com',
) {}
public function calculate(array $lines, Address $to, int $shippingCents = 0): TaxResult
{
$body = [
'currency' => $this->currency,
'customer_details' => [
'address' => [
'line1' => $to->street1, 'city' => $to->city, 'state' => $to->state,
'postal_code' => $to->zip, 'country' => $to->country,
],
'address_source' => 'shipping',
],
'line_items' => array_map(fn (TaxLine $l) => [
'amount' => $l->amountCents,
'reference' => $l->reference,
'tax_code' => $l->taxCode,
], $lines),
];
if ($shippingCents > 0) {
$body['shipping_cost'] = ['amount' => $shippingCents];
}
$res = Http::withBasicAuth($this->secretKey, '')->asForm()->timeout(8)
->post($this->baseUrl . '/v1/tax/calculations', $body)
->throw();
return new TaxResult(
$res->json('tax_amount_exclusive') + $res->json('tax_amount_inclusive'),
$res->json('amount_total'),
'stripe:' . $res->json('id'),
);
}
}
I did not call Stripe's live API. I pointed the calculator at stripe-mock v0.206.0 instead (docker run -d --name p4stripe --network p4net stripe/stripe-mock:v0.206.0 -http-port 12111, then new StripeTaxCalculator('sk_test_123', 'usd', 'http://p4stripe:12111')). Its responses are canned fixtures, but it rejects a broken request:
stripe-mock: tax=148 total=1947 source=stripe:taxcalc_RUzHIvfbdrJN6DS
bad body -> HTTP 400: {"error":{"message":"Request validation error: validator ... failed: object property 'customer_details' validation failed: object property 'address_source' validation failed: value is not i...
The first line is the calculator's real request accepted; the numbers are the mock's fixture, not a tax calculation. The second is a request I broke on purpose (address_source set to nonsense) and got a 400. That shows the field names and form encoding fit Stripe's published spec, not that production accepts them; compare with the Create a Tax Calculation reference. The unit test feeds Stripe's documented example object (tax 154 on a 1499 line plus 300 shipping, total 1953) and checks the parsed numbers and form body:
public function test_stripe_calculator_parses_documented_response(): void
{
Http::fake(['api.stripe.com/*' => Http::response([
'id' => 'taxcalc_1OduxkBUZ691iUZ4iWvpMApI', 'amount_total' => 1953,
'tax_amount_exclusive' => 154, 'tax_amount_inclusive' => 0,
])]);
$res = (new StripeTaxCalculator('sk_test_x'))
->calculate([new TaxLine('Music Streaming Coupon', 1499)], $this->addr(), 300);
$this->assertSame([154, 1953], [$res->taxCents, $res->totalCents]);
Http::assertSent(fn (Request $r) => str_contains($r->body(), 'line_items%5B0%5D%5Bamount%5D=1499')
&& str_contains($r->body(), 'shipping_cost%5Bamount%5D=300'));
}
With the Tax API you subtract discounts from the lines yourself before sending.
Tax rounding rules: half up, per line vs per invoice
Amounts are integer cents, rates basis points (1025 = 10.25%). Stripe rounds half up; integer arithmetic does that without float edge cases:
namespace App\Tax;
final class Rounding
{
/** Tax on a tax-exclusive amount, rounded half up to the cent. */
public static function exclusive(int $amountCents, int $bps): int
{
return intdiv($amountCents * $bps + 5000, 10000);
}
/** Tax portion of a tax-inclusive (gross) amount, rounded half up. */
public static function inclusive(int $grossCents, int $bps): int
{
$den = 10000 + $bps;
return intdiv($grossCents * $bps * 2 + $den, 2 * $den);
}
}
Stripe's docs show 79.20 EUR gross at 19% could round to 66.55 + 12.64 or 66.56 + 12.65, and say Stripe keeps the 79.20 total by reporting 66.55 net and 12.65 tax. Rounding::inclusive(7920, 1900) returns 1265, matching that choice, though it isn't the only defensible one. Negative amounts break it: intdiv truncates toward zero, so exclusive(50, 100) is 1 but exclusive(-50, 100) is 0, and exclusive(-150, 100) is -1, not -2. Compute refunds as original tax minus tax on what is kept. The rate table and tests:
namespace App\Tax;
use App\Shipping\Address;
final class RateTableTaxCalculator implements TaxCalculator
{
/** @param array<string,int> $bpsByState */
public function __construct(
private readonly array $bpsByState,
private readonly string $rounding = 'per_invoice', // per_line | per_invoice
private readonly bool $taxShipping = false,
) {}
public function calculate(array $lines, Address $to, int $shippingCents = 0): TaxResult
{
$bps = $this->bpsByState[$to->state] ?? 0;
$subtotal = array_sum(array_map(fn (TaxLine $l) => $l->amountCents, $lines));
if ($this->rounding === 'per_line') {
$tax = array_sum(array_map(fn (TaxLine $l) => Rounding::exclusive($l->amountCents, $bps), $lines));
$tax += $this->taxShipping ? Rounding::exclusive($shippingCents, $bps) : 0;
} else {
$taxable = $subtotal + ($this->taxShipping ? $shippingCents : 0);
$tax = Rounding::exclusive($taxable, $bps);
}
return new TaxResult($tax, $subtotal + $shippingCents + $tax, 'rate-table:' . $this->rounding);
}
}
public function test_inclusive_extraction_matches_stripe_docs_example(): void
{
$this->assertSame(1265, Rounding::inclusive(7920, 1900));
}
public function test_half_up_not_bankers(): void
{
$this->assertSame(1, Rounding::exclusive(50, 100)); // 0.5 -> 1
$this->assertSame(2, Rounding::exclusive(150, 100)); // 1.5 -> 2
}
public function test_per_line_and_per_invoice_rounding_can_differ_by_a_cent(): void
{
$lines = array_map(fn ($i) => new TaxLine("sku$i", 99), range(1, 10)); // ten $0.99 items
$to = $this->addr(); // Seattle, WA
$perLine = (new RateTableTaxCalculator(['WA' => 1025], 'per_line'))->calculate($lines, $to);
$perInvoice = (new RateTableTaxCalculator(['WA' => 1025], 'per_invoice'))->calculate($lines, $to);
fwrite(STDERR, sprintf("\nten x 0.99 @10.25%%: per_line tax=%d per_invoice tax=%d\n", $perLine->taxCents, $perInvoice->taxCents));
$this->assertNotSame($perLine->taxCents, $perInvoice->taxCents);
}
public function test_negative_amounts_do_not_mirror_positive_ones(): void
{
$this->assertSame(1, Rounding::exclusive(50, 100));
$this->assertSame(0, Rounding::exclusive(-50, 100));
$this->assertSame(2, Rounding::exclusive(150, 100));
$this->assertSame(-1, Rounding::exclusive(-150, 100));
}
public function test_refund_tax_is_original_minus_tax_on_what_is_kept(): void
{
$original = Rounding::exclusive(1050, 1000); // 105
$kept = Rounding::exclusive(1050 - 525, 1000); // 52.5 -> 53
$this->assertSame(52, $original - $kept);
}
public function test_tax_shipping_option_adds_shipping_to_the_taxable_base(): void
{
$lines = [new TaxLine('a', 1000)];
$off = (new RateTableTaxCalculator(['WA' => 1000]))->calculate($lines, $this->addr(), 500);
$on = (new RateTableTaxCalculator(['WA' => 1000], 'per_invoice', true))->calculate($lines, $this->addr(), 500);
$onLine = (new RateTableTaxCalculator(['WA' => 1000], 'per_line', true))->calculate($lines, $this->addr(), 500);
$this->assertSame([100, 1600], [$off->taxCents, $off->totalCents]);
$this->assertSame([150, 1650], [$on->taxCents, $on->totalCents]);
$this->assertSame([150, 1650], [$onLine->taxCents, $onLine->totalCents]);
}
The last test is the rule that changes totals: round per line, or once per invoice? It prints:
ten x 0.99 @10.25%: per_line tax=100 per_invoice tax=101
Each line's 10.1475 cents rounds to 10, so 100; the $9.90 taxed once is 101.475, so 101. Stripe's rounding docs say that for automatic tax it calculates tax across same-rate amounts before rounding the total, then allocates it back to line items. They don't say this for the Tax Calculations API this post calls, so I haven't confirmed it there. per_invoice mirrors only the sum-then-round half. Follow your jurisdiction's invoice rule (ask your accountant); if there is none, use per_invoice. Store the tax you charged on the order rather than recomputing it.
Run the tests
No network or keys needed. Output of php artisan test on the final code (the first line is the rounding test's STDERR print):
ten x 0.99 @10.25%: per_line tax=100 per_invoice tax=101
PASS Tests\Unit\ShippingAndTaxTest
✓ easypost provider sends ounces and inches and sorts by price 5.22s
✓ fake provider prices by weight and records calls 0.28s
✓ fallback quotes flat rate when the provider is down 1.10s
✓ stripe calculator parses documented response 0.26s
✓ inclusive extraction matches stripe docs example 0.24s
✓ half up not bankers 0.28s
✓ negative amounts do not mirror positive ones 0.24s
✓ refund tax is original minus tax on what is kept 0.23s
✓ per line and per invoice rounding can differ by a cent 0.29s
✓ tax shipping option adds shipping to the taxable base 0.24s
PASS Tests\Feature\StockReserverTest
✓ payment after release throws when stock was resold 4.94s
✓ cannot reserve more than available 0.29s
✓ commit moves units off the shelf and is idempotent 0.26s
✓ expired holds are released 0.27s
✓ timestamps share one clock 0.28s
✓ reserve cart is all or nothing 0.25s
✓ reserve cart holds every sku 0.26s
✓ db check constraint blocks a buggy writer 0.26s
✓ payment after release retakes stock when free 0.25s
Tests: 19 passed (32 assertions)
Duration: 17.59s
Forked children can't share a RefreshDatabase transaction, so the race is a separate CI step, race-check.sh:
#!/usr/bin/env bash
set -euo pipefail
out=$(php artisan shop:race atomic --workers=50 --stock=10)
echo "$out"
grep -q 'succeeded=10 rejected=40' <<<"$out" || { echo "race check failed" >&2; exit 1; }
Against the MySQL 8.4 container it exits 0 with atomic and fails with the strategy edited to naive:
atomic workers=50 stock=10 succeeded=10 rejected=40 counter=10 ok connections=50 (2209 ms after barrier)
exit=0
naive workers=50 stock=10 succeeded=50 rejected=0 counter=2 OVERSOLD by 40 connections=50 (525 ms after barrier)
race check failed
exit=1
With the 50-process race wired into CI, a change that reintroduces overselling now fails the build instead of reaching a customer.
Keep reading
Building a Modern SaaS Application with Laravel - Part 1: Multi-Tenancy Architecture & Database Foundations
58 min · 1,087 views
Operating SystemsAdvanced Laravel Tutorial: Queues, Events and Pest Tests
22 min · 276 views
PerformanceComplete Solution: Building a Secure E-commerce Website with Stripe and Laravel
28 min · 232 views
Bekzod Erkinov
AuthorFounder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.
Get the AI-Assisted Developer's Field Guide
The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.
Comments (0)
Please log in to leave a comment.
Log InRelated Articles
Building a REST API with Laravel - Part 1: API Design, Versioning & Sanctum Authentication
May 10, 2026
ChatGPT vs Claude: Real Performance Benchmarks From Production Testing
Apr 22, 2026
Building a Production-Grade E-Commerce Platform with Laravel 12 - Part 2: Product Catalog & Cart with Database Design
May 12, 2026