An ADT/HL7 parser providing structured messages

Maintainers

Package info

github.com/RoundingWell/hl7

pkg:composer/roundingwell/hl7

Transparency log

Statistics

Installs: 92

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

0.9.0 2026-07-23 13:48 UTC

README

An ADT/HL7 parser for PHP that turns raw HL7 messages into strongly-typed, structured objects.

Instead of hand-splitting pipe-delimited strings, you parse a message once and read its segments, fields, and data-type components through named accessors.

AI Note

This project contains code written by both humans and agentic tools. The core functionality was developed entirely by humans. Agentic tools have been used to expand the functionality and test coverage of the project. All code is reviewed by a human before being merged.

Requirements

  • PHP 8.4 or newer

Installation

composer require roundingwell/hl7

Quick start

use RoundingWell\HL7\MessageFactory;

$factory = new MessageFactory();

// Parse from a string...
$message = $factory->parse($rawHl7);

// ...or straight from a file.
$message = $factory->parseFile('/path/to/message.hl7');

The factory reads the delimiter and encoding characters from the MSH segment, detects the line ending (\r, \n, or \r\n), and returns a Message. When the message type maps to a known trigger event (for example A01), a message subclass is returned; otherwise a generic Message is used.

Reading segments and fields

$msh = $message->getMSH();
echo $msh->getMessageControlId()->getValue(); // "599102"
echo $msh->getMessageType()->getTriggerEvent()->getValue(); // "A01"

// Assuming the message is an A01
$pid = $message->getPID();

echo $pid->getDateOfBirth()->getValue();

// Repeating fields return a list of data-type instances.
foreach ($pid->getPatientName() as $name) {
    echo $name->getGivenName()->getValue();               // "DONALD"
    echo $name->getFamilyName()->getSurname()->getValue(); // "DUCK"
}

// Multiple occurrences of the same segment (e.g. DG1) are available too.
$diagnoses = $message->listDG1();

Some messages nest repeating groups of segments (e.g. the PROCEDURE group in an A01, which bundles a PR1 with its ROL segments). Groups expose the same lookup helpers as a message:

foreach ($message->getAll('PROCEDURE') as $procedure) {
    $pr1 = $procedure->get('PR1');
    $roles = $procedure->getAll('ROL');
}

Composite data types expose their components through named accessors, and those components may themselves be composites (sub-components), so you can drill down as far as the data type defines:

foreach ($pid->getIdentifierList() as $cx) {
    echo $cx->getId()->getValue();                              // "10006579"
    echo $cx->getAssigningAuthority()->getNamespaceId()->getValue(); // "1"
    echo $cx->getIdentifierTypeCode()->getValue();              // "MRN"
}

Every composite also exposes its components positionally via getComponent(int $index) (0-based) and getComponents(), which the named accessors are built on.

Generating acknowledgments

