sabas/edifact
A PHP library for parsing, generating, and validating EDIFACT EDI messages. It offers a fluent API for building segments and components, supports common message types, and works in Laravel or standalone PHP projects.
Install the package and let Composer resolve it:
composer require sabas/edifact
Where to look first:
src/Parser.php — the entry point; parse(string $raw): Interchange[].src/Interchange.php — the UNB/UNZ envelope: sender, receiver, control reference, messages.src/Message.php — the UNH/UNT transaction: message type/version, segments.src/Segment.php and src/Element.php — the segment/element/component tree.src/Segments/ — optional segment-specific helpers (BGM, DTM, NAD, RFF, LIN, QTY, etc.).src/Encoder.php — the reverse direction if you generate EDI. Confirm the exact constructor/API in the version you install, because this part of the surface has changed over time.First use case — parse an inbound ORDERS:D:96A and read the header:
use EDI\Parser;
$raw = file_get_contents(storage_path('app/edi/orders.edi'));
// parse() returns an array of Interchange objects
foreach ((new Parser())->parse($raw) as $interchange) {
echo $interchange->getSender(); // e.g. 123456789
echo $interchange->getControlNumber();
foreach ($interchange->getMessages() as $message) {
// e.g. ORDERS / D / 96A — verify method names in src/Message.php
echo $message->getMessageType();
echo $message->getMessageVersion();
echo $message->getMessageRelease();
foreach ($message->getSegments() as $segment) {
if ($segment->getName() === 'BGM') {
$elements = $segment->getElements();
// element 0: document name code, element 1: document number
$documentNumber = $elements[1]?->getValue();
}
}
}
}
A sensible Laravel scaffold:
app/Services/Edifact/EdifactReader.php # parse + map to DTOs
app/Services/Edifact/EdifactWriter.php # build outbound messages
app/Jobs/ProcessEdifactFile.php # queue entry point
config/edifact.php # separators, charset, partner mappings
tests/Fixtures/edi/*.edi # real samples from each partner
Bind EDI\Parser as a singleton in a small service provider if you want it injectable; it is stateless enough for normal request/worker use.
Do not let EDI\Segment objects leak into controllers or models. Isolate them in a mapper:
namespace App\Services\Edifact;
use EDI\Parser;
use EDI\Segment;
final class EdifactReader
{
public function __construct(private Parser $parser) {}
/** @return array<int, array<string, mixed>> */
public function readOrders(string $raw): array
{
$orders = [];
foreach ($this->parser->parse($raw) as $interchange) {
foreach ($interchange->getMessages() as $message) {
if ($message->getMessageType() !== 'ORDERS') {
continue;
}
$orders[] = $this->mapOrder($message);
}
}
return $orders;
}
private function mapOrder($message): array
{
$order = ['lines' => []];
$currentLine = null;
foreach ($message->getSegments() as $segment) {
switch ($segment->getName()) {
case 'BGM':
$order['document_type'] = $this->value($segment, 0);
$order['order_number'] = $this->value($segment, 1);
break;
case 'DTM':
// 137 = document date/time
if ($this->value($segment, 0, 0) === '137') {
$order['ordered_at'] = $this->value($segment, 0, 1);
}
break;
case 'NAD':
// BY = buyer, SU = supplier
if ($this->value($segment, 0) === 'BY') {
$order['buyer_name'] = $this->value($segment, 4, 0);
}
break;
case 'LIN':
if ($currentLine !== null) {
$order['lines'][] = $currentLine;
}
$currentLine = [
'line' => $this->value($segment, 0),
'sku' => $this->value($segment, 2, 0), // usually an EAN/GTIN composite
'quantity' => null,
];
break;
case 'QTY':
if ($currentLine !== null) {
$currentLine['quantity'] = $this->value($segment, 0, 1);
}
break;
}
}
if ($currentLine !== null) {
$order['lines'][] = $currentLine;
}
return $order;
}
private function value(?Segment $segment, int $element, int $component = 0): ?string
{
$el = $segment?->getElements()[$element] ?? null;
if (!$el) {
return null;
}
$components = $el->getComponents();
if ($components === []) {
return $el->getValue();
}
return $components[$component]?->getValue();
}
}
The value() helper is worth standardising: it removes the nested getElements()/getComponents() noise and makes magic indexes visible in one place.
EDIFACT is a flat sequence of segments with implicit loops (for ORDERS: LIN starts a line group; QTY, PRI, FTX, RFF inside it belong to that line). The parser gives you the flat list. Reconstruct loops with a small state machine, exactly as in the LIN/QTY case above. Keep the group rules per message type in config or a dedicated class rather than scattering if ($segment->getName() === ...) across the codebase.
Persist the original EDI alongside the parsed result:
Storage::disk('edi')->put("inbound/{$filename}", $raw);
ProcessedEdifact::create([
'filename' => $filename,
'interchange' => $interchange->getControlNumber(),
'message_type' => $message->getMessageType(),
'raw' => $raw,
'parsed' => $order,
]);
When a partner disputes a quantity, the raw file is the only source of truth. Store it before any transformation.
final class ProcessEdifactFile implements ShouldQueue
{
public function __construct(public string $path) {}
public function handle(EdifactReader $reader): void
{
$raw = Storage::disk('edi')->get($this->path);
foreach ($reader->readOrders($raw) as $order) {
$model = Order::create([...]);
event(new EdifactOrderReceived($model));
}
}
}
Poll SFTP/scheduled folders with a separate command that dispatches jobs; keep parsing out of the scheduler itself. Use UNB interchange control reference + UNH message reference as an idempotency key to avoid double-processing when a file is re-picked.
Use the package Encoder if it fits your version; otherwise wrap a small writer that respects the negotiated separators. Either way, build an internal message-array representation first and serialise last:
$payload = [
['BGM', ['220', $order->number, '9']],
['DTM', ['137', $order->created_at->format('YmdHi')]],
['NAD', ['BY', '', '', '', [$order->customer_name]]],
];
foreach ($order->lines as $line) {
$payload[] = ['LIN', [(string) $line->position, '', [$line->ean]]];
$payload[] = ['QTY', ['21', (string) $line->quantity]];
}
$edi = $writer->encode($payload, $sender, $receiver);
This keeps outbound logic testable without string assertions on the full envelope.
The library parses syntax, not the message specification. Add two layers:
BGM, DTM with qualifier 137, at least one LIN).Collect errors into a ValidationResult and reject the file with a partner-readable reason rather than throwing a bare exception.
Put one sample per partner/version in tests/Fixtures/edi/:
public function test_orders_d96a_parses(): void
{
$raw = file_get_contents(__DIR__.'/../Fixtures/edi/orders_d96a.edi');
$orders = app(EdifactReader::class)->readOrders($raw);
$this->assertSame('4500012345', $orders[0]['order_number']);
$this->assertCount(3, $orders[0]['lines']);
}
Add a snapshot/approval test on the mapped array. Partner EDIFACT files are the best regression suite you will ever have.
UNA segment and separators. If the file starts with UNA, it redefines component/element/decimal/release/reserved/segment separators. If it is absent, the defaults (:, +, ., ?, space, ') are assumed. A file using non-standard separators without UNA will parse silently wrong rather than fail loudly. Confirm what your Parser does with UNA and add a config for partner-specific separator sets.
Do not explode() EDIFACT yourself. The release character ? escapes +, :, ?, and the segment terminator (e.g. ?+ is a literal plus, ?? a literal question mark). A naive split will corrupt addresses and free-text fields. Always go through the parser.
Character sets matter. UNOA is uppercase-only; UNOC in newer syntax versions is UTF-8. Partners using UNOB / UNOC with ISO-8859-1 will produce mojibake in names and addresses if you treat the file as UTF-8 blindly. Detect the charset from UNB and mb_convert_encoding() before parsing, or keep everything as raw strings and convert only at output.
Amounts and quantities are strings. QTY, MOA, PRI are numeric strings with a decimal mark defined by UNA (usually ., sometimes ,). Never cast to float for money; use bccomp/bcadd or integer minor units.
Empty elements are meaningful. NAD+BY+++Name has deliberately empty elements; keep positions intact and never array_filter() the element list. Optional trailing elements may also be omitted entirely, so read by index with a null-safe helper.
Loops are not explicit. LIN groups, NAD groups with attached RFF/CTA, and nested SG groups are flat in the parser output. You must implement group boundaries per message type and version.
Version differences. D96A, D01B, D07A, etc. change segment order and composite structures. Treat the message version as first-class: either reject unknown versions or have a mapping class per version. Do not assume a single partner layout.
Multiple messages per interchange, multiple interchanges per file. Always loop parse() → getMessages() → getSegments(). A file with two UNB envelopes returns two Interchange objects; code that assumes $parsed[0] will silently drop the second.
Large files. The parser loads the whole string into memory and builds an object graph. For multi-megabyte partner dumps, process interchange-by-interchange or raise memory_limit on the queue worker. Watch out for max_execution_time in synchronous controllers.
No built-in EDIFACT schema validation. Syntax parsing succeeds on semantically invalid messages. Add your own required-segment checks, and consider a separate validator service for message-level rules.
Licence: LGPL-3.0. Composer autoloading counts as dynamic linkage for most purposes, which is generally fine for proprietary Laravel apps. If you patch the library, those patches must be released under LGPL. Do not copy source files into a closed-source app/ namespace; keep it as a Composer dependency.
Debugging. Pretty-print the parsed tree (segment->getName() + element/component values) next to the raw file. Cross-check against an EDI viewer when a partner says your interpretation is wrong. Log the UNB sender/receiver/control number and UNH message reference on every failure — that is what the partner’s support team will ask for.
Extension points. Segment-specific classes live under EDI\Segments\; check whether your version lets you register custom ones via a segment factory or by extending the parser. If not, keep all segment-specific logic in your own mapper — it is more portable across library upgrades than subclassing internals.
Pin the version and watch upgrades. The package has had API changes in method naming and encoder behaviour across releases. Pin a minor version in composer.json, add fixture tests, and read the diff before bumping.
How can I help you explore Laravel packages today?