Search by

ianfoxdev / money-lint

IanFoxDev

PHPStan rules for code that moves money: floats in amounts, HTTP calls inside database transactions, balance updates that skip the ledger, check-then-act without a lock.

Package info

github.com/IanFoxDev/money-lint

Type:phpstan-extension

pkg:composer/ianfoxdev/money-lint

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-10-05 06:26 UTC

This package is auto-updated.

Last update: 2026-11-05 06:43:24 UTC


README

php examples

PHPStan rules for code that moves money.

The bugs that cost money in payment and billing code are rarely clever. They are the same few mistakes in every codebase: a call to the payment provider inside a database transaction, a job queued before the commit, a balance read without a lock and written back, a balance changed without a ledger entry, an amount in a float. A careful reviewer catches them. A linter catches them every time.

Status: v0.1. Until 1.0 a minor version may change rule identifiers or settings; such changes are marked BREAKING in the CHANGELOG.

Install

composer require --dev ianfoxdev/money-lint

With phpstan/extension-installer the rules are on. Without it, add one line to phpstan.neon:

includes:
  - vendor/ianfoxdev/money-lint/extension.neon

PHPStan 2.1.3 or later, PHP 8.3 or later. Laravel and Doctrine are recognised by class names; the package does not depend on either. Works next to Larastan and phpstan-doctrine.

What it reports

DB::transaction(function () use ($order) {
    $order->update(['status' => 'paid']);
    Http::post('https://psp.example/charges', ['amount' => $order->amount]);
    CreditWallet::dispatch($order->id);
});
CheckoutController.php:25:Http::post() runs inside DB::transaction(): it cannot be rolled back with the transaction, and the transaction stays open while it waits. [identifier=money.sideEffectInTransaction]
CheckoutController.php:26:The job CreditWallet is queued inside DB::transaction(): a worker can run it before the commit and miss the data, or after a rollback. [identifier=money.dispatchInTransaction]

Each error links to a page that shows how the bug loses money and how to fix it.

Identifier Reports On by default
money.sideEffectInTransaction An HTTP call, email or broker message inside DB::transaction(), wrapInTransaction() or transactional() yes
money.dispatchInTransaction A Laravel job, queued closure or mail queued inside a transaction without afterCommit() yes
money.checkThenAct Money read without a lock, changed from its old value and saved: a lost update under concurrency once money is marked
money.float Money held in a float: typed float, cast with (float), rounded to decimal places once money is marked
money.balanceOutsideLedger A balance you listed changed outside the ledger: increment(), +=, UPDATE ... SET balance once balances are listed

Tell it what money is

PHPStan sees int $amount and int $quantity as the same type. The money rules check only values you mark, so nothing is marked by default and the first run on an old codebase is quiet. Why: docs/adr/0001-what-counts-as-money.md.

parameters:
  money:
    # property and parameter names, fnmatch() patterns, case-insensitive
    properties: ['*amount', '*balance', '*fee']

    # balances only the ledger may change, and the ledger
    balances:
      - App\Models\Wallet::balance
      - wallets.balance
    balanceWriters:
      - App\Ledger\Ledger

    # set when every queue connection has 'after_commit' => true
    queueAfterCommit: false

Or mark a property or parameter with #[IanFoxDev\MoneyLint\Attribute\Money]. PHP does not load an attribute class unless asked, so production code runs without the package.

Transactions and side effects are lists too. Add your payment provider's SDK or your own transaction helper:

parameters:
  money:
    transactions:
      - App\Database\Transactions::run
    sideEffects:
      - App\Psp\AcmeClient::*

On a large codebase, start from a baseline (vendor/bin/phpstan analyse --generate-baseline) and look at new code first. One line is silenced with // @phpstan-ignore money.float and the like.

How often it is right

On five open-source applications that handle money, money.float found money in floats with 94% precision, and one report in four was in code where it can cost a cent. Both transaction reports were real. The run and what it changed: docs/precision.md.

The rules prefer silence to guessing. A transaction opened in another method, a callback that may run later, a value PHPStan cannot type: none of these are reported.

Examples

examples/ has a Laravel checkout and Symfony refunds with each bug in one file and the same code fixed in another. CI checks that every bug is reported and the fixed code is clean, with Larastan and phpstan-doctrine loaded next to money-lint.

Documentation

Contributing

A false report is the most useful bug report: open an issue with the "False report" template and the smallest code that shows it. See CONTRIBUTING.md.

License

MIT