alwayscurious / laravel-vin
Decode US vehicle VINs into year, make, model and more via the NHTSA vPIC API.
Requires
- php: ^8.3
- illuminate/cache: ^11.0|^12.0|^13.0
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.14
- orchestra/testbench: ^9.0|^10.0|^11.0
- phpunit/phpunit: ^10.5|^11.0|^12.0
README
Decode a US vehicle VIN into year, make, model, series, trim, body class and more via the NHTSA vPIC API. Decodes are cached (a VIN's decode is immutable), with a version knob to invalidate every cached decode at once and a master switch to disable live lookups entirely.
NHTSA is the default provider, but the decoder is a driver — register your own
with Vin::extend() and select it with VIN_DRIVER, exactly like Laravel's cache,
mail and filesystem drivers.
Requirements
- PHP
^8.3 - Laravel 11, 12 or 13 (
illuminate/*^11.0|^12.0|^13.0)
Installation
composer require alwayscurious/laravel-vin
The service provider is auto-discovered — no manual registration needed.
Publishing the config file is optional; the package ships with sane defaults and reads everything from environment variables:
php artisan vendor:publish --tag=vin-config
Configuration
Every setting is driven by an environment variable, so you rarely need to touch the published config file:
| Env var | Config key | Default | Purpose |
|---|---|---|---|
VIN_DRIVER |
vin.driver |
nhtsa |
Which decoder driver lookups use. |
VIN_BASE_URL |
vin.decoders.nhtsa.base_url |
https://vpic.nhtsa.dot.gov/api |
NHTSA vPIC API base URL. |
VIN_TIMEOUT |
vin.decoders.nhtsa.timeout |
10 |
HTTP timeout (seconds) per decode request. |
VIN_ATTRIBUTES |
vin.decoders.nhtsa.attributes |
identity |
How much to hydrate: identity, typed, or full (see below). |
VIN_RETRY_TIMES |
vin.decoders.nhtsa.retry.times |
2 |
Max NHTSA request attempts (1 disables retrying). |
VIN_RETRY_SLEEP |
vin.decoders.nhtsa.retry.sleep |
200 |
Milliseconds between NHTSA retry attempts. |
VIN_CACHE_STORE |
vin.cache.store |
(app default) | Cache store for decodes (blank = the app's default store). |
VIN_CACHE_TTL |
vin.cache.ttl |
86400 |
How long (seconds) a decoded VIN stays cached. |
VIN_CACHE_VERSION |
vin.cache.version |
1 |
Bump to invalidate every cached decode at once. |
VIN_ENABLED |
vin.enabled |
true |
Master switch for live decoding. |
Usage
Use the Vin facade (auto-registered — no alias config needed):
use AlwaysCurious\Vin\Facades\Vin; // Throws VinLookupException on an invalid VIN, an API failure, or when // live decoding is disabled by configuration. $vehicle = Vin::lookup('7YAMYFS50TY009706'); // Optional model-year hint to improve decoding accuracy: $vehicle = Vin::lookup('7YAMYFS50TY009706', 2026);
Prefer dependency injection? Type-hint or resolve VinLookupService — it is the
default driver's lookup service and exposes the same lookup() / tryLookup() /
isValid() methods:
use AlwaysCurious\Vin\VinLookupService; public function __construct(private readonly VinLookupService $vin) {} // ... $vehicle = $this->vin->lookup('7YAMYFS50TY009706');
tryLookup()
Returns null instead of throwing on any failure:
$vehicle = Vin::tryLookup('7YAMYFS50TY009706'); if ($vehicle !== null) { // ... }
isValid()
Structurally validate a VIN (17 characters, excluding I, O and Q) without hitting the network:
Vin::isValid('7yamyfs50ty009706'); // true — input is normalized first Vin::isValid('NOT-A-VIN'); // false
Validating form input — the Vin rule and check digit
Validate a VIN field with the Rules\Vin rule object. By default it checks structure only (the same
as isValid()); call withCheckDigit() to also verify the ISO 3779 9th-position check digit and
catch a transposed/mistyped VIN before spending a decode call:
use AlwaysCurious\Vin\Rules\Vin as VinRule; $request->validate([ 'vin' => ['required', new VinRule], // structural // 'vin' => ['required', (new VinRule)->withCheckDigit()], // + check digit ]);
isValid() stays structural-only on purpose — some real, decodable VINs don't honor the check digit.
When you want the stricter gate without the network, use Vin::hasValidCheckDigit('…').
inspect() — validate with useful feedback
When a bare true/false isn't enough — you want to tell the user why their VIN was rejected —
Vin::inspect() runs every offline check and returns a VinValidation reporting the overall verdict,
the structural and check-digit dimensions separately, and a typed reason per failure. No network call:
$result = Vin::inspect('7YAMYFS50TY009706'); $result->valid; // true — structurally valid AND correct check digit $result->structurallyValid; // true — always equals Vin::isValid() $result->checkDigitValid; // true $result->errors; // [] // A structurally valid VIN with a mistyped check digit: $bad = Vin::inspect('7YAMYFS51TY009706'); $bad->fails(); // true $bad->messages(); // ['The VIN check digit (9th character) does not match; the VIN may be mistyped.'] // Each failure mode is distinguishable: Vin::inspect('IYAMYFS50TY009706')->errors; // [VinValidationError::IllegalCharacters] (I/O/Q rejected) Vin::inspect('7YAMYFS50TY00970')->errors; // [VinValidationError::WrongLength] (not 17 chars)
$result->valid is the strong (structure + check digit) verdict; $result->structurallyValid mirrors
the lenient isValid() the decode path uses. toArray() / JSON-encoding gives a flat, API-friendly
shape (vin, valid, structurally_valid, check_digit_valid, errors).
lookupMany() — decode a batch in one request
Importing a fleet? lookupMany() decodes many VINs in a single provider round-trip (NHTSA's
DecodeVinValuesBatch), reusing the per-VIN cache and only decoding the misses. It returns
VehicleData keyed by normalized VIN, in input order:
$vehicles = Vin::lookupMany(['7YAMYFS50TY009706', '1HGCM82633A004352']); $vehicles['7YAMYFS50TY009706']->make; // 'HYUNDAI'
The enabled gate and caching apply to the whole batch; a structurally invalid VIN throws before any
request (pre-filter with isValid() if your input may be dirty). A custom driver that doesn't
implement batching still works — lookupMany() transparently falls back to looping lookup().
Knowing why a lookup failed
VinLookupException carries a typed ->reason (VinFailureReason), so a single catch can render
the right message instead of pairing isValid() with tryLookup():
use AlwaysCurious\Vin\VinFailureReason; use AlwaysCurious\Vin\VinLookupException; try { $vehicle = Vin::lookup($request->input('vin')); } catch (VinLookupException $e) { return match ($e->reason) { VinFailureReason::InvalidVin => back()->withErrors(['vin' => 'That isn’t a valid VIN.']), VinFailureReason::Disabled => response('VIN decoding is temporarily disabled.', 503), default => $e->reason->isTransient() ? response('The VIN service is unavailable, try again shortly.', 503) : throw $e, }; }
Decode events
The package dispatches Events\VinDecoded on every successful lookup (with a fromCache flag) and
Events\VinDecodeFailed on every failure (with the VinFailureReason and the exception) — the latter
fires even when tryLookup() swallows the error. Wire them to your own telemetry:
use AlwaysCurious\Vin\Events\VinDecoded; use Illuminate\Support\Facades\Event; Event::listen(function (VinDecoded $event) { Telemetry::record('vin.decoded', [ 'vin' => $event->vehicle->vin, 'driver' => $event->driver, 'cached' => $event->fromCache, ]); });
The VehicleData value object
lookup() / tryLookup() return an immutable VehicleData. By default (VIN_ATTRIBUTES=identity)
it carries the clean core set that covers ~80% of use cases:
$vehicle->vin; // '7YAMYFS50TY009706' $vehicle->year; // 2026 (int|null) $vehicle->make; // 'HYUNDAI' $vehicle->model; // 'Ioniq 9' $vehicle->trim; // 'Calligraphy' $vehicle->bodyClass; // 'Sport Utility Vehicle (SUV)/Multi-Purpose Vehicle (MPV)' $vehicle->vehicleType; // 'MULTIPURPOSE PASSENGER VEHICLE (MPV)' $vehicle->manufacturer; // 'HYUNDAI MOTOR GROUP METAPLANT AMERICA' $vehicle->errorCode; // 0 (int|null) — primary NHTSA decode status $vehicle->errorText; // string|null $vehicle->series; // string|null — hydrated from the 'typed' level up (see below) $vehicle->decodedSuccessfully(); // true when NHTSA reports a clean decode (error code 0) $vehicle->isFullyIdentified(); // true when year + make + model are all present $vehicle->toArray(); // snake_cased array (identity + nested groups; see below) json_encode($vehicle); // JsonSerializable — same shape as toArray()
Want engine/safety/body/plant specs, series, or the raw NHTSA fields too? Raise the level with
VIN_ATTRIBUTES (see Trim the response) — everything
below this line needs typed or full.
decodedSuccessfully() is stricter than isFullyIdentified(): NHTSA can return
a full year/make/model while still flagging a non-blocking warning (e.g. a model
year mismatch), in which case isFullyIdentified() is true but
decodedSuccessfully() is false.
Filling a model — only() / toColumns()
To persist a decode, project the identity fields into a Model::fill()-ready array. only() keeps
the property names; toColumns() re-keys them onto your column names:
$vehicle->only(['make', 'model', 'year', 'trim']); // ['make' => 'HYUNDAI', 'model' => 'Ioniq 9', 'year' => 2026, 'trim' => 'Calligraphy'] $vehicle->toColumns(['year' => 'model_year', 'make' => 'make', 'bodyClass' => 'body_class']); // ['model_year' => 2026, 'make' => 'HYUNDAI', 'body_class' => 'Sport Utility Vehicle (SUV)/...'] $car->fill($vehicle->toColumns([...]));
Both throw on an unknown field (so a typo surfaces), and both project only the flat identity fields — your app keeps ownership of which columns win when merging into an existing row.
Extended attributes — engine, safety, body, plant
DecodeVinValues returns far more than identity. Beyond the fields above, four typed,
always-present groups carry the commonly-used specs. Each field is null when NHTSA has no
value for it — a missing numeric field (doors, horsepower, …) is null, never 0:
$vehicle->engine->fuelTypePrimary; // 'Electric' $vehicle->engine->horsepower; // int|null e.g. 422 $vehicle->engine->displacementL; // float|null e.g. 5.0 $vehicle->engine->driveType; // 'AWD' $vehicle->engine->transmissionStyle; // 'Automatic' $vehicle->engine->electrificationLevel; // 'BEV (Battery Electric Vehicle)' $vehicle->body->doors; // int|null e.g. 4 $vehicle->body->seats; // int|null $vehicle->body->gvwr; // 'Class 2E: 6,001 - 7,000 lb ...' $vehicle->safety->airbagCurtain; // 'All Rows' $vehicle->safety->rearVisibilitySystem; // 'Standard' — NHTSA's backup-camera field $vehicle->safety->electronicStabilityControl; // 'Standard' $vehicle->plant->city; // 'ELLABELL' $vehicle->plant->country; // 'UNITED STATES (USA)'
Each group is itself Arrayable + JsonSerializable. toArray() / json_encode() on the
VehicleData nest them under engine, safety, body and plant.
Raw attribute passthrough
For anything NHTSA returns that the typed groups don't surface (e.g. DestinationMarket,
NCSABodyType, Note), the complete non-empty response row is kept verbatim, keyed by the
original NHTSA field name:
$vehicle->attribute('DestinationMarket'); // 'North America' $vehicle->attribute('NoSuchField'); // null $vehicle->attribute('NoSuchField', 'unknown'); // 'unknown' — optional default $vehicle->attributes; // ['Make' => 'HYUNDAI', 'EngineHP' => '422', ...] full non-empty row
Raw values are strings exactly as NHTSA sent them (trimmed); use the typed groups above when
you want real int / float types. The raw bag is not embedded in toArray() /
json_encode() — it's reachable only via ->attributes and attribute(), so your serialized
payloads stay curated and stable.
Only need year/make/model? Trim the response
The default is deliberately lean. Building the typed groups — and especially keeping the full raw
passthrough — costs a little CPU per decode and, more importantly, makes each cached row
larger. Step up with VIN_ATTRIBUTES (vin.decoders.nhtsa.attributes) only when you need more:
VIN_ATTRIBUTES |
Core identity | series |
Typed groups | Raw attributes |
Use when… |
|---|---|---|---|---|---|
identity (default) |
✓ | You read year/make/model/trim/body class/vehicle type/manufacturer. Smallest cache, least work. | |||
typed |
✓ | ✓ | ✓ | You also want series and engine/safety/body/plant typed, but never the long tail. |
|
full |
✓ | ✓ | ✓ | ✓ | You want everything, including fields not lifted into a typed group. |
Core identity = year, make, model, trim, body class, vehicle type, manufacturer (plus VIN and
decode status, which are always present). At any level the groups are still present (never null) —
lighter levels just leave their fields null and keep ->attributes empty, so
$vehicle->engine->horsepower is always safe to read.
Because the level changes what's stored, bump
VIN_CACHE_VERSIONwhen you change it so already cached VINs are re-decoded at the new level (see below).
Invalidating cached decodes
A VIN's decode never changes, so results are cached for VIN_CACHE_TTL seconds.
If NHTSA corrects its data — or you change what the package stores — bump
VIN_CACHE_VERSION. The version is part of the cache key, so every previously
cached decode is bypassed at once without flushing your whole cache store.
Disabling live decoding
Set VIN_ENABLED=false to turn off all network calls. lookup() then throws a
VinLookupException and tryLookup() returns null — neither hits the API.
Using a different VIN provider
NHTSA is just the default driver. The package uses Laravel's Manager driver
system, so you register your own provider and select it by name — the same
ergonomics as Cache::extend() / Mail::extend().
A driver is any class implementing Contracts\VinDecoder. It only performs the
lookup and maps the response; the VIN it receives is already normalized (uppercased,
trimmed) and structurally validated, and validation, the enabled gate and caching
are applied around it by the package — so a driver never reimplements them.
namespace App\Vin; use AlwaysCurious\Vin\Contracts\VinDecoder; use AlwaysCurious\Vin\VehicleData; use AlwaysCurious\Vin\VinLookupException; use Illuminate\Support\Facades\Http; class AcmeVinDecoder implements VinDecoder { public function __construct(private readonly string $apiKey) {} public function decode(string $vin, ?int $modelYear = null): VehicleData { $response = Http::withToken($this->apiKey) ->acceptJson() ->get("https://vin.acme.test/decode/{$vin}"); if ($response->failed()) { throw VinLookupException::requestFailed($vin, $response->status()); } // Map the provider's shape onto VehicleData however it returns data. return new VehicleData( vin: $vin, year: $response->json('year'), make: $response->json('make'), model: $response->json('model'), series: $response->json('series'), trim: $response->json('trim'), bodyClass: $response->json('body_class'), errorCode: 0, errorText: null, ); } }
Register it as a named driver in a service provider's register() (or boot()).
The closure receives the container, so you can pull in config or other services:
use AlwaysCurious\Vin\Facades\Vin; use App\Vin\AcmeVinDecoder; Vin::extend('acme', fn ($app) => new AcmeVinDecoder($app['config']['services.acme.key']));
Then make it the default for the whole app:
VIN_DRIVER=acme
…or use it for a single call while NHTSA stays the default:
$vehicle = Vin::using('acme')->lookup('7YAMYFS50TY009706');
Because the package wraps every driver, your provider automatically inherits
structural validation, the VIN_ENABLED master switch, and caching. Cache entries
are namespaced per driver, so two providers never serve each other's cached decode.
Prefer to decode from a fixed dataset, a queue, or an in-memory table? Same seam —
your decode() doesn't have to make an HTTP call at all.
Driver config is captured when the driver is first resolved. If you change a driver's own settings (e.g.
vin.decoders.*) at runtime, callVin::forgetDrivers()to rebuild. The enabled gate and cache version are re-read on every lookup, so those take effect immediately.
Testing
Vin::fake() — the easy way
Swap the decoder for a fake so your tests never touch the network or the NHTSA wire format. Map a VIN
to a VehicleData (build one with VehicleData::fake()); unmapped VINs return a generated fake. The
fake still runs the real validation, enabled gate and caching, and records lookups for assertion:
use AlwaysCurious\Vin\Facades\Vin; use AlwaysCurious\Vin\VehicleData; $fake = Vin::fake([ '1FTFW1E50NKF12345' => VehicleData::fake(make: 'Ford', model: 'F-150'), ]); $vehicle = Vin::lookup('1FTFW1E50NKF12345'); // 'Ford' — no HTTP call $fake->assertLookedUp('1FTFW1E50NKF12345'); $fake->assertLookedUpCount(1);
Map a VIN to a Throwable to exercise a failure path (Vin::fake(['…' => VinLookupException::requestFailed('…', 503)])).
Faking the HTTP layer directly
Prefer to assert on the wire? The package uses Laravel's HTTP client, so you can fake NHTSA instead:
use AlwaysCurious\Vin\Facades\Vin; use Illuminate\Support\Facades\Http; Http::fake([ 'vpic.nhtsa.dot.gov/*' => Http::response([ 'Results' => [[ 'Make' => 'HYUNDAI', 'Model' => 'Ioniq 9', 'ModelYear' => '2026', 'ErrorCode' => '0', ]], ]), ]); $vehicle = Vin::lookup('7YAMYFS50TY009706');
Run the package's own suite with:
composer test # vendor/bin/phpunit composer lint # vendor/bin/pint
Versioning
This package follows Semantic Versioning. As of 1.0.0 the public API — the
Vin facade, the Contracts\VinDecoder driver seam, VehicleData, VinLookupException, and the
vin.* config keys / env vars — is stable and covered by SemVer. See the CHANGELOG
for what each release adds.
License
The MIT License (MIT). See LICENSE.