Any parsed message can produce an ACK response. Supply an acknowledgment code, a PSR-20 clock (for MSH-7), and an IdGenerator (for the acknowledgment's own MSH-10):

use RoundingWell\HL7\AcknowledgmentCode;
use RoundingWell\HL7\SymfonyUidGenerator;
use Symfony\Component\Clock\NativeClock;

$ack = $message->generateACK(
    AcknowledgmentCode::AA,
    new NativeClock(),
    new SymfonyUidGenerator(),
);

generateACK() swaps the sender/receiver, echoes the request's control ID into MSA-2, and writes the acknowledgment code to MSA-1. The returned ACK is a Message object, which can be serialized back to HL7 (see below).

SymfonyUidGenerator and NativeClock require the optional symfony/uid and symfony/clock packages. Any PSR-20 clock and any IdGenerator implementation work.

Serializing back to HL7

Any Message can be turned back into a wire string with serialize():

$wire = $message->serialize($encoding);

parse() followed by serialize() reproduces the original message, with two deviations from a byte-for-byte round-trip: trailing empty fields, components, and subcomponents are trimmed (HL7 treats trailing delimiters as optional), and segments are joined by the line ending rather than terminated by it (no trailing line ending is appended).

Debugging message structure

When you need to see where a value sits in a parsed message, debug() returns an indented dump of its populated structure. Each element is labelled with its access path and schema name, descending through composites to their subcomponents:

echo $message->debug();
// ADT_A01
//   MSH
//     MSH.1 (Field Separator): |
//     MSH.2 (Encoding Characters): ^~\&
//     MSH.9 (Message Type)
//       MSH.9.1 (Message Type): ADT
//       MSH.9.2 (Trigger Event): A01
//   PID
//     PID.1 (Set ID): 1
//     PID.5 (Patient Name)
//       PID.5.1 (Family Name)
//         PID.5.1.1 (Surname): SMITH
//       PID.5.2 (Given Name): JOHN

Empty fields are omitted, and a repeating field is indexed (PID.3[0], PID.3[1]) only when it has more than one repetition. Untyped content is shown too: values held by an untyped segment or field (see Untyped fields) live in extra components, which the dump renders with no schema name. A lone untyped value collapses onto its field line, while multiple parts expand:

echo $message->debug();
// ADT_A01
//   MSH
//     MSH.1 (Field Separator): |
//     MSH.2 (Encoding Characters): ^~\&
//   ZPD
//     ZPD.1: foo
//     ZPD.2
//       ZPD.2.1: bar
//       ZPD.2.2: baz

Concepts

Type Responsibility
MessageFactory Parses raw HL7 into a Message, resolving encoding and message type.
Message Interface for a whole message: a Group plus getMSH(), getVersion(), parse(), serialize(), and debug().
Group Interface for a named collection of Structures (segments and nested groups) with lookup helpers (get, getAll, getRepetition, getStructures, getNames, isRequired, isRepeating).
Message\ADT\Axx Specific ADT message subclasses (e.g. A01) add named accessors for message-specific segments.
Segment Interface for a collection of numbered fields (each a Type), read with getField() / getFieldRepetition(). Typed subclasses (e.g. PID, MSH) add named accessors.
Type An HL7 data type — a Primitive scalar (ST, NM, DTM, …), a Composite of other types, or a Varies placeholder for undefined fields.
Encoding The field, component, repetition, and sub-component separators, plus the escape and truncation characters and line ending.
AcknowledgmentCode Enum of the HL7 table 0008 acknowledgment codes (AA, AE, AR) accepted by generateACK().
IdGenerator Interface for generating unique message control IDs (MSH-10), e.g. for a generated ACK.
SymfonyUidGenerator IdGenerator implementation backed by symfony/uid, producing time-ordered UUIDv7 identifiers.

Untyped fields

Fields that are not defined for a segment are still parsed so no data is lost. Whole segments that have no typed subclass (for example GT1 in an ADT message) are exposed as GenericSegments, and their fields are GenericComposite instances — a schema-less composite that preserves any component (^) structure instead of flattening it.

A GenericComposite has no defined components, so every parsed component lands in its extra components (getExtraComponents()), each a Varies wrapping a GenericPrimitive. Read a scalar field through its single component:

$gt1 = $message->get('GT1');

$field = $gt1->getFieldRepetition(2, 0);                        // a GenericComposite
echo $field->getExtraComponents()->getComponent(0)->getData()->getValue(); // "8291"

Component structure is retained: an undefined field a^b^c keeps three components (one extra component per ^), and a component carrying subcomponents (a&b) keeps b as a subcomponent of that component rather than promoting it to its own component.

Unmatched segments

Typed messages never silently drop a segment. Any segment the schema cannot place, wherever it appears in the message, is recovered rather than dropped, so parse → serialize round trips do not lose data. A segment name the schema declares is parsed into its declared, typed slot even when it reappears after its slot has already been consumed, so it stays readable through get() / getAll() there like any other occurrence — getAll() may then return more than one match even for a non-repeating definition. Anything the schema does not declare at all — a site-defined Z-segment, or any other unrecognized name — is parsed as a GenericSegment:

$zds = $message->get('ZDS'); // a GenericSegment, readable like any untyped segment

Supported types

Messages: A01, A03, A04, A06, A07, A08, A13, ACK

Segments: DG1, DRG, EVN, MSA, MSH, NK1, OBX, PID, PV1, PV2

Data types: CE, CNE, CP, CWE, CX, DLD, DR, DT, DTM, EI, FC, FNx, Generic, HD, ID, IS, JCC, MO, MSG, NM, PL, PT, SAD, SI, SNM, ST, TS, TX, VID, Varies, XAD, XCN, XON, XPN, XTN

Error handling

Parsing failures throw exceptions extending RoundingWell\HL7\Exception\HL7Exception:

  • InvalidFile — the file does not exist or cannot be read.
  • InvalidMessage — the message is missing its MSH segment, delimiter, or encoding characters.
  • InvalidValue, InvalidDateTime — a field value fails validation.

Looking up structures on a parsed message throws standard SPL exceptions:

  • InvalidArgumentException — requesting a segment, field, or group structure that was never registered.
  • OutOfBoundsException — requesting a repetition of a non-repeating structure, or a negative repetition.

Development

composer lint      # check code style
composer format    # fix code style
composer analyze   # static analysis
composer test      # run tests and enforce 100% coverage
composer verify    # lint + analyze + test

License

Released under the MIT License.