Contract reference
Everything crosses a small set of contracts, so your app talks to billing — not to a provider. The two-layer
core is PaymentRails (moves money, stores mandates) and BillingEngine (the recurring cycle); money crosses
the boundary as a Money value object, never a raw provider response.
Extension seams
Bind your own implementation of any of these to change behavior without touching the core:
TierResolver— maps a billable to its tier (ColumnTierResolverby default;SubscriptionTierResolverreads the active price back).BillingEntityResolver— resolves the actor that owns billing (the user, or its team).TaxCalculator— the local tax-computation path (EU-OSS table / none).VatIdValidator— validates a buyer's VAT id (a VIES-backed implementation ships; the null default proves nothing offline).- The notifier contracts — dunning, trial-ending, receipt, payment-action-required, and the rest.
A custom implementation must honor the contract's guarantee: a resolver returns a stable answer for the same owner, a calculator never invents a rate it cannot justify, and a money-moving seam is idempotency-keyed.
The jurisdiction readings — four seams, and the trap around them
Four contracts answer what a jurisdiction makes of something. All four are bound to the shipped country's reading, and all four are yours to replace:
| Contract | What it answers |
|---|---|
ProductTaxonomy | what each kind of product is, for tax and for the buyer's right of withdrawal |
ReportingProfile | which information the active reporting regime asks a seller for |
TaxDisclosurePolicy | whether a self-billed document may state tax at all, per the seller's standing |
ConsumerWithdrawalPolicy | how a consumer's right of withdrawal is read |
The trap is that there are two ways to change jurisdiction and they do not talk to each other.
billing.tax_profile picks the profile; these bindings pick the readings. Replace the first and forget the
second and the install is a hybrid — a foreign profile deciding some questions while the shipped
country's reading decides the rest. It looks entirely valid. The documents it produces are wrong in a way
only a specialist reads, and nothing in the output says which half decided what.
php artisan billing:doctor reports this by name: with a foreign profile active, it lists every reading
still on the shipped one. It warns rather than fails, because inheriting a reading can be a deliberate
and correct choice — the package cannot know a reading is wrong for your jurisdiction, only that nobody
said it was right. Replace all four and it goes quiet.
One more thing worth knowing before you try: billing.tax_profile accepts only a profile the package
ships and refuses an unknown key outright. A jurisdiction of your own arrives as a container binding of
JurisdictionProfile, which takes precedence over the config entirely.
What a profile can opt into
JurisdictionProfile itself asks for two things: a key and the points your jurisdiction adds to the go-live
checklist. Everything else a profile can answer is a separate interface it opts into, and that shape is
deliberate: a method added to the base contract would be a fatal error in every profile class already
written, including yours.
So a profile answers exactly what it implements, and the package falls back for the rest.
| Implement | To answer | Fallback if you don't |
|---|---|---|
SuppliesTaxRates | taxRates(), taxRatesValidFrom() | the shipped EU rate table |
DefinesUnionMembership | unionMembers(), unionMembersValidFrom() | the shipped 27-member list |
SuppliesDistanceSaleThreshold | distanceSaleThresholdMinor(), distanceSaleThresholdValidFrom() | no limit is watched — every cross-border consumer sale is taxed at the destination |
CollectsSellerTaxDeclarations | sellerDeclarations(), regionalLimits(), regionalLimitsValidFrom() | nothing is asked, no limits are read |
SuppliesMarginSchemeWording | marginSchemeNote() | the shipped wording |
RequiresElectronicInvoicing | requiresElectronicInvoicing() | no obligation is asserted |
RequiresTaxStatusHold | (marker — no methods) | the configuration switches keep their effect |
Two of these are worth reading twice.
RequiresTaxStatusHold is the one that takes something away. Where a profile declares it, the two hold
switches in configuration stop having any effect at all. That is not an oversight: a switch that could
turn payouts back on under a profile requiring the hold would be a back door to precisely what the hold
prevents — choosing a default tax standing for people whose standing nobody knows, quietly, by config.
Anything with a date has a date for a reason. Rates, union membership, thresholds and regional limits
are not constants; they are somebody's published figures at a moment. A figure compiled into a class cannot
report that it has gone stale, and the way you find out is that a return was wrong for a year. Every
*ValidFrom() above is read by billing:doctor, so an operator can answer "how old is this" without
reading a git log — and the shipped fallbacks carry their own dates for the same reason. The distance-sale
threshold is the one with no shipped fallback to date: where no profile supplies a limit the doctor says so
in as many words, because a date borrowed from somewhere else would state the freshness of a number that
decides nothing here.
Note that these are about what your jurisdiction is, while the four readings in the previous section are
about what it makes of something. Both are yours to supply, and billing:doctor reports the second set
when you have replaced the profile but not the readings.
Coverage: what the package knows, and what it refuses to guess
The shipped profile answers for the EU. Beyond it, the rule is deliberate and worth stating plainly: what is genuinely known is built; what is not is refused rather than guessed.
That refusal is the feature. A country nobody has classified must not be priced at 0%, because a zero on an invoice reads as a relief — the document says zero, the return says zero, and nothing anywhere records that the zero was a gap rather than a rule. The only way to notice is an audit asking why.
So there are three states, not two:
| State | Meaning | Prices? |
|---|---|---|
| covered | a rate is known and can be defended | yes |
| deliberately untaxed | somebody classified it as outside the tax area | yes, at nothing |
| unknown | nobody has answered for it | refuses |
The middle and the last both produce no tax. Only the middle one is an answer.
Extending it
CoverageMap is replaced wholesale by your own jurisdiction profile — not grown country by country in the
package, which would put answers in it that nobody here can defend:
use Pushery\Billing\Tax\CoverageMap;
$this->app->bind(CoverageMap::class, fn (): CoverageMap => new CoverageMap(
covered: ['JP', 'AU'], // you have rates for these and stand behind them
deliberatelyUntaxed: ['DE', 'FR'], // you have decided you do not tax there
));
Anything you name in neither list refuses to price, which is the intended behavior for a country you have not thought about yet.
Exchange rates: the seam, and why it refuses by default
ExchangeRateSource::rateFor($from, $to, $on, $basis) answers with a FrozenExchangeRate — the rate as a
scaled integer, the date the publisher stated, the source, and the rule that made it correct. Never a
float: a float answers neither which day the rate belongs to nor which rule produced it, and those are the
two things a tax document has to be able to show.
The rule is an argument, not something the seam decides. Which one applies is jurisdiction knowledge and the rules contradict each other on the same turnover — German domestic turnover takes the ministry's monthly average, while OSS expressly excludes monthly averages and takes the central bank's rate at period end. A seam that chose for you would have to be wrong for somebody by law.
This package ships no rates, and the binding says so rather than being absent: the default
NoExchangeRateSource throws ExchangeRateUnavailable naming what is missing and why. That is a
deliberate choice over leaving the contract unbound, which would answer the same question with
Target [...] is not instantiable — a message that reads as a wiring mistake in your application.
A single-currency install never converts, so it never asks and never sees either.
When you do bind a source: read reference data you already hold rather than calling a bank while a payment
is waiting, and stamp the publisher's date rather than the clock. A daily rate file fetched on a Saturday
answers 200 carrying Friday's data — no error, no 404, the real date inside the document.
Routed money, and the reader that says what it is standing on
RoutedPayment is the supported path for a routed sale: it asks CanReceiveMoney whether the merchant may
be paid before it reaches the provider, charges, records the sale with the commission terms frozen onto it,
moves the merchant's share on the lane where the provider does not move it, and settles the row with the
reference of the transfer it actually made. Calling the rails directly skips both ends of that — no gate is
asked and no sale is recorded — and an unrecorded sale is invisible to every rule the money has to obey
afterwards.
That matters more than a missing log, and it is worth being concrete about why. The reversal caps, the earnings balance and the small-business threshold verdict are all computed from that table. A query against a table nobody writes does not throw — it answers. Earnings of zero. A balance of zero. And "below the threshold" for every seller, permanently, which keeps a seller who has long passed it being billed without tax while every document stays well-formed.
So SmallBusinessThresholdMonitor::hasObservedEarnings() and
TaxationBasisResolver::restsOnNoEarnings() exist to let a verdict say what it is standing on. They answer
rather than refuse: an install with no marketplace has an empty table for the perfectly good reason that it
has no merchants, and refusing there would break an install that never had the question. The caller about to
state tax on a document is the one with the context — and the only place where getting it wrong costs
anything, since an incorrectly stated tax is owed by whoever wrote it down.
Content ownership — one seam to read, two to answer with
ContentAccessReader answers "may this person reach this work right now, and through what". It composes two
sources that neither subsumes the other: a persisted ownership row, and the live subscription view. Access is
granted when either says yes — ownership is permanent and narrow, a subscription is broad and temporary,
and requiring both would take a bought work away the day somebody cancels.
The subscription half is never written down. It is recomputed on every read, because the moment it were stored it would be a fact that outlives the state it came from.
Two of the three seams are yours, and they fail closed in opposite directions on purpose:
SubscriptionContentScope— does this subscription cover this work? The package cannot answer it: whether a tier reaches the creator's back catalog, whether a higher tier includes a lower one's works, whether a resumed subscription reaches the gap are all product decisions, and a package that picked one would be wrong silently, by handing out works nobody meant to include. The shippedGrantsNothingBySubscriptioncovers nothing, so turning the register on grants nobody anything until you say what a tier reaches.ContentCatalog— is this work there right now? Asked in bulk, never one work at a time, so a library screen cannot become one query per row inside your own catalog. The shippedAssumesEverythingAvailablereports every work available: with no catalog wired the package has nothing to ask and one honest thing to say. This direction is deliberately the opposite of the scope's — availability is not a permission, and defaulting it to "gone" would show every owned work as taken down in an app that simply never wired one.
Availability never changes whether somebody owns something, only whether it can be handed over. Owning a work
that has been withdrawn is an ordinary state, not an error: the decision stays granted, and
availability says content_gone. A screen reads the pair and says "you own this, it is currently
unavailable" — which is a different sentence from "you do not own this", and the only one that is true.
"My downloads"
grantsFor() returns one OwnedWork per registered work: the decision accessFor would give, plus the
facts on the row a library line needs — how it was come by, when, whether it ends, which bundle it arrived
with. Returning decisions alone would send every consumer straight back to the rows to render a single line.
Two things it deliberately does not say:
- It names no seller. The platform is the contracting party for every content flow. A
merchanton the line is provenance — where the work came from — never "sold by". Rendering it as a seller would be wrong and would break the creator's anonymity, which the arrangement exists to protect. A platform sale carries no merchant at all rather than a sentinel that could be printed as one. revocableis not a right of withdrawal. It says this access can be taken away — by a refund, a chargeback, a takedown. Whether the buyer may still withdraw from the purchase turns on the extinguishment flow and the work's own withdrawal type, and the two answers come apart in both directions. A screen that needs the withdrawal status reads the snapshot on the grant.
Owning a work that is gone or not yet released is a state the line can express directly
(isOwnedButUnavailable()), separate from whether it can be opened (isDeliverable()) — two sentences on a
screen, one question in the model.
No file, no URL, no bytes: delivery is your domain, and a package that started returning URLs would be holding content it has no business holding.
Which version they get
accessFor() carries a VersionResolution — not the policy the sale was made under, but what that policy
means today. The two are different objects on purpose: a windowed grant resolves to "the newest one"
while its window is open and "the newest one from before this date" the day after it closes, and handing a
delivery path the policy and asking it to work that out would put the update rules in every consumer,
slightly differently.
The policy itself is frozen onto the grant at purchase, chosen work-first: the work's own policy, else the
merchant's default, else billing.content_ownership.default_update_policy. Both consumer levels are answered
through UpdatePolicyCatalog, whose shipped implementation expresses no preference at either — so an install
that never wires it gets one policy, the configured one, for everything.
ContentVersions is where you list what you have published; a version is an opaque reference and a
publication date, and the date is the only thing this package reasons about. VersionResolution::pick() then
answers which one to hand over, and reachable() which ones may be downloaded at all — they differ exactly
for latest_with_revisions, where the back catalog comes along.
Two answers are deliberately null rather than a fallback: a work with nothing published yet (a pre-order —
a state, not a failure), and a frozen grant whose pinned version the catalog no longer lists. Quietly handing
over the newest one there would give somebody the opposite of what they bought, invisibly.
None of this touches conformity updates. Those — a defect fix, a security patch — are owed regardless of the
policy and cannot be waived by a product setting, which is why frozen freezes what a buyer is entitled
to and says nothing about what the seller still owes.
Where ownership rows come from
ContentGrants is the write side: grantPurchase(), grantGift(), comp(), grantBundle(). A rental is a
purchase with an expiry.
Two seams decide whether anything is written at all, and both ship saying no:
AddonContentMap— is this add-on a work, or is it a thousand credits? Only your catalog knows. The shipped map answers "not a work" to everything, so an install that sells credits or seats has a purchase path byte-for-byte what it was.BundleContents— what is in a bundle right now. Asked, never stored: a copy of the membership here would freeze a moment and disagree with your catalog forever after.
Ownership is wired as a second webhook effect beside the crediting one rather than a branch inside it. They are different facts with different lifetimes, and folded together a failure in either half would roll back the other — leaving a buyer charged, credited, and without the row saying they own what they paid for.
Three things are worth knowing before you build a flow on it:
- Creating the row is provision. Where a consumer-rights profile is active and the work is one whose right of withdrawal ends on delivery, a missing declaration means no row — never "access now, paperwork later". Once somebody can read the work, the thing the record exists to preserve is gone.
- On a gift, the declaration is the payer's. The buyer has the contract; the recipient has none with you. Declarations collected from the recipient are worth nothing: the right does not end, and every refund inside the window stays a claim rather than a courtesy.
- A comp has no money, no document and no declaration step. No consideration, so no supply; no contract, so no right to extinguish.
Writes are idempotent on the work: a redelivered webhook returns the row it already wrote. That matters more here than for most ledgers, because two grants for one work are two revocation targets and revoking one leaves the other granting.
expireLapsedGrants() marks lapsed rows for reporting, and nothing depends on it having run — the reader
decides from the dates, so a rental stops granting the instant its term ends whether or not a sweep is late.
Taking access away
AccessRevocations is a decision of its own, deliberately not welded to the money. Every combination of
"money moved" and "access ended" is a real business:
- a goodwill refund that leaves access in place — common, and often the point;
- a chargeback that ends it immediately — involuntary, and the money is already gone;
- a takedown with no refund at all — a legal demand does not come with a payment instruction.
A build that hard-wired revocation to a refund would make the first impossible and the third unrepresentable, so both wirings are switchable and neither moves money.
Four ways in, and the last two are the ones a takedown needs:
| Call | Ends |
|---|---|
revoke($grant, $reason) | one grant |
revokePurchase($checkoutReference, $reason) | everything one purchase granted |
revokeForPayment($paymentReference, $reason) | the same, reached from the money side |
revokeContent($content, $reason, $merchant = null) | every owner of ONE WORK |
revokeForMerchant($merchant, $reason) | a whole seller's catalog |
"The work can no longer be delivered" and "access ends, by force" are different facts. A work whose
publication ended answers ContentAvailability::ContentGone through the catalog seam and the grant is left
untouched — somebody owning a work that is no longer sold is the ordinary case, not a revocation.
revokeContent() is the other level: a legal order, or an account deletion, where ownership itself has to
end. Using it for a delisting takes away something people bought.
The last two are chunked, because a popular work has tens of thousands of owners and a seller's catalog is not a handful of rows. Both keep the first-reason-wins rule: a row already ended by a legal order does not become a deleted creator's row because a later sweep passed over it.
"Uncoupled" is worth reading exactly: a refund still runs the whole correction cascade on the money side. What is uncoupled is whether the buyer keeps the file.
The reason is a column and is never flattened. A statutory withdrawal and a goodwill refund end in the same state and are not the same event — one is a right the buyer exercised, the other a decision the platform made. Revocation is idempotent and the first reason wins: a chargeback arriving after a takedown did not cause the loss of access.
Nothing is ever deleted. A revoked grant stops granting and stays, because "why can this person no longer read what they bought" is a question somebody will ask, and because a retention duty outlives the access by years.
A subscription withdrawal reaches none of this. Subscription access is never persisted, so there is no row to take away — it is settled in money, and the live view says no by itself the moment the subscription ends.
The second axis: what is owed after the sale
ConformityUpdatePolicy answers how long a seller owes conformity updates and whether that obligation can
be contracted away at all. It is a jurisdiction profile, bound beside the withdrawal one and live only
where billing.consumer_rights.profile is set — this is consumer law, a single seller in the same country
owes it just as much as a marketplace, and an operator may run it under a different jurisdiction than their
tax.
ConformityUpdateGate is the neutral question the core asks: does an update flow for this grant now? It
never reads update_policy, so no product setting can reach it, and a grant with no recorded end keeps
receiving them — "nobody wrote down when this stops" cannot mean "it has stopped".
With no profile there is no obligation, no end date is ever stamped, and an install is byte-for-byte what it was.
Whether security updates can validly be waived at all is genuinely disputed, and this package does not
resolve it. billing.consumer_rights.allow_conformity_waiver ships off; turning it on makes a waiver
possible one grant at a time, still against a recorded agreement, and is a decision to take on your own
legal advice rather than on this page.
There is deliberately no install-wide switch that turns conformity updates off, and one will not be added: that is precisely the blanket arrangement the law refuses to recognize, and offering it as a setting would let a legal obligation be configured away.
The whole surface is behind billing.content_ownership.enabled. Off, the seam resolves to a reader that
answers no to everything — an answer rather than a resolution error, so "off" and "miswired" stay
distinguishable at the call site.
Things you call, that the package never calls itself
A published package has classes it will never reach on its own, because you are the caller. They are listed here rather than left to be discovered, and a guard in the suite keeps the list honest: a class the package does not reach, and that no page mentions, fails the build until somebody says what it is for.
| What | When you reach for it |
|---|---|
BillingOwner | The marker you put on your own billable model. Nothing in the package implements it — that is the point. |
EntitlementsResolver | Ask what the current tier grants. The package binds the License contract; this is the resolver behind it. |
DocumentArtifactStore | Retrieve a stored invoice artifact. The package writes through the contract and never reads back — serving the file is yours. |
ElectronicInvoicePolicy | Ask whether a sale must be invoiced electronically, before you issue. |
CreditBalanceProrationStrategy | Bind it for a driver whose provider cannot prorate. Stripe delegates to the provider, so nothing selects it for you. |
MeteredCycleAmountResolver | Bind it when a cycle amount depends on metered usage rather than the plan price. |
NullMerchantOnboarding | Keep the marketplace surface resolvable without a provider behind it. |
For your test suite
Two doubles ship for your tests, not the package's:
ArrayMerchantTierRepository— merchant tiers in memory, so a suite needs no database.ArraySubscriptionStateReader— subscription state in memory. It delegates every decision to the real grant object, which is what keeps it from quietly becoming a second implementation with its own opinions.
Neither may be reached from production code, which is exactly why neither has a caller inside the package.
Drivers
| Capability | Stripe (shipping) | Local-engine driver |
|---|---|---|
| Subscriptions, proration, trials | native | package-local engine |
| Invoices / PDF | native | generated locally |
| Hosted portal | native | not available |
| Webhooks | signed | bare-id / HMAC |
The neutral contracts and the BillingEngine::tick() seam ship today; a local-engine driver itself
is not built yet. Under Stripe, tick() is a deliberate no-op — Stripe drives its own recurring cycle.
The Cashier coupling, and where it stops
The Stripe driver builds on the raw stripe/stripe-php SDK, not on Cashier's models or its Billable trait —
the package reimplements checkout, the portal, invoices, subscriptions and payment methods itself so the same
neutral contracts can back a future non-Cashier driver. Cashier is used for exactly two things, both confined
to the driver layer: it supplies the cashier.* config namespace the driver reads Stripe credentials from
(cashier.secret, cashier.webhook.secret), and Cashier::ignoreRoutes() is what the master switch calls to
drop Cashier's own routes when billing is off. The one place a raw Cashier/provider object is intentionally
passed straight through is the hosted invoice-PDF download response, which is streamed as the provider returns
it. An architecture test (tests/Unit/ArchTest.php) fails the build if any Stripe\ or Laravel\Cashier\
import, or a hardcoded ->stripe_id read, leaks outside src/Drivers/Stripe/, so this boundary cannot erode.