ID prefix: VIN
Status: Accepted
Version: 2.2.0
Last updated: 2026-07-07
Owners: @alwayscurious
Constrains how the package turns a raw VIN string into a VehicleData: input handling, the
enabled gate, caching, the pluggable decoder driver system, and the behavior of the
default NHTSA decoder. These are the guarantees a consuming app relies on and the reasons the
code is shaped the way it is.
In scope:
- Normalization and structural validation of VIN input
- The
vin.enabledmaster gate - Read-through caching, the cache store selection, and cache invalidation via
vin.cache.version - The
VinManagerdriver system: driver selection (vin.driver), custom driver registration (extend), per-call driver override (using), and theVinfacade - The
Contracts\VinDecoderseam and the defaultnhtsadriver - The default
Decoders\NhtsaVinDecoderrequest/response contract and error mapping
Out of scope:
- The field mapping / nullability of
VehicleData, its typed attribute groups, the raw attribute passthrough, and thedecodedSuccessfully()vsisFullyIdentified()distinction — now specified invehicle-data.md(VD-NNN) - NHTSA-specific decisions and the rationale for the decoder seam — see ADR-0002
- The driver-system, caching and config-model design rationale — see ADR-0004 (which supersedes parts of ADR-0003)
- The consumer-DX surface design rationale (test fake, validation rule, opt-in check digit, typed failure reason, decode events) — see ADR-0007; batch decoding rationale — see ADR-0006
Superseded scope note. Earlier versions of this spec listed check-digit (9th-position) verification as explicitly not performed. As of v2.1.0 it is an opt-in capability (
Rules\Vin::withCheckDigit(),Vin::hasValidCheckDigit()) that is deliberately kept out of structural validity (VIN-002 / VIN-003 are unchanged —isValid()never runs it). See VIN-019, VIN-020 and ADR-0007.
| Term | Definition |
|---|---|
| Normalize | strtoupper(trim($vin)) |
| Structurally valid | Matches ^[A-HJ-NPR-Z0-9]{17}$ — 17 chars, excluding I, O, Q |
| Decoder | An implementation of AlwaysCurious\Vin\Contracts\VinDecoder |
| Driver | A named decoder registered on VinManager (built-in nhtsa, or a host-registered name) |
| Manager | AlwaysCurious\Vin\VinManager, the Illuminate\Support\Manager that resolves drivers |
| Enabled gate | The vin.enabled config flag checked before any work |
[VIN-001] lookup(), tryLookup() and isValid() MUST normalize the input VIN
(uppercase + trim) before validating or decoding, and the VIN handed to a decoder MUST be
the normalized form.
Rationale: Users paste VINs with stray case/whitespace; decoders and the cache key must see one canonical form. Test:
tests/VinLookupServiceTest.php::test_it_normalizes_and_validates_the_vin,tests/DriverManagerTest.php::test_a_custom_driver_receives_the_normalized_vin_and_model_year_hint
[VIN-002] A VIN is valid only if it matches ^[A-HJ-NPR-Z0-9]{17}$. lookup() MUST throw
VinLookupException for an invalid VIN and MUST NOT invoke the decoder.
Rationale: Never spend a network call (or a custom provider call) on input that cannot be a VIN; fail fast and cheap. Test:
tests/VinLookupServiceTest.php::test_it_rejects_a_structurally_invalid_vin,tests/DriverManagerTest.php::test_validation_runs_before_a_custom_driver_is_called
[VIN-003] isValid() MUST report structural validity only, performing no decoder or
network call.
Rationale: Lets apps validate form input (e.g. return 422) without any I/O. Test:
tests/VinLookupServiceTest.php::test_is_valid_checks_structure_without_hitting_the_network
[VIN-004] When vin.enabled is false, lookup() MUST throw
VinLookupException::lookupDisabled(), tryLookup() MUST return null, and neither MUST
invoke the decoder or perform any network call. The gate MUST be read per lookup, so toggling
vin.enabled at runtime takes effect on the next call without a manager reset.
Rationale: A single master switch must guarantee no outbound traffic — useful for incident response, cost control, or offline environments — and must respond to a live toggle. Test:
tests/VinLookupServiceTest.php::test_lookup_throws_when_decoding_is_disabled,tests/VinLookupServiceTest.php::test_try_lookup_returns_null_without_calling_the_api_when_disabled,tests/DriverManagerTest.php::test_the_enabled_gate_short_circuits_a_custom_driver
[VIN-005] tryLookup() MUST return null for any failure that lookup() would throw
(invalid VIN, disabled gate, decoder/transport failure, unusable response).
Rationale: Gives display paths a total, non-throwing variant with a single null contract. Test:
tests/VinLookupServiceTest.php::test_try_lookup_returns_null_on_failure
[VIN-006] A successful decode MUST be cached and reused within the same
vin.cache.version; a cache hit MUST NOT invoke the decoder again.
Rationale: A VIN's decode is immutable, so a repeat decode is wasted work regardless of which provider is bound. Caching wraps the decoder, not the reverse. Test:
tests/VinLookupServiceTest.php::test_it_caches_a_decoded_vin_and_reuses_it_within_a_version,tests/DriverManagerTest.php::test_caching_wraps_a_custom_driver
[VIN-007] Bumping vin.cache.version MUST bypass every previously cached decode, without a
store-wide cache flush, and MUST take effect on the next lookup at runtime.
Rationale: When NHTSA corrects data, or the stored shape changes, hosts need one-knob invalidation that doesn't evict unrelated cache entries. Test:
tests/VinLookupServiceTest.php::test_bumping_the_cache_version_bypasses_the_previously_cached_vin
[VIN-017] Caching MUST use the cache store named by vin.cache.store; when it is null
the application's default store MUST be used.
Rationale: Hosts may want VIN decodes in a dedicated/persistent store separate from their default cache. Test:
tests/DriverManagerTest.php::test_caching_uses_the_configured_cache_store
[VIN-008] The active decoder MUST be resolved through the VinManager driver system. The
default driver is selected by vin.driver and defaults to nhtsa, which resolves to
Decoders\NhtsaVinDecoder. VinManager MUST wrap the resolved decoder in a VinLookupService
so validation, the enabled gate and caching apply to it.
Rationale: Drivers are swappable and selectable by name without touching
VinLookupService; the box works out of the box with NHTSA. Test:tests/DriverManagerTest.php::test_the_default_driver_is_the_nhtsa_decoder
[VIN-009] A custom driver MUST be able to replace the default (with no NHTSA HTTP call) and MUST receive the already-normalized, already-validated VIN plus the model-year hint. Validation (VIN-002), the enabled gate (VIN-004) and caching (VIN-006) MUST wrap every driver equally — a decoder MUST NOT be able to bypass them.
Rationale: A custom provider implements only the lookup + mapping and inherits validation, gating and caching for free — it must not be able to bypass them. Test:
tests/DriverManagerTest.php::test_a_custom_driver_replaces_nhtsa_without_any_http_call,tests/DriverManagerTest.php::test_a_custom_driver_receives_the_normalized_vin_and_model_year_hint,tests/DriverManagerTest.php::test_validation_runs_before_a_custom_driver_is_called,tests/DriverManagerTest.php::test_caching_wraps_a_custom_driver
[VIN-013] vin.driver MUST select the default driver used by lookup(), tryLookup(),
isValid() and app(VinLookupService::class). VinManager::using($name) MUST perform a
lookup through the named driver without changing the default driver.
Rationale: One env var (
VIN_DRIVER) picks the provider app-wide;using()allows a per-call override for mixed-provider apps. Test:tests/DriverManagerTest.php::test_using_selects_a_named_driver_without_changing_the_default
[VIN-014] A decoder registered via VinManager::extend($name, fn ($app) => $decoder) MUST
be selectable by $name (through vin.driver or using($name)) and MUST be wrapped with the
same validation, gate and caching as the default driver.
Rationale: The idiomatic Laravel extension point; a host registers its provider once in a service provider and selects it by name. Test:
tests/DriverManagerTest.php::test_an_extended_driver_is_selectable_by_name,tests/DriverManagerTest.php::test_caching_wraps_a_custom_driver
[VIN-015] The Vin facade MUST resolve to VinManager and expose lookup, tryLookup,
isValid, using, extend, getDefaultDriver and forgetDrivers.
Rationale: A first-class facade is the primary consumer entry point and the driver-system control surface. Test:
tests/DriverManagerTest.php::test_the_facade_resolves_to_the_manager
[VIN-016] The cache key MUST include the active driver name so decodes produced by different drivers for the same VIN never collide.
Rationale: Two providers can disagree on a VIN's decode; their cached results must be kept apart, and switching
vin.drivermust not serve another provider's cached value. Test:tests/DriverManagerTest.php::test_cache_is_namespaced_per_driver
[VIN-010] NhtsaVinDecoder MUST request
GET {base_url}/vehicles/decodevinvalues/{VIN}?format=json, forwarding the model-year hint as
a modelyear query parameter when provided, and map the flat Results.0 row into a
VehicleData. base_url comes from vin.decoders.nhtsa.base_url.
Rationale: The
DecodeVinValuesendpoint returns one flat row; the model-year hint improves NHTSA's accuracy. Test:tests/VinLookupServiceTest.php::test_it_decodes_a_vin_into_vehicle_data,tests/VinLookupServiceTest.php::test_it_passes_the_model_year_hint_to_the_api
[VIN-011] A failed NHTSA HTTP response MUST surface as VinLookupException::requestFailed()
via lookup() (and therefore null via tryLookup() per VIN-005).
Rationale: Transport/HTTP failures are mapped to the package's own exception type, never leaked as a raw
RequestException. Test:tests/VinLookupServiceTest.php::test_it_throws_when_the_api_returns_an_error_status
[VIN-012] The NHTSA ErrorCode field (a comma-separated list, e.g. "0,12") MUST be
reduced to its first segment as the primary decode status on VehicleData::$errorCode.
Rationale: Only the first code is the primary status; the rest are secondary flags.
0means a clean decode. Test:tests/VinLookupServiceTest.php::test_it_decodes_a_vin_into_vehicle_data
[VIN-018] The NHTSA decoder's transient-failure retry MUST be configurable via
vin.decoders.nhtsa.retry.times (max attempts, default 2) and vin.decoders.nhtsa.retry.sleep
(milliseconds between attempts, default 200). times of 1 MUST disable retrying.
Rationale: The retry policy was hard-coded; hosts on flaky links or tight latency budgets need to tune or disable it without patching the decoder. Test:
tests/RetryConfigTest.php::test_the_client_retries_transient_connection_failures_per_config,tests/RetryConfigTest.php::test_a_retry_times_of_one_disables_retrying
[VIN-019] The package MUST ship a Rules\Vin validation rule implementing
Illuminate\Contracts\Validation\ValidationRule. By default it MUST check structure only (the
VIN-002 pattern, after normalization) — matching isValid() — and MUST NOT verify the check digit.
Rationale: Form validation should be a first-class, idiomatic rule object, not a hand-rolled regex at every call site. Test:
tests/VinRuleTest.php::test_the_rule_passes_a_structurally_valid_vin_after_normalizing,tests/VinRuleTest.php::test_the_rule_fails_a_structurally_invalid_vin
[VIN-020] Check-digit (ISO 3779 9th-position) verification MUST be available as an opt-in
that never changes structural validity: Rules\Vin::withCheckDigit() and
Vin::hasValidCheckDigit() MUST additionally verify it, while isValid() (VIN-003) MUST remain
structural-only. Verification MUST perform no network call.
Rationale: Catch a transposed/typo'd VIN before spending a decode call, without rejecting the otherwise-valid VINs whose check digit a manufacturer did not honor. Test:
tests/VinRuleTest.php::test_the_check_digit_is_only_enforced_when_opted_in,tests/VinRuleTest.php::test_has_valid_check_digit_is_stricter_than_is_valid,tests/VinRuleTest.php::test_the_check_digit_helper_matches_the_iso_3779_standard
[VIN-025] Vin::inspect(string $vin) (and VinLookupService::inspect()) MUST return a
VinValidation value object describing the outcome of every offline check: an overall valid
verdict (structurally valid and correct check digit), the structurallyValid and
checkDigitValid dimensions separately, and a list of typed VinValidationError reasons for any
failure. The reasons MUST distinguish WrongLength (not 17 characters), IllegalCharacters (a
character outside [A-HJ-NPR-Z0-9], i.e. I/O/Q or punctuation) and InvalidCheckDigit (a
structurally valid VIN whose 9th-position check digit does not match). inspect() MUST perform no
decoder or network call and MUST NOT change the structural-only semantics of isValid() (VIN-003):
inspect($vin)->structurallyValid MUST equal isValid($vin).
Rationale: Form feedback needs to say why a VIN was rejected — wrong length vs. an illegal character vs. a bad check digit — not just return a bool, and without spending a decode call. The overall
validverdict is the strong (structure + check digit) answer, distinct from the lenient structural gateisValid()keeps for the decode path. Test:tests/VinValidationTest.php::test_a_valid_vin_passes_every_check,tests/VinValidationTest.php::test_a_bad_check_digit_fails_with_a_reason,tests/VinValidationTest.php::test_an_illegal_character_is_rejected,tests/VinValidationTest.php::test_a_wrong_length_is_rejected,tests/VinValidationTest.php::test_inspect_makes_no_network_call,tests/VinValidationTest.php::test_inspect_agrees_with_is_valid_on_structure
[VIN-021] VinLookupException MUST carry a typed VinFailureReason on ->reason set by every
named constructor (InvalidVin, Disabled, ConnectionFailed, RequestFailed,
UnexpectedResponse), so a caller can branch on the cause from one catch. The historical positional
constructor (message, code, previous) MUST keep working (reason defaults).
Rationale:
tryLookup()collapsing every failure tonullforced two code paths; a typed reason lets a single catch render the right message without string-matching. Test:tests/VinFailureReasonTest.php::test_an_invalid_vin_carries_the_invalid_reason,tests/VinFailureReasonTest.php::test_the_disabled_gate_carries_the_disabled_reason,tests/VinFailureReasonTest.php::test_an_http_failure_carries_the_transient_request_failed_reason,tests/VinFailureReasonTest.php::test_the_historical_constructor_signature_still_works
[VIN-022] VinLookupService MUST dispatch Events\VinDecoded on a successful lookup (carrying
the VehicleData, driver name, model-year hint and a fromCache flag), and Events\VinDecodeFailed
on any failure (carrying the VIN, driver, VinFailureReason and the exception) — including failures
that tryLookup() swallows.
Rationale: Hosts already running telemetry want a decode event to slot into, without wrapping every call site; a swallowed
tryLookup()failure must still be observable. Test:tests/DecodeEventsTest.php::test_a_successful_decode_dispatches_vin_decoded,tests/DecodeEventsTest.php::test_a_cache_hit_dispatches_vin_decoded_flagged_from_cache,tests/DecodeEventsTest.php::test_a_failed_decode_dispatches_vin_decode_failed_with_reason
[VIN-023] lookupMany(array $vins, ?int $modelYear = null) MUST decode many VINs and return
VehicleData keyed by normalized VIN in input order. It MUST apply the enabled gate to the whole
batch, normalize + validate + de-duplicate every VIN up front (throwing VinLookupException on the
first structurally invalid VIN, before any provider call), serve cache hits per VIN, and decode only
the misses — via Contracts\BatchVinDecoder::decodeMany() when the driver implements it, otherwise
looping decode(). The NHTSA driver MUST implement BatchVinDecoder via DecodeVinValuesBatch.
Rationale: A fleet import should be one round-trip, not N — while inheriting the same gate, validation and per-VIN caching, and while custom drivers keep working without batch support. Test:
tests/BatchLookupTest.php::test_the_nhtsa_driver_decodes_a_batch_in_one_http_request,tests/BatchLookupTest.php::test_lookup_many_uses_the_batch_capability_when_available,tests/BatchLookupTest.php::test_lookup_many_reuses_cache_and_only_batches_the_misses,tests/BatchLookupTest.php::test_lookup_many_falls_back_to_looping_decode_without_batch_support,tests/BatchLookupTest.php::test_lookup_many_throws_on_a_structurally_invalid_vin_before_any_call,tests/BatchLookupTest.php::test_lookup_many_honors_the_enabled_gate
[VIN-024] Vin::fake(array $map = []) MUST swap the manager for a Testing\VinFake that routes
every decode to preset VehicleData (or a preset Throwable) keyed by VIN — generating a
VehicleData::fake() for unmapped VINs — with no network call, while still applying validation,
the enabled gate and caching, and recording lookups for assertion (assertLookedUp,
assertNotLookedUp, assertNothingLookedUp, assertLookedUpCount).
Rationale: Consumers should test their integration without reverse-engineering the NHTSA URL or
Results.0shape; faking at the decoder seam keeps their tests exercising the real validation/gate/cache and decouples them from any driver's wire format. Test:tests/VinFakeTest.php::test_fake_returns_preset_data_with_no_http_call,tests/VinFakeTest.php::test_fake_generates_data_for_unmapped_vins,tests/VinFakeTest.php::test_fake_records_lookups_for_assertion,tests/VinFakeTest.php::test_fake_still_applies_validation_and_the_enabled_gate,tests/VinFakeTest.php::test_fake_can_simulate_a_failure
- INV-1: The cache key is
vin:v{cache.version}:{driver}:{normalized VIN}:{modelYear|'auto'}— version, driver, VIN and model-year hint all participate, so different versions, drivers or hints never collide. (Guarded by VIN-006 / VIN-007 / VIN-016.) - INV-2: Any VIN reaching
VinDecoder::decode()has already been normalized (per VIN-001) and structurally validated (per VIN-002). - INV-3: Validation, the enabled gate and caching are applied by the
VinLookupServicethatVinManagerbuilds around every driver, never by a decoder — so they hold identically for the default and every custom driver. (VIN-009.) - INV-4:
VinManagercaches decoder instances (per theManagercontract), but builds a freshVinLookupServiceper lookup that re-reads the gate and cache config — so runtime changes tovin.enabledandvin.cache.versiontake effect withoutforgetDrivers(), while driver-level config (vin.decoders.*) is captured at first resolution and refreshed viaVin::forgetDrivers(). (VIN-004 / VIN-007.)
- Every MUST / MUST NOT above has a passing test carrying its
@spec VIN-NNNdocblock - INV-1 through INV-4 are each exercised by at least one test
- No new behavior contradicts an existing requirement here
- Spec
Version:bumped if any requirement changed
- Spec: Vehicle Data spec — the
VehicleDatafield mapping, typed groups and raw passthrough - ADR: ADR-0002 — NHTSA vPIC provider & the decoder seam
- ADR: ADR-0003 — Caching & config model (superseded in part)
- ADR: ADR-0004 — Manager driver system
- Narrative (contributor):
../../CLAUDE.md - Narrative (consumer):
../../README.md,../../resources/boost/guidelines/core.blade.php