nvl / primitives
Immutable application value objects, exact money, validation, and ISO reference catalogs for Laravel
Requires
- php: ^8.3
- ext-ctype: *
- ext-filter: *
- ext-json: *
- ext-mbstring: *
- brick/math: ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18
- brick/money: ^0.11 || ^0.14
- giggsey/libphonenumber-for-php: ^9.0
- jschaedl/iban-validation: ^2.7
- laravel/framework: ^13.0
- nesbot/carbon: ^2.72 || ^3.0
- nvl/core: ^2.0
- spatie/laravel-data: ^4.23
- spatie/typescript-transformer: ^3.3
- symfony/intl: ^7.0 || ^8.0
- symfony/polyfill-intl-icu: ^1.33
Requires (Dev)
- larastan/larastan: ^3.10
- laravel/pint: ^1.27
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
Suggests
- ext-intl: Enables locale-aware monetary formatting through Brick Money.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-25 14:14:13 UTC
README
Quick reference
| Item | Value |
|---|---|
| Installed through | composer require nvl/primitives:^2.0 |
| Module identifier | nvl/primitives |
| PHP namespace | Nvl\Primitives |
| Service provider | Nvl\Primitives\Providers\PrimitivesServiceProvider |
| Configuration | config/primitives.php |
Immutable application value objects, exact money, Eloquent casts, validation rules, currency conversion, and standards-backed reference catalogs for Laravel 13.
Purpose and boundaries
Primitives replaces the reusable parts of a traditional application “Core” module without becoming a second application framework. It owns:
- immutable value objects and canonical serialization;
- Eloquent casts for scalar, JSON, and monetary values;
- exact money arithmetic and an exchange-rate provider boundary;
- international phone and IBAN parsing backed by maintained registries;
- current ISO country, currency, and language catalogs;
- application locale, city, and bank option boundaries;
- Spatie Data DTOs and generated TypeScript contracts.
It does not own users, settings, authorization, controllers, application routes, UI components, live exchange-rate HTTP clients, or a global cities/banks database.
Requirements and installation
- PHP 8.4+
- Laravel 13
ext-mbstring,ext-json, andext-ctype- optional
ext-intlfor locale-aware money formatting
The package uses Brick Money/Math, Google's numbering metadata through giggsey/libphonenumber-for-php, the SWIFT-derived IBAN registry through jschaedl/iban-validation, and Symfony Intl data.
composer require nvl/primitives:^2.0 php artisan vendor:publish --tag=primitives-config php artisan vendor:publish --tag=primitives-translations php artisan vendor:publish --tag=primitives-skills
The package has no migrations and works without a database.
The translation tag publishes validation overrides to
lang/vendor/primitives while package defaults continue to load directly.
Value objects
| Value object | Canonical storage | Important behavior |
|---|---|---|
EmailAddress |
validated address string | normalizes the domain and masks for display |
Url |
absolute HTTP(S) string | normalizes scheme, host, and default ports; rejects credentials |
CountryCode |
ISO alpha-2 uppercase string | validates current country data |
CurrencyCode |
known ISO 4217 uppercase string | name, symbol, and fraction digits |
LocaleCode |
normalized BCP 47 core tag | enforces language, script, region, then variants |
PhoneNumber |
E.164 string | international parsing and formatting |
Iban |
electronic IBAN string | country format/checksum and masking |
DateTimeValue |
Y-m-d\TH:i:s.u\Z |
strict RFC 3339 input and canonical UTC output |
Percentage |
exact decimal ratio | exact percentage calculations |
Weight |
exact grams | kilograms, pounds, and ounces |
Length |
JSON metres | explicit scale and rounding for unit conversion |
Coordinates |
JSON string coordinates | WGS84 validation and distance |
PostalAddress |
JSON object | international components with an optional postal code |
Money |
{"minor":"…","currency":"…"} |
exact arithmetic and explicit conversion rounding |
Every primitive is immutable, JSON serializable, stringable, and type-safe for equality.
Eloquent casts
Declare the value-object class directly:
protected function casts(): array { return [ 'email' => EmailAddress::class, 'phone' => PhoneNumber::class, 'coordinates' => Coordinates::class, 'price' => Money::class, ]; }
Scalar primitives use one string/decimal column. Coordinates, PostalAddress, and default Money use JSON. Null remains null; malformed persisted values fail during hydration.
Money storage
Default JSON contains only integer minor units and currency:
{"minor":"1999","currency":"EUR"}
For an explicitly fixed-currency schema:
protected function casts(): array { return [ 'price_minor' => Money::class.':minor,EUR', 'price_decimal' => Money::class.':decimal,EUR', ]; }
Fixed modes accept a Money instance or a scalar representing that column's minor/decimal value. The cast rejects a different currency. An explicit Money::class.':json,EUR' currency is also enforced on both assignment and hydration. For variable-currency relational schemas, keep amount and currency in separate application-owned columns and construct Money at the model boundary.
SQLite hydrates DECIMAL columns as floating-point values. Decimal-mode casts normalize only those database-hydrated floats to the currency's fraction digits; assigning a float remains invalid. Prefer JSON or fixed-currency minor-unit storage when amounts may exceed SQLite's exact numeric range.
Exact money
Construct with decimal strings or integer minor units—never floats:
use Brick\Math\RoundingMode; use Nvl\Primitives\Services\MoneyFormatter; use Nvl\Primitives\ValueObjects\Money; $price = Money::of('19.99', 'EUR'); $shipping = Money::minor(500, 'EUR'); $total = $price->add($shipping); // EUR 24.99 $triple = $price->multiply(3); // EUR 59.97 $share = $price->divide(3, RoundingMode::HalfUp); $total->amount(); // "24.99" $total->minorAmount(); // "2499" $total->currency(); // CurrencyCode("EUR") $formatter->format($total, 'de-DE'); // locale-aware with ext-intl
Currency is always explicit. An unrepresentable amount or operation throws unless a rounding mode is explicit. Arithmetic across currencies fails rather than converting implicitly. MoneyFormatter uses its injected configuration repository and falls back deterministically to amount currency when native ext-intl is unavailable.
Currency conversion
Configure deterministic rates:
'exchange_rates' => [ 'implementation' => ConfiguredExchangeRateProvider::class, 'rates' => [ 'EUR/USD' => '1.10', 'EUR/BGN' => '1.95583', ], ],
$usd = $converter->convert( money: Money::of('100.00', 'EUR'), target: 'USD', roundingMode: RoundingMode::HalfUp, );
Rates mean “target major units for one source major unit.” Every direction must be configured explicitly; inverse rates are never inferred. Rate values must be positive decimal strings, not floats or booleans. Optional as_of and max_age_seconds metadata must be supplied together; future, stale, malformed, and missing rates fail explicitly.
For production rates, implement and bind ExchangeRateProvider. It may use a database/API but must return an auditable decimal string and respect the optional effective date. Network calls do not belong in Money or CurrencyConverter.
Phones and IBANs
$phone = PhoneNumber::fromRegion('044 668 18 00', 'CH'); (string) $phone; // +41446681800 $phone->international(); // +41 44 668 18 00 $phone->national(); // 044 668 18 00 $phone->rfc3966(); // tel:+41-44-668-18-00 $phone->region(); // CountryCode("CH") $iban = Iban::from('DE89 3704 0044 0532 0130 00'); $iban->storageValue(); // DE89370400440532013000 $iban->formatted(); $iban->masked(); $iban->country(); // CountryCode("DE")
National phone input requires an explicit region or primitives.phone.default_region. Prefer an explicit request-specific region.
Locale and ISO codes
$country = CountryCode::from('bg'); // BG $currency = CurrencyCode::from('eur'); // EUR $locale = LocaleCode::from('zh_hans_cn'); // zh-Hans-CN $country->name('en'); $currency->symbol('de'); $currency->fractionDigits(); $locale->language(); $locale->script(); $locale->regionCode(); // alpha country or numeric UN M49 region $locale->region();
Locale tags support a language followed by an optional script, optional alpha/numeric region, and valid variant subtags. Extensions and private-use subtags are intentionally outside the package contract. Application-supported locales live under primitives.locales.supported.
Exact quantities and structured values
$discount = Percentage::fromPercent(20); $discount->decimal(); // "0.2" $discount->of('125.00'); // "25" $discount->ofRounded( '125.00', 2, RoundingMode::HalfUp, ); // "25.00" $weight = Weight::kilograms('1.5'); $weight->inGrams(); // "1500.000" $weight->inOunces(); // "52.911" $length = Length::from('12', 'in'); $length->in('cm', 2, RoundingMode::Unnecessary); // "30.48" $sofia = Coordinates::from('42.6977', '23.3219'); $plovdiv = Coordinates::from('42.1354', '24.7453'); $sofia->distanceTo($plovdiv); // kilometres $sofia->googleMapsUrl();
PostalAddress keeps components separate for country-specific presentation and permits countries without postal codes. DateTimeValue accepts only timezone-qualified RFC 3339 input with valid offset hours and minutes, preserves microseconds, and stores one canonical UTC value; convert only at display boundaries. Coordinate arrays accept numeric strings, integers, or floats and reject booleans. Email normalization preserves the complete local part, including quoted @ characters, and lowercases only the domain.
Laravel validation
'email' => [ 'required', new ValidPrimitive(EmailAddress::class, 'email address'), ], 'iban' => [ 'nullable', new ValidPrimitive(Iban::class, 'IBAN'), ], 'phone' => [ 'required', new ValidPhoneNumber('BG'), ],
Validation messages ship in English and Bulgarian and use Laravel's translation pipeline. Request validation improves messages, but domain code should still construct the primitive before persistence.
Reference catalogs
ReferenceCatalog returns ReferenceOption DTOs:
$countries = $catalog->countries('Bulg', 'en', 20); $currencies = $catalog->currencies('Euro', 'en'); $languages = $catalog->languages('Bulgarian', 'en'); $locales = $catalog->locales('bg', 'en');
Countries, currencies, and languages come from installed Symfony Intl standards data. Locale labels include script and region qualifiers so supported variants remain distinguishable. Cities and banks are deployment-specific, so configure small application lists:
'reference' => [ 'cities' => [ 'sofia-bg' => [ 'label' => 'Sofia', 'country' => 'BG', 'region' => 'Sofia City', ], ], 'banks' => [ 'UNCRBGSF' => [ 'label' => 'UniCredit Bulbank', 'country' => 'BG', ], ], ],
Malformed catalog entries and limits outside 1..250 fail immediately. Large or dynamic datasets should use an application service/repository and the same ReferenceOption response shape. Primitives intentionally avoids mandatory reference tables, seeders, and unauthenticated routes.
Spatie Data and TypeScript
The package registers with Core's Data provider. Public contracts include MoneyData, LengthData, CoordinatesData, PostalAddressData, and ReferenceOption.
php artisan nvl:data:types:generate php artisan nvl:data:types:check
Expose DTOs rather than Brick, libphonenumber, or IBAN vendor objects in JSON APIs.
Adoption
- Replace existing money values only after auditing every float construction and making rounding explicit.
- Replace custom locale/country/currency enums or casts with validated codes.
- Replace phone/IBAN regex and checksum logic with registry-backed objects.
- Keep settings in
nvl/settings; Primitives does not own settings. - Retain application reference tables only when persistence, translation, or domain metadata is necessary.
- Replace suggestion controllers with application controllers calling
ReferenceCatalogor an application-owned directory.
Audit data before changing cast modes. JSON, decimal, and minor-unit money columns require an explicit migration.
Quality
composer install composer quality
The package gate runs Pint, PHPStan at maximum strictness, and isolated Testbench/Pest tests. The monorepo adds dependency analysis, Composer audit, integration tests, and Laravel 13 gates.
See UPGRADING.md, SECURITY.md, CONTRIBUTING.md, and CHANGELOG.md.
License
Released under the MIT License.