janisvepris / gs1-decoder
A library for parsing GS1 codes in PHP
Requires
- php: ^8.2
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.75
- phpstan/phpstan: ^2
- phpunit/phpunit: ^11
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
GS1 Barcode decoder
A zero-dependency PHP library that decodes GS1 barcode strings into typed application identifier objects.
Every application identifier in the GS1 General Specifications is supported — see supported identifiers for the full list, which is generated from the code.
Installation
This package requires PHP ^8.2 and has no runtime dependencies.
composer require janisvepris/gs1-decoder
Usage
<?php use Janisvepris\Gs1Decoder\Decoder\Decoder; use Janisvepris\Gs1Decoder\Enum\Gs1ApplicationIdentifier; $decoder = new Decoder(); $decoded = $decoder->decode('0109521234543213' . '3103000189' . '17250630'); $decoded->hasIdentifier(Gs1ApplicationIdentifier::Gtin); // true $decoded->getIdentifier(Gs1ApplicationIdentifier::Gtin)->getValue(); // '09521234543213' $decoded->getIdentifier(Gs1ApplicationIdentifier::NetWeightKg)->getValue(); // 0.189 $decoded->getIdentifier(Gs1ApplicationIdentifier::ExpirationDate)->getValue(); // DateTime
Application identifier codes are cases of the Gs1ApplicationIdentifier enum rather than strings, so a typo is a
compile-time problem rather than a silent null. If you are handed a code as a string, tryFrom() turns it into a
case, or null when the package does not know it:
$code = Gs1ApplicationIdentifier::tryFrom('01'); // Gs1ApplicationIdentifier::Gtin $code = Gs1ApplicationIdentifier::tryFrom('9999'); // null
Set the delimiter to match your input
Variable length identifiers are terminated by an FNC1 separator. The default delimiter is the literal string
[FNC1], which is not what a barcode scanner produces — scanners emit FNC1 as the ASCII group separator, 0x1D.
If you are decoding scanner or raw symbol data, set the delimiter first:
$decoder = (new Decoder())->setDelimiter("\x1d");
Getting this wrong does not raise an error, it silently mis-decodes: the variable length field runs on past its real end and swallows the identifiers that follow it.
$scanned = "0109521234543213" . "10ABC123" . "\x1d" . "17250630"; (new Decoder())->decode($scanned)->getIdentifierCount(); // 2 - AI 10 ate the rest (new Decoder())->setDelimiter("\x1d")->decode($scanned)->getIdentifierCount(); // 3 - correct
Reading the result
decode() returns a Barcode:
$decoded->getRawValue(); // the string that was decoded $decoded->getIdentifierCount(); // how many identifiers were found $decoded->getAllIdentifiers(); // ApplicationIdentifierInterface[] $decoded->toArray(); // plain nested array, safe to json_encode
Every identifier exposes:
$identifier->getCode(); // Gs1ApplicationIdentifier case $identifier->getValue(); // typed value - see below $identifier->getRawValue(); // the characters as they appeared in the barcode $identifier->getEnglishTitle(); // e.g. 'Net weight, KG'
A repeated identifier code overwrites the earlier one, so a Barcode holds at most one identifier per code.
Typed values
getValue() is typed per identifier, and some shapes expose extra accessors for the parts of a compound field:
| Identifiers | getValue() |
Also available |
|---|---|---|
| Most identifiers | string |
|
Measures and amounts (31nn–36nn, 390, 392, 394, 395) |
float, already shifted by the decimal point position |
getDecimalPosition() |
Dates (11–17, 4326, 7006) |
DateTime at the end of the day |
|
Date and time (4324, 4325, 7003, 7250, 7251, 8008) |
DateTime as given |
|
Harvest date (7007) |
DateTime opening the range at midnight |
getEndDate(): ?DateTime |
Test by date (7011) |
DateTime |
hasTime(): bool |
Amount with currency (391, 393) |
float amount |
getCurrencyCode() |
Postal code / processor with country (421, 7030–7039) |
string without the country code |
getCountryCode() |
Certification reference (7230–7239) |
string without the scheme |
getSchemeId() |
GRAI (8003) |
string serial, empty when absent |
getAssetType() |
ITIP (8006, 8026) |
string, the whole 18 digit field |
getGtin(), getPieceNumber(), getTotalPieces() |
Temperature (4330–4333) |
float, negative when the field is signed |
isNegative() |
$amount = $decoded->getIdentifier(Gs1ApplicationIdentifier::AmountPayableWithIsoCurrency); $amount->getCurrencyCode(); // '978' $amount->getValue(); // 1234.56 $itip = $decoded->getIdentifier(Gs1ApplicationIdentifier::Itip); $itip->getGtin(); // '98410843114508' $itip->getPieceNumber(); // 2 $itip->getTotalPieces(); // 3
What the decoder does not do
It does not validate. There is no check digit verification, no character set enforcement, and no check that an identifier is allowed to appear where it does. The decoder reads what it can and hands it back.
It does not throw for bad input. Decoding always returns a Barcode, possibly a partial one:
- an identifier whose value cannot be parsed — an impossible date, say — is skipped, and decoding continues
- a truncated identifier at the end of the barcode ends the scan, keeping whatever was found before it
- characters that do not resolve to a known identifier code end the scan. The code being accumulated is
never reset, so the decoder consumes the rest of the barcode looking for a match and finds nothing:
0109521234543213ZZ17250630yields only the GTIN, and the17after the junk is never seen
If you need to know that a barcode was fully understood, compare getIdentifierCount() against what you expect, or
check for the identifiers you require.
Undelimited variable length fields will over-consume. This is inherent to the format rather than a limitation of the decoder: a variable length identifier reads until the delimiter or its maximum length, so if it is neither last in the barcode nor followed by an FNC1, it will take the following identifier's characters with it. Setting the right delimiter is what avoids this.
Customising
Restrict or replace the identifier map
The decoder is built with a map of every identifier that ships with the package. Passing your own replaces it entirely, which is a cheap way to decode only the identifiers you care about:
<?php use Janisvepris\Gs1Decoder\ApplicationIdentifier\Gtin; use Janisvepris\Gs1Decoder\ApplicationIdentifier\ExpirationDate; use Janisvepris\Gs1Decoder\Decoder\Decoder; use Janisvepris\Gs1Decoder\Enum\Gs1ApplicationIdentifier; use Janisvepris\Gs1Decoder\IdentifierMap\IdentifierMap; $decoder = new Decoder(new IdentifierMap([ Gs1ApplicationIdentifier::Gtin->value => Gtin::class, Gs1ApplicationIdentifier::ExpirationDate->value => ExpirationDate::class, ]));
The map is keyed by ->value rather than by the case itself, because PHP arrays cannot be keyed by an enum instance.
Its methods take the case:
$map = $decoder->getIdentifierMap(); $map->hasIdentifierClass(Gs1ApplicationIdentifier::Gtin); $map->getIdentifierClass(Gs1ApplicationIdentifier::Gtin); $map->removeIdentifierClass(Gs1ApplicationIdentifier::Gtin); $map->addIdentifierClass(Gs1ApplicationIdentifier::Gtin, Gtin::class);
Define your own identifier class
Extend the abstract that matches the field's shape, assign the enum case, and declare the lengths:
<?php use Janisvepris\Gs1Decoder\ApplicationIdentifier\Abstract\SimpleIdentifier; use Janisvepris\Gs1Decoder\Decoder\Decoder; use Janisvepris\Gs1Decoder\Enum\Gs1ApplicationIdentifier; class MyGtin extends SimpleIdentifier { protected Gs1ApplicationIdentifier $code = Gs1ApplicationIdentifier::Gtin; protected int $length = 14; protected string $englishTitle = 'GTIN, our wording'; } // addIdentifierClass() throws DuplicateIdentifierCodeException if the code is already // mapped, so remove the shipped class before swapping yours in $decoder = new Decoder(); $decoder->getIdentifierMap() ->removeIdentifierClass(Gs1ApplicationIdentifier::Gtin) ->addIdentifierClass(Gs1ApplicationIdentifier::Gtin, MyGtin::class);
The abstracts in ApplicationIdentifier\Abstract\ cover the shapes the specification uses:
| Abstract | Field | Value |
|---|---|---|
SimpleIdentifier |
fixed length | string |
VariableLengthIdentifier |
min to max length | string |
DecimalIdentifier |
fixed length, decimal point position | float |
VariableLengthDecimalIdentifier |
variable length, decimal point position | float |
CurrencyAmountIdentifier |
ISO 4217 currency code plus amount | float |
DateIdentifier |
YYMMDD |
DateTime |
DateTimeIdentifier |
YYMMDDHHMM |
DateTime |
LongDateIdentifier |
YYYYMMDD or YYYYMMDDHHMM |
DateTime |
OptionalTimeDateIdentifier |
YYMMDD with optional HHMM |
DateTime |
DateRangeIdentifier |
start date with optional end date | DateTime |
ProductionDateTimeIdentifier |
YYMMDDHH with optional minutes and seconds |
DateTime |
PrefixedIdentifier |
leading characters that identify rather than describe | string |
TemperatureIdentifier |
six digits with an optional sign | float |
ItipIdentifier |
GTIN plus piece number plus total pieces | string |
PrefixedIdentifier is the base for CountryPrefixedIdentifier, CertificationReferenceIdentifier and
AssetTypePrefixedIdentifier; extend it directly if you need a different prefix length.
Anything implementing ApplicationIdentifierInterface works, and implementing DecimalIdentifierInterface or
VariableLengthIdentifierInterface is what makes the decoder consume a decimal point position or read up to a
delimiter — the decoder branches on the contracts in ApplicationIdentifier\Contract\, not on the abstracts.
Because a code is an enum case rather than an arbitrary string, an identifier class can only carry a code the enum
already lists. Overriding a shipped identifier works, but decoding a code this package does not know needs a case
added to Gs1ApplicationIdentifier first — a map entry on its own is never reached.
Development
composer test # phpunit composer cs # php-cs-fixer, dry run - CI fails on any diff composer cs-fix # php-cs-fixer, apply composer stan # phpstan, level 9 composer check-all # cs + stan + test
docs/SupportedIdentifiers.md is generated; run composer generate-ai-list after adding an identifier rather than
editing it by hand.
Changelog
Notable changes are recorded in CHANGELOG.md.
License
MIT. See LICENSE.