Upgrading
What each released version asks of you, newest first. Most ask for nothing beyond running the migrations.
Before 1.0, a minor may break
The package is pre-1.0, so a minor version is allowed to change a published API. Every such change is listed in the CHANGELOG under a heading that says so, and repeated here with what to do about it. Pin a minor if you want no surprises:
"pushery/billing-for-laravel": "~0.13.0"
Every upgrade ends the same way, because a minor may add tables or columns:
composer update pushery/billing-for-laravel
php artisan migrate
0.14.0
The sale's tax characteristics travel as one object
Affects you if you call FanReceiptIssuer::issue(), SelfBillingEngine::issue() or any of
SubscriptionCycleBilling's three issuing methods directly. Through the hosted lanes there is nothing
to do.
Those five signatures each took the same run of tax primitives — ?TaxArchetype $archetype,
?PlaceOfSupplyRule $placeOfSupply, ?TaxRateCategory $rateCategory and, on the receipt issuer, five
more. They now take one SupplyTaxCharacteristics:
// before
$receipts->issue($buyer, $tier, $gross, $bps, $soldOn, $ref, null, $period, $archetype, $place, $band);
// after
$receipts->issue($buyer, $tier, $gross, $bps, $soldOn, $ref, period: $period, characteristics: new SupplyTaxCharacteristics(
archetype: $archetype,
placeOfSupply: $place,
rateCategory: $band,
));
Every field is nullable and defaults to null, so a caller that passed none of them needs no change at
all: SupplyTaxCharacteristics::unknown() and simply omitting the argument write the same row.
Why this is worth the edit. The receipt issuer's signature was seventeen parameters wide and two
callers filled it positionally. A parameter inserted in its middle shifted every argument after it past
two type-compatible pairs — ?CarbonImmutable $deliveredOn against CarbonImmutable $soldOn, and
?string $chargeReference against ?string $provider. Static analysis cannot tell those apart, and a
document that comes out with the wrong date is a tax error rather than a display one.
It also widens what a subscription cycle can state: it could reach three of the eight characteristics and now reaches all eight, so a cycle that knows its delivery date or its exemption reason can say so.
Support\Navigation and NavItem are gone
Affects you if you resolved either class. Use Pushery\Billing\Account\Navigation —
visible() for the grouped form the sidebar renders, visibleItems() for a flat list.
They were a second parser of config('billing.navigation'), and the two disagreed: the surviving one
knows the web_only flag and drops such an item on a native runtime, the removed one had no property to
carry it. An operator who hid the account-deletion flow saw it leave the sidebar and stay on the hub's
landing page, one click from a working deletion. Removing the second parser is what stops that recurring;
teaching it the flag would have left two readings of one key in place.
Some value objects and events carry more required arguments
Affects you only if you construct any of these by hand. Resolved from the container, or read from an event you receive, nothing changes — the package fills the new arguments itself.
Each gained required constructor arguments because it now carries a fact it previously had nowhere to put:
| Class | Required arguments |
|---|---|
ValueObjects\InboundTaxTreatment | 5 → 11 |
ValueObjects\WithdrawalConsent | 4 → 8 |
ValueObjects\RetentionRule | 13 → 15 |
Events\AddonPurchased | 4 → 7 |
Notifications\PaymentSucceededNotification | 2 → 9 |
Marketplace\BuyerProtectionClock | 1 → 7 |
Service constructors widened as well — StripeDriver, StripeOneTimeCharge, StripePaymentMethods,
BillingAdmin, EuOssTaxCalculator, TaxCalculatorFactory — but every documented usage resolves those
from the container, so they are named here for completeness rather than as something to do.
Three methods were removed because nothing read them: SellerActivityThreshold::isExemptFromReporting(),
InvoiceNumberSequence::format() and RateChangeExclusions::dryRunRequired(). The purging() helpers on
BillingEvent and TaxReturnExportRecord did not disappear — they moved into the shared AppendOnly
concern and are still callable on both models.
ConsumerWithdrawal takes two collaborators fewer
Affects you only if you construct it by hand — resolved from the container, nothing changes.
It no longer takes a RoutedRefundCorrector or a CreatorTaxStatusResolver. The chain correction moved
into BillingAdmin::refund(), which is where it belonged: that verb is the package's only refund entry
point and was the one path of four that corrected no documents. Leaving the correction in both places
would have written two correcting documents per leg for one event, out of a gapless number series.
0.13.0
This release carries the work stamped 0.10.0, 0.11.0 and 0.12.0. None of the three was ever
tagged, so v0.9.0 is the last published version before this one and upgrading from it lands you here in
one step. The changelog folds them the same way and for the same reason: there is no release a reader
could be sitting on in between, so three sets of instructions were three descriptions of one upgrade.
Six items, and all but one need a decision before you deploy — the toast change matters only if you listen for the browser event yourself. Anything not listed here is additive. Run the migrations last.
1. A routed one-time sale now issues the buyer's receipt, and needs to be told who the buyer is
Affects you if you call RoutedPayment::charge(), FanPayment::tip() or
FanPayment::payWhatYouWant() directly. If you only use the hosted checkout, there is nothing to do.
Until now this lane charged the buyer, settled the sale and paid the merchant their share without producing any document. A fan who bought once had no receipt, and the sale's supply regime was written down nowhere — the charge table has no column for it, so the document is the only place it can be frozen.
Issuing it needs two facts the package cannot invent: who the buyer is, and whether the small-value rules of your own country apply to them (which decides the document tier). Both are now required parameters:
// before
$payments->charge($merchant, $gross, $fee, $taxBps, $token, $routing, $archetype);
// after
$payments->charge($merchant, $buyer, $gross, $fee, $taxBps, $buyerIsDomestic, $token, $routing, $archetype);
On FanPayment the buyer sits after the merchant, and the flag after the existing TaxContext:
$fan->tip($merchant, $buyer, $chosen, $normalFee, $taxContext, $buyerIsDomestic, $token, $routing, $soldAlongside);
$fan->payWhatYouWant($merchant, $buyer, $chosen, $fee, $taxContext, $buyerIsDomestic, $token, $routing, $archetype);
$buyer is the model the receipt belongs to — the same one you would pass to the subscription lane.
$buyerIsDomestic is supplied rather than derived, exactly as SubscriptionCycleBilling already takes
it: the package has no second opinion about where your buyer is, and inventing one would give the two
receipt-issuing lanes two different answers.
Why both are mandatory rather than optional with a default. An optional buyer would mean the receipt
goes on being skipped for every call site not yet updated — silently, and for precisely the sales that
already work today. Required, your existing positional call raises a TypeError on the first run
instead, which is the one failure mode you cannot miss.
The document is issued only after the provider confirms the payment succeeded, so a declined or still pending charge produces nothing. It is idempotent on the charge reference: a redelivered webhook returns the document already written rather than drawing a second number from a series that must have no gaps.
One posture issues nothing, by design. Where you have declared the merchant as seller of record, the platform is not a party to the supply and has no document to issue. Where the platform merely arranges, the intermediation receipt is not wired yet — it states the commission's own tax rate, which is still an open question at that seam.
2. The realtime toast names its severity differently
Only affects you if you listen for the wirekit-toast browser event yourself. If a WireKit host or
this package's own opt-in region renders your toasts, there is nothing to do.
The bridge used to dispatch the severity as detail.level. A toast region reads detail.variant, so the
severity never arrived: every toast rendered in the neutral style, and a failed payment was announced to a
screen reader without urgency. It now dispatches detail.variant. Change your handler to read that.
The broadcast payload is unchanged — AccountToastNotified still sends { message, level }. Only the
browser event moved, because only that end has a reader that decides what the key means.
3. A routed subscription on the shipped defaults now refuses
Only affects you if billing.marketplace.enabled is true. With it false — the default — nothing
changes.
StripeCheckout used to check the configured charge type and then assemble a payload that ignored it. The
shipped charge_type is separate_transfer, which the posture table permits for platform_deemed_supplier,
so the guard passed — while the session it opened carried transfer_data.destination, which is a
destination charge, and destination + platform_deemed_supplier is precisely the pairing
billing.marketplace.charge_type_by_posture forbids. The money went straight to the merchant while the
documents named the platform as seller.
That combination now throws MarketplaceUnsupported instead of opening a session. Two ways forward:
- Move the merchant's share yourself. Keep
charge_typeonseparate_transferand route the sale throughPushery\Billing\Marketplace\RoutedPayment, which makes both provider calls and records them. - Use a destination charge honestly. Set
charge_typetodestinationand a posture the table permits for it —platform_intermediary, orseller_of_recordif the Art. 9a rebuttal genuinely holds for you (the package refusesseller_of_recordfor an electronically-supplied service unless you assert it).
If you were running the old default and your Stripe payouts reconciled, they were reconciling against a shape the package's own configuration disallowed. That is the change worth reviewing before you deploy.
4. ExchangeRateBasis::MinistryMonthlyAverage is renamed
It is now CentralBankMonthlyAverage, named after the source rather than the place of publication. The old
name described a table nobody could actually fetch, which is what made an unsupplied basis look supplied.
No data migration is needed: nothing ever wrote the old ministry_monthly_average value, so no stored row
carries it. Update any code that names the case; a match over the enum will fail to compile rather than
fall through, which is the intended way to find them.
The monthly average is now computed from the daily series the package already imports — the arithmetic mean of the month's published rates, averaged over what was published rather than divided by the calendar.
5. billing.tax_oss.required_signals is removed
It was read in exactly one place, and only to describe a decision it had no part in — and the expression
could not represent 3 at all, so a valid standard of three sources was written as 2. Both halves now read
the same standard through one shared reader.
Delete the key from your published config. Leaving it does nothing; the package no longer reads it.
6. Run the migrations
This release adds columns rather than changing existing ones — a subdivision on the place evidence, a rounding direction on the merchant charge, a seller posture and a tax-exemption reason on issued documents:
composer update pushery/billing-for-laravel
php artisan migrate
billing_place_evidence.resolved_subdivision is written only for a country listed in
billing.tax_evidence.subdivision_countries (shipped: the US alone) and only from a subdivision you
already supply — the package has no input finer than the country and does not go looking for one. Every non-US
sale is byte-identical to before, and billing.tax_evidence.collect_subdivision switches the whole thing off.
0.9.0
Nothing to do. Documentation only: the pages that described unshipped features are gone, and the configuration, database, event and troubleshooting references are written from the code. No code, config or schema changed.
One key that the boot guard already read is now declared in the published config —
billing.retention.allow_below_statutory_minimum, default false. Behavior is unchanged; it was previously
discoverable only from the exception message. Re-publish the config to pick up the declaration, or ignore it:
the package merges its own defaults underneath yours.
0.8.0
If you mapped Money to a decimal-string amount shape, that pair of methods is gone. It had no shipped
consumer, so it was removed before anything could depend on it. Use Money::toDecimal() and
Money::fromDecimal(), which are unchanged.
Everything else: nothing to do. Amounts remain integer minor units end to end.
0.7.0
If you ship your own driver implementing SubscriptionActions, add the new optional parameter:
public function cancel(Model $billable, ?CancellationSurvey $survey = null): void
Callers are unaffected — the argument defaults to null — and the built-in drivers already have it. A driver that ignores the survey is a valid driver; the parameter only has to exist so the contract is satisfied.
Run the migrations: 0.7.0 adds the cancellation-survey table.
0.6.0
The largest upgrade so far. Three things need attention.
The cancellation "credit note" is now an "invoice correction". Credit note is reserved for the self-billing document, which is a different document with a different type code. Rename your references:
| Removed | Use instead |
|---|---|
ValueObjects\CreditNoteSnapshot | ValueObjects\InvoiceCorrectionSnapshot |
Events\InvoiceCredited | Events\InvoiceCorrected, reading $event->correction (was $event->creditNote) |
Webhooks\Effects\PersistCreditNote | Webhooks\Effects\PersistInvoiceCorrection |
InvoiceRecord::isCreditNote() | InvoiceRecord::isCorrection() |
translation key billing::invoice.credit_note | billing::invoice.correction |
The event is the gentle one: InvoiceCorrected also fires InvoiceCredited for one deprecation window, so
an existing listener keeps being called rather than going quiet. The value object, effect and model method
are hard renames — a stale reference is a loud "class not found", not a silent no-op. See the
event reference.
The invoice retention floor dropped from ten years to eight, and the clock now runs from the end of the
issue year rather than the issue instant. An erased owner's retained invoices become prunable up to two years
earlier than before. That is the point: keeping them the full ten years over-retains personal data past its
obligation. If your jurisdiction requires longer, set billing.retention.erased_financial_days higher —
a longer window is always allowed. The separate audit window stays at ten years.
InvoiceCorrectionSnapshot now validates itself. It refuses a negative amount (a correction carries
positive magnitudes; the document's nature inverts the meaning, not the sign) and refuses an amendment with
no reference to the invoice it corrects. If you construct snapshots yourself, pass absolute amounts.
Also worth knowing, though neither needs action: the documentation moved out of the README into docs/, and
several tables were added — run the migrations.
0.5.0
If you keep golden copies of DATEV exports, regenerate them. The EXTF header's
Festschreibekennzeichen was emitted as 0, marking a booking batch as still alterable after import. It is
now 1, which changes the bytes of every generated file.
Your app may now refuse to boot where it previously started. Two silent failures became loud:
- an unresolvable
billing.tax— a typo, or the key turned into an array by adding a sub-key under it — now raisesTaxModeUnsupportedat boot instead of falling through to "no tax" and issuing every invoice at 0% billing.tax = 'stripe'is now correctly classified as provider tax, so it is accepted on the driver that needs it and refused on one that cannot apply it
A malformed country code now throws instead of zero-rating. EuOssTaxCalculator treated any code it had
no rate for as zero-rated, so "DEU" or an empty string was indistinguishable from a genuine supply outside
the EU VAT area. An unassigned code now raises UnknownTaxCountry. Real countries outside the EU VAT area
are still zero-rated. If your data carries three-letter or full-name country codes, normalize them to
ISO 3166-1 alpha-2 before this upgrade.
0.4.0 and 0.4.1
Nothing to do. 0.4.1 is release-note housekeeping with no functional change.
0.4.0 adds the opt-in billing.marketplace config block, off by default, so single-merchant behavior is
unchanged. It also adds a billing umbrella publish tag —
php artisan vendor:publish --tag=billing now publishes config, migrations, views and translations in one
go, and the specific tags still work.
Contributors only: the static-analysis composer script was renamed to analyze.
0.3.0
Nothing to do. The admin console and the ZUGFeRD PDF/A-3 writer are both additive and both optional.
The hybrid PDF/A-3 needs a real PDF toolchain, so it is a suggested dependency: run
composer require horstoeko/zugferd to use it. Without it the method throws MissingPdfEmbedder rather than
fataling on an undefined class. The XML writers need none of it.
0.2.0
Check your VAT setup — this release closed two under-charging holes, and both change what customers are billed.
- The EU reverse charge now requires a validated VAT id. Previously any supplied id, verified or not,
earned the zero-rate. The default
VatIdValidatorproves nothing, so the zero-rate is not granted until you bind a real validator. BindViesVatIdValidatorif you sell B2B across EU borders; otherwise your business customers will now be charged domestic VAT. - A domestic B2B sale is no longer zero-rated. The reverse charge applies only when the buyer's country
differs from
billing.company.country. Set that key — when the seller country is unknown, nothing is zero-rated, which is the safe direction but probably not what you want.
Also in this release: reverse-charge invoices no longer leak VAT into their totals (an EN 16931 violation a validator rejects), the EU-OSS table is matched case-insensitively, and a VIES outage is treated as unavailable rather than invalid. Every provider link-out is now scheme-validated before the redirect.
Run the migrations: 0.2.0 adds the coupon tables and the e-invoicing columns.
0.1.1
If your app deletes accounts from its own flow, dispatch BillableAccountDeleting before you delete the
model:
use Pushery\Billing\Events\BillableAccountDeleting;
event(new BillableAccountDeleting($user));
$user->delete();
Without it, a deleted owner stays active and charging at the provider. The package's own eraser dispatches it for you; a custom delete button does not. Dispatch it after re-confirming identity and before the delete, so the listener can still resolve the owner's provider reference.
If you ship your own driver
A driver is a set of contract implementations, so what an upgrade asks of you is exactly which contracts moved. Across the versions above:
- 0.7.0 —
SubscriptionActions::cancel()gained?CancellationSurvey $survey = null - 0.6.0 —
PersistCreditNotebecamePersistInvoiceCorrection, andInvoiceCreditedbecameInvoiceCorrected; a driver's webhook mapper that produced the old event must produce the new one - 0.5.0 — nothing on the driver contracts, but a driver that reports no provider tax will now be refused
at boot if
billing.taxisprovider, and one that defers tax will be refused on a local mode
Two contracts are worth re-reading after any upgrade because their guarantees are what the fail-closed guards
check: PaymentRails (moves money, stores mandates) and BillingEngine (the recurring cycle). PaymentRails
is deliberately not eligibility-gated — the gate belongs at the entry seams where a payment begins, so
that a dunning retry for a subscriber who was eligible when they subscribed is never refused later.
See the contract reference for what each seam guarantees.
The marketplace surface is opt-in
The multi-merchant marketplace (Stripe Connect) is additive and does nothing until you turn it on. Three things a driver author needs to know:
- Nothing existing was widened to make room for it.
PaymentRails,BillingDriverand the argument order ofChargeResultare untouched. The routing dimension arrives as an optional trailing?ChargeRouting $routing = nullon the money methods, defaulting to null — which is exactly today's behavior. A payment with no routing reaches the provider with exactly the fields it always has. - The routing capability is opt-in, at the driver. A driver joins the marketplace by implementing the
RoutesMoneycontract; one that does not implement it cannot produce a rails object, so no configuration can route through it. If you have written your own driver, it keeps working unchanged as a single-seller driver until you choose to implementRoutesMoney. - A driver that cannot serve a routing must THROW, never no-op. This is the one that loses money if you get it wrong. A routing the driver silently ignores settles the whole payment on the platform account, and the merchant is never paid — with nothing in the result saying so. The failure has to be loud: refuse the operation rather than complete it as an unrouted charge.
And two things an APPLICATION needs to know, not just a driver
The three points above are for whoever writes a driver. If you are adopting the marketplace in an app, the part that costs money is different:
- Charge through
RoutedPayment, not through the rails. It is the recommended path and not an enforced one, because nothing in this package calls the payment verbs — only your application knows when a sale happens. Going through the rails directly skips three things at once: the routed-charge row, the receiving gate and the tax-standing gate. The row is the one that bites quietly, because the reversal cap, the merchant's earnings total and the small-business threshold verdict are all computed from it — and their readers do not fail when it is missing, they answer zero. - The tax-standing hold arrives with a date, and it refuses everybody until you set one. A merchant
nobody has declared for is
Unclarified, which is the standing that blocks — sobilling.marketplace.tax_status_hold.enforce_fromstarts null and nothing is refused. Pick a date far enough out to collect declarations from the merchants you already have, tell them, and let it arrive.billing:marketplace:preflightreports an unset date as outstanding rather than as configured.
The mechanics are in the marketplace overview; the byte-identical single-seller guarantee is stated there once and holds for every marketplace release.