swiftmade/nova-money-field gives Laravel Nova a Money field type that stores amounts as integer minor units and renders them via moneyphp/money, so you stop hand-rolling number_format($cents / 100, 2) in every resource.
Install and pin the money library explicitly — v1.5.0 is the release that added moneyphp/money v4 support, so this is the version you want on a modern stack:
composer require swiftmade/nova-money-field:^1.5 moneyphp/money:^4.0
If you are on PHP 8.0+ and already use moneyphp/money elsewhere in the app, constrain it yourself rather than letting the field's transitive requirement decide for you — that is the single most common source of surprise on this upgrade (see Gotchas).
Look first at:
config/ file (if published) — default currency, locale, and whether the field expects a currency column per row or a fixed currency.Swiftmade\NovaMoneyField\Money) — read the resolve/fill methods to confirm exactly what hits the database, because that determines whether your casts and accessors agree with it.First use case — a single-currency price column:
use Swiftmade\NovaMoneyField\Money;
Money::make('Price')
->currency('USD')
->locale('en_US'),
and on the model:
protected $casts = [
'price' => 'integer', // minor units
];
Treat the database column as the source of truth in minor units. The field is a presentation/input layer. Keep the column as bigInteger (or unsignedBigInteger for non-negative amounts) and resist the temptation to add a decimal column "just for reporting" — dual representations drift.
Single-currency vs per-row currency. Two distinct patterns:
// Fixed currency — simplest, and the one to default to
Money::make('Total')->currency('USD')
// Per-row currency — requires a second column the field can read
Money::make('Total')->currency(fn ($resource) => $resource->currency_code)
The per-row form only works if every row actually has a valid ISO code. Add a fallback in the closure rather than letting a null blow up the index view.
Pair the field with a Money cast on the model so non-Nova code (jobs, mailers, API resources) speaks the same language:
// app/Casts/MoneyCast.php - a thin wrapper around moneyphp/money
public function get($model, $key, $value, $attributes)
{
return new \Money\Money(
(string) $value,
new \Money\Currency($attributes['currency_code'] ?? 'USD')
);
}
public function set($model, $key, $value, $attributes)
{
if (! $value instanceof \Money\Money) {
throw new \InvalidArgumentException('Expected a Money instance.');
}
return [(string) $value->getAmount()]; // minor units, as string
}
Keeping the cast in your own codebase (rather than relying on the package) means upgrading Nova or the field doesn't ripple through your domain layer.
Sorting and filtering. Nova sorts on the underlying column. Because you stored integers, sortable() just works and sorts numerically. If you ever store formatted strings, sorting silently becomes lexicographic ("9.99" after "10.00") — a classic and hard-to-spot bug.
Validation belongs on the field, not the model only. Nova's rules() runs against the submitted major-unit value:
Money::make('Price')
->currency('USD')
->rules('required', 'numeric', 'min:0.01', 'max:99999.99'),
Also set ->step(0.01) on the input so the browser doesn't reject decimals on submit — without it, type="number" defaults to integer steps in some browsers and the form refuses 12.50.
Index vs detail presentation. Use the field's formatter hook for dense index columns (drop the decimals, or use a compact notation) while keeping full precision on detail:
Money::make('Revenue')
->currency('USD')
->displayUsing(fn ($value) => $value->formatTo('en_US'))
->onlyOnDetail(),
Multi-currency aggregates. For dashboards showing totals across currencies, never sum the minor-unit columns — they're not commensurate. Aggregate per currency and render one field per currency, or convert at a stored FX rate first.
The v1.5.0 upgrade is a transitive-constraint change, and that is where the risk sits. The Nova field's own API is unchanged, but the release widens the moneyphp/money requirement to include v4. If your composer.json said moneyphp/money:^3.0, you are fine — Composer will keep v3. If you left it unpinned, composer update can now pull v4 and your own Money-touching code is what breaks, not the field. Pin the major version explicitly in require before upgrading.
moneyphp/money v4 is a major for a reason. Minimum PHP is 8.0+, and the library tightened types and removed/renamed several v3 helpers. Practical fallout:
getAmount() returns a string, not an int. $money->getAmount() === 100 is always false. Use ->equals() or compare strings/cast deliberately.==) on amount strings bite you ("100" == "1e2" is true in PHP 8). Use ->equals() or ->compareTo().Money::stringToUnits, Money::unitsToDecimal) are gone — replace with a DecimalMoneyParser.PHP version gate. v4 needs PHP 8.0+. If a CI matrix leg or a legacy service still runs 7.4, Composer will quietly resolve money back to v3 while you're on field v1.5.0 — meaning dev and prod can end up on different Money majors. Assert the version in CI (composer show moneyphp/money) rather than assuming.
Currency defaulting is implicit and easy to forget. A missing/null currency column produces a Money object with an empty or fallback currency, which formats as $0.00 with no symbol or throws deep inside the formatter. Validate the currency attribute in the model, not just the amount.
Don't round in the view layer. The field formats; it does not fix bad data. If 1.005 was stored as 100 because someone rounded a float on the way in, no amount of formatter config recovers the cent. Do conversion once, at the boundary, using DecimalMoneyParser — never (int) ($value * 100).
Testing Nova resources needs the Nova test harness. Field-level tests won't render without Nova::actingAs($user) and the Nova testing traits; if the field returns an unexpected raw value, log $field->resolve($model) directly before assuming the field is broken — usually the mismatch is between the field's expected column and your cast.
Extension points worth knowing. The formatter/display callback and the currency resolver closure are the two supported seams. Anything beyond that (custom input masks, multi-currency switchers) means subclassing the field — check the parent's component() name before you do, since Nova resolves the Vue component by that string and a missing build step produces a blank field rather than an error.
Debugging checklist when the field shows the wrong number: (1) is the column integer minor units, (2) does the cast agree, (3) is the currency resolver returning what you think for that row, (4) is the locale using the separator convention you expect (de_DE renders 1.234,56).
How can I help you explore Laravel packages today?