Skip to main content
WintertraceLive demo
Browse the documentation

Building modules

Formats & units

Currency, numbers, dates, times, temperature, speed, distance and weight follow one installation-wide setting. How a module stores canonical values and prints them through the Formatter.

Since version 1.2.0, currency, number format, date, time, temperature, speed, distance and weight all follow a single installation-wide setting. The operator picks it once under Admin → Settings → Units & Formats, and every screen, report, export and email is expected to obey it.

That expectation includes your module. A module that formats a value itself overrides the operator’s choice, and the result is a page that contradicts itself: an American installation reads Kilogramm on your screen and pounds on every other one.

The rule fits in one sentence: store canonical, format on display.

The canonical units

These are what the database holds — in the core and in your module’s own tables. They never change when a setting changes. Switching an installation from Celsius to Fahrenheit rewrites nothing.

DimensionStored asColumn type
Moneyinteger centsinteger (e.g. price_amount_cents)
TemperatureCelsiusdecimal / float
Speedkm/hdecimal / float
Distancemetresdecimal / float
Weightkilogramsdecimal / float
Snow depthcentimetresdecimal / float
Precipitationmillimetresdecimal / float
TimestampsUTCtimestamp / datetime

Switching a setting is a relabelling, not a conversion. This matters most for money, where there is deliberately no currency conversion: change currency_code from EUR to USD and the stored 12345 still prints as one hundred twenty-three point four five, with a different sign in front of it. An operator who genuinely changes currency has to reprice. Silently multiplying every stored amount by an exchange rate that happened to be true on the day of the switch would be the worse behaviour.

The nine settings

You can read these directly, but prefer the Formatter — it already holds them, and it applies them consistently.

KeyAllowed values
format_regionde at ch us ca gb (plus whatever modules register)
currency_codeISO 4217, e.g. EUR USD CAD GBP CHF
currency_positionbefore after
number_formatdot_comma (1.234,56) · comma_dot (1,234.56) · apostrophe_dot (1’234.56)
date_formatdmy_dot (06.08.2026) · dmy_slash (06/08/2026) · mdy_slash (08/06/2026) · iso (2026-08-06)
time_format24h 12h
unit_distancemetric imperial
unit_temperaturecelsius fahrenheit
unit_weightmetric imperial

Where a value is unset, the de preset’s values apply. A container deployment (Docker, Coolify) never runs the browser installer, so a migration seeds all nine from APP_LOCALE instead.

The units are three independent keys rather than one metric/imperial switch, because real countries are not consistent: Canada uses Canadian dollars but stays metric, and the UK drives in miles while weighing in kilograms.

Speed has no setting of its own — it follows unit_distance. No country measures its roads in miles and drives in km/h, so a separate key would only be a way to end up inconsistent.

The Formatter

Class: App\Services\Format\Formatter, bound as a singleton in AppServiceProvider.

public const PLACEHOLDER = '—';

date(?DateTimeInterface $moment): string
time(?DateTimeInterface $moment, bool $withSeconds = false): string
dateTime(?DateTimeInterface $moment, bool $withSeconds = false): string

number(?float $value, int $decimals = 1): string
money(?int $cents): string
currencySymbol(): string

temperature(?float $celsius): string
speed(?float $kmh): string
distance(?float $metres): string
weight(?float $kilograms): string
snowDepth(?float $centimetres): string
precipitation(?float $millimetres): string

parseDecimal(string $input): ?float        // the other direction — see "Input" below
parseMoneyToCents(string $input): ?int

browserSettings(): array                   // the three values the layout hands to JS

Two habits to unlearn:

Drop your null-ternaries. Every display method accepts null and returns PLACEHOLDER for it. Writing $x ? format_date($x) : '—' duplicates what the service already does, and it duplicates it slightly differently at each call site.

Do not set the timezone yourself. The display timezone is applied inside the Formatter. Calling ->setTimezone(config('app.display_timezone')) before handing a value over applies it twice — a bug that waits patiently until a customer in another timezone reads the report.

One caveat for your test suite: because the Formatter is a container singleton, a test that changes a format setting has to forget the instance before the change takes effect.

Helpers

Thin wrappers in app/helpers.php, available everywhere your module runs — including its Blade views:

format_date($moment)                          format_number($value, $decimals = 1)
format_time($moment, $withSeconds = false)    format_money($cents)
format_datetime($moment, $withSeconds = false)

