@@ -43,6 +43,9 @@ the published config file:
4343| ` VIN_DRIVER ` | ` vin.driver ` | ` nhtsa ` | Which decoder driver lookups use. |
4444| ` VIN_BASE_URL ` | ` vin.decoders.nhtsa.base_url ` | ` https://vpic.nhtsa.dot.gov/api ` | NHTSA vPIC API base URL. |
4545| ` VIN_TIMEOUT ` | ` vin.decoders.nhtsa.timeout ` | ` 10 ` | HTTP timeout (seconds) per decode request. |
46+ | ` VIN_ATTRIBUTES ` | ` vin.decoders.nhtsa.attributes ` | ` identity ` | How much to hydrate: ` identity ` , ` typed ` , or ` full ` (see below).|
47+ | ` VIN_RETRY_TIMES ` | ` vin.decoders.nhtsa.retry.times ` | ` 2 ` | Max NHTSA request attempts (` 1 ` disables retrying). |
48+ | ` VIN_RETRY_SLEEP ` | ` vin.decoders.nhtsa.retry.sleep ` | ` 200 ` | Milliseconds between NHTSA retry attempts. |
4649| ` VIN_CACHE_STORE ` | ` vin.cache.store ` | * (app default)* | Cache store for decodes (blank = the app's default store). |
4750| ` VIN_CACHE_TTL ` | ` vin.cache.ttl ` | ` 86400 ` | How long (seconds) a decoded VIN stays cached. |
4851| ` VIN_CACHE_VERSION ` | ` vin.cache.version ` | ` 1 ` | Bump to invalidate every cached decode at once. |
@@ -98,35 +101,134 @@ Vin::isValid('7yamyfs50ty009706'); // true — input is normalized first
98101Vin::isValid('NOT-A-VIN'); // false
99102```
100103
104+ ### Validating form input — the ` Vin ` rule and check digit
105+
106+ Validate a VIN field with the ` Rules\Vin ` rule object. By default it checks structure only (the same
107+ as ` isValid() ` ); call ` withCheckDigit() ` to also verify the ISO 3779 9th-position check digit and
108+ catch a transposed/mistyped VIN * before* spending a decode call:
109+
110+ ``` php
111+ use AlwaysCurious\Vin\Rules\Vin as VinRule;
112+
113+ $request->validate([
114+ 'vin' => ['required', new VinRule], // structural
115+ // 'vin' => ['required', (new VinRule)->withCheckDigit()], // + check digit
116+ ]);
117+ ```
118+
119+ ` isValid() ` stays structural-only on purpose — some real, decodable VINs don't honor the check digit.
120+ When you want the stricter gate without the network, use ` Vin::hasValidCheckDigit('…') ` .
121+
122+ ### ` lookupMany() ` — decode a batch in one request
123+
124+ Importing a fleet? ` lookupMany() ` decodes many VINs in a single provider round-trip (NHTSA's
125+ ` DecodeVinValuesBatch ` ), reusing the per-VIN cache and only decoding the misses. It returns
126+ ` VehicleData ` keyed by normalized VIN, in input order:
127+
128+ ``` php
129+ $vehicles = Vin::lookupMany(['7YAMYFS50TY009706', '1HGCM82633A004352']);
130+
131+ $vehicles['7YAMYFS50TY009706']->make; // 'HYUNDAI'
132+ ```
133+
134+ The enabled gate and caching apply to the whole batch; a structurally invalid VIN throws before any
135+ request (pre-filter with ` isValid() ` if your input may be dirty). A custom driver that doesn't
136+ implement batching still works — ` lookupMany() ` transparently falls back to looping ` lookup() ` .
137+
138+ ### Knowing * why* a lookup failed
139+
140+ ` VinLookupException ` carries a typed ` ->reason ` (` VinFailureReason ` ), so a single ` catch ` can render
141+ the right message instead of pairing ` isValid() ` with ` tryLookup() ` :
142+
143+ ``` php
144+ use AlwaysCurious\Vin\VinFailureReason;
145+ use AlwaysCurious\Vin\VinLookupException;
146+
147+ try {
148+ $vehicle = Vin::lookup($request->input('vin'));
149+ } catch (VinLookupException $e) {
150+ return match ($e->reason) {
151+ VinFailureReason::InvalidVin => back()->withErrors(['vin' => 'That isn’t a valid VIN.']),
152+ VinFailureReason::Disabled => response('VIN decoding is temporarily disabled.', 503),
153+ default => $e->reason->isTransient()
154+ ? response('The VIN service is unavailable, try again shortly.', 503)
155+ : throw $e,
156+ };
157+ }
158+ ```
159+
160+ ### Decode events
161+
162+ The package dispatches ` Events\VinDecoded ` on every successful lookup (with a ` fromCache ` flag) and
163+ ` Events\VinDecodeFailed ` on every failure (with the ` VinFailureReason ` and the exception) — the latter
164+ fires even when ` tryLookup() ` swallows the error. Wire them to your own telemetry:
165+
166+ ``` php
167+ use AlwaysCurious\Vin\Events\VinDecoded;
168+ use Illuminate\Support\Facades\Event;
169+
170+ Event::listen(function (VinDecoded $event) {
171+ Telemetry::record('vin.decoded', [
172+ 'vin' => $event->vehicle->vin,
173+ 'driver' => $event->driver,
174+ 'cached' => $event->fromCache,
175+ ]);
176+ });
177+ ```
178+
101179### The ` VehicleData ` value object
102180
103- ` lookup() ` / ` tryLookup() ` return an immutable ` VehicleData ` :
181+ ` lookup() ` / ` tryLookup() ` return an immutable ` VehicleData ` . ** By default** (` VIN_ATTRIBUTES=identity ` )
182+ it carries the clean core set that covers ~ 80% of use cases:
104183
105184``` php
106185$vehicle->vin; // '7YAMYFS50TY009706'
107186$vehicle->year; // 2026 (int|null)
108187$vehicle->make; // 'HYUNDAI'
109188$vehicle->model; // 'Ioniq 9'
110- $vehicle->series; // string|null
111189$vehicle->trim; // 'Calligraphy'
112190$vehicle->bodyClass; // 'Sport Utility Vehicle (SUV)/Multi-Purpose Vehicle (MPV)'
113- $vehicle->manufacturer; // 'HYUNDAI MOTOR GROUP METAPLANT AMERICA'
114191$vehicle->vehicleType; // 'MULTIPURPOSE PASSENGER VEHICLE (MPV)'
192+ $vehicle->manufacturer; // 'HYUNDAI MOTOR GROUP METAPLANT AMERICA'
115193$vehicle->errorCode; // 0 (int|null) — primary NHTSA decode status
116194$vehicle->errorText; // string|null
117195
196+ $vehicle->series; // string|null — hydrated from the 'typed' level up (see below)
197+
118198$vehicle->decodedSuccessfully(); // true when NHTSA reports a clean decode (error code 0)
119199$vehicle->isFullyIdentified(); // true when year + make + model are all present
120200
121201$vehicle->toArray(); // snake_cased array (identity + nested groups; see below)
122202json_encode($vehicle); // JsonSerializable — same shape as toArray()
123203```
124204
205+ Want engine/safety/body/plant specs, ` series ` , or the raw NHTSA fields too? Raise the level with
206+ ` VIN_ATTRIBUTES ` (see [ Trim the response] ( #only-need-yearmakemodel-trim-the-response ) ) — everything
207+ below this line needs ` typed ` or ` full ` .
208+
125209` decodedSuccessfully() ` is stricter than ` isFullyIdentified() ` : NHTSA can return
126210a full year/make/model while still flagging a non-blocking warning (e.g. a model
127211year mismatch), in which case ` isFullyIdentified() ` is ` true ` but
128212` decodedSuccessfully() ` is ` false ` .
129213
214+ #### Filling a model — ` only() ` / ` toColumns() `
215+
216+ To persist a decode, project the identity fields into a ` Model::fill() ` -ready array. ` only() ` keeps
217+ the property names; ` toColumns() ` re-keys them onto your column names:
218+
219+ ``` php
220+ $vehicle->only(['make', 'model', 'year', 'trim']);
221+ // ['make' => 'HYUNDAI', 'model' => 'Ioniq 9', 'year' => 2026, 'trim' => 'Calligraphy']
222+
223+ $vehicle->toColumns(['year' => 'model_year', 'make' => 'make', 'bodyClass' => 'body_class']);
224+ // ['model_year' => 2026, 'make' => 'HYUNDAI', 'body_class' => 'Sport Utility Vehicle (SUV)/...']
225+
226+ $car->fill($vehicle->toColumns([...]));
227+ ```
228+
229+ Both throw on an unknown field (so a typo surfaces), and both project only the flat identity fields —
230+ your app keeps ownership of * which* columns win when merging into an existing row.
231+
130232### Extended attributes — engine, safety, body, plant
131233
132234` DecodeVinValues ` returns far more than identity. Beyond the fields above, four typed,
@@ -146,7 +248,7 @@ $vehicle->body->seats; // int|null
146248$vehicle->body->gvwr; // 'Class 2E: 6,001 - 7,000 lb ...'
147249
148250$vehicle->safety->airbagCurtain; // 'All Rows'
149- $vehicle->safety->backupCamera; // 'Standard'
251+ $vehicle->safety->rearVisibilitySystem; // 'Standard' — NHTSA's backup-camera field
150252$vehicle->safety->electronicStabilityControl; // 'Standard'
151253
152254$vehicle->plant->city; // 'ELLABELL'
@@ -175,6 +277,26 @@ you want real `int` / `float` types. The raw bag is **not** embedded in `toArray
175277` json_encode() ` — it's reachable only via ` ->attributes ` and ` attribute() ` , so your serialized
176278payloads stay curated and stable.
177279
280+ ### Only need year/make/model? Trim the response
281+
282+ The default is deliberately lean. Building the typed groups — and especially keeping the full raw
283+ passthrough — costs a little CPU per decode and, more importantly, makes each ** cached** row
284+ larger. Step up with ` VIN_ATTRIBUTES ` (` vin.decoders.nhtsa.attributes ` ) only when you need more:
285+
286+ | ` VIN_ATTRIBUTES ` | Core identity | ` series ` | Typed groups | Raw ` attributes ` | Use when… |
287+ | -------------------- | :-----------: | :------: | :----------: | :--------------: | --------- |
288+ | ` identity ` (default) | ✓ | | | | You read year/make/model/trim/body class/vehicle type/manufacturer. Smallest cache, least work. |
289+ | ` typed ` | ✓ | ✓ | ✓ | | You also want ` series ` and engine/safety/body/plant typed, but never the long tail. |
290+ | ` full ` | ✓ | ✓ | ✓ | ✓ | You want everything, including fields not lifted into a typed group. |
291+
292+ Core identity = year, make, model, trim, body class, vehicle type, manufacturer (plus VIN and
293+ decode status, which are always present). At any level the groups are still present (never null) —
294+ lighter levels just leave their fields ` null ` and keep ` ->attributes ` empty, so
295+ ` $vehicle->engine->horsepower ` is always safe to read.
296+
297+ > Because the level changes what's stored, bump ` VIN_CACHE_VERSION ` when you change it so already
298+ > cached VINs are re-decoded at the new level (see below).
299+
178300### Invalidating cached decodes
179301
180302A VIN's decode never changes, so results are cached for ` VIN_CACHE_TTL ` seconds.
@@ -271,8 +393,31 @@ your `decode()` doesn't have to make an HTTP call at all.
271393
272394## Testing
273395
274- Because the package uses Laravel's HTTP client, you can fake the NHTSA API in
275- your own tests:
396+ ### ` Vin::fake() ` — the easy way
397+
398+ Swap the decoder for a fake so your tests never touch the network or the NHTSA wire format. Map a VIN
399+ to a ` VehicleData ` (build one with ` VehicleData::fake() ` ); unmapped VINs return a generated fake. The
400+ fake still runs the real validation, enabled gate and caching, and records lookups for assertion:
401+
402+ ``` php
403+ use AlwaysCurious\Vin\Facades\Vin;
404+ use AlwaysCurious\Vin\VehicleData;
405+
406+ $fake = Vin::fake([
407+ '1FTFW1E50NKF12345' => VehicleData::fake(make: 'Ford', model: 'F-150'),
408+ ]);
409+
410+ $vehicle = Vin::lookup('1FTFW1E50NKF12345'); // 'Ford' — no HTTP call
411+
412+ $fake->assertLookedUp('1FTFW1E50NKF12345');
413+ $fake->assertLookedUpCount(1);
414+ ```
415+
416+ Map a VIN to a ` Throwable ` to exercise a failure path (` Vin::fake(['…' => VinLookupException::requestFailed('…', 503)]) ` ).
417+
418+ ### Faking the HTTP layer directly
419+
420+ Prefer to assert on the wire? The package uses Laravel's HTTP client, so you can fake NHTSA instead:
276421
277422``` php
278423use AlwaysCurious\Vin\Facades\Vin;
@@ -299,6 +444,13 @@ composer test # vendor/bin/phpunit
299444composer lint # vendor/bin/pint
300445```
301446
447+ ## Versioning
448+
449+ This package follows [ Semantic Versioning] ( https://semver.org ) . As of ** 1.0.0** the public API — the
450+ ` Vin ` facade, the ` Contracts\VinDecoder ` driver seam, ` VehicleData ` , ` VinLookupException ` , and the
451+ ` vin.* ` config keys / env vars — is stable and covered by SemVer. See the [ CHANGELOG] ( CHANGELOG.md )
452+ for what each release adds.
453+
302454## License
303455
304456The MIT License (MIT). See [ LICENSE] ( LICENSE ) .
0 commit comments