A small PHP library to parse, write and validate GAEB DA XML 3.3 files.

Maintainers

Package info

github.com/bambamboole/gaeb-php

pkg:composer/bambamboole/gaeb

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 1

v0.1.0 2026-07-31 19:50 UTC

README

A small PHP library that parses GAEB DA XML 3.3 files (exchange phases X80–X87 plus X89 invoices) into a typed, readonly PHP object graph, and writes schema-valid X84 bids, X87 order confirmations, and X89 invoices back out. Reading is lenient — missing optional elements simply become null instead of throwing. Writing is strict — see "Writing a bid (X84)" and "Writing an X89 invoice from a contract" below.

Install

composer require bambamboole/gaeb

Requires PHP ^8.4 and the ext-dom extension (usually bundled) plus brick/math for decimal-exact money handling in the write path. No other runtime dependencies. Built on PHP 8.4's native Dom\XMLDocument API for lightweight XML processing.

Usage

$gaeb = GaebParser::fromString(file_get_contents('tender.x83'));
// returns a GaebFile; the library itself never touches the filesystem

$gaeb->info;      // GaebInfo
$gaeb->project;   // ProjectInfo
$gaeb->boq;       // ?BoQ

foreach ($gaeb->boq->allItems() as $item) { ... }   // lazy, flattened

All money and quantity fields in the object graph are Brick\Math\BigDecimal (?BigDecimal, never floats) — values stay decimal-exact end to end, and json_encode() emits them as strings ("45.50"). Non-numeric content in a numeric element reads as null (lenient), like every other unparseable field.

$gaeb->info exposes the GAEB version, exchange phase (8089), date, and generating program. $gaeb->project exposes the project name, label, and currency. $gaeb->boq is null when the file has no bill of quantities; otherwise it holds the BoQ label/currency/totals plus the top-level category/item tree, and allItems() lazily yields every Item depth-first with its full position number (rNo, e.g. 01.02.0030) resolved. An item's descriptionXml holds the raw XML (the serialized Description element, self-contained with its own xmlns="…" declaration).

The read model covers the money-relevant LV elements beyond plain items: markup/surcharge positions (markupItems on BoQ/BoQCategory, with type, IDREF references, sub-quantities, and — on the priced side — percent and totals), remarks and performanceDescriptions, category totals and notApplicable (NotApplBoQ), unit-price components (Item->upComponents plus the BoQ->noUpComponents/upComponentLabels breakdown labels), item-level vat and discountPercent, per-rate Totals->vatParts, Totals->netUpComponents, and the notOffered/qtyToBeDetermined flags (a not-offered position is distinct from a 0.00 unit price).

X84/X86 files also expose the parties and award data:

$gaeb->owner;      // ?Party — client (Auftraggeber, OWN)
$gaeb->contractor; // ?Party — contractor/bidder (Auftragnehmer, CTR)
$gaeb->award;      // ?AwardData — populated from AwardInfo on any phase; meaningfully filled (dates, duration) on X86

Nachträge (change orders) are exposed on every level that carries them: $gaeb->award->changeOrders lists the AwardInfo/COInfo entries (ChangeOrder — number, phase, ChangeOrderStatus, initiator, reason, date), and BoQ, BoQCategory and Item each expose changeOrderNo/changeOrderStatus from their CONo/COStatus pair (null on main-contract elements). createInvoice() refuses to bill a Nachtrag position whose status is not Approved and carries the CONo/COStatus pair into the written X89.

Item classification & totals

