vatly/vatly-laravel
Composer 安装命令:
composer require vatly/vatly-laravel
包简介
Laravel integration for Vatly, inspired by Laravel Cashier
README 文档
README
Vatly Laravel
Alpha — under active development. Expect breaking changes between minor versions.
A Cashier-style integration for Vatly in your Laravel application. Drop a Billable trait on your User model and you get subscriptions, checkouts, customer management, hosted billing update links, and a fully wired webhook endpoint — built around Eloquent and Laravel's IoC, events, and routing.
If you've used a Cashier-style billing package, this will feel familiar. Vatly handles Merchant of Record billing for EU SaaS, so you get a similar developer experience without managing VAT, invoicing, or payment compliance yourself.
Documentation
Full docs at docs.vatly.com. In this repo:
- Getting started
- Configuration
- Customers
- Checkouts
- Subscriptions
- Orders
- Webhooks
- How Vatly compares — Vatly vs. other Laravel billing packages, for greenfield apps
- Migrating from Cashier — adding Vatly next to an existing Merchant of Record, side by side
Requirements
- PHP 8.3+
- Laravel 12 or 13
- A Vatly API key (vatly.com)
Installation
composer require vatly/vatly-laravel:v0.7.0-alpha.14
Pin to an exact version during alpha — the API will change.
Setup
-
Publish the config:
php artisan vendor:publish --tag=vatly-config
-
Add credentials to
.env:VATLY_KEY=test_xxxxxxxxxxxx VATLY_WEBHOOK_SECRET=your-webhook-secret VATLY_REDIRECT_URL_SUCCESS=https://your-app.test/checkout/success VATLY_REDIRECT_URL_CANCELED=https://your-app.test/checkout/canceled
See
.env.examplefor a ready-to-copy stub and docs/configuration.md for the full list. Testmode is inferred from the key prefix (test_vslive_) — no extra config needed. -
Publish and run migrations:
php artisan vendor:publish --tag=vatly-migrations php artisan vendor:publish --tag=vatly-billable-migrations php artisan migrate
This adds a
vatly_idcolumn to your users table plusvatly_subscriptions,vatly_orders,vatly_order_lines,vatly_refunds,vatly_chargebacks, andvatly_webhook_callstables. -
Add the
Billabletrait to your User model:use Vatly\Laravel\Billable; class User extends Authenticatable { use Billable; }
Already running another billing provider and migrating gradually? Reach for the
VatlyBillabletrait instead — it exposes the same surface undervatly*-prefixed names, so Vatly sits beside your existing biller without a trait collision. See Migrating from Cashier.
Usage
// Vatly ids are prefixed — subscription plans are `subscription_plan_…`, // one-off products `one_off_product_…`. Find them in the Vatly dashboard // or via `GET /subscription-plans`. // Start a subscription checkout $checkout = $user->subscribe() ->toPlan('subscription_plan_7Hd9Kf2Lm') ->create(); return redirect($checkout->links->checkoutUrl->href); // Start it with a free trial — billing begins after the trial elapses. // Either set whole days… $user->subscribe() ->toPlan('subscription_plan_7Hd9Kf2Lm') ->withTrialDays(14) ->create(); // …or an end date (rounded up to whole days, so the trial never ends early): $user->subscribe() ->toPlan('subscription_plan_7Hd9Kf2Lm') ->withTrialEndsAt(now()->addMonth()) ->create(); // One-off checkouts with explicit items // Each item id is a Vatly product: `one_off_product_…` for a one-off product // or `subscription_plan_…` for a subscription plan. $checkout = $user->checkout()->create( items: [['id' => 'one_off_product_3Qb8Wz1Yt', 'quantity' => 1]], redirectUrlSuccess: 'https://example.com/success', redirectUrlCanceled: 'https://example.com/canceled', ); // Guest checkout: put {CHECKOUT_ID} in the return URL — Vatly fills it in, // and claimVatlyCustomerFromReturn() links the purchase on the way back. $user->checkout()->create( items: [['id' => 'one_off_product_3Qb8Wz1Yt', 'quantity' => 1]], redirectUrlSuccess: route('vatly.return').'?checkout_id={CHECKOUT_ID}', redirectUrlCanceled: 'https://example.com/billing', ); // …on the return route (multi-tab safe; no session/cookie plumbing): $request->user()->claimVatlyCustomerFromReturn($request); // see docs/Customers.md // Subscription state — Cashier-shape predicates $user->subscribed(); // bool, default type $user->subscribed('team'); // bool, custom type $user->subscription()->active(); $user->subscription()->onGracePeriod(); $user->subscription()->canceled(); $user->subscription()->valid(); $user->subscription()->ended(); // Subscription operations $user->subscription()->swap('subscription_plan_7Hd9Kf2Lm'); $user->subscription()->cancel(); // Vatly decides immediate vs grace $user->subscription()->resume(); // while in grace period $user->subscription()->updateBilling(); // signed link for hosted update flow // Orders — Cashier-style iteration works on the Eloquent collection too foreach ($user->orders as $order) { echo $order->invoiceUrl(); // hosted invoice URL } // Or explicit lookup $invoiceUrl = $user->order('order_abc')->invoiceUrl(); // Order lines & a subscription's renewal orders $order->lines; // line items recorded for this order $subscription->orders; // initial + renewal orders this subscription generated // Refunds & chargebacks — owned-by-customer and per-order relations $user->refunds; // all refunds for this customer $user->chargebacks; // all chargebacks (disputes) for this customer $order->refunds; // refunds against this order $order->chargebacks; // chargebacks against this order // Reversal progress — read live from the Vatly API (status stays `paid`). // Combines refunds and chargebacks: "did money come back, and how much". $order->isReversed(); // any subtotal reversed? $order->isPartiallyReversed(); // reversed, but not in full $order->isFullyReversed(); // full subtotal reversed $order->reversedSubtotal(); // reversed subtotal, in cents $order->refundableSubtotal(); // still-reversible subtotal, in cents // Static finders $user = User::findBillable('customer_xyz'); // ?User $user = User::findBillableOrFail('customer_xyz'); // User // Test vs live — each local record carries the mode it was created in, // stored in a `testmode` column and read back via the cast property / // `isTestmode()`. Use it to segregate test data and pick the matching API key. $order->testmode; // bool (cast) $order->isTestmode(); // also on Subscription / Refund / Chargeback
See docs/Subscriptions.md and docs/Checkouts.md for the full surface.
Webhooks
The package registers POST /webhooks/vatly automatically. Set this URL and your VATLY_WEBHOOK_SECRET in the Vatly dashboard, and subscriptions/orders sync to your database automatically.
Vatly events are dispatched on Laravel's event bus — register listeners the usual way:
use Vatly\API\Webhooks\Events\OrderPaid; Event::listen(OrderPaid::class, function (OrderPaid $event) { $event->total->toCents(); // 9900 — minor units $event->total->currency; // "EUR" // send receipt, etc. });
The webhook event DTOs live in vatly-api-php (alongside the rest of the API
contracts) under the Vatly\API\Webhooks\Events\ namespace, so a webhook field
change is a single api-php release that flows through every integration. The
exceptions are SubscriptionWasCreatedFromWebhook and OrderWasCreatedFromWebhook,
which are internal fluent signals (not webhook payloads) and stay under
Vatly\Fluent\Events\.
Events available:
Vatly\API\Webhooks\Events\OrderPaid— carriestotal,subtotal(bothVatly\API\Types\Money),taxSummary(full per-rate breakdown),invoiceNumber,paymentMethod. Read the currency via$event->total->currencyand minor units via$event->total->toCents()(there's no standalonecurrencyfield). Materialize local invoices without an extra API call.Vatly\API\Webhooks\Events\OrderCanceled— the local order's status is mirrored tocanceled.Vatly\API\Webhooks\Events\OrderChargebackReceived/OrderChargebackReversed— dispute signals carrying the affectedorderId, enriched withcustomerId, disputestatus, totals andtaxSummary; persisted tovatly_chargebacks(see below). Also react to suspend/reinstate access — a chargeback never mutates the local order row.Vatly\API\Webhooks\Events\OrderPaymentFailed— same enriched order shape asOrderPaid; typically the start of dunning.Vatly\API\Webhooks\Events\CheckoutPaid/CheckoutFailed/CheckoutCanceled/CheckoutExpired— hosted-checkout lifecycle signals carryingcheckoutId, nullablecustomerId/orderId,statusandmetadata. Dispatched straight from the payload (no enrichment GET, no local row);CheckoutPaidfires beforeOrderPaidso you can drive in-app receipt/analytics UI, while the others feed retry / cart-abandonment flows.Vatly\API\Webhooks\Events\RefundCompleted/RefundFailed/RefundCanceled— each with fulltaxSummary; persisted tovatly_refunds(see below).Vatly\API\Webhooks\Events\SubscriptionStartedVatly\API\Webhooks\Events\SubscriptionBillingUpdated— the stored mandate (mandate_method/mandate_masked_identifier) is refreshed.Vatly\API\Webhooks\Events\SubscriptionUpdated— an immediate plan/price/interval/quantity change; the local subscription'splan_id,nameandquantityare refreshed from the payload (price is not stored locally).Vatly\API\Webhooks\Events\SubscriptionUpdateScheduled— a change scheduled for the next billing cycle; the local row is left unchanged (the change hasn't applied yet). Dispatched only, exposing the target values via a typedscheduledUpdate(Vatly\API\Types\ScheduledSubscriptionUpdate) so you can, e.g., warn the customer of an upcoming price change.Vatly\API\Webhooks\Events\SubscriptionResumed— the stored end date is cleared.Vatly\API\Webhooks\Events\SubscriptionCanceledImmediatelyVatly\API\Webhooks\Events\SubscriptionCanceledWithGracePeriodVatly\API\Webhooks\Events\SubscriptionCancellationGracePeriodCompleted— the grace period set at cancellation has elapsed; carriescustomerId,subscriptionId,endsAt. The local subscription'sends_atis stamped to the actual end (self-healing a missedsubscription.canceled_with_grace_periodwebhook and correcting any drift); also dispatched so you can flip your own application-level "fully ended" state without polling.Vatly\API\Webhooks\Events\WebhookSetupReceived— awebhook.setupendpoint-verification call; no resource to enrich and no local row to touch, just acknowledge with a2xx.Vatly\API\Webhooks\Events\UnsupportedWebhookReceivedVatly\Fluent\Events\SubscriptionWasCreatedFromWebhook— internal fluent signal (not a webhook payload).Vatly\Fluent\Events\OrderWasCreatedFromWebhook— the order analogue ofSubscriptionWasCreatedFromWebhook: an internal fluent signal that fires once when a brand-new localOrderrow is created from anorder.paidwebhook. A clean hook for receipts / fulfillment.
Refund webhooks (refund.completed / refund.failed / refund.canceled) are persisted to the vatly_refunds table via the bundled Refund model and EloquentRefundRepository. Chargeback webhooks (order.chargeback_received / order.chargeback_reversed) are persisted the same way to the vatly_chargebacks table via the bundled Chargeback model and EloquentChargebackRepository — Vatly's public order status doesn't change on a chargeback, so also wire your own listener if you need to suspend/reinstate access.
The webhook route is named vatly.webhook — reach it with route('vatly.webhook').
See docs/Webhooks.md for signature verification, retries, and customising reactions.
Testing
composer test
Faking Vatly in your app's tests
For feature tests that drive checkout/subscription flows, call Vatly::fake() — it binds a FakeVatly into the container, so every subscribe() / checkout() / subscription() call routes through recording fakes instead of the real API. Script only what you care about and assert against the returned fake (in the spirit of Notification::fake()), no hand-rolled Mockery stubs:
use Vatly\Fluent\Testing\FakeCheckout; use Vatly\Laravel\Facades\Vatly; $vatly = Vatly::fake(); // Optional: script the Checkout returned on subscription create $vatly->onSubscriptionCreate( fn (string $planId) => FakeCheckout::make('https://checkout.vatly.test/chk_1'), ); $this->actingAs($user) ->post('/billing/subscribe', ['plan' => 'plan_pro']) ->assertRedirect('https://checkout.vatly.test/chk_1'); $vatly->assertSubscriptionCreated('plan_pro'); $vatly->assertNothingCanceled();
Available assertions: assertSubscriptionCreated, assertCheckoutCreated, assertSubscriptionSwapped, assertSubscriptionCanceled, assertNothingCanceled, assertNothingCreated.
Vatly::fake() lives on the Vatly\Laravel\Facades\Vatly facade — the package's single static-helper surface. The same facade proxies the composition root (Vatly::order($order), Vatly::subscription($subscription), …) and exposes the host-side helpers Vatly::findBillable($vatlyCustomerId), Vatly::findBillableOrFail($vatlyCustomerId), and Vatly::cleanUp().
Faking the webhook API fetch
For the order.paid webhook flow, the package fetches the full Order from the Vatly API to populate the tax breakdown. The actions are encapsulated by the Vatly composition root (not individually bound in the container), so swap one via reflection on the singleton:
use Mockery; use ReflectionClass; use Vatly\Fluent\Actions\GetOrder; use Vatly\Fluent\Vatly; use Vatly\Fluent\Webhooks\WebhookProcessor; $action = Mockery::mock(GetOrder::class); $action->shouldReceive('execute')->andReturn($yourFakeApiOrder); $vatly = $this->app->make(Vatly::class); $ref = (new ReflectionClass($vatly))->getProperty('getOrder'); $ref->setAccessible(true); $ref->setValue($vatly, $action); // Clear downstream caches that captured the previous action foreach (['webhookEventFactory', 'webhookProcessor'] as $prop) { $r = (new ReflectionClass($vatly))->getProperty($prop); $r->setAccessible(true); $r->setValue($vatly, null); } $this->app->forgetInstance(WebhookProcessor::class);
See tests/Http/Controllers/VatlyInboundWebhookControllerTest.php for the helper used in this package's own tests.
Under the hood
The ecosystem splits into three layers:
-
vatly/vatly-api-phpowns the API client and every wire contract — REST resources, value types (Money,TaxSummaryCollection), theVatly\API\Types\OrderLineDataDTO, and the webhook event DTOs underVatly\API\Webhooks\Events\. A webhook-payload change is a single api-php release. -
vatly/vatly-fluent-phpis the framework-agnostic core: the contracts, composition root (Vatly), webhook pipeline (factory / processor / reactions that consume the api-php event DTOs), and the operation wrappers (Vatly\Fluent\SubscriptionHandle,Vatly\Fluent\OrderHandle). -
This package is the thin Laravel driver on top of fluent. It supplies:
-
Concrete Eloquent-backed impls of fluent's contracts (subscription / order / webhook-call repositories, customer-binding repository, models, config reader, event dispatcher)
-
The
Billabletrait with Cashier-style shortcuts and static finders -
The HTTP route and controller for inbound webhooks
-
Publishable migrations and configuration
The driver bindings live in VatlyServiceProvider: each fluent contract is bound to its Eloquent / Laravel impl, then Vatly::class is registered as a singleton built from a Vatly\Fluent\Wiring DTO. The new CustomerBindingRepository contract replaces the old CustomerRepositoryInterface — fluent never touches the host model directly; it only consults the binding repo for host-id ↔ vatly-id lookups. Every other fluent service (Customers helper, WebhookProcessor, actions, operation wrappers) resolves lazily through the singleton.
License
MIT
vatly/vatly-laravel 适用场景与选型建议
vatly/vatly-laravel 是一款 基于 PHP 开发的 Composer 扩展包,目前已累计 27 次下载、GitHub Stars 达 1, 最近一次更新时间为 2026 年 02 月 26 日, 在 PHP 生态内属于活跃度较高的组件。
它主要适用于以下技术方向: 「laravel」 「billing」 「subscriptions」 「vatly」 等业务场景。在实际项目中,围绕这些方向常见需要落地的问题包括:接口对接、性能调优、并发安全、与既有框架(Laravel / ThinkPHP / Yii / Webman 等)的兼容适配,以及生产环境的日志埋点与稳定性保障。
我们在过去多个企业项目中使用过 vatly/vatly-laravel 或与其功能相近的方案,如果你在选型或落地过程中遇到问题,例如 版本兼容、二次改造、私有化封装、与内部系统对接、生产 BUG 排查,欢迎联系我们协助评估。
基于 vatly/vatly-laravel 在你已有业务上做功能扩展、字段裁剪、UI 适配、与内部账号 / 权限 / 日志系统的深度对接。
线上偶发问题、内存泄漏、慢查询、并发异常等排查修复;针对高流量场景做缓存、队列、索引层面的调优。
承接完整的项目从需求 → 设计 → 开发 → 上线 → 长期运维;也可按月提供技术保姆服务。
与 vatly/vatly-laravel 相关的其它包
同方向 / 同关键字的高下载量 PHP Composer 包推荐,方便对比选型:
Laravel Cashier provides an expressive, fluent interface to Braintree's subscription billing services.
Metamel Addresses is a polymorphic Laravel package, for address book management. You can add addresses to any eloquent model with ease.
Handling Appstore server to server notifications
A package provide pricing plans for Laravel.
A PHP (and Laravel) package to interface with the Snipcart api.
统计信息
- 总下载量: 27
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 1
- 点击次数: 27
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-02-26
