kodedjackson / bachs-laravel
Laravel client for the Bachs payment API
Requires
- php: ^8.2
- guzzlehttp/guzzle: ^7.8
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A fluent, native Laravel integration for the Bachs.io payment API.
This package provides a clean Facade for interacting with the Bachs API — Checkout Sessions, Products, and Subscriptions — and automatically handles secure webhook signature verification out of the box.
Installation
You can install the package via composer:
composer require kodedjackson/bachs-laravel
Publish the configuration file:
php artisan vendor:publish --tag="bachs-config"
This will create a config/bachs.php file in your application where you can configure your default settings.
Configuration
Add your Bachs API keys to your .env file. You can find these in your Bachs developer dashboard.
BACHS_PUBLIC_KEY=pk_test_... BACHS_SECRET_KEY=sk_sandbox_... BACHS_WEBHOOK_SECRET=whsec_... BACHS_BASE_URL=https://sandbox-api.bachs.io/v1 BACHS_CURRENCY=USD BACHS_WEBHOOK_TOLERANCE=300
(Note: When moving to production, change your Base URL to https://api.bachs.io/v1 and use your live keys).
BACHS_WEBHOOK_TOLERANCE is the number of seconds a webhook delivery's timestamp is allowed to drift before it's rejected as stale (default 300).
Usage
1. Creating a Checkout Session
You can create a hosted checkout session by passing a pricing array for raw amounts, or a product_cart array for catalog items.
use Kodedjackson\Bachs\Facades\Bachs; $session = Bachs::createCheckoutSession([ 'pricing' => [ 'currency' => 'USD', 'amount' => '42.00', ], 'customer' => [ 'email' => 'jane@example.com', 'name' => 'Jane Doe', ], 'success_url' => url('/thanks'), // Must be a public URL in Live mode 'cancel_url' => url('/cart'), ]); // Redirect the user to the Bachs hosted checkout page return redirect($session['checkout_url']);
2. Creating a Product
You can create a product in your Bachs catalog. If you omit the currency, the package will automatically use your configured default (BACHS_CURRENCY).
use Kodedjackson\Bachs\Facades\Bachs; $product = Bachs::createProduct([ 'name' => 'Pro Plan', 'description' => 'Monthly access to all Pro features.', 'price' => [ 'price_type' => 'fixed', 'amount' => '29.00', ], 'billing_cycle' => [ 'interval' => 'month', 'frequency' => 1, ], ]);
Add a billing_cycle and the product becomes recurring — checking out for it starts a subscription (see below). Leave it off for a one-time product.
Subscriptions
A subscription is a customer's ongoing, recurring relationship with a product. There's no "create subscription" call — a subscription is created automatically the moment a customer completes a checkout for a product that has a billing_cycle. From there, Bachs renews it automatically: at the end of each period it opens an invoice and charges the customer's saved card off-session.
Selling a subscription
Give a product a billing_cycle (and, optionally, a trial_period — see Trials) and sell it exactly like any other product cart item:
use Kodedjackson\Bachs\Facades\Bachs; $session = Bachs::createCheckoutSession([ 'product_cart' => [ ['product_id' => 'prod_abc123', 'quantity' => 1], ], 'customer' => [ 'email' => 'jane@example.com', ], 'success_url' => url('/thanks'), 'cancel_url' => url('/pricing'), ]); return redirect($session['checkout_url']);
If prod_abc123 has a billing_cycle, completing that checkout creates the subscription and bills the first cycle. The saved card is reused for every renewal.
Subscription statuses
| Status | Meaning |
|---|---|
trialing |
In a free trial. No charge yet — the first charge happens when the trial ends. |
active |
Billing normally. The card is charged at the end of each cycle. |
past_due |
A renewal charge failed and Bachs is retrying (see Failed payments). |
unpaid |
Recovery was exhausted and the account is set to keep unpaid subscriptions. A later successful payment reactivates it. |
canceled |
Terminal. No further charges; a later payment can't revive it. |
Retrieving subscriptions
// List (filter with 'customer_id', 'status', 'limit', 'offset') $subscriptions = Bachs::listSubscriptions(['status' => 'active']); // Get a single subscription $subscription = Bachs::getSubscription('sub_1a2b3c4d5e6f');
Managing subscriptions
Every change to a subscription carries exactly one intent — change the plan, move the trial, swap the payment method, or update metadata. The Bachs API rejects a request that combines them, so the client exposes one focused method per intent, all built on updateSubscription():
// Change the plan. proration_behavior: 'invoice_now' (default), 'next_cycle', or 'none'. Bachs::changeSubscriptionPlan('sub_1a2b3c4d5e6f', 'prod_premium', 'invoice_now'); // Swap the saved payment method Bachs::updateSubscriptionPaymentMethod('sub_1a2b3c4d5e6f', 'pm_9f8e7d6c5b'); // Merge metadata (send a key with an empty string to remove it) Bachs::updateSubscriptionMetadata('sub_1a2b3c4d5e6f', ['plan_tier' => 'pro']); // Or drop down to the raw call for anything else Bachs::updateSubscription('sub_1a2b3c4d5e6f', ['product_id' => 'prod_premium']);
The target product must bill at the same interval as the subscription and have a price in its currency, or the request fails.
Canceling subscriptions
// Let it run out the period the customer already paid for Bachs::cancelSubscriptionAtPeriodEnd('sub_1a2b3c4d5e6f', 'Customer requested'); // Cancel right now Bachs::cancelSubscriptionImmediately('sub_1a2b3c4d5e6f');
reason is optional (max 255 characters) and stored on the subscription. cancel_at_period_end sets cancel_at_period_end: true and the subscription keeps working until current_period_end; canceling immediately ends access straight away. Once canceled, a subscription is terminal — the customer has to start a new one.
Trials
A trial is a property of the product: set a trial_period alongside billing_cycle when creating it. The customer's card is saved at checkout but nothing is charged until the trial ends.
$product = Bachs::createProduct([ 'name' => 'Pro Plan', 'price' => ['price_type' => 'fixed', 'amount' => '10.00'], 'billing_cycle' => ['interval' => 'month', 'frequency' => 1], 'trial_period' => ['interval' => 'day', 'frequency' => 14], // 14-day trial ]);
Checking out for that product creates the subscription in trialing status with no charge, and schedules the first charge for trial_end. Nothing else to do — Bachs bills the first cycle automatically when the trial ends.
To extend or end a trial on an existing subscription, use the trial_end field:
// Extend (or start) the trial — postpones billing Bachs::extendSubscriptionTrial('sub_1a2b3c4d5e6f', '2026-08-10T12:00:00Z'); // End the trial right now — bills the first cycle immediately Bachs::endSubscriptionTrial('sub_1a2b3c4d5e6f');
A future trial_end extends the trial (this even works on an active subscription, which becomes trialing again). Ending a trial early is only valid while the subscription is actually trialing.
Note: Bachs trials are in beta; behavior may change upstream.
Proration
When a subscription's plan changes mid-cycle, there's an unused portion of the period already paid for. Bachs prorates by the exact time remaining and settles it according to the proration_behavior you pass to changeSubscriptionPlan() / updateSubscription():
| Behavior | What happens |
|---|---|
invoice_now (default) |
Applies immediately; an upgrade is charged now on a one-off invoice, a downgrade becomes credit. |
next_cycle |
Applies immediately, but the price difference is rolled into the next renewal invoice instead of being charged now. |
none |
The plan changes with no proration — no charge, no credit. |
Upgrades are billed for the remaining time on the new plan; downgrades are always turned into customer credit (never refunded to the card), which Bachs draws down automatically against the customer's future invoices before charging their card again.
Bachs::changeSubscriptionPlan('sub_1a2b3c4d5e6f', 'prod_premium', 'next_cycle');
Failed payments & payment recovery
If a renewal charge fails, Bachs doesn't cancel the subscription immediately. It moves to past_due and runs an automated recovery (dunning) flow: three retries — after 1 day, then 3 days, then 5 days — plus an email to the customer with a hosted link to update their card.
- A successful retry (or card update) moves the subscription back to
active. - If all retries fail, recovery is exhausted and the subscription either becomes
canceled(with reasonpayment_failed) orunpaid, depending on your Bachs dashboard settings.
You don't need to poll for this — listen for the webhook events below and react to status changes as they happen.
Handling Webhooks
Bachs recommends never relying on the browser redirect to fulfill an order or grant/revoke access. Instead, listen for webhooks.
This package automatically exposes a secure webhook endpoint at POST /bachs/webhook. It verifies the cryptographic signature of every incoming request and dispatches native Laravel events.
Step 1: Configure your Dashboard
In your Bachs developer portal, set your webhook URL to:
https://yourdomain.com/bachs/webhook
Subscribe to the events you care about (see the table below), and paste the endpoint's signing secret into your .env file as BACHS_WEBHOOK_SECRET.
Step 2: Verification
Every delivery is signed with two headers, X-Bachs-Timestamp and X-Bachs-Signature. The package reconstructs "{timestamp}.{raw_body}", computes the HMAC-SHA256 digest with your webhook secret, and compares it to X-Bachs-Signature — rejecting the request if it doesn't match, or if the timestamp is older than BACHS_WEBHOOK_TOLERANCE seconds. You don't need to do anything for this beyond setting BACHS_WEBHOOK_SECRET.
Step 3: Listen for events
Each webhook type is dispatched as its own Laravel event, carrying the payload's data object:
| Webhook type | Event class | Property |
|---|---|---|
collection.succeeded |
Kodedjackson\Bachs\Events\CollectionSucceeded |
$paymentData |
customer.subscription.created |
Kodedjackson\Bachs\Events\SubscriptionCreated |
$subscription |
customer.subscription.updated |
Kodedjackson\Bachs\Events\SubscriptionUpdated |
$subscription |
customer.subscription.deleted |
Kodedjackson\Bachs\Events\SubscriptionCanceled |
$subscription |
invoice.created |
Kodedjackson\Bachs\Events\InvoiceCreated |
$invoice |
invoice.paid |
Kodedjackson\Bachs\Events\InvoicePaid |
$invoice |
invoice.payment_failed |
Kodedjackson\Bachs\Events\InvoicePaymentFailed |
$invoice |
Listen for them in your application (e.g., inside EventServiceProvider or a dedicated listener):
use Illuminate\Support\Facades\Event; use Kodedjackson\Bachs\Events\CollectionSucceeded; use Kodedjackson\Bachs\Events\SubscriptionCanceled; use Kodedjackson\Bachs\Events\SubscriptionCreated; use Kodedjackson\Bachs\Events\InvoicePaid; use Kodedjackson\Bachs\Events\InvoicePaymentFailed; // A one-off payment succeeded Event::listen(function (CollectionSucceeded $event) { $data = $event->paymentData; // Order::where('checkout_id', $data['checkout_id'])->update(['status' => 'paid']); }); // A subscription started (fires with status "trialing" or "active") Event::listen(function (SubscriptionCreated $event) { $subscription = $event->subscription; // User::where('customer_id', $subscription['customer']['customer_id']) // ->update(['subscription_id' => $subscription['id'], 'plan_status' => $subscription['status']]); }); // A renewal (or the first post-trial charge, or a recovery retry) succeeded Event::listen(function (InvoicePaid $event) { // Grant/extend access based on $event->invoice }); // A renewal charge failed — subscription is now past_due Event::listen(function (InvoicePaymentFailed $event) { // Optionally notify the customer; Bachs already emails them a card-update link }); // The subscription reached the terminal "canceled" status Event::listen(function (SubscriptionCanceled $event) { // Revoke access });
Treat these webhooks — not the checkout redirect — as the source of truth for granting and revoking access.
Testing
The package ships its own PHPUnit test suite (built on Orchestra Testbench), covering the client's HTTP calls (via Http::fake()), webhook signature verification, event dispatch for every webhook type, and the service provider's bindings/routes.
composer install
composer test
Security Vulnerabilities
If you discover any security-related issues, please email directly instead of using the issue tracker.
License
The MIT License (MIT).