lanos/laravel-cashier-stripe-connect
Laravel Cashier Stripe Connect adds Stripe Connect support to Laravel Cashier, helping you manage connected accounts, charges, and payouts for marketplace or platform payments with familiar Cashier APIs.
1. Install and scaffold
composer require lanos/laravel-cashier-stripe-connect
# Config only
php artisan vendor:publish --provider="Lanos\CashierConnect\CashierConnectServiceProvider" --tag="config"
# Migrations only
php artisan vendor:publish --provider="Lanos\CashierConnect\CashierConnectServiceProvider" --tag="migrations"
# Tenancy migrations (only if one model can own multiple accounts)
php artisan vendor:publish --provider="Lanos\CashierConnect\CashierConnectServiceProvider" --tag="tenancy-migrations"
# Everything
php artisan vendor:publish --provider="Lanos\CashierConnect\CashierConnectServiceProvider"
php artisan migrate
As of v1.3.2 the publish tags follow normal Laravel conventions — config and migrations instead of the old cashier-connect-config / cashier-connect-migrations. If you have CI scripts, deploy runbooks, or a Docker entrypoint invoking the old tags, those silently publish nothing now. Grep for both strings before upgrading. The provider FQCN is Lanos\CashierConnect\CashierConnectServiceProvider (note the Lanos namespace, not L4nos) — use the provider form when you need a deterministic single-shot publish, since bare vendor:publish with no arguments prompts interactively.
The package sits on top of laravel/cashier, so Cashier must be installed and configured first. If you have not already run Cashier's own install, do that before anything else — this package does not replace it.
2. Configure environment
You need the platform account's keys plus the Connect client ID:
STRIPE_KEY=pk_test_...
STRIPE_SECRET=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_... # platform endpoint
STRIPE_CONNECT_CLIENT_ID=ca_... # only for OAuth-style Standard onboarding
CASHIER_CONNECT_WEBHOOK_SECRET=whsec_... # Connect endpoint (separate secret)
Publish and read config/cashier-connect.php first — it defines the model/table used for connected accounts and the default account type. Everything else in the package hangs off those two values.
3. Add the trait to your Billable model
use Laravel\Cashier\Billable;
use Lanos\CashierConnect\BillableAsConnectAccount;
class User extends Authenticatable
{
use Billable, BillableAsConnectAccount;
}
The trait expects a nullable stripe_account_id column (added by the published migration) and exposes the account lifecycle methods. Read the trait source before assuming method names — it is the authoritative API surface, and the package is small enough to skim in a few minutes.
4. First use case: onboard a seller
Route::post('/connect/onboard', function (Request $request) {
$account = $request->user()->createOrGetConnectAccount();
return redirect($request->user()->connectAccountLink(
returnUrl: route('connect.return'),
refreshUrl: route('connect.refresh'),
));
});
Stripe redirects the user to hosted onboarding, then back to returnUrl (success) or refreshUrl (link expired). On return, re-fetch the account and check details_submitted, charges_enabled, payouts_enabled before allowing them to sell. That status check is the first thing to wire up; everything else in the package depends on it.
Onboarding is a three-state machine. Treat each connected account as pending → restricted → enabled and gate features on capability flags rather than a boolean "onboarded" column:
public function canSell(): bool
{
$account = $this->stripeConnectAccount(); // cached Stripe account object
return $account->charges_enabled && $account->payouts_enabled;
}
Drive the UI off charges_enabled / payouts_enabled / requirements.disabled_reason. They change over time (new KYC requirements, disputes, tax forms), so refresh them from a Connect webhook and cache the result with a short TTL.
Account types are a design decision, not a config detail. Express is the usual choice: Stripe hosts onboarding and the dashboard, the platform pays fees and is liable for negative balances. Standard pushes liability to the seller but requires OAuth (STRIPE_CONNECT_CLIENT_ID and a redirect/callback flow). Custom means you build the whole KYC UI — the package gives you API access but no UI. Pick before you write the first line, because switching later means new accounts.
Direct vs. destination vs. separate charges. Three patterns are worth knowing:
// Direct charge: money never touches the platform. Seller pays Stripe fees.
$user->stripe()->charges->create([
'amount' => 2000, 'currency' => 'usd', 'source' => $token,
], ['stripe_account' => $user->stripe_account_id]);
// Destination charge: platform is merchant of record, takes a cut.
$user->stripe()->charges->create([
'amount' => 2000,
'currency' => 'usd',
'source' => $token,
'application_fee_amount' => 200,
'transfer_data' => ['destination' => $user->stripe_account_id],
]);
// Separate charges & transfers: decouple charge from payout (marketplaces, multi-seller carts).
Cashier's stripe() accessor returns a client authenticated with the platform secret key; for direct/destination calls on a connected account pass ['stripe_account' => ...] as the per-request options array, or build a dedicated client. Keep a small helper so you never forget it.
Subscriptions on connected accounts. Cashier's subscription helpers assume the billable entity is a customer of the platform. For sellers who pay you (e.g., a SaaS fee for using your marketplace), create a real Cashier customer and subscribe normally. For subscriptions billed by a connected account, you bypass the Cashier models and call the API with the stripe_account header — the package provides the authenticated account context but not subscription lifecycle handling for it. Be explicit in your code about which of the two a given user is.
Webhooks are the source of truth. You need two endpoints: one for platform events (Cashier handles most of these) and one for Connect events. Connect events carry an account property; route them yourself:
Route::post('/stripe/connect/webhook', function (Request $request) {
$event = Webhook::constructEvent(
$request->getContent(),
$request->header('Stripe-Signature'),
config('cashier-connect.webhook_secret'),
);
match ($event->type) {
'account.updated' => app(SyncConnectAccount::class)->handle($event->data->object),
'capability.updated' => /* re-check payout status */,
'account.application.deauthorized' => /* unlink */,
default => null,
};
return response('', 200);
});
Use account.updated as the single trigger to refresh your local copy — it fires for KYC changes, capability changes, and requirement updates.
One account per model, by default. Add the trait to the model that actually owns the Stripe account — User for a simple marketplace, or Team / Store if your sellers are organizational. Retrofitting from User to a team model later means migrating stripe_account_id values, so decide up front. The default migration uses a unique index on stripe_account_id, which is what you want for the single-account case.
Multiple accounts per model (v1.3.2+). The new tenancy-migrations tag ships schema for models that own more than one connected account — e.g. a freelancer with several storefronts under one login, or a platform where a single User can operate accounts in multiple countries. Publish it instead of the standard migrations (they are alternatives, not additive) and read the migration before writing queries: the stripe_account_id column moves off the billable model, so every $model->stripeConnectAccount() style call needs an explicit account context. Budget a refactor of your checkout and webhook handlers — the account property on the webhook event becomes your lookup key, not the billable model. If you are not sure you need this, you don't; the single-account path is simpler and better documented.
Testing. Use Stripe CLI with connect forwarding:
stripe listen --forward-connect-to localhost:8000/stripe/connect/webhook
In tests, Stripe::fake() is not a thing — Cashier ships with Mockery-based HTTP fakes, or you can Http::fake() the api.stripe.com calls. For onboarding flows, assert the redirect URL contains connect.stripe.com and stub the account object rather than calling the API. Use php artisan migrate:fresh between tests since account rows persist. If you are on tenancy migrations, add a test that asserts two accounts on the same billable model resolve to distinct stripe_account_id values — that class of bug is silent and expensive.
The publish tags changed in v1.3.2 — this is a breaking change for automation. cashier-connect-config and cashier-connect-migrations are gone, replaced by config and migrations. Local devs who already ran vendor:publish are fine (files exist), but fresh CI environments and new hires will get a no-op publish followed by "table not found" at migrate time. Check Dockerfile, Makefile, composer.json post-autoload-dump scripts, and any deploy hooks. Also verify the namespace in your use statements is Lanos\... — older docs and blog posts reference L4nos\..., and autoload failure on a trait is a confusing fatal at class-load time, not at the call site.
A user is often two Stripe entities. They are a Cashier customer of your platform and a connected account. $user->createAsStripeCustomer() and $user->createOrGetConnectAccount() are independent. Cashier's invoices(), subscription(), defaultPaymentMethod() all operate on the customer; the Connect trait operates on the account. Mixing them up is the most common source of confusing 404s.
The stripe_account option is silent when omitted. There is no guardrail preventing you from calling $user->stripe()->charges->create(...) without the header and accidentally charging the platform. Wrap connected-account calls in a helper:
$user->stripeForConnect()->charges->create([...]); // thin wrapper adding stripe_account
Account links expire in minutes and are single-use. Always regenerate on refresh_url — never store them. Users who close the tab and come back will hit the refresh route.
Test-mode connected accounts are separate entities. Onboarding in test mode does not create a live account; you will onboard again. Stripe CLI's --forward-connect-to only forwards events for accounts created in the current mode. This confuses people during QA — a live acct_... ID pasted into a test env simply won't resolve.
Webhook signature verification uses the platform secret. Even Connect events are signed with the platform webhook secret, but they are delivered to the endpoint you configured for Connect. If you point the same route at both, Webhook::constructEvent succeeds for both — the account property is what distinguishes them. Don't assume Cashier's WebhookController will route Connect events; it handles the platform payloads only.
Account status can regress. charges_enabled can flip to false after disputes, KYC expiry, or sanctions screening. Do not cache canSell() forever, and make sure your checkout path re-checks it. A cached "true" is a real-money bug.
Express dashboard links are a separate API. Logging a seller into Stripe's dashboard requires accounts->createLoginLink($accountId); the package doesn't abstract it. Same for accounts->createPerson and accounts->update for Custom accounts.
Fees and liability. On Express accounts, the platform is liable for negative balances and pays Stripe fees unless you set application_fee_amount. That is a business-model decision encoded in code — document it in the checkout service, not just in a config value.
Extension points. The account type, config, and model are all configurable; the trait is small enough to copy into your app if you need to change signatures. If you need a custom Stripe client (proxy, logging, retry), bind it in the container and override the trait's client accessor — this is cleaner than forking the package. Note that the tenancy migrations and the standard migrations are mutually exclusive paths; forking one means you own the upgrade merge for it.
Version pinning. The package is still thin and low-traffic — you are effectively the QA team. Pin to an exact minor ("lanos/laravel-cashier-stripe-connect": "1.3.*") and read the release notes for each bump; v1.3.2 changed publish tags, which is the kind of thing that only shows up on a fresh deploy. Watch for Cashier major upgrades (v14 → v15 changed several client internals), and vendor-in a test that asserts onboarding and a destination charge end-to-end. The package is thin enough that this is cheap insurance.
Missing pieces you will end up writing yourself: refunds on connected accounts, payout reconciliation, tax reporting (1099-K/1099-NEC), dispute handling, and — even with the tenancy migrations — the account-selection UX and per-request account resolution for multi-account models. Budget for these; the package handles account lifecycle and delegation, not your marketplace ledger.
How can I help you explore Laravel packages today?