nsavinov/nova-percent-field
A Laravel Nova field for entering and displaying percentage values. Adds a dedicated percent input with formatting and validation, so percentages store and render cleanly in Nova resources without custom field code.
Install and verify compatibility before writing any resource code.
composer require nsavinov/nova-percent-field
The release date (2018-09-26) is the first thing to deal with. That puts the package in the Nova 1/2 era: Vue 2, Laravel Mix, Nova::script() registering a prebuilt dist/js/field.js. Nova is not distributed via Packagist, so the package's laravel/nova constraint is typically a hard pin against an old major. On a current Nova install the require either fails to resolve or resolves against a stale constraint. If so, fork it, loosen the constraint, and pull it in via a VCS or path repository:
"repositories": [
{ "type": "vcs", "url": "https://github.com/your-org/nova-percent-field" }
]
Read these three things, in this order, before you build on it:
src/ — the field class. Confirm whether it extends Laravel\Nova\Fields\Number (likely) or implements Field directly, and note the $component string. Extending Number means you inherit rules(), nullable(), sortable(), step() and friends for free.boot() — a Nova::script() / Nova::style() call against dist/js/field.js is the Nova 1–3 asset pattern. Confirm your Nova major still honours it; if your app is Vite-built, assume the shipped bundle will not register.resources/js/components/FormField.vue — this is where the actual percent behaviour lives: the % suffix, the scaling (×100 / ÷100), rounding, and null handling. The PHP class is thin; the component is the product.First use case — a discount rate on a resource:
use Nsavinov\PercentField\Percent;
public function fields(Request $request)
{
return [
Percent::make('Discount Rate', 'discount_rate')
->rules('numeric', 'min:0', 'max:100'),
];
}
If the field never appears, the cause is almost always asset registration — look for a 404 on dist/js/field.js in the browser console or a stale nova:publish — not the field class. Check the console before reading PHP.
Before your first save, write down the storage contract. Does 15 in the column mean 15%, or does 0.15 mean 15%? Read the Vue component to see which side of the transform your column lands on, then enforce that decision once in a wrapper field (below) so nobody re-litigates it per resource — or per seeder, factory, API resource, or CSV import.
Wrap it in your own field class. With 21 stars, zero dependents, and no release since 2018, treat this as vendored code you own. One App\Nova\Fields\Percent that sets your standard rules, step, and formatting gives you a single file to patch when you fork and a single file to delete when you replace it:
namespace App\Nova\Fields;
use Nsavinov\PercentField\Percent as BasePercent;
class Percent extends BasePercent
{
public function __construct($name, $attribute = null, $resolveCallback = null)
{
parent::__construct($name, $attribute, $resolveCallback);
$this->step(0.01)
->rules('numeric', 'min:0', 'max:100')
->nullable();
}
}
Then every resource just uses \App\Nova\Fields\Percent::make('VAT Rate').
Do any scaling in fillUsing/resolveUsing, symmetrically. If you store fractions but users type whole percents, convert next to the field that owns the value rather than in a model mutator or accessor:
Percent::make('Rate')
->fillUsing(function ($request, $model, $attribute) {
$value = $request->{$attribute};
$model->{$attribute} = ($value === null || $value === '') ? null : $value / 100;
})
->resolveUsing(fn ($value) => $value === null ? null : $value * 100);
The pair must be exact inverses. An asymmetric pair is the single biggest source of "saved 1500%" bugs. If you write one test for this package, make it round-trip through this pair: fill a request, resolve the model value, assert it equals the input.
Separate display variants from input variants. If you want 15% in the index table but a raw number input on the form, use displayUsing and keep the input untouched:
Percent::make('Discount Rate')
->displayUsing(fn ($value) => $value === null ? '—' : number_format($value, 2).'%')
->onlyOnIndex();
Mirror every client-side constraint into ->rules(). The component's min/max/step are browser hints. API writes, Nova actions, imports, and pasted values bypass them entirely. Duplicate them server-side every time — the field will not do it for you.
Keep multi-field logic out. Percentages rarely stand alone (weights summing to 100, discount vs. margin, tax inclusive vs. exclusive). This field has zero awareness of sibling fields. Validate totals in a Rule on the request or in a Nova action handler, never inside the field.
Normalise at the boundary for bulk paths. Actions, imports, and artisan commands don't render Vue. Whatever convention you picked (whole percent vs. fraction) has to be applied in those paths by hand — a small Percent::toStorage($value) helper on your wrapper keeps them consistent.
When to skip it entirely. For greenfield work, Number::make('Rate')->step(0.01)->min(0)->max(100)->suffix('%')->rules(...) covers the vast majority of cases with zero third-party surface area. Reach for this package only when maintaining an existing install that already depends on it, or when you genuinely want the fraction↔percent convention shipped as-is and are willing to own it.
FormField.vue rewrite, not a drop-in. Verify in staging against your exact Nova major before promising a timeline.parseFloat turns null into 0, silently converting "no discount set" into "0% discount". Check setInitialValue/prepare in the component and add an explicit null guard on both sides of the round trip. ->nullable() on the PHP side does not protect you here.step of 1 blocks 12.5 at the browser level with no server-side error to explain it — users just can't type the value. Set ->step(0.01) deliberately. For float comparisons in tests, use rounded comparison or bccomp; or store basis points as integers and sidestep the problem.12,5. The 2018-era component almost certainly assumes a dot and may truncate silently. Normalise server-side or document the limitation for internal users.sortable() sorts the raw column while the header shows a transformed value. Monotonic transforms are usually harmless — confirm it before someone files "wrong ordering."php artisan nova:publish, hard-refresh, and check the browser console for an asset 404. Nova::script() registrations also fail silently if two fields reuse the same key — a real hazard if you have forked a copy and left the original installed alongside it.FormField.vue, keep the build step (or a checked-in dist/) reproducible, and pin the VCS reference to a tag — otherwise every composer update on a branch ref becomes a surprise.fillUsing / resolveUsing / displayUsing. Do not expect the package to grow features. Plan the fork or the replacement now, ideally before the next Nova major upgrade forces it on you.How can I help you explore Laravel packages today?