Skip to content

Commit 9196405

Browse files
committed
Add batch decode and consumer DX APIs
Ship the 1.0 public surface additions around VIN lookup ergonomics: batch decoding via `lookupMany()`/`BatchVinDecoder`, a validation rule with opt-in check-digit verification, typed failure reasons, decode events, and a shipped `Vin::fake()` test double. This also makes NHTSA retry and attribute hydration levels configurable, adds `VehicleData` projection/fake helpers, updates the decoder and lookup service to support the new behavior, and documents/specs/tests the expanded API.
1 parent d37df6c commit 9196405

36 files changed

Lines changed: 2399 additions & 83 deletions

CHANGELOG.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Changelog
2+
3+
All notable changes to `alwayscurious/laravel-vin` are documented here. This project adheres to
4+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html) and the format of
5+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6+
7+
## [1.0.0] - 2026-07-07
8+
9+
First stable release. The public API — the `Vin` facade, the `VinDecoder` driver seam, `VehicleData`,
10+
`VinLookupException`, and the `vin.*` config keys and their env vars — is now covered by SemVer.
11+
12+
### Added
13+
14+
- **`Vin::fake()`** — a shipped test double (`Testing\VinFake` + `Testing\FakeVinDecoder`) that returns
15+
preset `VehicleData` by VIN with no network call, still applies validation/gate/caching, and records
16+
lookups for assertion (`assertLookedUp`, `assertNotLookedUp`, `assertNothingLookedUp`,
17+
`assertLookedUpCount`). Pair with `VehicleData::fake(...)`. (VIN-024, VD-009, ADR-0007)
18+
- **`Rules\Vin`** — a `ValidationRule` for form input: structural by default, `withCheckDigit()` to
19+
also verify the ISO 3779 9th-position check digit. (VIN-019, VIN-020)
20+
- **`Vin::hasValidCheckDigit()`** and **`Support\VinCheckDigit`** — opt-in check-digit verification,
21+
deliberately separate from `isValid()` (which stays structural-only). (VIN-020)
22+
- **`Vin::lookupMany()`** and **`Contracts\BatchVinDecoder`** — decode many VINs in one request
23+
(NHTSA `DecodeVinValuesBatch`), reusing the per-VIN cache and looping `decode()` for drivers without
24+
batch support. (VIN-023, ADR-0006)
25+
- **Typed failure reason**`VinLookupException::$reason` (`VinFailureReason`), so a single `catch`
26+
can branch on the cause; `->reason->isTransient()` flags retryable failures. (VIN-021)
27+
- **Decode events**`Events\VinDecoded` (with a `fromCache` flag) and `Events\VinDecodeFailed` (with
28+
the reason), dispatched by `VinLookupService` for host telemetry. (VIN-022)
29+
- **`VehicleData::only()` / `toColumns()`** — project the identity fields into a `Model::fill()`-ready
30+
array; throw on an unknown field. (VD-008)
31+
- **Configurable NHTSA retry**`vin.decoders.nhtsa.retry.times` / `.sleep`
32+
(`VIN_RETRY_TIMES` / `VIN_RETRY_SLEEP`), replacing the hard-coded `retry(2, 200)`. (VIN-018)
33+
34+
### Notes
35+
36+
- All of the above are **additive** — existing call sites are unaffected. `VinLookupException`'s
37+
historical positional constructor still works (the new `reason` defaults).
38+
- `Vin::isValid()` behavior is unchanged: it remains structural-only. Check-digit verification is a
39+
separate opt-in (see ADR-0007, which supersedes the earlier "check digit not performed" scope note).

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ before tests run, so run `composer lint` before pushing.
4242
| `Contracts/VinDecoder.php` | The driver seam: `decode(string $vin, ?int $modelYear): VehicleData`. Host apps register their own via `Vin::extend()`. |
4343
| `Decoders/NhtsaVinDecoder.php` | Default `nhtsa` driver. Owns the NHTSA vPIC HTTP call, retry and response mapping. |
4444
| `VehicleData.php` | Immutable `final readonly` value object returned by lookups. Typed identity + the `engine`/`safety`/`body`/`plant` groups + a raw `attributes` passthrough. `Arrayable` + `JsonSerializable`. |
45-
| `Vehicle/*.php` | The typed attribute groups (`Engine`, `Safety`, `Body`, `Plant`) and the `ParsesNhtsaFields` row-extraction trait they share with `VehicleData`. |
45+
| `Vehicle/*.php` | The typed attribute groups (`Engine`, `Safety`, `Body`, `Plant`), the `ParsesNhtsaFields` row-extraction trait they share with `VehicleData`, and the `AttributeLevel` enum (`identity`/`typed`/`full`) that bounds how much a decode hydrates. |
4646
| `VinLookupException.php` | `RuntimeException` with a named constructor per failure mode. |
4747
| `VinServiceProvider.php` | Merges + publishes `config/vin.php`, registers the `VinManager` singleton (+ `vin` alias) and the default-driver `VinLookupService` binding. |
4848

README.md

Lines changed: 158 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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
98101
Vin::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)
122202
json_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
126210
a full year/make/model while still flagging a non-blocking warning (e.g. a model
127211
year 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
176278
payloads 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

180302
A 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
278423
use AlwaysCurious\Vin\Facades\Vin;
@@ -299,6 +444,13 @@ composer test # vendor/bin/phpunit
299444
composer 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

304456
The MIT License (MIT). See [LICENSE](LICENSE).

config/vin.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,24 @@
3333

3434
// HTTP timeout (seconds) per decode request.
3535
'timeout' => (int) env('VIN_TIMEOUT', 10),
36+
37+
// How much of each decode to hydrate onto VehicleData:
38+
// 'identity' — year/make/model/trim/body class/vehicle type/manufacturer
39+
// (the ~80% set; VIN + decode status are always present)
40+
// 'typed' — the above plus series and the engine/safety/body/plant groups
41+
// 'full' — the above plus the raw attribute passthrough (every NHTSA field)
42+
// 'identity' is the default: clean and cheap. Step up when you need more. Lighter
43+
// levels skip that mapping work and cache a smaller row; changing the level changes
44+
// the stored shape, so bump VIN_CACHE_VERSION to re-decode already-cached VINs.
45+
'attributes' => env('VIN_ATTRIBUTES', 'identity'),
46+
47+
// Transient-failure retry for each decode request. NHTSA occasionally blips; the HTTP
48+
// client retries this many times, sleeping this many milliseconds between attempts,
49+
// before a failure surfaces as VinLookupException::requestFailed / connectionFailed.
50+
'retry' => [
51+
'times' => (int) env('VIN_RETRY_TIMES', 2),
52+
'sleep' => (int) env('VIN_RETRY_SLEEP', 200),
53+
],
3654
],
3755

3856
],

0 commit comments

Comments
 (0)