bambamboole / gaeb
A small PHP library to parse, write and validate GAEB DA XML 3.3 files.
Requires
- php: ^8.4
- ext-dom: *
- brick/math: ^0.19.0
Requires (Dev)
- laravel/pint: ^1.16
- pestphp/pest: ^3.8
- phpstan/phpstan: ^2.1
This package is auto-updated.
Last update: 2026-07-31 23:38:55 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 (80–89), 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 singlebasic amountshare is emitted),COInfo/Nachträge, cash-discountTermswhen writing invoices - Per-item
VAT,UPComp1–6price components, sub-description prices,TimeQu,Product, andMarkupItempricing 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.