juststeveking/gtin-php
Validate and work with GTIN barcodes in PHP. juststeveking/gtin-php offers a clean API for checking GTIN-8, GTIN-12, GTIN-13, and GTIN-14 codes, including check digit verification, so you can trust product identifiers across your Laravel or PHP applications.
Install and verify the dependency graph before you write a line of code.
composer require juststeveking/gtin-php:^1.0
composer show juststeveking/gtin-php
The 1.0.1 patch moved illuminate/support from require to require-dev. In a normal Laravel app this is a no-op (Illuminate is already present), but it matters for slim containers — queue workers built from --no-dev, Lambda layers, standalone CLI importers. If any of those ship the package, confirm it does not reference Illuminate\* at runtime:
composer why illuminate/support
If juststeveking/gtin-php shows up in the output, treat it as a red flag and read src/ before shipping to a dev-less build.
Where to look first, in this order:
src/ — the package is a few small classes. Reading them takes less time than trawling the README and is the only reliable way to learn the real method names (make(), validate(), isValid(), generate() — confirm casing) and the actual namespace. Do not trust examples you find online; this package has ~5 stars and no ecosystem around it.composer.json — check require.php. Last release was January 2021, so PHP 8.2/8.3 are almost certainly undeclared even if the code runs. On PHP 8.3 you'll likely see a Composer warning; that's cosmetic until it isn't.tests/ — the test suite is the canonical example set, especially for the GTIN-8/12/13/14 boundary and leading-zero cases.Your first use case: a supplier product-import form. It's the highest-value, lowest-risk entry point. A buyer pastes or uploads 5012345678900; you reject it before it hits the catalogue table rather than discovering it three steps downstream in a listing or a fulfilment feed.
A five-minute sanity check in php artisan tinker against your own corpus:
use JustSteveKing\Gtin\Gtin; // confirm namespace from src/
Gtin::make('5012345678900')->validate(); // true
Gtin::make('5012345678901')->validate(); // false — bad check digit
Gtin::make('012345678905')->validate(); // GTIN-12, leading zero preserved
If the leading-zero case behaves differently from the same value passed as an integer, you have just found the reason you must never store GTINs as bigint.
The package ships no service provider and no config. That's fine — but you don't want controllers or import mappers calling Gtin::make() directly. Two small classes buy you a swappable seam and one place to enforce string-typed input:
namespace App\Support\Gtin;
interface GtinValidator
{
public function isValid(string $value): bool;
public function generate(int $length = 13): string;
}
namespace App\Support\Gtin;
use JustSteveKing\Gtin\Gtin;
final class PackageGtinValidator implements GtinValidator
{
public function isValid(string $value): bool
{
$value = trim($value);
if ($value === '' || ! ctype_digit($value)) {
return false;
}
try {
return (bool) Gtin::make($value)->validate();
} catch (\Throwable) {
return false;
}
}
public function generate(int $length = 13): string
{
return (string) Gtin::generate($length); // adjust to real API
}
}
Bind it in AppServiceProvider::register():
$this->app->singleton(GtinValidator::class, PackageGtinValidator::class);
The ctype_digit guard and the try/catch are doing real work: they stop a malformed scanner payload or an upstream exception from escaping as a 500 mid-import.
namespace App\Rules;
use App\Support\Gtin\GtinValidator;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
final class ValidGtin implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_scalar($value) || ! app(GtinValidator::class)->isValid((string) $value)) {
$fail('The :attribute is not a valid GTIN (8, 12, 13 or 14 digits with a correct check digit).');
}
}
}
On Laravel 8.x, implement Illuminate\Contracts\Validation\Rule with passes() and message() instead. Also consider Validator::extend('gtin', ...) in AppServiceProvider::boot() — the string form 'gtin' is convenient when rules are assembled from config or iterated dynamically in an import loop.
public function rules(): array
{
return [
'gtin' => ['bail', 'required', 'string', 'max:14', new ValidGtin],
'supplier_sku' => ['required', 'string', 'max:64'],
];
}
bail prevents the rule running against an empty string; string prevents Laravel's implicit numeric coercion from turning 012345678905 into 12345678905 before your rule ever sees it. max:14 is cheap defence against a pasted barcode blob.
A supplier feed should produce a complete rejection report, not a fail-fast on row 3 of 40,000:
foreach ($rows as $index => $row) {
foreach (preg_split('/[\s,;|]+/', (string) $row['gtin'], -1, PREG_SPLIT_NO_EMPTY) as $candidate) {
if (! $this->gtin->isValid($candidate)) {
$errors[] = [
'row' => $index + 2, // +1 for header, +1 for 1-based rows
'value' => $candidate, // store the RAW value
'reason' => 'invalid_check_digit',
];
}
}
}
Store the raw rejected value. When a supplier disputes the rejection, you want their exact string, not your normalised version.
$table->string('gtin', 14)->nullable()->index();
Use an accessor/mutator purely for normalisation at the boundary:
protected function gtin(): Attribute
{
return Attribute::make(
set: fn (?string $value) => $value === null ? null : trim($value),
);
}
That is not validation. Keep the ValidGtin rule on the FormRequest, in the import handler, and in the artisan command that backfills historical data — the third one is the write path everyone forgets, and it typically runs with withoutEvents() and raw queries where mutators and FormRequests are both bypassed.
This is the underrated half of the package:
// database/seeders/ProductSeeder.php
Product::factory()->count(50)->create([
'gtin' => app(GtinValidator::class)->generate(13),
]);
And a negative-path test that proves your rule rejects rather than silently passes:
it('rejects a GTIN with a bad check digit', function () {
$valid = app(GtinValidator::class)->generate(13);
$broken = substr($valid, 0, -1) . ((int) substr($valid, -1) + 1) % 10;
$this->post('/supplier/products', ['gtin' => $broken])
->assertSessionHasErrors('gtin');
});
final class FakeGtinValidator implements GtinValidator
{
public array $seen = [];
public function isValid(string $value): bool
{
$this->seen[] = $value;
return strlen($value) === 13;
}
public function generate(int $length = 13): string
{
return str_pad('1', $length, '0', STR_PAD_LEFT);
}
}
$this->app->instance(GtinValidator::class, $fake) in a test case lets you assert that validation was attempted on the right values without depending on upstream behaviour. That's the payoff for wrapping it in the first place.
Once the form is guarded, run a nightly job over legacy catalogue rows and flag failures into a quarantine table (or a needs_review boolean) rather than nulling them out. Data-quality regressions become visible instead of being discovered by a buyer.
Leading zeros are the whole ballgame. 012345678905 (GTIN-12 / UPC-A) and 12345678905 are not the same identifier. Any int cast, bigint column, (int) conversion, or Excel round-trip silently destroys the leading zero and produces either a false failure or — worse — a false pass against a different product. Enforce string at the form, the cast, the migration, and the model.
Integers "work" until they don't. The package may accept integers, which is convenient in tinker and dangerous everywhere else. GTIN-14 values can exceed a signed 32-bit int, and leading-zero identity is gone the moment you cast. Only ever hand it strings from application code.
Check length before you trust the check digit. The modulo-10 algorithm will happily return "valid" for some wrong-length inputs, because the arithmetic doesn't know which format you intended. Confirm the package enforces the 8/12/13/14 whitelist; if it's lenient, add a strlen() guard in your wrapper.
UPC-A, EAN-13 and GTIN-12/13 are not distinct data types. A GTIN-12 zero-padded to GTIN-13 is the same product. Pick your canonical stored length now (14 is the usual GS1 answer) and normalise on write. Otherwise your uniqueness index will let the same product in twice under two lengths — the package cannot catch that for you.
ITF-14 is not the same promise as GTIN-14. The check digit is identical; the presentation (outer-case barcode, packaging indicator in the leading digit) is not. If your import accepts case-level codes, say so explicitly in the error message, or suppliers will just resend the same rejected value.
The v1.0.1 dependency move is a metadata fix, not a guarantee. Composer will now resolve the package into projects with no Illuminate installed. If any runtime code path still references Illuminate\Support\*, you've converted a version conflict into a production Class not found. Verify on a bare PHP install — composer require in an empty directory, then exercise the API — rather than trusting the manifest.
There is no service provider, no config file, no facade. Don't go hunting for config/gtin.php or a publishable asset — it doesn't exist. Everything you customise lives in your adapter and your rule. That's fine; it just means the ~15 lines of container binding and the test around them are yours.
It validates format, and only format. No GS1 registry lookup, no company-prefix ownership check, no catalogue-wide uniqueness. Be precise in the validation message and in ticket comments: "invalid GTIN" from this rule means wrong length or wrong check digit, nothing more. If the product brief promises "verified product data," that's a separate roadmap item.
Pin and plan the exit now. Last release January 2021, single maintainer, zero dependents. Pin ^1.0, note the decision in your dependency register, and record the fallback: vendoring ~100 lines of MIT code. Because you wrapped it behind GtinValidator, that swap is one class plus a container binding — but only if you resisted calling Gtin::make() from a controller on day one.
Log rejects, don't just return 422. For the first weeks of a supplier onboarding flow, the failure payloads are the data-quality report. Emit a structured log line with supplier id, raw value, and reason, so you can tell product "38% of this supplier's feed has bad check digits" instead of "imports are failing."
Debugging tips. If a valid-looking code fails, in order: (1) var_dump(strlen($value)) — almost always the wrong length; (2) check for a leading zero already lost upstream; (3) check for stray whitespace / non-breaking spaces from copy-pasted spreadsheet cells (trim() handles ASCII, not \u{00A0} — use preg_replace('/\s+/u', '', $value) if suppliers paste from Excel); (4) only then suspect the package itself and test in a bare PHP script outside Laravel.
How can I help you explore Laravel packages today?