vatly / vatly-laravel
Laravel integration for Vatly, inspired by Laravel Cashier
Requires
- php: ^8.3
- illuminate/contracts: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/routing: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- vatly/vatly-fluent-php: ^0.8.0-alpha.21
Requires (Dev)
- larastan/larastan: ^3.9
- laravel/pint: ^1.29
- mockery/mockery: ^1.6
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.0|^12.0
This package is auto-updated.
Last update: 2026-06-05 18:15:31 UTC
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 Laravel Cashier for Stripe, 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:
Requirements
- PHP 8.3+
- Laravel 12 or 13
- A Vatly API key (vatly.com)
Installation
composer require vatly/vatly-laravel:v0.7.0-alpha.3
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; }
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\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
