Billing
arrel/billing is a separate package that adds paid subscriptions to an Arrel panel. It ships the data model (products, plans, prices, subscriptions, orders and discounts), a payment provider contract with a Stripe driver, the webhook endpoint and a billing page that joins the panel as a plugin. It needs Arrel 1.2 or later.
Panel::make('admin')
->plugins([BillingPlugin::make()])The page shows the current plan and its status, the plans on offer with a button to subscribe, a link to the provider's customer portal and the payment history.
Installation
The package lives in a private repository, like Arrel. Add it to composer.json next to Arrel's; the same GitHub token (Installation) needs read access to it:
{
"repositories": [
{ "type": "vcs", "url": "https://github.com/arrelphp/arrel" },
{ "type": "vcs", "url": "https://github.com/arrelphp/billing" }
]
}Then install it:
composer require arrel/billing
php artisan migrateThe migrations run straight from the package. Publish them, the config or the texts only when you need to change them:
php artisan vendor:publish --tag=billing-config
php artisan vendor:publish --tag=billing-migrations
php artisan vendor:publish --tag=billing-translationsThe page's script and stylesheet are served by the package itself, with a content hash in the URL. There are no assets to publish on deploy.
Configuration
STRIPE_SECRET=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...The rest lives in config/billing.php:
| Key | Default | Description |
|---|---|---|
connection | null | Database connection for every billing table. In a multi-tenant application, the central connection. |
ability | billing.manage | The ability checked before showing the page or starting a payment. |
provider | stripe | The active payment provider. |
webhook.path | billing/webhooks/{provider} | Where the provider sends its events. |
webhook.middleware | ['throttle:300,1'] | Middleware on the webhook route. It runs outside the web group: no session and no CSRF check. |
Every table is prefixed with billing_.
Who pays
The model you bill implements Billable with the HasSubscriptions trait:
use Arrel\Billing\Concerns\HasSubscriptions;
use Arrel\Billing\Contracts\Billable;
final class Team extends Model implements Billable
{
use HasSubscriptions;
}The trait adds subscriptions(), orders() and currentSubscription(), the most recently created subscription. Subscriptions and orders are polymorphic and store the key as a string, so they work with integer, UUID or string keys.
Then tell the package who pays for each request. If you do not, the authenticated user pays, when their model is Billable:
use Arrel\Billing\Billing;
Billing::resolveBillableUsing(fn (Request $request): ?Team => $request->user()?->currentTeam);In a multi-tenant application, the tenant usually pays. A request with nobody to bill gets a 403.
The page
Register the plugin on the panels that should show billing:
Panel::make('admin')
->plugins([
BillingPlugin::make()->sort(90)->group('Settings'),
])The navigation entry only appears to users with the configured ability, and the page's routes (/arrel-plugins/billing) check the same ability. Grant it the way you grant anything else, with a Gate, a Policy or a permission:
Gate::define('billing.manage', fn (User $user): bool => $user->is_owner);Checkout and the customer portal return to the billing page of the panel they were opened from. The panel identifier is checked against the registered panels, so the provider never redirects to an address the browser chose.
The catalog
Products, plans and prices are plain Eloquent models. Create them like any other data, for instance in a seeder:
$product = Product::query()->updateOrCreate(['key' => 'app'], ['name' => 'App']);
$plan = Plan::query()->updateOrCreate(
['key' => 'basic'],
['product_id' => $product->id, 'name' => 'Basic', 'sort_order' => 1],
);
Price::query()->updateOrCreate(
['plan_id' => $plan->id, 'interval' => PriceInterval::Month],
['currency' => 'eur', 'amount' => 2900],
);Amounts are integers in minor units. A price can only be sold through a provider once it is mapped to that provider's own price id:
php artisan billing:map-price basic month stripe price_1Q...Starting a payment for a price with no mapping for the active provider throws MissingPriceMappingException.
Webhooks
Point the provider at the webhook route, /billing/webhooks/stripe by default. The Stripe endpoint must use API version 2025-03-31.basil or later.
The Stripe driver acts on these events and answers every other one without storing it:
customer.subscription.created,customer.subscription.updatedandcustomer.subscription.deletedupdate the subscription.invoice.paidandinvoice.payment_failedrecord an order.
Every event is verified with its signature and stored once per provider: a repeated event does nothing. If applying it fails, everything is rolled back and the provider's retry is processed.
Checkout stores the local subscription id in the provider subscription's metadata, so each event finds its subscription whatever order it arrives in. A status the package does not know is stored as canceled: an unexpected state never grants access.
Reacting to changes
SubscriptionUpdated fires inside the same transaction that applies the webhook. If a listener throws, the event is rolled back and the provider sends it again:
use Arrel\Billing\Events\SubscriptionUpdated;
final class SyncTeamFeatures
{
public function handle(SubscriptionUpdated $event): void
{
$team = $event->subscription->billable;
$event->subscription->isUsable()
? $team->enableFeatures($event->subscription->plan)
: $team->disableFeatures();
}
}isUsable() is true while the subscription is active, trialing or past due. Access survives while the provider retries a failed charge, and ends when the subscription is canceled.
Tests
To keep tests away from the provider, register FakePaymentProvider. It records the customers, checkout sessions and portal sessions it is asked for:
use Arrel\Billing\Contracts\PaymentProvider;
use Arrel\Billing\Testing\FakePaymentProvider;
$provider = new FakePaymentProvider;
$this->app->instance(PaymentProvider::class, $provider);
// ...
expect($provider->checkoutSessions)->toHaveCount(1);The models ship factories for the catalog, subscriptions and orders.