Skip to content

Latest commit

 

History

History
486 lines (359 loc) · 20.1 KB

File metadata and controls

486 lines (359 loc) · 20.1 KB

Laravel VIN

Laravel VIN — powerful VIN lookup for Laravel

Latest Version on Packagist Tests License: MIT

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_VERSION when 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, call Vin::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.