roundingwell / hl7
An ADT/HL7 parser providing structured messages
Requires
- php: ^8.4
- psr/clock: ^1.0
Requires (Dev)
- carthage-software/mago: ^1.29
- ergebnis/composer-normalize: ^2.52
- phpunit/phpunit: ^12.5
- rregeer/phpunit-coverage-check: ^0.3.1
- symfony/clock: ^8.0
- symfony/uid: ^8.0
- symfony/var-dumper: ^8.0
Suggests
- symfony/clock: Provides a PSR-20 clock (e.g. NativeClock) to pass to AbstractMessage::generateACK()
- symfony/uid: Required by SymfonyUidGenerator, the reference UUIDv7 IdGenerator implementation
This package is auto-updated.
Last update: 2026-07-23 13:50:03 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).
SymfonyUidGeneratorandNativeClockrequire the optionalsymfony/uidandsymfony/clockpackages. Any PSR-20 clock and anyIdGeneratorimplementation 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 itsMSHsegment, 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.