Each Item also carries: provisional (Provisional::WithoutTotal|WithTotal — a Bedarfsposition, null when the item isn't one), hourlyWork (Stundenlohnarbeiten), notApplicable (a dropped/void position), and alternativeGroupNo/alternativeSerialNo (the ALNGroupNo/ALNSerNo pair linking a base position to its alternatives). Sum semantics are the caller's responsibility: for an alternative group only the awarded serial number counts, Provisional::WithoutTotal and notApplicable items are excluded from BoQ totals, and hourlyWork items are typically listed separately from the main sum.

$gaeb->boq->totals (?Totals) holds the full breakdown when present: total, discountPercent, discountAmount, totalAfterDiscount, vat, vatAmount, totalNet, totalGross.

Bid data

Each Item also carries three bid-related fields. textComplements (list<TextComplement>) are the Bieterlücken — gaps embedded in the short/long text that either the tendering office fills in (TextComplementKind::Owner) or the bidder must fill in (TextComplementKind::Bidder). bidderComment is every BidComm on the item flattened and joined with "\n" (null when absent). subDescriptions (list<SubDescription>) are the item's SubDescr children, each with its own subDNo, shortText/longText/ descriptionXml, qty, unit, and unitPrice.

Find the gaps a bidder still needs to fill:

$gaps = array_values(array_filter($item->textComplements, fn ($c) => $c->kind === TextComplementKind::Bidder));

Writing a bid (X84)

GaebDocument opens a received tender (X81/X83) and turns it into a new, schema-valid X84 bid — the source document is never mutated. Collect the bid's prices, filled bidder text gaps, and comments on a Bid builder, then transform:

use Bambamboole\Gaeb\GaebDocument;
use Bambamboole\Gaeb\Write\Bid;
use Bambamboole\Gaeb\Dto\Party;

$tender = GaebDocument::fromString(file_get_contents('tender.x83'));

$contractor = new Party(
    name: 'ACME Bau GmbH',
    street: 'Musterstraße 1',
    zip: '12345',
    city: 'Berlin',
    email: 'info@acme.example',
    phone: '+49 30 1234567',
);

$bid = new Bid($contractor, currency: 'EUR', date: '2026-07-31');
// currency defaults to the source's currency, date defaults to today —
// pass both explicitly for a byte-deterministic output.
// progSystem: 'my-erp 1.0' overrides the <ProgSystem> stamp
// (default 'bambamboole/gaeb'); Invoice takes the same option.

$bid->setUnitPrice('01.02.0010', '12.50')   // decimal strings are exact, floats convenient
    ->fillGap('01.02.0010', 1, 'Musterhersteller GmbH')
    ->setComment('01.02.0010', 'Lieferzeit 4 Wochen')
    ->setNotOffered('01.02.0020');          // spec 4.6.4: distinct from UP 0.00
// ...one setUnitPrice() or setNotOffered() per priceable item (every item
// that isn't marked notApplicable) — createBid() throws GaebWriteException
// naming any that's missing a price, any rNo it doesn't recognize, or any
// position that is both priced and marked not offered.

$award = $tender->createBid($bid);

$errors = $award->validate();   // [] means schema-valid
if ($errors !== []) {
    throw new RuntimeException(implode("\n", $errors));
}

file_put_contents('bid.x84', (string) $award);

The computed BoQ total sums each emitted item's IT, excluding Provisional::WithoutTotal items and non-base alternatives (alternativeGroupNo set with alternativeSerialNo !== 1); notApplicable items are never emitted at all. Reading tolerates schema deviations and wild-file spellings; writing refuses to guess — anything that would corrupt or invalidate the bid (a missing price, an unknown rNo) throws GaebWriteException instead of silently producing a bad file.

Writing an X89 invoice from a contract

use Bambamboole\Gaeb\Dto\InvoiceType;
use Bambamboole\Gaeb\Dto\Payment;
use Bambamboole\Gaeb\GaebDocument;
use Bambamboole\Gaeb\Write\Invoice;

$contract = GaebDocument::fromString(file_get_contents('contract.x86'));

$invoice = new Invoice(
    invoiceNo: 'RE-2026-001',
    invoiceDate: '2026-10-31',
    type: InvoiceType::Deduction,           // Abschlagsrechnung
    servicePeriodStart: '2026-09-01',
    servicePeriodEnd: '2026-10-31',
    creatorTaxNo: 'DE123456789',
    vatPercent: '19',                       // falls back to the contract's Totals/VAT
);
$invoice->billQty('01.0010', '30')          // CUMULATIVE billed quantities
    ->addPayment(new Payment(               // prior payments, emitted as PaymentMade
        total: '1190.00', totalVat: '190.00',
        discountAmount: null,
        paymentDate: '2026-10-05', invoiceNo: 'RE-2026-000',
    ));

$x89 = $contract->createInvoice($invoice);  // new GaebDocument, source untouched
$x89->validate();                           // [] — schema-valid against the official X89 XSD

Reading X89 files works through the same parser: $gaeb->invoice carries the header, parties (with tax number), payments and gross total; billed items appear in the BoQ with Item->billedQty.

E-invoice attachment (X86 → X89B)

Under Germany's e-invoicing mandate the commercial invoice travels as XRechnung/ZUGFeRD — the GAEB X89B ("Rechnungsbegründende Unterlage") is the audit attachment carrying the billed LV alongside it. It applies the same strict billing rules as createInvoice() (cumulative quantities, Nachtrag approval, exact money), but by design carries no commercial data: no recipient, shares, payments, or invoice-level TotalGross — those live in the e-invoice. Its header holds only RefInvoiceNo (the e-invoice's number, taken from Invoice::$invoiceNo) and the service period, so the same Invoice builder produces both documents:

