PHPackages                             vatly/vatly-fluent-php - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Payment Processing](/categories/payments)
4. /
5. vatly/vatly-fluent-php

ActiveLibrary[Payment Processing](/categories/payments)

vatly/vatly-fluent-php
======================

Framework-agnostic fluent PHP SDK for Vatly billing

v0.8.0-alpha.22(4w ago)21.7k↓32.5%[3 issues](https://github.com/Vatly/vatly-fluent-php/issues)1MITPHPPHP ^8.1CI passing

Since Feb 26Pushed 4w agoCompare

[ Source](https://github.com/Vatly/vatly-fluent-php)[ Packagist](https://packagist.org/packages/vatly/vatly-fluent-php)[ RSS](/packages/vatly-vatly-fluent-php/feed)WikiDiscussions main Synced 2d ago

READMEChangelog (10)Dependencies (36)Versions (89)Used By (1)

[![Vatly Fluent PHP](art/banner.png)](art/banner.png)

Vatly Fluent PHP
================

[](#vatly-fluent-php)

[![Latest Version on Packagist](https://camo.githubusercontent.com/87ec417c1e5050c6b5e1d0a3198757e3b5abc0339f6c16bebbbdf391e62850e7/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f7661746c792f7661746c792d666c75656e742d7068702e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/vatly/vatly-fluent-php)[![Tests](https://github.com/Vatly/vatly-fluent-php/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/Vatly/vatly-fluent-php/actions/workflows/tests.yml)[![Total Downloads](https://camo.githubusercontent.com/4fe744f668ec28dd76301b704ff5351c4c11bf43813c6a5189c917dd43b77e32/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f7661746c792f7661746c792d666c75656e742d7068702e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/vatly/vatly-fluent-php)

> **Alpha — under active development. Expect breaking changes between minor versions.**

Framework-agnostic SDK for [Vatly](https://vatly.com). Sits between `vatly/vatly-api-php` (the raw HTTP client) and a framework driver (e.g. `vatly/vatly-laravel`).

Pick the right package
----------------------

[](#pick-the-right-package)

Use casePackageLaravel app[`vatly/vatly-laravel`](https://github.com/Vatly/vatly-laravel) — already wires fluent for youBuilding a driver for another framework (Symfony, Yii, …)**`vatly/vatly-fluent-php`** (this)Standalone PHP script / CLI tool**`vatly/vatly-fluent-php`** (this, api-only mode)Just need raw HTTP requests, no domain model[`vatly/vatly-api-php`](https://github.com/Vatly/vatly-api-php)Fluent vs `vatly-api-php`
-------------------------

[](#fluent-vs-vatly-api-php)

`vatly-api-php` is the wire layer: turn method calls into HTTPS requests, parse responses into typed resources. Fluent is the layer above:

Concern`vatly-api-php``vatly-fluent-php`Make API calls✓✓ (via `vatly-api-php` underneath)Domain model (`Subscription`, `Order`, `CustomerService` helper)—✓Repository contracts for persisting subscriptions / orders / customer bindings—✓Webhook signature verification✓ (low-level)✓ (full pipeline incl. parsing, reactions, dispatch)Typed domain events (`OrderPaid`, `SubscriptionStarted`, …)—✓Builders for checkout / subscription flows—✓Single composition root (`Vatly`) that wires everything from contracts—✓If you only need to fetch a customer or create a checkout from a script, `vatly-api-php` is enough. As soon as you want webhook handling, subscription state tracking, or anything resembling an integration, use fluent.

Installation
------------

[](#installation)

Requires PHP 8.1+ and a Vatly API key.

```
composer require vatly/vatly-fluent-php:v0.8.0-alpha.1
```

Pin to an exact version during alpha.

---

Quick start — standalone / api-only
-----------------------------------

[](#quick-start--standalone--api-only)

For one-off scripts that just hit the API. No persistence, no webhook processing, no event dispatching.

1. **Build a `Vatly` with just an API key:**

    ```
    use Vatly\Fluent\Vatly;

    $vatly = Vatly::apiOnly('test_xxxxxxxxxxxxxxxxxx');
    ```
2. **Call any action accessor:**

    ```
    $customer = $vatly->createCustomer()->execute([
        'email' => 'sander@example.com',
        'name'  => 'Sander',
    ]);

    $order = $vatly->getOrder()->execute('order_abc123');
    $sub   = $vatly->getSubscription()->execute('sub_xyz789');
    ```

    Available accessors: `createCustomer`, `getCustomer`, `getOrder`, `createCheckout`, `getSubscription`, `cancelSubscription`, `resumeSubscription`, `swapSubscriptionPlan`, `updateSubscriptionBilling`.
3. **Override the API endpoint** (e.g. for sandbox / proxy):

    ```
    use Vatly\Fluent\Configuration\ArrayConfiguration;
    use Vatly\Fluent\Vatly;
    use Vatly\Fluent\Wiring;

    $vatly = new Vatly(new Wiring(
        config: new ArrayConfiguration([
            'api_key'     => 'test_xxxxxxxxxxxxxxxxxx',
            'api_url'     => 'https://api.sandbox.vatly.com',
            'api_version' => 'v1',
        ]),
    ));
    ```

Methods that need persistence or event dispatching (`customers()`, `webhookProcessor()`, `subscription()`, `order()`) throw `IncompleteWiringException` in api-only mode. Use the driver guide below if you need them.

---

Step-by-step — building a framework driver
------------------------------------------

[](#step-by-step--building-a-framework-driver)

A driver is a thin glue package (e.g. `vatly-laravel`) that supplies fluent with concrete implementations of its contracts and exposes the API surface idiomatically for its framework.

### Webhook pipeline at a glance

[](#webhook-pipeline-at-a-glance)

For incoming Vatly webhooks, fluent dispatches a typed event and runs a built-in reaction that calls back into your repos. A driver author only needs to implement the repo methods — the wiring is fixed. For the full event → reaction → repo-method matrix **including the fields each `Store*Data` / event carries**, see [docs/webhook-flow.md](docs/webhook-flow.md).

> **No API round-trip.** Vatly sends **fat, HMAC-signed** webhook deliveries: the payload's `object` is the full resource — byte-identical to the corresponding `GET /…/{id}` body (subtotal, the complete tax summary, lines, mandate). The HMAC signature is the trust boundary, so fluent builds every typed event straight from the signed payload — the money/tax-bearing events by hydrating the matching api-php Resource in memory, the rest from the envelope. There is **no follow-up `GET` to "enrich"**, so a transient API blip can never block a webhook.

> The typed webhook event DTOs (`OrderPaid`, `OrderPaymentFailed`, `SubscriptionStarted`, …) live in **`vatly-api-php`** under the `Vatly\API\Webhooks\Events\*` namespace and are consumed by fluent — fluent no longer ships its own copies. Import them from `Vatly\API\Webhooks\Events\…` when you handle dispatched events. The factory that turns a signed payload into those typed events, `Vatly\API\Webhooks\WebhookEventFactory`, also lives in **`vatly-api-php`** (verify → parse → map). Fluent consumes it and owns the downstream orchestration (the reactions and `WebhookProcessor`, which record → react → dispatch) plus the driver-side `Vatly\Fluent\Events\{SubscriptionWasCreatedFromWebhook,OrderWasCreatedFromWebhook,NullEventDispatcher}`.

> **Event money is `Money`, not int cents (api-php ≥ `0.1.0-alpha.18`):** the `total` / `subtotal` fields on the money-bearing events are `Vatly\API\Types\Money` value objects (decimal-string `value` + `currency`). Read the currency with `$event->total->currency` and flatten to integer cents with `$event->total->toCents()`. The standalone `currency` field was **removed** from `OrderPaid`, `OrderPaymentFailed`, and the `Refund*` events (their `total` / `subtotal` are non-null `Money`); the chargeback events (`OrderChargebackReceived` / `OrderChargebackReversed`) instead carry **nullable** `?Money` and **keep** a standalone `currency` string. Order lines moved to `Vatly\API\Types\OrderLineData[]` (from the old `Vatly\API\Data\`), and a line's `basePrice` / `total` / `subtotal` are `Money` too. Fluent's built-in reactions flatten `Money → int` at the persistence edge, so the `Store*Data` / `Update*Data` DTOs your driver implements against still receive integer-cents `total` / `subtotal` and a `currency` string — no driver change required.

Vatly eventDispatched event classBuilt-in reactionRepo method(s) called`order.paid``OrderPaid``StoreOrderOnPaid``OrderWriter::store` (new, then dispatches `OrderWasCreatedFromWebhook`) / `OrderWriter::update` (existing)`order.payment_failed``OrderPaymentFailed``StoreOrderOnPaymentFailed``OrderWriter::store` (new) / `OrderWriter::update` (existing); mirrors upstream status (typically `pending` during dunning)`order.canceled``OrderCanceled``CancelOrderOnCanceled``OrderWriter::update` (mirrors `canceled` status)`order.chargeback_received``OrderChargebackReceived``SyncChargebackOnStatusChange` *(opt-in, persistence)*`ChargebackWriter::store` (new)`order.chargeback_reversed``OrderChargebackReversed``SyncChargebackOnStatusChange` *(opt-in, persistence)*`ChargebackWriter::update` (existing)`refund.completed` / `refund.failed` / `refund.canceled``RefundCompleted` / `RefundFailed` / `RefundCanceled``SyncRefundOnStatusChange` *(opt-in)*`RefundWriter::store` (new) / `::update` (existing)`subscription.started``SubscriptionStarted``SyncSubscriptionOnStarted``SubscriptionWriter::store` (new) / `::update` (existing)`subscription.billing_updated``SubscriptionBillingUpdated``SyncSubscriptionOnBillingUpdated``SubscriptionWriter::update` (refreshes mandate)`subscription.resumed``SubscriptionResumed``ResumeSubscriptionOnResumed``SubscriptionWriter::update` (clears end date)`subscription.canceled``SubscriptionCanceledImmediately` / `SubscriptionCanceledWithGracePeriod``CancelSubscriptionOnCanceled``SubscriptionWriter::update``subscription.cancellation_grace_period_completed``SubscriptionCancellationGracePeriodCompleted``EndSubscriptionOnGracePeriodCompleted``SubscriptionWriter::update` (stamps actual end date)`checkout.paid` / `checkout.failed` / `checkout.canceled` / `checkout.expired``CheckoutPaid` / `CheckoutFailed` / `CheckoutCanceled` / `CheckoutExpired`— (dispatched only)none — driver-handled`webhook.setup``WebhookSetupReceived`— (dispatched only)none — endpoint verification ping; acknowledge with `2xx``OrderWriter::store`, `SubscriptionWriter::store`, and `RefundWriter::store` may return `null` if your driver can't route the data (see the adapter recipe below). Built-in reactions tolerate null — `SyncSubscriptionOnStarted` skips its follow-up `SubscriptionWasCreatedFromWebhook` dispatch (and `StoreOrderOnPaid` its `OrderWasCreatedFromWebhook` dispatch) when store returns null. Both driver-side events fire exactly once per brand-new local row (not on the update path).

`subscription.billing_updated`, `subscription.resumed`, and `order.canceled` are find-or-skip: they update an existing local record but never create one. `subscription.billing_updated` carries the fresh mandate (card last-4, masked IBAN) in its signed payload, so the stored mandate stays in step with the payment method on file without an API call; `subscription.resumed` clears the stored end date so a resume reactivates the derived state; `order.canceled` mirrors Vatly's `canceled` status onto the local order.

**Refunds** are opt-in: supply a `RefundRepositoryInterface` via `Wiring(refunds: …)` and the built-in `SyncRefundOnStatusChange` reaction persists `refund.*` webhooks (store-or-update, like orders) — unblocking terminal-state refund reconciliation. Omit it and the typed refund events are still dispatched for you to handle. The refund webhook payload already carries the full tax breakdown (like `order.paid`), so the event is built straight from it — no API call.

Read the refunds back idiomatically with `RefundReader::listForOrder` / `listForCustomer`, or via the handle: `$vatly->order($localOrder)->refunds()` returns the `RefundInterface[]` recorded against that order (local read, no API call; empty array when no refund repo is wired).

The order's reversal progress is read live from the Vatly API rather than synthesized into a local status — the order's own `status` stays terminal `paid`. `OrderHandle` exposes `reversedSubtotal()` / `refundableSubtotal()` (integer cents) and `isReversed()` / `isPartiallyReversed()` / `isFullyReversed()`, fetched once and memoized per handle instance. Because the API's `reversedSubtotal` combines refunds **and** chargebacks, these helpers answer "did money come back, and how much" regardless of how it was reversed.

**Chargebacks** mirror refunds and are opt-in: supply a `ChargebackRepositoryInterface` via `Wiring(chargebacks: …)` and the built-in `SyncChargebackOnStatusChange` reaction persists `order.chargeback_*` webhooks store-or-update (storing on receipt, updating on reversal). It does **not** mutate the order's status — the order stays `paid`, and whether money came back (chargebacks included) is read via the `OrderHandle` reversal helpers above. Omit the repository and the typed `OrderChargebackReceived` / `OrderChargebackReversed` events are still dispatched for you to handle (e.g. suspend access on receipt, reinstate on reversal). The chargeback webhook payload already carries the customer id, dispute status, and full tax breakdown, so the events are built straight from it (no second API call) and the reversed VAT can be reconciled directly. Read chargebacks back via `ChargebackReader::listForOrder` / `listForCustomer` or `$vatly->order($localOrder)->chargebacks()`.

**Checkout events** are dispatched only — no built-in reaction. The `checkout.*` deliveries carry the full Checkout resource (status, `customerId`, `orderId`, `metadata`). Use `CheckoutPaid` for an analytics/receipt handoff at the earliest "customer paid" moment — ahead of `order.paid` — and `CheckoutFailed` / `CheckoutCanceled` / `CheckoutExpired` for retry and cart-abandonment funnel hooks. `customerId` is nullable: an anonymous checkout only gets a customer attributed once payment completes.

**`subscription.cancellation_grace_period_completed`** stamps the actual end date onto the local row via `EndSubscriptionOnGracePeriodCompleted`. In the happy path the grace end was already stamped by `CancelSubscriptionOnCanceled` when the cancellation arrived, so this is an idempotent re-write — but it self-heals a missed or out-of-order cancellation webhook (which would otherwise leave `endsAt` null and the subscription looking active forever) and corrects any drift between the scheduled and actual end. The event is also dispatched so a driver can flip local state atomically instead of polling `endsAt < now` on a scheduled job; whether to additionally write a `fully_ended` status is driver-specific (Vatly has no such status to mirror), so that's left to the consumer.

`additionalWebhookReactions` (on `WebhookProcessorFactory::create`) lets you append driver-specific reactions without losing the built-ins.

### 1. Implement `ConfigurationInterface`

[](#1-implement-configurationinterface)

Read your framework's config and return values.

```
namespace Acme\VatlySymfony;

use Vatly\Fluent\Concerns\DerivesTestmodeFromApiKey;
use Vatly\Fluent\Contracts\ConfigurationInterface;

final class SymfonyVatlyConfig implements ConfigurationInterface
{
    use DerivesTestmodeFromApiKey;  // free isTestmode() from key prefix

    public function __construct(
        private string $apiKey,
        private string $apiUrl = 'https://api.vatly.com',
        private string $apiVersion = 'v1',
        private ?string $webhookSecret = null,
        private string $successUrl = '',
        private string $canceledUrl = '',
    ) {}

    public function getApiKey(): string { return $this->apiKey; }
    public function getApiUrl(): string { return $this->apiUrl; }
    public function getApiVersion(): string { return $this->apiVersion; }
    public function getWebhookSecret(): ?string { return $this->webhookSecret; }
    public function getDefaultRedirectUrlSuccess(): string { return $this->successUrl; }
    public function getDefaultRedirectUrlCanceled(): string { return $this->canceledUrl; }
}
```

### 2. Implement `CustomerBindingRepository`

[](#2-implement-customerbindingrepository)

The binding repository links a Vatly customer id (`cus_...`) to whatever id your app uses for its billing entity (User, Organization, Tenant, …). Fluent never touches your host model directly — it only asks "what is the Vatly customer id for this host customer id?" and the reverse.

```
use Vatly\Fluent\Contracts\CustomerBindingRepository;

final class SymfonyCustomerBindingRepository implements CustomerBindingRepository
{
    public function __construct(private Connection $db) {}

    public function bind(string $vatlyCustomerId, string $hostCustomerId): void
    {
        $this->db->executeStatement(
            'UPDATE users SET vatly_id = ? WHERE id = ?',
            [$vatlyCustomerId, $hostCustomerId],
        );
    }

    public function record(string $vatlyCustomerId): void
    {
        // No-op: rows arrive with a null host customer id via the
        // anonymous-checkout flow and get attributed later. Override if
        // your driver tracks unattributed customers in a join table instead.
    }

    public function hostCustomerIdFor(string $vatlyCustomerId): ?string
    {
        return $this->db->fetchOne('SELECT id FROM users WHERE vatly_id = ?', [$vatlyCustomerId]) ?: null;
    }

    public function vatlyCustomerIdFor(string $hostCustomerId): ?string
    {
        return $this->db->fetchOne('SELECT vatly_id FROM users WHERE id = ?', [$hostCustomerId]) ?: null;
    }
}
```

**What if I can't add a `vatly_id` column?** — vendor user model, third-party identity provider, multi-tenant…Implement the same four methods against a dedicated join table. The host class itself stays untouched.

```
CREATE TABLE vatly_customer_bindings (
    host_customer_id  VARCHAR(255) NOT NULL,
    vatly_customer_id VARCHAR(255) NOT NULL,
    created_at        TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (host_customer_id),
    UNIQUE      (vatly_customer_id)
);
```

`bind` `INSERT … ON CONFLICT … DO UPDATE`; `hostCustomerIdFor` / `vatlyCustomerIdFor` are single-column lookups; `record` is allowed to be a no-op (or insert a row with an empty host id if you want an audit trail for unattributed customers — `attribute()` can fill it in later).

**Multi-tenant fan-out.** If multiple host types (User, Organization, Tenant) should all participate as Vatly customers, add an `owner_type` column to the primary key and inject which type the repository handles at construction time — one repository instance per host type.

### 3. Implement your `SubscriptionInterface` model

[](#3-implement-your-subscriptioninterface-model)

State accessors + the derived predicates. Use `DerivesSubscriptionState` to get the six predicates for free.

```
use DateTimeInterface;
use Vatly\Fluent\Concerns\DerivesSubscriptionState;
use Vatly\Fluent\Contracts\SubscriptionInterface;

class Subscription implements SubscriptionInterface
{
    use DerivesSubscriptionState;  // isActive, isCancelled, isOnGracePeriod,
                                   // isValid, isRecurring, isEnded

    public function getVatlyId(): string { /* ... */ }
    public function getType(): string { /* ... */ }
    public function getPlanId(): string { /* ... */ }
    public function getName(): string { /* ... */ }
    public function getQuantity(): int { /* ... */ }
    public function getEndsAt(): ?DateTimeInterface { /* ... */ }

    // Mandate summary on file — persist these alongside the rest so portals
    // render "card ending in 4242" without a per-request API roundtrip.
    public function getMandateMethod(): ?string { /* 'card', 'sepa_debit', null, ... */ }
    public function getMandateMaskedIdentifier(): ?string { /* '4242', 'NL91****4300', null */ }
}
```

### 4. Implement your `OrderInterface` model

[](#4-implement-your-orderinterface-model)

```
use Vatly\Fluent\Contracts\OrderInterface;

class Order implements OrderInterface
{
    public function getVatlyId(): string { /* ... */ }
    public function getStatus(): string { /* ... */ }
    public function getInvoiceNumber(): ?string { /* ... */ }
    public function getTotal(): int { /* ... */ }
    public function getCurrency(): string { /* ... */ }
    public function getPaymentMethod(): ?string { /* ... */ }
    public function isPaid(): bool { return $this->status === 'paid'; }
    public function isTestmode(): bool { return $this->testmode; }
}
```

**What if my host already has Order / Subscription tables?** — bolting onto an ecosystem plugin (FluentCart, PMPro, MemberPress, EDD…)This is the common case for ecosystem-plugin drivers: the host already models orders and subscriptions, and you can't (or shouldn't) add a parallel set. **Adapt, don't duplicate.** Write a thin wrapper that implements fluent's interface against the host's record:

```
use Vatly\Fluent\Contracts\OrderInterface;

final class FluentCartOrder implements OrderInterface
{
    public function __construct(private OrderTransaction $txn) {}

    public function getVatlyId(): string       { return $this->txn->vatly_id; }
    public function getStatus(): string        { return $this->txn->status; }
    public function getInvoiceNumber(): ?string{ return $this->txn->invoice_no; }
    public function getTotal(): int            { return (int) $this->txn->total; }
    public function getCurrency(): string      { return $this->txn->currency; }
    public function getPaymentMethod(): ?string{ return $this->txn->payment_method; }
    public function isPaid(): bool             { return $this->txn->status === 'paid'; }
    public function isTestmode(): bool         { return (bool) $this->txn->testmode; }
}
```

Your `OrderRepositoryInterface::store` then routes the incoming `StoreOrderData` to the right host record — typically by reading `$data->metadata` to find a host-side id the original checkout stamped onto the Vatly order. When the routing legitimately doesn't match (metadata is missing, host record was deleted, etc.), return `null`:

```
public function store(StoreOrderData $data): ?OrderInterface
{
    $txnId = $data->metadata['fluentcart_transaction_id'] ?? null;
    if ($txnId === null) {
        return null; // anonymous / audit-only — nothing to attach to
    }

    $txn = OrderTransaction::find($txnId);
    if ($txn === null) {
        return null;
    }

    $txn->vatly_id = $data->vatlyId;
    $txn->save();

    return new FluentCartOrder($txn);
}
```

Same shape for `SubscriptionRepositoryInterface::store`. Built-in reactions tolerate null returns.

**Full walkthrough:** [docs/recipes/host-owns-its-tables.md](docs/recipes/host-owns-its-tables.md) — covers the adapter wrapper, confirming (not duplicating) rows in `store()`, `findByVatlyId` as the idempotency hinge for safe re-deliveries, and discriminating renewal-vs-initial payments inside one `store()`.

### 5. Implement the three repository contracts

[](#5-implement-the-three-repository-contracts)

Each entity-side contract has three methods. See [src/Contracts](src/Contracts) for signatures.

- `SubscriptionRepositoryInterface` — `findByVatlyId`, `store`, `update`
- `OrderRepositoryInterface` — `findByVatlyId`, `store`, `update`
- `RefundRepositoryInterface` — `findByVatlyId`, `listForOrder`, `listForCustomer`, `store`, `update` (**optional** — only needed to persist `refund.*` webhooks)
- `ChargebackRepositoryInterface` — `findByVatlyId`, `listForOrder`, `listForCustomer`, `store`, `update` (**optional** — only needed to persist `order.chargeback_*` webhooks)
- `WebhookCallRepositoryInterface` — record received webhook calls (audit log)

`StoreSubscriptionData` and `StoreOrderData` both carry an optional `hostCustomerId` resolved from the binding repo when fluent persists from a webhook reaction. Use it to fill your host-side owner column when it's set, and accept `null` for the anonymous-checkout flow.

Every `Store*Data` (order/subscription/refund/chargeback) also carries a required `testmode` bool, sourced from the originating Vatly record. Persist it on your local row and surface it through the matching entity interface's `isTestmode()` — this keeps test and live records segregated and lets you select the matching API key per record (vs. the global config mode). Note this is per-record `isTestmode()`, distinct from `ConfigurationInterface::isTestmode()`, which reflects the configured key.

```
public function store(StoreSubscriptionData $data): SubscriptionInterface
{
    $attrs = [
        'vatly_id' => $data->vatlyId,
        'type'     => $data->type,
        'plan_id'  => $data->planId,
        'name'     => $data->name,
        'quantity' => $data->quantity,
    ];

    if ($data->hostCustomerId !== null) {
        $attrs['owner_id'] = $data->hostCustomerId;
    }

    return Subscription::create($attrs);
}
```

> Each entity-side repo is also exposed as a Reader / Writer pair (`SubscriptionReader` + `SubscriptionWriter`, etc.). The combined interface extends both. Typehint the narrowest role you actually need.

> **If your repo needs to call back into the SDK** — e.g. `GetOrder` to read a fresh resource on demand — don't inject `Vatly` directly. `Vatly` is being constructed *with* your repo, so a direct dependency is circular. Instead, inject a lazy resolver (your host's container, a singleton accessor, or a closure that returns `Vatly`) and resolve at call time.
>
> ```
> // ✗ Circular — $vatly doesn't exist yet at Wiring-construction time:
> new Vatly(new Wiring(orders: new MyOrderRepository($vatly), …));
>
> // ✓ Closure resolver — the repo only touches Vatly at call time:
> new Vatly(new Wiring(orders: new MyOrderRepository(fn () => $container->get(Vatly::class)), …));
>
> // ✓ Singleton accessor — same idea via a static entry point:
> new Vatly(new Wiring(orders: new MyOrderRepository(Plugin::vatly(...)), …));
> ```
>
>
>
> Inside the repo, resolve lazily: `($this->vatly)()->getOrder()->execute($vatlyId)` for the closure form, or `Plugin::vatly()->getOrder()->execute($vatlyId)` for the accessor form.

### 6. Implement `EventDispatcherInterface`

[](#6-implement-eventdispatcherinterface)

```
use Vatly\Fluent\Contracts\EventDispatcherInterface;

class SymfonyEventDispatcher implements EventDispatcherInterface
{
    public function __construct(private \Symfony\Contracts\EventDispatcher\EventDispatcherInterface $bus) {}

    public function dispatch(object $event): void
    {
        $this->bus->dispatch($event);
    }
}
```

If you don't need events, use `Vatly\Fluent\Events\NullEventDispatcher` (no-op).

### 7. Construct `Vatly` from a `Wiring`

[](#7-construct-vatly-from-a-wiring)

```
use Vatly\Fluent\Vatly;
use Vatly\Fluent\Wiring;

$vatly = new Vatly(new Wiring(
    config:           $config,                  // your ConfigurationInterface impl
    subscriptions:    $subscriptionsRepo,
    orders:           $ordersRepo,
    webhookCalls:     $webhookCallsRepo,
    events:           $eventDispatcher,
    customerBindings: $customerBindingsRepo,
));
```

Register the `Vatly` instance as a singleton in your framework's container. Everything else resolves through it.

If your driver ships plugin-specific webhook reactions (e.g. assigning a membership level on `subscription.started`), pass them as `additionalWebhookReactions`:

```
$vatly = new Vatly(new Wiring(
    config:           $config,
    // ... repos + events + bindings ...
    additionalWebhookReactions: [
        new AssignMembershipLevelOnStarted(...),
        new RevokeMembershipLevelOnCanceled(...),
    ],
));
```

They run after fluent's built-in reactions (subscription sync, order persistence, cancellation handling).

### 8. Use the SDK — two paths

[](#8-use-the-sdk--two-paths)

**Action-driven.** For drivers that want consumers to reach the SDK explicitly. Reach `Vatly` through your container:

```
$vatly = $container->get(Vatly::class);

// 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`.

// Create a checkout — pass in a CustomerProfile carrying whatever the host knows.
use Vatly\Fluent\CustomerProfile;

// Each checkout item id is a Vatly product: `one_off_product_…` for a one-off
// product or `subscription_plan_…` for a subscription plan. Create the product
// in the Vatly dashboard first — there is no API to make one on the fly.
$checkout = $vatly
    ->checkoutBuilder(new CustomerProfile(vatlyId: $user->vatly_id))
    ->withRedirectUrlSuccess('https://app.example.com/done')
    ->withRedirectUrlCanceled('https://app.example.com/oops')
    ->create([['id' => 'one_off_product_3Qb8Wz1Yt', 'quantity' => 1]], '...', '...');

// Need a custom amount? Override the product's price with a Money object
// (`value` is a decimal string; precision follows the currency). The item id
// still points at a pre-configured product.
$checkout = $vatly
    ->checkoutBuilder(new CustomerProfile(vatlyId: $user->vatly_id))
    ->create([[
        'id' => 'one_off_product_3Qb8Wz1Yt',
        'quantity' => 1,
        'price' => ['value' => '49.99', 'currency' => 'EUR'],
    ]], 'https://app.example.com/done', 'https://app.example.com/oops');

// Subscribe
$checkout = $vatly
    ->subscriptionBuilder(new CustomerProfile(vatlyId: $user->vatly_id))
    ->toPlan('subscription_plan_7Hd9Kf2Lm')
    ->create();

// Subscribe with a free trial. withTrialDays() is the whole-day form;
// withTrialEndsAt() takes a DateTimeInterface and rounds up to whole days
// (Vatly's trial input is day-granular) so the trial never ends early.
$checkout = $vatly
    ->subscriptionBuilder(new CustomerProfile(vatlyId: $user->vatly_id))
    ->toPlan('subscription_plan_7Hd9Kf2Lm')
    ->withTrialDays(14)
    ->create();

// Operate on a stored Subscription / Order
$vatly->subscription($localSubscription)->cancel();
$vatly->order($localOrder)->invoiceUrl();

// Billing address / VAT / company name changes go through a hosted Vatly
// flow. Returns a fresh redirect URL per call — don't cache.
// `redirectUrlSuccess` and `redirectUrlCanceled` are filled in from the
// config defaults when omitted; pass them in $prefillData to override.
$url = $vatly->subscription($localSubscription)->updateBilling();

// Optionally prefill the billing address:
$url = $vatly->subscription($localSubscription)->updateBilling([
    'billingAddress' => [
        'streetAndNumber' => 'Damrak 1',
        'city'            => 'Amsterdam',
        'country'         => 'NL',
    ],
]);
```

> **Source of truth for payload fields.** Fluent passes checkout/subscription payloads through to `vatly-api-php`, whose schema is the canonical [`openapi.yaml`](https://docs.vatly.com/openapi.yaml) (also vendored at `vendor/vatly/vatly-api-php/openapi.yaml`). For the authoritative API request/response fields and the incoming webhook delivery shape, read the spec rather than guessing from an example.

**Customer helper.** For host-first flows where you create a Vatly customer for a known host entity and want the link recorded automatically:

```
$customer = $vatly->customers()->createFor(
    hostCustomerId: (string) $user->id,
    profile:        new CustomerProfile(email: $user->email, name: $user->name),
);
// $customer->id is now bound to $user->id via your CustomerBindingRepository.

// Forward extra create-customer API keys via $additionalPayload (locale,
// metadata, or anything else the create-customer endpoint accepts).
$customer = $vatly->customers()->createFor(
    hostCustomerId:    (string) $user->id,
    profile:           new CustomerProfile(email: $user->email, name: $user->name),
    additionalPayload: ['locale' => 'nl_NL', 'metadata' => ['internal_id' => $user->id]],
);

// Look up later
$existing = $vatly->customers()->findByHostCustomerId((string) $user->id);
```

Drivers commonly wrap these calls in idiomatic shortcuts — e.g. a Laravel trait that adds `$user->subscribe()->toPlan(...)->create()` on top of `subscriptionBuilder($user->customerProfile())`.

### 9. Wire the webhook receiver

[](#9-wire-the-webhook-receiver)

Vatly POSTs webhooks to a URL of your choice. Your driver:

1. Defines an HTTP route in your framework.
2. The route handler reads the raw request body and the `Vatly-Signature` header.
3. Passes both into `$vatly->webhookProcessor()->handle($payload, $signature)`.
4. Returns `201` on success, `403` on `InvalidWebhookSignatureException`.

Example handler:

```
use Vatly\Fluent\Exceptions\InvalidWebhookSignatureException;
use Vatly\Fluent\Webhooks\SignatureVerifier;

public function handle(SomeRequest $request)
{
    try {
        $this->vatly->webhookProcessor()->handle(
            payload:   $request->getRawBody(),
            signature: $request->headers->get(SignatureVerifier::SIGNATURE_HEADER_NAME, ''),
        );
    } catch (InvalidWebhookSignatureException) {
        return new Response(status: 403);
    }

    return new Response(status: 201);
}
```

The processor handles signature verification, parses the payload into typed events, runs reactions that persist state via your repos (consulting the binding repo to fill `hostCustomerId` on stored rows), and dispatches domain events on your event bus.

### 10. (Optional) Expose operations on your local models

[](#10-optional-expose-operations-on-your-local-models)

For Cashier-style ergonomics, give your Eloquent / Doctrine entities operation methods that delegate to fluent:

```
class Subscription implements SubscriptionInterface
{
    // ... interface state accessors (getVatlyId, getMandateMethod, etc.) ...

    public function cancel(): void
    {
        $container->get(Vatly::class)->subscription($this)->cancel();
    }

    public function swap(string $planId): self
    {
        $container->get(Vatly::class)->subscription($this)->swap($planId);
        return $this;
    }

    public function billingUpdateUrl(array $prefillData = []): string
    {
        return $container->get(Vatly::class)
            ->subscription($this)
            ->updateBilling($prefillData);
    }
}
```

This makes `foreach ($user->subscriptions as $sub) $sub->cancel()` work naturally.

---

What `Vatly` (the composition root) exposes
-------------------------------------------

[](#what-vatly-the-composition-root-exposes)

```
$vatly->getApiClient();                            // raw VatlyApiClient
$vatly->getSignatureVerifier();                    // raw webhook signature verifier
$vatly->getWebhookEventFactory();                  // api-php Vatly\API\Webhooks\WebhookEventFactory (parses webhook payloads)

// Actions (lazy, cached)
$vatly->createCustomer();    $vatly->getCustomer();
$vatly->getOrder();          $vatly->createCheckout();
$vatly->getSubscription();   $vatly->cancelSubscription();
$vatly->resumeSubscription(); $vatly->swapSubscriptionPlan();
$vatly->updateSubscriptionBilling();

// Composed services — require repos in Wiring
$vatly->customers();                               // CustomerService (lazy, cached)
$vatly->checkoutBuilder($profile);                 // CheckoutBuilder (per-call)
$vatly->subscriptionBuilder($profile);             // SubscriptionBuilder (per-call)
$vatly->subscription($localSubscription);          // SubscriptionHandle wrapping local state
$vatly->order($localOrder);                        // OrderHandle wrapping local state
$vatly->webhookProcessor();                        // WebhookProcessor (also needs events dispatcher)
```

Calling a composed-services method without the required repos in `Wiring` throws `IncompleteWiringException` with a message naming what's missing.

Contracts at a glance
---------------------

[](#contracts-at-a-glance)

In [src/Contracts](src/Contracts):

- `SubscriptionInterface` — local subscription state + derived predicates
- `OrderInterface` — local order state
- `CustomerBindingRepository` — bidirectional mapping between Vatly customer ids and host ids
- `SubscriptionRepositoryInterface` — subscription persistence (3 methods). Splits into `SubscriptionReader` (find) + `SubscriptionWriter` (store/update).
- `OrderRepositoryInterface` — order persistence (3 methods). Splits into `OrderReader` (find) + `OrderWriter` (store/update).
- `RefundRepositoryInterface` — refund persistence (optional). Splits into `RefundReader` (find + `listForOrder` / `listForCustomer`) + `RefundWriter` (store/update).
- `ChargebackRepositoryInterface` — chargeback persistence (optional). Splits into `ChargebackReader` (find + `listForOrder` / `listForCustomer`) + `ChargebackWriter` (store/update).
- `WebhookCallRepositoryInterface` — webhook audit log (write-only by nature)
- `EventDispatcherInterface` — fire domain events
- `ConfigurationInterface` — API key, URL, version, webhook secret, redirect defaults
- `WebhookReactionInterface` — extension point for adding your own webhook reactions. Compose multiple via `Webhooks\Reactions\WebhookReactionChain` (variadic constructor; the chain itself implements the same interface).

Domain events
-------------

[](#domain-events)

Dispatched by webhook reactions through your `EventDispatcherInterface`. Subscribe to them in your framework's event bus.

- `WebhookReceived` — raw webhook envelope (typed shape; `object` is the resource payload)
- `OrderPaid` — order with full `taxSummary` breakdown, ready to materialize local invoices
- `OrderPaymentFailed` — failed payment attempt (typically the start of dunning); carries the full order shape, mirroring `OrderPaid`
- `OrderCanceled`
- `OrderChargebackReceived` / `OrderChargebackReversed` — dispute signals carrying the affected `orderId`
- `RefundCompleted` / `RefundFailed` / `RefundCanceled` — each with full `taxSummary` breakdown
- `SubscriptionStarted`
- `SubscriptionBillingUpdated` — billing/mandate changed; carries the refreshed mandate summary
- `SubscriptionResumed`
- `SubscriptionCanceledImmediately`
- `SubscriptionCanceledWithGracePeriod`
- `SubscriptionCancellationGracePeriodCompleted`
- `CheckoutPaid` / `CheckoutFailed` / `CheckoutCanceled` / `CheckoutExpired`
- `WebhookSetupReceived` — endpoint verification ping (`webhook.setup`); dispatched-only, acknowledge with `2xx`
- `UnsupportedWebhookReceived`

Driver-side events (namespace `Vatly\Fluent\Events`, carrying the freshly persisted local record — fired exactly once per brand-new row):

- `SubscriptionWasCreatedFromWebhook` — dispatched by `SyncSubscriptionOnStarted` on a brand-new subscription
- `OrderWasCreatedFromWebhook` — dispatched by `StoreOrderOnPaid` on a brand-new order

Testing
-------

[](#testing)

```
composer test
```

### Test helpers for consumers

[](#test-helpers-for-consumers)

`Vatly\Fluent\Testing` ships fakes so consumers don't hand-roll a Mockery stub for every fluent entry point (which breaks the moment fluent grows a method):

```
use Vatly\Fluent\Testing\FakeVatly;
use Vatly\Fluent\Testing\FakeCheckout;

$fake = (new FakeVatly())->onSubscriptionCreate(
    fn (string $planId) => FakeCheckout::make('https://checkout.vatly.test/chk_1'),
);
$this->app->instance(Vatly::class, $fake); // drop-in: FakeVatly extends Vatly

$this->get('/vatly/subscription-checkout/plan_pro')
    ->assertRedirect('https://checkout.vatly.test/chk_1');

$fake->assertSubscriptionCreated('plan_pro');
$fake->assertNothingCanceled();
```

- **`FakeVatly`** — drop-in `Vatly` that hands out recording builders/handles and returns scriptable `Checkout`s (`onSubscriptionCreate` / `onCheckoutCreate` / `withDefaultCheckout`).
- **`FakeCheckout::make($url)`** — a minimal `Checkout` with a working `links->checkoutUrl->href`.
- **Assertions** — `assertSubscriptionCreated($planId)`, `assertCheckoutCreated(productId:)`, `assertSubscriptionSwapped(from:, to:)`, `assertSubscriptionCanceled($id)`, `assertNothingCanceled()`, `assertNothingCreated()`.

Swap/cancel/resume routed through `$fake->subscription($localSub)` are recorded too. Ships in-package (like Cashier's helpers); the PHPUnit dependency is only touched from the `assert*` methods.

Contributing
------------

[](#contributing)

See [CONTRIBUTING.md](CONTRIBUTING.md).

License
-------

[](#license)

MIT

###  Health Score

42

—

FairBetter than 88% of packages

Maintenance74

Regular maintenance activity

Popularity24

Limited adoption so far

Community16

Small or concentrated contributor base

Maturity45

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 98.9% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~3 days

Total

36

Last Release

29d ago

PHP version history (3 changes)v0.1.0-alpha.1PHP ^8.2

v0.4.0-alpha.2PHP ^8.0

v0.8.0-alpha.1PHP ^8.1

### Community

Maintainers

![](https://www.gravatar.com/avatar/78913b3d9fc2c28cf333e08366566ac23d0894a14d4c0914365acfb208420c29?d=identicon)[sandervanhooft](/maintainers/sandervanhooft)

---

Top Contributors

[![sandervanhooft](https://avatars.githubusercontent.com/u/7265703?v=4)](https://github.com/sandervanhooft "sandervanhooft (87 commits)")[![ag84ark](https://avatars.githubusercontent.com/u/10678506?v=4)](https://github.com/ag84ark "ag84ark (1 commits)")

---

Tags

billingframework-agnosticphpsaassubscriptions-and-paymentsvatlybillingpaymentssubscriptionsvatly

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Type Coverage Yes

### Embed Badge

![Health badge](/badges/vatly-vatly-fluent-php/health.svg)

```
[![Health](https://phpackages.com/badges/vatly-vatly-fluent-php/health.svg)](https://phpackages.com/packages/vatly-vatly-fluent-php)
```

###  Alternatives

[mollie/laravel-cashier-mollie

Laravel Cashier provides an expressive, fluent interface to Mollie's subscription billing services.

178204.3k1](/packages/mollie-laravel-cashier-mollie)[mmanos/laravel-billing

A billing package for Laravel 4.

461.3k](/packages/mmanos-laravel-billing)[chargebee/cashier

Laravel Cashier for Chargebee provides an expressive, fluent interface to Chargebee's subscription billing services.

649.9k1](/packages/chargebee-cashier)[enupal/stripe

Allows customers sign up for recurring and one-time payments with Stripe, perfect for orders, donations, subscriptions, and events. Create simple payment forms in seconds easily without coding. For Craft CMS 3.x

3416.6k1](/packages/enupal-stripe)[ptuchik/billing

Billing package for Laravel 5.5 supporting packages, plans, coupons, addons, payments and subscriptions

3021.2k](/packages/ptuchik-billing)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
