Repository navigation
v4.0.0-alpha.1
Pre-releaseFirst pre-release of 4.0, a modernization of the SDK on PHP 8.5. The public API may still change before 4.0.0 — feedback via issues is very welcome.
Highlights
- PHP 8.5 minimum;
ext-bcmathrequired. - PSR-18/PSR-17 transport: bring any HTTP client (Symfony, Guzzle, …); Symfony is no longer a runtime dependency.
- Exact money: amounts are
Money(onBcMath\Number) instead offloat. - Immutable write models with named arguments and
with*()withers,Clear::Fieldto empty a field; typed list filters andOrderBy. - Nested, lazy sub-resources:
$billomat->invoices->comments->list($invoiceId), uniformlist()/listPage()/iterateAll(). - Resilience and observability: retries for 429 (honoring
Retry-After) and, on GET, for 502/503/504 and network errors, PSR-3 logging and PSR-14 events, error classification (isTransient(),mayHaveBeenProcessed()). - Webhook parsing (
src/Webhook/),getOrFail(),pdf()/pdfContent(), forward-compatible models ($extrakeeps unknown fields). - Models and options are generated from the API spec; checked by PHPStan level max (bleeding edge, no baseline), mutation testing and recorded contract fixtures from the Billomat sandbox.
- English documentation at https://billomat-php.justpilot.io
Install
composer require justpilot/billomat-php-api:^4.0@alphaUpgrading from 3.x
Follow UPGRADE-4.0.md. A shipped Rector set handles the mechanical changes:
vendor/bin/rector process src tests --config vendor/justpilot/billomat-php-api/rector/upgrade-4.0.php --dry-runFull changelog
Added
- PSR-18 transport. The SDK works with any PSR-18 client (Symfony, Guzzle, …) and PSR-17 factories, found via
php-http/discoveryor passed toBillomatClient. Without a client, Symfony'sPsr18Clientis used withBillomatConfig::$timeoutwhensymfony/http-clientis installed. - Automatic retries via
BillomatConfig::$retry(RetryPolicy, default: 2 retries, 500 ms base delay, 10 s cap). 429 responses are retried for every method after the wait Billomat announces (Retry-AfterorX-Rate-Limit-Reset); GET requests are also retried on 502/503/504 and network errors with exponential backoff and jitter.RetryPolicy::none()turns retries off. RateLimitException(429) andTransportException(no response, wraps the PSR-18 exception), both belowBillomatException.- Error classification:
BillomatException::isTransient()(429, 502, 503, 504 and network errors) andmayHaveBeenProcessed()(falsefor 4xx,truefor 5xx and network errors), so callers no longer inspect status codes orgetPrevious()to decide whether to retry later or to check before resending a write request. HttpException::getRetryAfter(): seconds announced by Billomat, set for 429 (Retry-AfterorX-Rate-Limit-Reset) and for 502/503/504 (Retry-After).WriteOptionsinterface withtoArray(), implemented by all*CreateOptions/*UpdateOptionsand the other write options.Clear::Fieldempties a field in*UpdateOptionsandSettingsUpdateOptions:nullstill means "not sent, unchanged";Clear::Fieldis sent as JSONnull, which makes Billomat clear the field.with*()methods on every options class return a copy with one field changed (PHP 8.5clonewith properties), e.g.$options->withNote('…');withItem()on document create options appends a position.- Typed list filters for the main resources:
ClientFilter,SupplierFilter,ArticleFilter,UserFilter,InvoiceFilter,OfferFilter,ConfirmationFilter,CreditNoteFilter,DeliveryNoteFilter,LetterFilter,ReminderFilter,IncomingFilter,RecurringFilterandTemplateFilter, generated from the Billomat docs. They're immutable (named arguments pluswith*()), use enums for status and payment types,DateTimeImmutableforfrom/toand lists for multi-value filters (sent comma-separated).list(),listPage()anditerateAll()accept them next to the plain arrays, which keep working.extrapasses further query parameters. OrderBybuildsorder_by:OrderBy::desc('date')->thenAsc('invoice_number')givesdate+DESC,invoice_number+ASC.- Sorting and pagination for sub-resources. Items, comments, tags, reminder items, recurring e-mail receivers and property definitions take a
SubResourceFilter(orderBy,page,perPage,extra) next to plain arrays, e.g.$billomat->invoices->items->list($id, new SubResourceFilter(orderBy: OrderBy::asc('position'))). Payments and property values get typed filters generated from the Billomat docs:InvoicePaymentFilter,CreditNotePaymentFilter,IncomingPaymentFilter(from,to,typeaslist<PaymentType>,userId) andClientPropertyValueFilter,ArticlePropertyValueFilter,SupplierPropertyValueFilter,IncomingPropertyValueFilter; a filter of another resource throws anInvalidArgumentException. All of them havelistPage()anditerateAll()now (Billomat paginates these endpoints like the top-level lists, checked in the sandbox). Moneyvalue object (Justpilot\Billomat\Model\Money) for exact amounts, based onBcMath\Number:Money::of('19.99'),sum(),add(),subtract(),multiply(),round(), comparisons,isZero()/isPositive()/isNegative(),format(2, ',', '.')andtoString()in Billomat's canonical form. Requiresext-bcmath.- Webhook helpers in
Justpilot\Billomat\Webhook:WebhookEvent(all 83 events, withresource(),action(),envelopeKey()andmodelClass()),WebhookHeaders::fromServerRequest()/fromArray()for theX-Billomat-Webhook-*headers (request ID as int), andWebhookPayload::fromJson()/fromArray(), which unwraps the REST-style envelope and hydrates the matching model viamodel(). They work on stored requests as well. Errors throwInvalidWebhookException. - PSR-3 logging and PSR-14 events:
BillomatClient(andcreate()) take an optionalloggerandeventsdispatcher. The SDK logs successful responses (debug), retries (warning) and final failures (noticefor 4xx,errorfor 5xx and network errors) with method, path, attempt, status and duration, never headers, query or body. It dispatchesRequestStarted,ResponseReceived,RetryScheduledandRequestFailed(Justpilot\Billomat\Event). New runtime dependencies:psr/logandpsr/event-dispatcher(interfaces only). - Unknown fields are kept: every generated read model has
$extrawith the fields Billomat returns that the model doesn't map (yet), raw as in the response.toArray()includes them, so new Billomat fields no longer get lost. Mapped fields win overextraentries with the same key. getOrFail()on every resource and sub-resource withget(): returns the model or throwsNotFoundExceptioninstead of returningnull.BillomatConfig::$language(andBillomatClient::create(language: …)) sets theAccept-Languageheader, e.g.'en-gb'; default stays'de-de',nullomits the header.- Exception messages include the method, the path and Billomat's error text, e.g.
Billomat responded with HTTP 422 to POST /clients. Name is missing. OffersApi::uncancel()/unclear(),ConfirmationsApi::uncancel()/unclear()andRemindersApi::uncancel()for the/uncanceland/unclearendpoints (checked in the sandbox).- Contract fixtures: anonymized sandbox responses under
tests/Fixtures/Contract/andContractFixturesTestcatch drift between the Billomat API and the models in CI without credentials;composer fixtures:recordre-records them (read-only GETs, seedocs/testing.md). User::$rights(array<string, AccessLevel>): the access level per area (articles,banking, …) Billomat returns with every user; before, it ended up raw inUser::$extra. New enumAccessLevel(READ,UPDATE,DELETE) withlabel()andallows()(DELETEallowsUPDATEandREAD). The generator has amapfield type for such objects (area → enum value); empty and unknown values are dropped.Client::$propertyValues,Supplier::$propertyValues(theclient-property-values/supplier-property-valuesBillomat returns with every client and supplier) andRole::$created/$updated, found by the contract fixtures.- Rector upgrade set
rector/upgrade-4.0.phpfor the mechanical parts of the 3.x → 4.0 upgrade: flat sub-resource properties to nested ones ($billomat->invoiceComments→$billomat->invoices->comments),listBy{Parent}()→list(), removed sub-resource, e-mail option and enum classes to their 4.0 replacements,OfferStatus::ACCEPTED/REJECTED→WON/LOST,getItems()→items,putEmptyResponse()→putVoid(). The rules live under theJustpilot\Billomat\Rectornamespace;rector/rectoris only needed to run them (see UPGRADE-4.0.md). InvoiceItemUpdateOptions,OfferItemUpdateOptions,CreditNoteItemUpdateOptions,ConfirmationItemUpdateOptions,DeliveryNoteItemUpdateOptionsandRecurringItemUpdateOptionsfor partial item updates: every field is optional (X|Clear|null, withwith*()), only set fields are sent. Billomat keeps the other fields of the item (checked in the sandbox).
Changed
-
Development: PHPStan runs with the bleeding-edge ruleset and without
ignoreErrorsfor the tests; the tests capture the outgoingMockHttpClientrequest as a typedCapturedRequest(method, URL, query, headers, decoded JSON body) instead of nestedmixedarrays. No change to the public API. -
The PHPStan baseline is empty and
phpstan-baseline.neonis gone (composer analyse:baselinetoo): the findings were fixed in the code. Filter and query parameters are typedarray<string, scalar|array<scalar|null>|null>, list responses are normalized in one place (AbstractApi), andListCaster::toNestedList()reads thetaxes/*-itemswrappers of the generated models. Behaviour is unchanged, except that a response body that is a JSON list instead of an object now throwsUnexpectedResponseException. -
BC break: amounts are
Moneyinstead offloatin all read models and write options (totalGross,totalNet,*Unreduced,totalReduction,openAmount,paidAmount,discountAmount,unitPrice,salesPrice–salesPrice5,purchasePrice, paymentamount,chargeAmount,total,revenue*/costs*,finapiTransactionAmount, theamountof tax rows andInvoiceGrouptotals). Payloads send amounts as decimal strings,toArray()exports them as strings. Quantities, tax and discount rates and exchange rates (quote) stayfloat. -
BC break:
ext-bcmathis required. -
BC break: tax rows are
TaxSummaryobjects.taxeson invoices, offers, credit notes, confirmations, delivery notes and recurrings is alist<TaxSummary>(name,rate,amountasMoney) instead of a list of arrays;toArray()exports the rows as before, with the amount as a string. -
BC break:
OfferStatus::ACCEPTED/REJECTEDare nowWON/LOST. Billomat sets won and lost offers toWONandLOST(checked in the sandbox), sowin()/lose()used to leave$offer->statusatnull, andOfferFilter(status: [OfferStatus::ACCEPTED])sent a value Billomat doesn't know. -
BC break: more fields are enums instead of strings.
Client::$taxRule(TaxRule),$netGross(NetGross) and the five*TypefieldsdiscountRateType,discountDaysType,dueDaysType,reminderDueDaysType,offerValidityDaysType(ValueType), in the model and inClientCreateOptions/ClientUpdateOptions;*PropertyValue::$type(PropertyType),InvoiceGroup::$status(InvoiceStatus),FreeText::$type(newFreeTextType) andEmailTemplate::$type(newEmailTemplateType). Unknown values hydrate tonull. -
BC break:
InvoicePaymentType,InvoicePdfTypeandInvoiceItemTypeare nowPaymentType,PdfTypeandItemType(same namespaceJustpilot\Billomat\Model\Enum, same cases). They were always used for credit notes, incomings, offers, letters, reminders and all document items as well. -
BC break:
ArticleTypeis merged intoItemType. Both had the casesPRODUCTandSERVICE;Article::$typeand thetypeofArticleCreateOptions/ArticleUpdateOptionsare?ItemTypenow. -
BC break: payment types are
list<PaymentType>instead of a comma-separated string:Invoice::$paymentTypes,Recurring::$paymentTypes,Client::$defaultPaymentTypesand the matching create/update options. Payloads andtoArray()still send the comma-separated string; an empty list is not sent,Clear::Fieldempties the field. Unknown values are dropped when hydrating. -
BC break: every enum has the same
fromApi(?string): ?self(traitModel\Enum\FromApi):null,""and unknown values givenull. Enums that had nofromApi()(NetGross,TaxRule,ValueType,SupplyDateType,RecurringAction,RecurringCycle, the*CommentActionKeyenums, …) get one; a few returned a case for""before. -
BC break:
RecurringEmailReceiver::$typeis?RecurringEmailReceiverTypeandRecurringEmailReceiverType::fromApi()returnsnullfor unknown values instead of silently falling back toTO(a typo likebcused to turn a BCC receiver into a visible To receiver). Lower-case values (bcc) are still accepted. -
BC break:
Role::$permissionsisarray<string, AccessLevel>instead ofarray<string, ?string>. Areas without access ("") and unknown values are left out instead of beingnull.Role::toArray()exports the permissions as top-level fields like Billomat ('articles' => 'DELETE') instead of underpermissions.Role::AREASlists the known areas. -
BC break:
InvoiceMailOptions::$paperWeightis?int(80 or 90) instead of?string, likeletterPaperWeighton recurrings. -
BC break:
pdf($id, $type, rawPdf: true)is nowpdfContent($id, $type). On invoices, offers, confirmations, credit notes, delivery notes, letters and reminders,pdf()always returns the*Pdfmodel (metadata plus base64) andpdfContent()the binary PDF asstring, so callers no longer narrowInvoicePdf|string. Thebool $rawPdfflag is gone. The Rector set rewritespdf(…, rawPdf: true)topdfContent(…)(PdfContentRector). -
BC break:
pdfContent()checks the body. It throwsUnexpectedResponseExceptionwhen Billomat answers 2xx with an empty body or something that does not start with%PDF-, instead of returning it as a PDF. Custom APIs can use the newAbstractApi::getPdf(). -
BC break: PHP 8.5 is now the minimum version (
"php": "^8.5"). CI runs on PHP 8.5 (required) and 8.6 (experimental); Rector uses the PHP 8.5 rule set. -
Read-only API methods are marked
#[\NoDiscard]:get(),getOrFail(),getMyself(),list(),listPage(),listGrouped(),iterateAll(),cloud(),pdf(),pdfContent(),thumb(),avatar()andSearchApi::query(). PHP 8.5 emits a warning when their result is ignored; cast to(void)to discard it on purpose. Write methods (create(),update(),delete(), …) are not marked. -
BC break:
BillomatConfigvalidates its input. The Billomat ID must be a valid subdomain (letters, digits, inner hyphens, at most 63 characters), and the base URI is parsed with PHP 8.5'sUri\Rfc3986\Uriand must be absolute. Otherwise the constructor throws anInvalidArgumentException, instead of sending requests to a wrong host. -
BC break:
symfony/http-clientis no longer a dependency. Install a PSR-18 client and PSR-17 factories (e.g.symfony/http-client+nyholm/psr7, orguzzlehttp/guzzle).BillomatClient::__construct()andcreate()take aPsr\Http\Client\ClientInterfaceinstead of a SymfonyHttpClientInterface; wrap Symfony clients inPsr18Client. -
BC break:
BillomatHttpClientInterface::request()returns a PSR-7ResponseInterfaceand throws the SDK exceptions itself. CustomAbstractApisubclasses no longer catch Symfony exceptions; read the body with(string) $response->getBody(). -
BC break: a 2xx response whose body is not a JSON object throws
UnexpectedResponseExceptioninstead ofJsonException. -
BillomatClientcreates its APIs lazily: the 30 resource properties are PHP 8.4 property hooks (public private(set)), so an API object is only built on first access and reused afterwards.BillomatClientis no longer areadonlyclass; the properties can't be assigned from outside (they couldn't before either). -
BC break: sub-resources hang off their parent resource.
$billomat->invoiceCommentsbecomes$billomat->invoices->comments,$billomat->clientTagsbecomes$billomat->clients->tags, and so on for all items, payments, comments, tags, properties, property values and recurring e-mail receivers (see the table in UPGRADE-4.0.md). They are created on first access. -
BC break: one generic class per kind of sub-resource.
CommentsApi,TagsApi,ItemsApi,PaymentsApi,PropertiesApiandPropertyValuesApireplace the 39 per-resource classes (InvoiceCommentsApi,ClientTagsApi, …); they are typed with@template, so PHPStan and IDEs know the model of$billomat->invoices->comments->get().ReminderItemsApiandRecurringEmailReceiversApistay as classes.create()/update()throw anInvalidArgumentExceptionwhen passed the options of another resource (e.g.OfferCommentCreateOptionsto$billomat->invoices->comments), as do comment filters with another resource's*CommentActionKey. -
BC break: sub-resources take the parent ID as the first argument of
create(). Payments, comments, tags, property values and recurring e-mail receivers work like items already did:$billomat->invoices->payments->create($invoiceId, new InvoicePaymentCreateOptions(amount: …))instead ofcreate(new InvoicePaymentCreateOptions(invoiceId: $invoiceId, amount: …)). The parent ID fields (invoiceId,clientId,recurringId, …) and theirwith*()methods are removed from the*PaymentCreateOptions,*CommentCreateOptions,*TagCreateOptions,*PropertyValueCreateOptionsandRecurringEmailReceiverCreateOptions; the SDK adds the field to the payload.create()now mirrorslist($parentId), and one options object can be reused for several parents. The Rector set moves the ID when the options are built inside thecreate()call (ParentIdToCreateArgumentRector). -
BC break:
listBy{Parent}()is nowlist()on comments, tags, items, recurring e-mail receivers and contacts (ContactsApi::listByClient()→list()).list()on payments and property values takes the parent ID as optional first argument:list(?int $parentId = null, array $filters = []), likewiselistPage()anditerateAll()on property values. -
BC break: methods that always returned
truenow returnvoid:delete(),complete(),cancel(),uncancel(),clear(),undo(),win(),lose(),email(),mail(),encash(),upload()anduploadSignature(). Errors were and are reported as exceptions. -
BC break: options are immutable. All
*CreateOptions/*UpdateOptions,EmailOptions,InvoiceMailOptions,InboxDocumentCreateOptionsandSettingsUpdateOptionsarefinal readonlyclasses; every field is a constructor parameter with a default, so they are built with named arguments (new ClientCreateOptions(name: 'ACME')) instead of assigning properties. Required fields (e.g.clientIdonInvoiceCreateOptions) stay the first positional parameters. -
BC break: positions go into the constructor.
addItem()andgetItems()onInvoiceCreateOptions,OfferCreateOptions,ConfirmationCreateOptions,CreditNoteCreateOptions,DeliveryNoteCreateOptionsandRecurringCreateOptionsare replaced by theitems:parameter, the$itemsproperty andwithItem(). -
BC break: one
EmailOptionsclass replacesInvoiceEmailOptions,OfferEmailOptions,ConfirmationEmailOptions,CreditNoteEmailOptions,DeliveryNoteEmailOptions,ReminderEmailOptionsandLetterEmailOptions, which had identical fields. -
BC break:
ClientCreateOptions::$dunningRundefaults tonulland is only sent when set, so Billomat's account default applies. Before,dunning_run=0was always sent. -
BC break: read-model IDs are
intinstead of?inton every model of an addressable resource (documents, clients, suppliers, articles, contacts, items, payments, comments, tags, properties, property values, settings models,User,Role,InboxDocument, …;IncomingCategory::$idis a non-nullstring). A create → sub-resource flow needs no null check any more.fromArray()throwsUnexpectedResponseException(naming model and field) when Billomat sends no usableidinstead of hydratingnull.unitPriceon document items andamounton payments are required the same way (they wereMoneywith a silentMoney::zero()fallback). The ID stays?inton*TagCloudEntry,Country,Currency,ActivityandSearchResult. -
BC break: read-model constructors. Only those required fields have no default and come first (
InvoiceItem(id:, unitPrice:, …),InvoicePayment(id:, amount:, …)); every other parameter now defaults tonull(or0/''/falsefor the non-nullable ones). Use named arguments. -
BC break:
ItemsApi::update()takes*ItemUpdateOptionsinstead of*ItemCreateOptions, so an item update no longer has to repeatquantityandunitPrice. Passing create options throwsInvalidArgumentException. -
WebhookPayload::model()throwsInvalidWebhookException(with theUnexpectedResponseExceptionas previous) when the record lacks a required field such asid. -
Codegen:
'model' => ['required' => true]now means "Billomat always sends this field": non-nullable type without default,fromArray()throws viaScalarCaster::required()when it is missing. It no longer just removes the constructor default;nonNullamounts (Money::zero()is no constant expression) are rejected in favour ofrequired. -
Sub-resource
get()throwsUnexpectedResponseExceptionwhen the response lacks the expected envelope; some classes returnednullbefore. -
Documentation: the document pages no longer claim that
delete()only works on drafts. Billomat deletes documents in any status via the API (checked in the sandbox for completed, cancelled, won and lost documents); the docs recommendcancel()for completed documents because of bookkeeping rules. -
Array query parameters go out as
key%5B%5D=v(PSR-7 URIs encode the brackets per RFC 3986, which servers decode to the samekey[]). -
composer generatefails when a spec field has a fixed set of values but its definition maps it to no enum ('enum' => 'X', or'enum' => falseto keep the string on purpose). -
Mutation testing covers the generated code on purpose: reflection-based tests check every read model's
fromArray()/toArray()round trip and every write option's payload against theBillomat-Feldin its docblock. Covered MSI rises from 77 % to 97 % (generated code 73 % → 99.7 %),minMsi/minCoveredMsifrom 60 to 95.PublicVisibility/ProtectedVisibilityare disabled (about 1,500 mutants that never survived). CI mutates only the changed lines on pull requests (--git-diff-lines, 90 %) and all code on pushes tomain/4.x. -
Documentation website at billomat-php.justpilot.io, built with VitePress from
docs/(local search, edit links, sidebar from the page titles) and deployed to GitHub Pages by.github/workflows/docs.yml; links to files outsidedocs/point to GitHub. -
README is an English landing page (≈100 lines: dynamic badges, install, quickstart, features, documentation map, supported versions);
README.en.mdis gone, the resource table moved todocs/resources/README.mdwith the nested 4.x access paths. -
CONTRIBUTING.md,SECURITY.md, the pull request template and the issue templates are in English and describe the 4.x workflow (PRs against4.x, generated code, PSR-only dependencies, supported versions 4.x/3.x). Packagist keywords dropsymfony/http-clientand addpsr-3,psr-14,moneyande-invoice. -
The examples in
examples/follow the 4.x API and are checked in CI: PHPStan (level max), PHP-CS-Fixer and Rector cover them likesrc/andtests/. A sharedexamples/bootstrap.phpreads the environment variables and builds the client; the scripts usegetOrFail(),Money,OrderByand the enums, andexamples/README.mdis in English.
Removed
- BC break: everything marked
@deprecated since 3.1is gone.DunningLevelsApi,$billomat->dunningLevelsand theDunningLevelmodel (/dunning-levelsanswers with 404). Use$billomat->reminderTextsfor reminder levels.AbstractApi::putEmptyResponse()(useputVoid()).- The read-model properties Billomat no longer returns:
Article::$unit/$purchasePriceCurrencyCode/$categoryId,Client::$customerportalUrl,Contact::$note,*Property::$position,TaxRate::$accountId,ReminderText::$dueDays,Country::$nameDe/$eu,User::$phone/$mobile/$fax,Invoice::$customerportalUrl,CreditNote::$customerportalUrl,Offer/Confirmation::$discountRate/$discountDate/$discountDays/$discountAmount/$customerportalUrl,Letter::$letterNumber/$number/$numberPre/$numberLength/$note/$customerportalUrl,Reminder::$reminderNumber/$number/$numberPre/$numberLength/$totalNet/$quote,Recurring::$numberPre,Incoming::$supplyDate/$dueDays/$paidAt/$intro,DeliveryNote::$totalGross/$totalNet/$netGross/$reduction/$totalGrossUnreduced/$totalNetUnreduced/$currencyCode/$quote/$customerportalUrl,$createdonInvoiceItem/OfferItem/CreditNoteItem/ConfirmationItem,ReminderItem::$totalGross/$totalNet/$createdandDeliveryNoteItem::$unitPrice/$taxName/$taxRate/$reduction/$totalGross/$totalNet/$totalGrossUnreduced/$totalNetUnreduced/$created. They also disappear from the models' constructors andtoArray(). Fields that are writable stay in the*CreateOptions/*UpdateOptions.
AbstractApi::mapHttpException()andHttpException::fromResponse()(Symfony-specific; the mapping now lives inBillomatHttpClient).- BC break: status methods for endpoints Billomat doesn't have.
OffersApi::undo()andConfirmationsApi::undo()are gone (PUT …/undoanswers 404); useuncancel()orunclear().DeliveryNotesApiandLettersApilosecancel(),clear()andundo(): Billomat answerscancelwith 400 ("cancellation not supported") andclear/undowith 404, so these methods could never succeed.