$contract = GaebDocument::fromString(file_get_contents('contract.x86'));
$x89  = $contract->createInvoice($invoice);            // the full GAEB invoice
$x89b = $contract->createSupportingDocument($invoice); // the e-invoice attachment
$x89b->validate();  // [] — against the bundled X89B XSD

Payments and invoice type/date on the Invoice are used by the X89 twin and simply have no representation in the X89B.

Confirming an order (X86 → X87)

The contractor's order confirmation is a re-stamp of the received contract — same content under the DA87 namespace with DP 87 and a fresh GAEBInfo:

$contract = GaebDocument::fromString(file_get_contents('contract.x86'));
$x87 = $contract->createOrderConfirmation(date: '2026-06-20');  // source untouched
$x87->validate();  // []

Together with createBid() and createInvoice() this closes the contractor workflow: bid (X84) → confirm (X87) → invoice (X89).

GaebDocument::validate(?string $xsdDir = null): array schema-checks the document against the XSDs bundled in the package, resolved per DP token and phase family under docs/gaeb/3.3/2021-05_Leistungsverzeichnis/ for X80–X87, 2021-05_Rechnung/ for X89/X89B; pass $xsdDir to validate against a different XSD set instead. An empty array means valid; otherwise it's the flattened list of libxml error strings.

Custom drivers / instance API

GaebParser::fromString() is a shortcut for new GaebParser. Use the instance API directly to inject your own driver(s):

$parser = new GaebParser([new MyDriver, new GaebXmlDriver]);
$gaeb = $parser->parse($content);

The library performs no file I/O on documents: it takes XML strings (or an existing Dom\XMLDocument via GaebDocument::fromDocument()) and returns objects/strings — reading from and writing to disk is the caller's concern. The only files it touches are its own bundled XSDs in validate().

Drivers are tried in order; the first one whose supports(string $content): bool returns true handles the parse. Implement Bambamboole\Gaeb\Driver\Driver to add support for another format without touching this library.

Out of scope

  • Writing formats other than the X84 bid, X87 confirmation, X89 invoice, and X89B attachment transforms above: from-scratch X81/X83 authoring, X86 award/rejection writing
  • Multiple InvoiceShares (only a single basic amount share is emitted), COInfo/Nachträge, cash-discount Terms when writing invoices
  • Per-item VAT, UPComp1–6 price components, sub-description prices, TimeQu, Product, and MarkupItem pricing when writing bids
  • Legacy formats (GAEB 90, GAEB 2000)

These may be added later without breaking the public API.

Testing

All fixtures under tests/fixtures/ are synthetic, hand-crafted XML files written for this project to exercise specific parsing paths — see tests/fixtures/README.md.

License

MIT — see LICENSE.md.