Facturation
arrel/billing est un paquet séparé qui ajoute des abonnements payants à un panneau Arrel. Il fournit le modèle de données (produits, plans, prix, abonnements, commandes et remises), un contrat de fournisseur de paiement avec un driver Stripe, l'endpoint des webhooks et une page de facturation qui rejoint le panneau en tant que plugin. Il nécessite Arrel 1.2 ou une version ultérieure.
Panel::make('admin')
->plugins([BillingPlugin::make()])La page affiche le plan actuel et son statut, les plans proposés avec un bouton pour s'abonner, un lien vers le portail client du fournisseur et l'historique des paiements.
Installation
Le paquet se trouve dans un dépôt privé, comme Arrel. Ajoutez-le à composer.json à côté de celui d'Arrel ; le même token GitHub (Installation) doit y avoir accès en lecture :
{
"repositories": [
{ "type": "vcs", "url": "https://github.com/arrelphp/arrel" },
{ "type": "vcs", "url": "https://github.com/arrelphp/billing" }
]
}Puis installez-le :
composer require arrel/billing
php artisan migrateLes migrations s'exécutent directement depuis le paquet. Publiez-les, ou la configuration ou les textes, uniquement si vous devez les modifier :
php artisan vendor:publish --tag=billing-config
php artisan vendor:publish --tag=billing-migrations
php artisan vendor:publish --tag=billing-translationsLe script et la feuille de style de la page sont servis par le paquet lui-même, avec un hash du contenu dans l'URL. Aucun asset n'est à publier lors du déploiement.
Configuration
STRIPE_SECRET=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...Le reste se trouve dans config/billing.php :
| Clé | Par défaut | Description |
|---|---|---|
connection | null | Connexion de base de données de toutes les tables de facturation. Dans une application multi-tenant, la connexion centrale. |
ability | billing.manage | L'autorisation vérifiée avant d'afficher la page ou de lancer un paiement. |
provider | stripe | Le fournisseur de paiement actif. |
webhook.path | billing/webhooks/{provider} | L'adresse où le fournisseur envoie ses événements. |
webhook.middleware | ['throttle:300,1'] | Middleware de la route du webhook. Elle s'exécute en dehors du groupe web : ni session ni vérification CSRF. |
Toutes les tables portent le préfixe billing_.
Qui paie
Le modèle que vous facturez implémente Billable avec le trait HasSubscriptions :
use Arrel\Billing\Concerns\HasSubscriptions;
use Arrel\Billing\Contracts\Billable;
final class Team extends Model implements Billable
{
use HasSubscriptions;
}Le trait ajoute subscriptions(), orders() et currentSubscription(), l'abonnement créé le plus récemment. Les abonnements et les commandes sont polymorphes et stockent la clé sous forme de texte : ils fonctionnent avec des clés entières, UUID ou textuelles.
Indiquez ensuite au paquet qui paie pour chaque requête. Sans cela, c'est l'utilisateur authentifié qui paie, si son modèle est Billable :
use Arrel\Billing\Billing;
Billing::resolveBillableUsing(fn (Request $request): ?Team => $request->user()?->currentTeam);Dans une application multi-tenant, c'est généralement le tenant qui paie. Une requête sans personne à facturer reçoit un 403.
La page
Enregistrez le plugin sur les panneaux qui doivent afficher la facturation :
Panel::make('admin')
->plugins([
BillingPlugin::make()->sort(90)->group('Paramètres'),
])L'entrée de navigation n'apparaît qu'aux utilisateurs qui ont l'autorisation configurée, et les routes de la page (/arrel-plugins/billing) vérifient la même autorisation. Accordez-la comme n'importe quelle autre, avec un Gate, une Policy ou une permission :
Gate::define('billing.manage', fn (User $user): bool => $user->is_owner);Le paiement et le portail client reviennent à la page de facturation du panneau depuis lequel ils ont été ouverts. L'identifiant du panneau est vérifié par rapport aux panneaux enregistrés : le fournisseur ne redirige jamais vers une adresse choisie par le navigateur.
Le catalogue
Les produits, les plans et les prix sont des modèles Eloquent ordinaires. Créez-les comme n'importe quelle autre donnée, par exemple dans un seeder :
$product = Product::query()->updateOrCreate(['key' => 'app'], ['name' => 'App']);
$plan = Plan::query()->updateOrCreate(
['key' => 'basic'],
['product_id' => $product->id, 'name' => 'Basique', 'sort_order' => 1],
);
Price::query()->updateOrCreate(
['plan_id' => $plan->id, 'interval' => PriceInterval::Month],
['currency' => 'eur', 'amount' => 2900],
);Les montants sont des entiers, en centimes. Un prix ne peut être vendu via un fournisseur qu'une fois associé à l'identifiant de prix de ce fournisseur :
php artisan billing:map-price basic month stripe price_1Q...Lancer un paiement pour un prix sans association avec le fournisseur actif lève MissingPriceMappingException.
Webhooks
Configurez le fournisseur pour qu'il envoie les événements à la route du webhook, /billing/webhooks/stripe par défaut. L'endpoint Stripe doit utiliser la version d'API 2025-03-31.basil ou ultérieure.
Le driver Stripe agit sur ces événements et répond à tous les autres sans les enregistrer :
customer.subscription.created,customer.subscription.updatedetcustomer.subscription.deletedmettent à jour l'abonnement.invoice.paidetinvoice.payment_failedenregistrent une commande.
Chaque événement est vérifié par sa signature et enregistré une seule fois par fournisseur : un événement répété n'a aucun effet. Si son application échoue, tout est annulé et la nouvelle tentative du fournisseur est traitée.
Le paiement enregistre l'identifiant de l'abonnement local dans les métadonnées de l'abonnement du fournisseur : chaque événement retrouve son abonnement, quel que soit l'ordre d'arrivée. Un statut que le paquet ne connaît pas est enregistré comme canceled : un état inattendu ne donne jamais accès.
Réagir aux changements
SubscriptionUpdated est déclenché dans la même transaction que celle qui applique le webhook. Si un listener lève une exception, l'événement est annulé et le fournisseur le renvoie :
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() est vrai tant que l'abonnement est actif, en période d'essai ou en retard de paiement (past_due). L'accès est ainsi maintenu pendant que le fournisseur relance un prélèvement échoué, et prend fin quand l'abonnement est annulé.
Tests
Pour ne pas appeler le fournisseur dans les tests, enregistrez FakePaymentProvider. Il note les clients, les paiements et les sessions de portail qui lui sont demandés :
use Arrel\Billing\Contracts\PaymentProvider;
use Arrel\Billing\Testing\FakePaymentProvider;
$provider = new FakePaymentProvider;
$this->app->instance(PaymentProvider::class, $provider);
// ...
expect($provider->checkoutSessions)->toHaveCount(1);Les modèles fournissent des factories pour le catalogue, les abonnements et les commandes.