chemaclass / edifact-parser
An EDIFACT file parser to extract the values from any defined segment
Fund package maintenance!
Requires
- php: >=8.0
- ext-json: *
- ext-mbstring: *
- sabas/edifact: ^1.2
- webmozart/assert: ^1.12
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.57
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^9.6
- rector/rector: ^2.0
- symfony/var-dumper: ^5.4
- vimeo/psalm: ^4.30
This package is auto-updated.
Last update: 2026-07-24 07:50:53 UTC
README
A complete PHP toolkit for UN/EDIFACT β read, write, validate, and stream EDI interchanges with a typed, object-oriented API.
EDIFACT β Electronic Data Interchange For Administration, Commerce, and Transport β is the international standard for structured business documents (orders, invoices, despatch advices, transport instructions). π New to it? Start here.
Why this library
- π₯ Parse any interchange β unknown tags degrade gracefully to raw values, so no message type is unsupported.
- π€ Write it back β serialize segments to a valid
.edistring, or assemble a fullUNBβ¦UNZinterchange with auto-computed control counts. - β Validate β pluggable rule sets for required segments, cardinality, and order.
- π Stream β parse multi-gigabyte files in bounded memory (one message at a time).
- π§± Model the full envelope β interchange β functional groups (
UNG/UNE) β messages, with duplicate-preserving access and typed metadata on every envelope segment. - π·οΈ 32 typed segments out of the box with domain accessors, plus qualifier constants β and trivially extensible with your own.
- π Fluent query API and a statistics analyzer for extracting data.
- π Charset-aware (
UNOAβ¦UNOY), strictly typed (PHP 8.0+, PSR-4), and fully covered by PHPUnit, PHPStan, Psalm, Rector and PHP-CS-Fixer.
Table of Contents
- Installation
- Quick Start
- Parsing Β· Streaming large files
- Reading data Β· Typed accessors Β· Query API Β· Line items Β· Context hierarchy Β· Envelope metadata Β· Functional groups Β· Statistics Β· Qualifier constants Β· Character sets Β· Built-in segments
- Writing EDIFACT
- Validation
- Extending
- Debugging
- Development
- Contributing
πΎ Installation
composer require chemaclass/edifact-parser
Requires PHP 8.0+ with ext-json and ext-mbstring.
π Quick Start
<?php declare(strict_types=1); use EdifactParser\EdifactParser; require 'vendor/autoload.php'; $result = EdifactParser::createWithDefaultSegments() ->parseFile('/path/to/order.edi'); // or ->parse($ediString) foreach ($result->transactionMessages() as $message) { echo $message->messageType(); // 'ORDERS', 'INVOIC', 'IFTMIN', β¦ // Typed accessors β no magic array indices $buyer = $message->segmentByTagAndSubId('NAD', 'BY'); echo $buyer?->name(); // 'ACME Corporation' echo $buyer?->countryCode(); // 'DE' foreach ($message->lineItems() as $lineItem) { $qty = $lineItem->segmentByTagAndSubId('QTY', '21'); echo $qty?->quantityAsFloat(); // 100.0 } }
The parser never throws on unknown segments β they become UnknownSegments you can still
read via rawValues(), so you can process any interchange and add typed segments later.
π₯ Parsing
EdifactParser::parse() / parseFile() return a ParserResult:
$result = EdifactParser::createWithDefaultSegments()->parse($ediString); $result->transactionMessages(); // list<TransactionMessage> β the UNHβ¦UNT blocks $result->functionalGroups(); // list<FunctionalGroup> β UNGβ¦UNE groups, if any $result->globalSegments(); // TransactionMessage β file-level UNA/UNB/UNZ
A message starts at UNH and ends at UNT; an interchange wraps messages between
UNB and UNZ, optionally grouped by UNG/UNE. Invalid input throws
InvalidFile.
Streaming large files
Stream messages one at a time in bounded memory β ideal for large interchanges. A
leading UNA service-string advice (custom separators/release char) is honoured
automatically:
use EdifactParser\StreamingParser; foreach (StreamingParser::createWithDefaultSegments()->parseFile('/path/to/large.edi') as $message) { process($message); // only one message is held in memory at a time }
π Reading data
Typed accessors
Typed segments expose their fields as methods β self-documenting and IDE-friendly:
// NAD (Name & Address) $nad->partyQualifier(); // 'BY' $nad->name(); // 'ACME Corporation' $nad->street(); $nad->city(); $nad->postalCode(); $nad->countryCode(); // ISO 3166-1 alpha-2 // QTY / PRI β with numeric conversion $qty->quantityAsFloat(); // float $qty->measureUnit(); // 'PCE', 'KGM', β¦ $pri->priceAsFloat(); // float // DTM β with date parsing $dtm->asDateTime(); // DateTimeImmutable|null
Every segment also exposes the raw structure when you need it:
$segment->tag(); // 'NAD' $segment->subId(); // 'BY' $segment->rawValues(); // ['NAD', 'BY', ['0410106314', '160', 'Z12'], β¦]
Accessing segments
// Fastest single lookup, by tag + subId $nad = $message->segmentByTagAndSubId('NAD', 'BY'); // ?SegmentInterface // All segments with a tag (keyed by subId) $allNad = $message->segmentsByTag('NAD'); $nad?->name(); // always null-check β not every segment exists in every message
Fluent query API
Chain filters and transformations over every segment (order preserved, duplicates included):
// Filter $message->query()->withTag('NAD')->withSubId('CN')->get(); $message->query()->withTags(['NAD', 'LIN'])->get(); $message->query()->ofType(NADNameAddress::class)->get(); $message->query()->withTag('PRI')->where(fn($s) => $s->priceAsFloat() > 1000)->get(); // Chain + paginate $message->query() ->withTag('NAD')->withSubId('SU') ->where(fn($s) => $s->countryCode() === 'DE') ->limit(10)->skip(0)->get(); // Transform / inspect $message->query()->withTag('NAD')->map(fn($s) => $s->name()); $message->query()->withTag('NAD')->first(); // ?SegmentInterface $message->query()->withTag('NAD')->count(); $message->query()->withTag('UNS')->exists(); // bool
query()and$message->segments()return every segment in original order, duplicates included. The keyed lookups (segmentByTagAndSubId(),allSegments()) index by tag + subId and keep the last occurrence β use the query API when duplicates matter.
Line items
Line items group each LIN with its related detail segments (QTY, PRI, PIA, β¦) β
ideal for orders and invoices:
foreach ($message->lineItems() as $lineItem) { $lin = $lineItem->segmentByTagAndSubId('LIN', '1'); $qty = $lineItem->segmentByTagAndSubId('QTY', '21'); echo $lin?->itemNumber(); // product identifier echo $qty?->quantityAsFloat(); }
Hierarchical context segments
Context segments preserve parent β child relationships (e.g. NAD β CTA β COM):
foreach ($message->contextSegments() as $context) { if ($context->tag() === 'NAD') { foreach ($context->children() as $child) { // $child->tag(), $child->rawValues(), β¦ } } }
Interchange & envelope metadata
Every envelope segment exposes typed metadata:
$unb = $result->globalSegments()->segmentByTagAndSubId('UNB', 'UNOC'); $unb?->syntaxIdentifier(); // 'UNOC' $unb?->senderIdentification(); $unb?->recipientIdentification(); $unb?->preparationDate(); // 'YYMMDD' $unb?->interchangeControlReference(); $unz = $result->globalSegments()->segmentByTagAndSubId('UNZ', '1'); $unz?->interchangeControlCount(); // number of messages/groups $unt = $message->query()->withTag('UNT')->first(); // segmentCount(), messageReferenceNumber() $bgm = $message->query()->withTag('BGM')->first(); // documentCode() e.g. '220', documentNumber()
Functional groups (UNG/UNE)
When an interchange wraps messages in UNGβ¦UNE groups, read them directly. Interchanges
without groups return an empty list β messages stay available flat via
transactionMessages():
foreach ($result->functionalGroups() as $group) { $group->messageType(); // e.g. 'ORDERS' (from the UNG) $group->header()->groupReference(); $group->trailer()?->controlCount(); foreach ($group->messages() as $message) { // β¦ } }
Statistics & analysis
MessageAnalyzer extracts counts and aggregates:
use EdifactParser\Analysis\MessageAnalyzer; $analyzer = new MessageAnalyzer($message); $analyzer->getType(); // 'ORDERS' $analyzer->segmentCount(); $analyzer->lineItemCount(); $analyzer->segmentCountByTag('QTY'); $analyzer->getPartyQualifiers(); // ['BY', 'SU', 'CN'] (unique) $analyzer->getCurrencies(); // ['EUR'] $analyzer->calculateTotalAmount('125'); // sum MOA with qualifier 125 $analyzer->calculateTotalQuantity('21'); // sum ordered quantities $analyzer->hasSummarySection(); // UNS present? $analyzer->getSummary(); // array of the above
Qualifier constants
Avoid magic strings with typed qualifier catalogs (IDE autocomplete, usable in match):
use EdifactParser\Segments\Qualifier\NADQualifier; $message->query() ->withTag('NAD') ->where(fn($s) => $s->partyQualifier() === NADQualifier::BUYER) // 'BY' ->get();
| Class | Covers |
|---|---|
NADQualifier |
Party roles β BY, SU, CN, CZ, DP, IV, PR, CA, FW, MF, UC, WH |
QTYQualifier |
Quantity types β 1, 3, 11, 12, 21, 33, 46, 47, 48, 192 |
PRIQualifier |
Price types β AAA, AAB, AAE, AAF, AAG, CAL, CT, DIS, LIS, MIN, RRP |
DTMQualifier |
Date/time types β 137, 2, 3, 4, 10, 11, 13, β¦ |
RFFQualifier |
Reference types β ON, IV, DQ, CU, SRN, CT, POR, β¦ |
Character sets
The parser reads raw bytes. Decode non-ASCII values to UTF-8 from the interchange's syntax identifier:
use EdifactParser\Charset\Charset; $unb = $result->globalSegments()->segmentByTagAndSubId('UNB', 'UNOC'); $unb?->characterEncoding(); // 'ISO-8859-1' $name = Charset::toUtf8($nad->name(), $unb->syntaxIdentifier());
UNOA/UNOB β ASCII, UNOCβUNOK β ISO-8859-*, UNOY β UTF-8.
Built-in segments
32 segments are typed and registered by default:
- Envelope / service:
UNB,UNG,UNH,UNS,UNT,UNE,UNZ - Header:
BGM,DTM,RFF,NAD,CUX,TDT,LOC,FTX - Party / terms:
CTA,COM,PAT,PCD,TAX,TOD - Detail / summary:
LIN,PIA,IMD,QTY,PRI,MEA,PAC,GID,MOA,PCI,CNT
Any other tag parses as an UnknownSegment (readable via rawValues()); add your own typed
class in a few lines β see Extending.
π€ Writing EDIFACT
Build individual segments
Fluent, type-safe builders produce segment objects:
use EdifactParser\Segments\NADNameAddress; use EdifactParser\Segments\Qualifier\NADQualifier; $nad = NADNameAddress::builder() ->withQualifier(NADQualifier::BUYER) ->withPartyId('123456') ->withName('ACME Corporation') ->withCity('Springfield') ->withCountryCode('US') ->build();
NADNameAddress, QTYQuantity and PRIPrice provide ::builder().
Serialize segments to a string
EdifactSerializer is the inverse of parsing β it round-trips a parsed interchange
byte-for-byte and escapes separators/release chars for you:
use EdifactParser\Serializer\EdifactSerializer; use EdifactParser\Serializer\UnaSeparators; $serializer = new EdifactSerializer(); echo $serializer->serializeSegment($nad); // NAD+BY+123456++ACME Corporation++Springfield+++US' $edi = $serializer->serialize([$unh, $bgm, $nad, $unt], includeUna: true); // Custom delimiters new EdifactSerializer(new UnaSeparators(component: '#', element: '|'));
Assemble a full interchange
InterchangeBuilder writes a complete UNBβ¦UNZ interchange and fills in the UNT segment
counts and the UNZ control count automatically:
use EdifactParser\Writer\InterchangeBuilder; use EdifactParser\Writer\MessageBuilder; $edi = InterchangeBuilder::create('SENDER', 'RECIPIENT', 'REF1') ->preparedAt('200101', '1200') ->addMessage( MessageBuilder::create('1', 'ORDERS') ->addSegment($bgm) ->addSegment($nad) ) ->toString(); // ready-to-send EDIFACT string
β Validation
Check a message against a pluggable rule set β required segments, cardinality, and relative order. The validator never throws; an empty result means the message conforms:
use EdifactParser\Validation\MessageRuleSet; use EdifactParser\Validation\MessageValidator; $rules = MessageRuleSet::forType('ORDERS') ->require('UNH', 'BGM', 'UNT') // mandatory segments ->occurs('NAD', 1, 5) // between 1 and 5 NAD segments ->occurs('LIN', 1) // at least 1 line item ->inSequence('UNH', 'BGM', 'UNT'); // relative order of these tags $validator = new MessageValidator(); foreach ($validator->validate($message, $rules) as $violation) { echo "{$violation->segmentTag()}: {$violation->message()}\n"; } $validator->isValid($message, $rules); // bool
Ready-made rule sets for common message types are provided as starting points:
use EdifactParser\Validation\MessageRuleSets; $validator->validate($message, MessageRuleSets::orders()); // orders(), invoic(), desadv(), iftmin()
π§ Extending
Custom segments
Extend AbstractSegment and register your class. The shared accessor helpers
(element(), component(), firstComponent()) safely read simple and composite elements:
namespace YourApp\Segments; use EdifactParser\Segments\AbstractSegment; /** @psalm-immutable */ final class EQDEquipmentDetails extends AbstractSegment { public function tag(): string { return 'EQD'; } // EQD+CN+ABCU1234567+22G1 public function equipmentQualifier(): string { return $this->element(1); // 'CN' } public function equipmentId(): string { return $this->firstComponent(2); // 'ABCU1234567' } }
use EdifactParser\EdifactParser; use EdifactParser\Segments\SegmentFactory; use YourApp\Segments\EQDEquipmentDetails; $factory = SegmentFactory::withAdditionalSegments([ 'EQD' => EQDEquipmentDetails::class, // added on top of the 32 built-ins ]); $parser = new EdifactParser($factory);
withAdditionalSegments()keeps every default and merges your tags on top β registering a custom class under a default tag overrides that default. UsewithSegments()instead when you want an explicit, closed set of segments.
Composable segment bundles
The defaults are exposed as two composable bundles so you can build a lean factory
that only types the tags you care about β everything else still parses as a readable
UnknownSegment:
SegmentFactory::ENVELOPE_SEGMENTSβ the UN* service/control segments (7).SegmentFactory::BUSINESS_SEGMENTSβ header, party/terms, detail and summary (25).SegmentFactory::DEFAULT_SEGMENTSβ the union of both (32).
// Envelope structure + just the segments you extract: $factory = SegmentFactory::withSegments( SegmentFactory::ENVELOPE_SEGMENTS + [ 'NAD' => NADNameAddress::class, 'LIN' => LINLineItem::class, ], );
Custom grouping rules
Context hierarchies and line-item boundaries are driven by GroupingRules. Pass a
customized instance to change which tags open a context, attach as children, or close a
line-item section:
use EdifactParser\EdifactParser; use EdifactParser\GroupingRules; use EdifactParser\Segments\SegmentFactory; $rules = GroupingRules::default() ->withContextTags(['NAD', 'LIN']) ->withChildTags(['CTA', 'COM', 'DTM']) ->withBreakLineItemTags(['UNS', 'CNT', 'UNT']); $parser = new EdifactParser(SegmentFactory::withDefaultSegments(), $rules);
More examples in example/: extracting data,
query filtering,
printing segments,
context segments.
π Debugging
$segment->toArray(); // ['tag' => 'NAD', 'subId' => 'CN', 'rawValues' => [...]] $segment->toJson(); // pretty-printed JSON
Error handling
use EdifactParser\Exception\InvalidFile; try { $result = $parser->parseFile('invalid.edi'); } catch (InvalidFile $e) { $e->getErrors(); // parser errors $e->getContext(); // extra context, formatted into getMessage() }
π οΈ Development
composer install composer test # PHPUnit (unit + functional) composer quality # PHP-CS-Fixer, Psalm, PHPStan, Rector composer csfix # apply code-style fixes
- PHP 8.0+, strict types, PSR-4. Type hints and tests required for new functionality.
- All code must pass PHP-CS-Fixer, Psalm, PHPStan and Rector (CI is authoritative).
Local toolchain note: the pinned Psalm (
vimeo/psalm ^4.30) runs on PHP β€ 8.3 β run it under 8.3 if your CLI is newer. On PHP > 8.3, PHP-CS-Fixer needsPHP_CS_FIXER_IGNORE_ENV=1.
π€ Contributing
Contributions of all kinds are welcome β bug fixes, ideas, and improvements.
- π Report an issue
- π§ Open a pull request
π See the contributing guide to get started.