Laravel
Cashier-shaped billing layer with a Billable trait, an Eloquent Subscription model and a verified webhook route.
Install
Then run the migrations. They create billkit_subscriptions and add a billkit_customer_id column to your billable model’s table. Run them again after every upgrade. If your billable model uses UUID or ULID keys, call Schema::morphUsingUuids() or Schema::morphUsingUlids() in a service provider first, so the billable_id column is created to match.
The service provider is auto-discovered. Set your credentials in .env.
BILLKIT_API_KEY=bk_test_...
BILLKIT_WEBHOOK_SECRET=bkwhsec_...Everything else is optional. Publish config/billkit.php with php artisan vendor:publish --tag=billkit-config to read it in full, or set the variables directly.
| Variable | Default | Effect |
|---|---|---|
| BILLKIT_BASE_URL | https://api.billkit.eu | API host. |
| BILLKIT_WEBHOOK_TOLERANCE | 300 | Seconds a webhook timestamp may differ from now before it is rejected. |
| BILLKIT_PATH | billkit | Prefix of the webhook route, which is registered at /{path}/webhook. Set billkit.path to null in the config to register your own route instead. |
| BILLKIT_MODEL | App\Models\User | The billable model. The webhook controller uses it to find the owner of a customer id. |
| BILLKIT_SUCCESS_URL, BILLKIT_CANCEL_URL | none | Fallback redirect URLs for checkout(), checkoutCharge() and checkoutChargeEmbedded() when a call passes none. |
| BILLKIT_LOG_CHANNEL | none | See Logging. |
The package follows semantic versioning from 1.0.0. Coming from 0.x, read Upgrading from 0.x.
The package wraps billkit-eu/billkit-php 1.x, which it pulls in for you. The client is a container singleton: reach it with the billkit() helper or $user->billkitClient() for anything the package has no method for. Its responses are typed objects that also read as arrays, so $customer->id and $customer['id'] are the same value.
Make a model billable
<?php
use BillKit\Laravel\Billable;
class User extends Authenticatable
{
use Billable;
}Start a subscription
BillKit creates a subscription when the customer completes checkout, and there is no call that creates one
directly. So the flow is checkout, then redirect, then the package’s webhook route creates the local
Subscription row.
checkout() creates the BillKit customer on first use, stores its id on the model, and returns a Checkout. Checkout is Responsable, so a controller can return it and Laravel does the redirect. It also exposes id(), url() and clientSecret().
return $request->user()->checkout('price_9XKp2vQ1', [
'success_url' => route('billing.done'),
'cancel_url' => route('pricing'),
'trial_days' => 14,
'coupon_code' => 'LAUNCH',
]);trial_days replaces the price’s trial for this checkout, and is the only spelling: trial_days_override throws. idempotency_key is sent as the request’s key, as on every method that takes options. Other options: payment_method to fix the payment method (the API’s own name; the 0.x method throws InvalidArgumentException rather than being dropped), ui_mode for an embedded session, and country, the buyer’s ISO 3166-1 alpha-2 code. Pass country when you know it, because it is what lets VAT apply to the first charge. metadata is attached to the checkout session, as in Cashier, and comes back on the session and on checkout.session.completed, which is how you join a completed checkout to your own order. email, name and country_code are used only when the customer is created. success_url and cancel_url, here or in config/billkit.php, may be a route name or an absolute URL.
One-off charges
checkoutCharge() takes a single payment through the hosted page and stores no mandate. It is Cashier’s name for the same flow; there is no charge(), because nothing is stored that could be charged off-session later. It is redirect-based too, so it returns a Checkout, and the payment settles through the one_shot_payment.succeeded and one_shot_payment.failed webhooks. Its third argument is the payment method, sent as the API’s payment_method: one of creditcard, ideal, bancontact, eps, applepay, paypal or banktransfer. The package also passes directdebit through, and the API refuses it, because SEPA Direct Debit needs an existing mandate. A bank transfer stays open for days while the payer sends the money, so wait for the webhook rather than polling. Pass tax_behavior as exclusive to treat the amount as net and add VAT on top, and metadata to tag the payment.
return $request->user()->checkoutCharge(1999, 'EUR', 'creditcard', [
'success_url' => route('thanks'),
'cancel_url' => route('cart'),
'description' => 'One premium widget',
'refund_window_days' => 30, // 0 disables refunds; default 30; max 365
]);
// Later, within the window:
$user->refundOneShot('osp_9XKp2vQ1'); // full
$user->refundOneShot('osp_9XKp2vQ1', ['amount_cents' => 500]); // partial
// A subscription charge is refunded by its payment id:
$user->refund('pay_9XKp2vQ1', ['amount_cents' => 500]);Neither refund method checks that the payment belongs to that model; the API only checks that it belongs to your account. Refunds settle asynchronously, so handle refund.succeeded and refund.failed.
To take the payment inside your own page instead, call checkoutChargeEmbedded($amountCents, $currency, $options). It has no method argument, because the buyer picks one in the BillKit payment element, and it returns the one-off payment (a BillKit\Model\OneShotPayment) rather than a Checkout: pass its client_secret to mountOneShotPaymentElement (or <OneShotPaymentElement/>) in the browser. The secret is on that response only. It settles through the same webhooks. See Take the payment on your own page.
Gate access
if ($user->subscribed()) {
// active, trialing, past_due, or inside a cancellation grace period
}
$user->onTrial();
$user->onGracePeriod();None of them takes a subscription type: BillKit records no type, so there is nothing to select by. Passing one, as in Cashier’s subscribed('pro'), throws InvalidArgumentException rather than being ignored, because ignoring it would let a customer on any plan through a gate meant for one. To gate on a plan, compare $user->subscription()?->price_id. $user->subscription() is the subscription serving the customer, the newest valid one or else the newest. subscribed() follows the API’s serves_customer, so a past_due subscription still counts while its payment is retried; Cashier’s default does not. A paused or cancelling subscription counts until the end of the period already paid for. The Subscription model also has active(), trialing(), pastDue(), paused(), canceled() and ended(). paused() reads renewal_state, because a paused subscription keeps status at active while its current period is paid for.
The row also mirrors the subscription’s discount and the payment method its renewals are charged to: hasDiscount(), couponId() and discountEndsAt(), and hasPaymentMethod(), paymentMethodType() (creditcard, directdebit or paypal), hasCard(), cardBrand() and cardLastFour(). The payment method lives on the subscription rather than on the billable, because one customer can renew two subscriptions on two different cards. hasPaymentMethod() is false only while a subscription imported from Stripe waits for its customer to authorise a payment method. These columns come from the package’s migrations, so run php artisan migrate after upgrading; existing rows fill on their next subscription.* webhook or action.
Manage a subscription
$sub = $user->subscription(); // BillKit\Laravel\Subscription
$sub->previewSwap('price_pro_yearly'); // quote the proration first
$sub->swap('price_pro_yearly'); // change plan
$sub->cancel(); // at period end
$sub->reactivate(); // undo a scheduled cancellation
$sub->pause();
$sub->resume();
// Hosted pages; both take the URL to send the customer back to.
return redirect($sub->updatePaymentMethod(route('billing')));
return $sub->redirectToBillingPortal(route('billing'));Each action calls the API, updates the local row from the response and dispatches SubscriptionSynced once the write is committed. Called inside your own DB::transaction(), it dispatches when that transaction commits, and not at all if it rolls back. cancel(), pause(), resume(), reactivate(), swap() and previewSwap() take request options as a last argument, such as ['idempotency_key' => "swap-{$order->id}"]. After swap() the row usually keeps the old price until the proration charge settles and subscription.updated arrives. billingPortalUrl() returns the portal URL as a string instead of a redirect.
Metered usage
For a subscription on a metered price, report usage against the subscription. Pass an identifier derived from the job or domain event, not a fresh random value: a second report with the same identifier returns the first record rather than billing twice.
$sub = $user->subscription();
$sub->reportUsage(quantity: 1200, identifier: $job->uuid());
$sub->usageRecords(['invoice_id' => 'pending']); // what the next period close will bill
$summary = $sub->usageSummary(); // the same, priced
if ($summary->will_charge) {
// gross_cents is what the customer will be charged
}A period whose total is under the minimum charge is not billed; it rolls into the next period. Check will_charge before you show an amount.
Webhooks
The package registers POST /billkit/webhook outside the web middleware group, so CSRF does not apply. It verifies the signature, answering 403 to a bad one or when BILLKIT_WEBHOOK_SECRET is not set, and updates the local Subscription row on every subscription.* event. Point a BillKit webhook endpoint at that URL, subscribe it to at least the subscription.* events, and set BILLKIT_WEBHOOK_SECRET.
Every verified event dispatches WebhookReceived before the package syncs anything, and WebhookHandled after. Both carry payload, the delivery as an array, and event, the same delivery as a typed BillKit\Model\Event subclass. A subscription.* event that updated a local row also dispatches SubscriptionSynced, which carries subscription, the local Subscription row, and event, the delivery (null when an action verb rather than a webhook wrote the row). Listen for them to handle the rest. The Subscription model and the webhook controller are final, so these events, and Eloquent’s resolveRelationUsing(), are how you extend them.
use BillKit\Laravel\Events\WebhookReceived;
Event::listen(function (WebhookReceived $event) {
if ($event->payload['type'] === 'invoice.paid') {
// $event->payload['data'] is the invoice
}
});Logging
Off unless you name a channel from config/logging.php. A channel name that is not defined there logs one warning and leaves the SDK silent.
BILLKIT_LOG_CHANNEL=billkitYou then get one debug record per HTTP attempt and per response, and one warning per retry. The channel’s own level still applies. API keys, bodies and query strings are never written.
Upgrading from 0.x
1.0 is a deliberate break, and every change is listed in the package’s CHANGELOG.md with before and after code. It requires billkit-eu/billkit-php 1.x, whose own upgrade notes apply to anything you call on the client directly; see Upgrading from 0.x on the PHP page. In short:
- Laravel 12 or 13 and PHP 8.2 or newer. Run
php artisan migrate: the fallback for an unmigratedbillkit_subscriptionstable is gone. charge()ischeckoutCharge()andchargeEmbedded()ischeckoutChargeEmbedded(), with the same arguments.- No subscription types.
subscription(),subscribed(),onTrial()andonGracePeriod()take no argument, and passing a type now throwsInvalidArgumentException: 0.x filtered on the row’stype, and 1.0 refuses rather than silently answering for every plan.subscription()returns the newest valid subscription, falling back to the newest of any state. - Positional options became arrays:
usageRecords('pending', 25)isusageRecords(['invoice_id' => 'pending', 'limit' => 25]), and it returns aBillKit\Collection. checkout()takestrial_daysonly;trial_days_overridethrows.checkout()takes the payment method aspayment_method, the API’s name; the 0.xmethodoption throws.checkoutCharge()’s third parameter is named$paymentMethod.- Methods return typed objects (
Customer,Refund,PlanChangePreview,UsageRecord,UsageSummary). Array access keeps working; use->toArray()where a function wants a real array. SubscriptionandWebhookControllerare final. Listen forSubscriptionSyncedorWebhookReceivedinstead of subclassing, and add relations withSubscription::resolveRelationUsing().WebhookReceivedandWebhookHandledgainedevent, the typed delivery, besidepayload.- The action verbs take the PHP client’s request options as a last argument.
- From the PHP client:
catch (ApiException)now catches every HTTP error, 4xx included, not only 5xx; catchServerExceptionwhere you meant a server error. Timeouts are seconds.
BillKit