format_temperature($celsius)   format_speed($kmh)
format_distance($metres)       format_weight($kilograms)
format_snow_depth($centimetres)   format_precipitation($millimetres)
{{-- In a module view --}}
<td>{{ format_datetime($delivery->created_at) }}</td>
<td>{{ format_weight($delivery->grit_kilograms) }}</td>
<td>{{ format_money($delivery->price_cents) }}</td>

The helpers work outside Blade too, so code that generates PDFs, exports or mail can call them directly rather than reaching for number_format().

Scale switching

distance() and weight() pick their scale from the magnitude of the value, so you pass the canonical number and get back something a person would actually say:

Inputmetricimperial
distance(850.0)850 m2,789 ft
distance(2000.0)2,0 km1.2 mi
weight(250.0)250 kg551 lb
weight(1500.0)1,5 t1.7 tn
snowDepth(12.0)12,0 cm4.7 in
precipitation(1.2)1,2 mm0.05 in

The comma in the metric column is not a typo: those examples come from an installation on the de region preset, where the decimal separator is a comma. The imperial column is from a us installation. Number format and unit system are separate settings, and this table happens to vary both at once.

Imperial weight uses short tons (2,000 lb), not the British long ton.

Snow depth and precipitation follow unit_distance — nobody measures the road in miles and the snow lying on it in centimetres. Precipitation keeps two decimals in inches, because a millimetre is 0.04 in and one decimal would round a real rainfall down to 0.0.

Input: the other direction

A form field that accepts a number has to parse it in the operator’s format. 1.234,56 and 1,234.56 are the same amount typed by two different people, and PHP’s own cast is no help at all: (float) '1.234,56' is 1.0.

$cents = app(Formatter::class)->parseMoneyToCents($request->input('price'));

if ($cents === null) {
    // Unparsable. Reject it — never fall back to storing 0.
}

parseDecimal() is strict about the configured separators and does not guess. In a comma-decimal installation, 12,34,56 returns null rather than 123456. Guessing would mean silently reading a typo as a number a hundred times too large, and that number ends up in a contract.

Do not put Laravel’s numeric validation rule on a raw operator-typed number field — numeric rejects 1.234,56. Parse first, then validate the parsed value.

Unit abbreviations live in translations

°C, km/h, lb and their relatives come from lang/{locale}/format.php. They are never literals in code, so that a language pack can localise them.

If your module needs an abbreviation the core does not carry, ship it in your own namespaced translation file rather than inlining the string. See Translations for how namespacing works.

The core enforces this with a test, tests/Feature/FormatDriftGuardTest.php, which fails the build on a hardcoded , km/h or °C, on an mm or cm glued to a printed value or baked into a translation, and on format('d.m.Y…') or a bare format('H:i…') anywhere under app/, resources/views/ or lang/. Consider the same guard for your own module. The class exists because lang/en/customer_object.php shipped Price (€) for several releases and nobody noticed.

What stays untouched

Machine formats are not display decisions. Leave these exactly as they are:

  • Y-m-d in an <input type="date"> and Y-m-d\TH:i in a datetime-local value — the browser parses these, not a human
  • Y-m-d_His in generated filenames — a slash from mdy_slash would become a path separator
  • ISO-8601 in API responses and in the data-protection export, which has to be machine-readable
  • Database columns, JSON payloads, and toISOString() in JavaScript date pickers
  • Latitude and longitude, which are technical coordinates and always dot-decimal

The browser side

The layout hands JavaScript exactly three values, through Formatter::browserSettings():

window.schneespurFormat             // { timeFormat, dateFormat, numberFormat }
window.schneespurFormatTime(date)   // Date → "2:30 PM" / "14:30"
window.schneespurFormatDate(date)   // Date → "08/06/2026" / "06.08.2026"

Both functions take a Date object and refuse anything else. They return the same the server prints, and warn once per page rather than once per call.

Guard your own call site as well, because new Date(null) is the 1st of January 1970 rather than an invalid date:

window.schneespurFormatDate(value ? new Date(value) : null);

Almost nothing belongs on this side. If the server can render it, render it on the server.

RegionRegistry

A language pack can bring its own region template along, so that installing it offers a matching set of format defaults. See RegionRegistry in the registries reference.

Checklist for a module that shows numbers

  • Stores canonically — cents, Celsius, km/h, metres, kilograms, centimetres, millimetres, UTC
  • Every display goes through a format_*() helper or the Formatter
  • No null-ternaries around them, and no setTimezone() before them
  • Every operator-typed number goes through parseDecimal() or parseMoneyToCents()
  • No , km/h, °C, d.m.Y or H:i literals anywhere in the module
  • Machine formats left alone