PHPackages                             alwayscurious/laravel-vin - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. alwayscurious/laravel-vin

ActiveLibrary[Utility &amp; Helpers](/categories/utility)

alwayscurious/laravel-vin
=========================

Decode US vehicle VINs into year, make, model and more via the NHTSA vPIC API.

v1.0.0(1mo ago)412↓83.3%MITPHP ^8.3

Since Jul 6Compare

[ Source](https://github.com/AlwaysCuriousCo/laravel-vin)[ Packagist](https://packagist.org/packages/alwayscurious/laravel-vin)[ Docs](https://github.com/alwayscurious/laravel-vin)[ RSS](/packages/alwayscurious-laravel-vin/feed)WikiDiscussions Synced 1w ago

READMEChangelog (5)Dependencies (7)Versions (4)Used By (0)

Laravel VIN
===========

[](#laravel-vin)

[![Laravel VIN — powerful VIN lookup for Laravel](art/header.png)](art/header.png)

[![Latest Version on Packagist](https://camo.githubusercontent.com/55e560f5c405755358b1348a92e35d21a2d3394815c2041fba5024732dce92c6/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f616c77617973637572696f75732f6c61726176656c2d76696e2e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/alwayscurious/laravel-vin)[![Tests](https://github.com/alwayscurious/laravel-vin/actions/workflows/tests.yml/badge.svg)](https://github.com/alwayscurious/laravel-vin/actions/workflows/tests.yml)[![License: MIT](https://camo.githubusercontent.com/942e017bf0672002dd32a857c95d66f28c5900ab541838c6c664442516309c8a/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d626c75652e7376673f7374796c653d666c61742d737175617265)](LICENSE)

Decode a US vehicle VIN into year, make, model, series, trim, body class and more via the [NHTSA vPIC API](https://vpic.nhtsa.dot.gov/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
------------

[](#requirements)

- PHP `^8.3`
- Laravel 11, 12 or 13 (`illuminate/*` `^11.0|^12.0|^13.0`)

Installation
------------

[](#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

[](#configuration)

Every setting is driven by an environment variable, so you rarely need to touch the published config file:

Env varConfig keyDefaultPurpose`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
-----

[](#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()`

[](#trylookup)

Returns `null` instead of throwing on any failure:

```
$vehicle = Vin::tryLookup('7YAMYFS50TY009706');

if ($vehicle !== null) {
    // ...
}
```

### `isValid()`

[](#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

[](#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

[](#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

[](#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

[](#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

[](#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

[](#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](#only-need-yearmakemodel-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()`

[](#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

[](#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

[](#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

[](#only-need-yearmakemodel-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 groupsRaw `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

[](#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

[](#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
------------------------------

[](#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
-------

[](#testing)

### `Vin::fake()` — the easy way

[](#vinfake--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

[](#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
----------

[](#versioning)

This package follows [Semantic Versioning](https://semver.org). 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](CHANGELOG.md)for what each release adds.

License
-------

[](#license)

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

###  Health Score

41

—

FairBetter than 87% of packages

Maintenance90

Actively maintained with recent releases

Popularity9

Limited adoption so far

Community2

Small or concentrated contributor base

Maturity51

Maturing project, gaining track record

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~1 days

Total

3

Last Release

47d ago

Major Versions

v0.1.0 → v1.0.02026-07-07

### Community

Maintainers

![](https://www.gravatar.com/avatar/fa0676f3242298155f63579f1f3823e6cc441cb651e35aa4423494a30ccf22ce?d=identicon)[codearachnid](/maintainers/codearachnid)

---

Tags

laravelautomotivevehiclevinvin decodernhtsavpic

###  Code Quality

TestsPHPUnit

Code StyleLaravel Pint

### Embed Badge

![Health badge](/badges/alwayscurious-laravel-vin/health.svg)

```
[![Health](https://phpackages.com/badges/alwayscurious-laravel-vin/health.svg)](https://phpackages.com/packages/alwayscurious-laravel-vin)
```

###  Alternatives

[psalm/plugin-laravel

Psalm plugin for Laravel

3345.4M354](/packages/psalm-plugin-laravel)[laravel/mcp

Rapidly build MCP servers for your Laravel applications.

79227.1M246](/packages/laravel-mcp)[aedart/athenaeum

Athenaeum is a mono repository; a collection of various PHP packages

265.2k](/packages/aedart-athenaeum)[propaganistas/laravel-disposable-email

Disposable email validator

6023.2M7](/packages/propaganistas-laravel-disposable-email)[api-platform/laravel

API Platform support for Laravel

58190.1k21](/packages/api-platform-laravel)[defstudio/telegraph

A laravel facade to interact with Telegram Bots

818355.4k3](/packages/defstudio-telegraph)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
