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.
| Dimension | Stored as | Column type |
|---|---|---|
| Money | integer cents | integer (e.g. price_amount_cents) |
| Temperature | Celsius | decimal / float |
| Speed | km/h | decimal / float |
| Distance | metres | decimal / float |
| Weight | kilograms | decimal / float |
| Snow depth | centimetres | decimal / float |
| Precipitation | millimetres | decimal / float |
| Timestamps | UTC | timestamp / 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.
| Key | Allowed values |
|---|---|
format_region | de at ch us ca gb (plus whatever modules register) |
currency_code | ISO 4217, e.g. EUR USD CAD GBP CHF |
currency_position | before after |
number_format | dot_comma (1.234,56) · comma_dot (1,234.56) · apostrophe_dot (1’234.56) |
date_format | dmy_dot (06.08.2026) · dmy_slash (06/08/2026) · mdy_slash (08/06/2026) · iso (2026-08-06) |
time_format | 24h 12h |
unit_distance | metric imperial |
unit_temperature | celsius fahrenheit |
unit_weight | metric 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:
| Input | metric | imperial |
|---|---|---|
distance(850.0) | 850 m | 2,789 ft |
distance(2000.0) | 2,0 km | 1.2 mi |
weight(250.0) | 250 kg | 551 lb |
weight(1500.0) | 1,5 t | 1.7 tn |
snowDepth(12.0) | 12,0 cm | 4.7 in |
precipitation(1.2) | 1,2 mm | 0.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-din an<input type="date">andY-m-d\TH:iin adatetime-localvalue — the browser parses these, not a humanY-m-d_Hisin generated filenames — a slash frommdy_slashwould 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 theFormatter - No null-ternaries around them, and no
setTimezone()before them - Every operator-typed number goes through
parseDecimal()orparseMoneyToCents() - No
€,km/h,°C,d.m.YorH:iliterals anywhere in the module - Machine formats left alone