{"id":45845070,"url":"https://github.com/vatly/vatly-fluent-php","last_synced_at":"2026-06-03T01:01:39.291Z","repository":{"id":340841429,"uuid":"1153983584","full_name":"vatly/vatly-fluent-php","owner":"vatly","description":"Framework-agnostic, fluent and expressive PHP SDK for Vatly billing","archived":false,"fork":false,"pushed_at":"2026-05-26T21:48:13.000Z","size":2508,"stargazers_count":1,"open_issues_count":1,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-26T23:25:10.840Z","etag":null,"topics":["billing","framework-agnostic","php","saas","subscriptions-and-payments","vatly"],"latest_commit_sha":null,"homepage":"https://vatly.com","language":"PHP","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/vatly.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":"CONTRIBUTING.md","funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-02-09T22:18:31.000Z","updated_at":"2026-05-26T21:47:08.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/vatly/vatly-fluent-php","commit_stats":null,"previous_names":["vatly/vatly-fluent-php"],"tags_count":12,"template":false,"template_full_name":null,"purl":"pkg:github/vatly/vatly-fluent-php","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vatly%2Fvatly-fluent-php","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vatly%2Fvatly-fluent-php/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vatly%2Fvatly-fluent-php/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vatly%2Fvatly-fluent-php/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/vatly","download_url":"https://codeload.github.com/vatly/vatly-fluent-php/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vatly%2Fvatly-fluent-php/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33843611,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-02T02:00:07.132Z","response_time":109,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["billing","framework-agnostic","php","saas","subscriptions-and-payments","vatly"],"created_at":"2026-02-27T01:23:38.166Z","updated_at":"2026-06-03T01:01:39.284Z","avatar_url":"https://github.com/vatly.png","language":"PHP","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Vatly Fluent PHP\n\n[![Latest Version on Packagist](https://img.shields.io/packagist/v/vatly/vatly-fluent-php.svg?style=flat-square)](https://packagist.org/packages/vatly/vatly-fluent-php)\n[![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)\n[![Total Downloads](https://img.shields.io/packagist/dt/vatly/vatly-fluent-php.svg?style=flat-square)](https://packagist.org/packages/vatly/vatly-fluent-php)\n\n\u003e **Alpha — under active development. Expect breaking changes between minor versions.**\n\nFramework-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`).\n\n## Pick the right package\n\n| Use case | Package |\n| --- | --- |\n| Laravel app | [`vatly/vatly-laravel`](https://github.com/Vatly/vatly-laravel) — already wires fluent for you |\n| Building a driver for another framework (Symfony, Yii, …) | **`vatly/vatly-fluent-php`** (this) |\n| Standalone PHP script / CLI tool | **`vatly/vatly-fluent-php`** (this, api-only mode) |\n| Just need raw HTTP requests, no domain model | [`vatly/vatly-api-php`](https://github.com/Vatly/vatly-api-php) |\n\n## Fluent vs `vatly-api-php`\n\n`vatly-api-php` is the wire layer: turn method calls into HTTPS requests, parse responses into typed resources. Fluent is the layer above:\n\n| Concern | `vatly-api-php` | `vatly-fluent-php` |\n| --- | --- | --- |\n| Make API calls | ✓ | ✓ (via `vatly-api-php` underneath) |\n| Domain model (`Subscription`, `Order`, `CustomerService` helper) | — | ✓ |\n| Repository contracts for persisting subscriptions / orders / customer bindings | — | ✓ |\n| Webhook signature verification | ✓ (low-level) | ✓ (full pipeline incl. parsing, reactions, dispatch) |\n| Typed domain events (`OrderPaid`, `SubscriptionStarted`, …) | — | ✓ |\n| Builders for checkout / subscription flows | — | ✓ |\n| Single composition root (`Vatly`) that wires everything from contracts | — | ✓ |\n\nIf 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.\n\n## Installation\n\nRequires PHP 8.1+ and a Vatly API key.\n\n```bash\ncomposer require vatly/vatly-fluent-php:v0.8.0-alpha.1\n```\n\nPin to an exact version during alpha.\n\n---\n\n## Quick start — standalone / api-only\n\nFor one-off scripts that just hit the API. No persistence, no webhook processing, no event dispatching.\n\n1. **Build a `Vatly` with just an API key:**\n\n   ```php\n   use Vatly\\Fluent\\Vatly;\n\n   $vatly = Vatly::apiOnly('test_xxxxxxxxxxxxxxxxxx');\n   ```\n\n2. **Call any action accessor:**\n\n   ```php\n   $customer = $vatly-\u003ecreateCustomer()-\u003eexecute([\n       'email' =\u003e 'sander@example.com',\n       'name'  =\u003e 'Sander',\n   ]);\n\n   $order = $vatly-\u003egetOrder()-\u003eexecute('order_abc123');\n   $sub   = $vatly-\u003egetSubscription()-\u003eexecute('sub_xyz789');\n   ```\n\n   Available accessors: `createCustomer`, `getCustomer`, `getOrder`, `createCheckout`, `getSubscription`, `cancelSubscription`, `resumeSubscription`, `swapSubscriptionPlan`, `updateSubscriptionBilling`.\n\n3. **Override the API endpoint** (e.g. for sandbox / proxy):\n\n   ```php\n   use Vatly\\Fluent\\Configuration\\ArrayConfiguration;\n   use Vatly\\Fluent\\Vatly;\n   use Vatly\\Fluent\\Wiring;\n\n   $vatly = new Vatly(new Wiring(\n       config: new ArrayConfiguration([\n           'api_key'     =\u003e 'test_xxxxxxxxxxxxxxxxxx',\n           'api_url'     =\u003e 'https://api.sandbox.vatly.com',\n           'api_version' =\u003e 'v1',\n       ]),\n   ));\n   ```\n\nMethods 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.\n\n---\n\n## Step-by-step — building a framework driver\n\nA 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.\n\n### Webhook pipeline at a glance\n\nFor 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).\n\n| Vatly event              | Dispatched event class                                            | Built-in reaction              | Repo method(s) called                                |\n|--------------------------|-------------------------------------------------------------------|--------------------------------|------------------------------------------------------|\n| `order.paid`             | `OrderPaid`                                                       | `StoreOrderOnPaid`             | `OrderWriter::store` (new) / `OrderWriter::update` (existing) |\n| `order.canceled`         | `OrderCanceled`                                                   | `CancelOrderOnCanceled`        | `OrderWriter::update` (mirrors `canceled` status)    |\n| `order.chargeback_received` | `OrderChargebackReceived`                                   | `SyncChargebackOnStatusChange` *(opt-in, persistence)* | `ChargebackWriter::store` (new) |\n| `order.chargeback_reversed` | `OrderChargebackReversed`                                   | `SyncChargebackOnStatusChange` *(opt-in, persistence)* | `ChargebackWriter::update` (existing) |\n| `refund.completed` / `refund.failed` / `refund.canceled` | `RefundCompleted` / `RefundFailed` / `RefundCanceled` | `SyncRefundOnStatusChange` *(opt-in)* | `RefundWriter::store` (new) / `::update` (existing) |\n| `subscription.started`   | `SubscriptionStarted`                                             | `SyncSubscriptionOnStarted`    | `SubscriptionWriter::store` (new) / `::update` (existing) |\n| `subscription.billing_updated` | `SubscriptionBillingUpdated`                               | `SyncSubscriptionOnBillingUpdated` | `SubscriptionWriter::update` (refreshes mandate)    |\n| `subscription.resumed`   | `SubscriptionResumed`                                             | `ResumeSubscriptionOnResumed`  | `SubscriptionWriter::update` (clears end date)       |\n| `subscription.canceled`  | `SubscriptionCanceledImmediately` / `SubscriptionCanceledWithGracePeriod` | `CancelSubscriptionOnCanceled` | `SubscriptionWriter::update`                         |\n| `subscription.cancellation_grace_period_completed` | `SubscriptionCancellationGracePeriodCompleted` | `EndSubscriptionOnGracePeriodCompleted` | `SubscriptionWriter::update` (stamps actual end date) |\n| `checkout.paid` / `checkout.failed` / `checkout.canceled` / `checkout.expired` | `CheckoutPaid` / `CheckoutFailed` / `CheckoutCanceled` / `CheckoutExpired` | — (dispatched only) | none — driver-handled |\n\n`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 `LocalSubscriptionCreated` dispatch when store returns null.\n\n`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` re-fetches the subscription to keep the stored mandate (card last-4, masked IBAN) in step with the payment method on file; `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.\n\n**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. Refund events are enriched via `GetRefund` so they carry the full tax breakdown, mirroring `order.paid`.\n\nRead the refunds back idiomatically with `RefundReader::listForOrder` / `listForCustomer`, or via the handle: `$vatly-\u003eorder($localOrder)-\u003erefunds()` returns the `RefundInterface[]` recorded against that order (local read, no API call; empty array when no refund repo is wired).\n\nThe 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.\n\n**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). When a `GetChargeback` action is wired the events are enriched (customer id, dispute status, tax breakdown) so the reversed VAT can be reconciled without a second API call; without it they fall back to the sparse webhook payload. Read chargebacks back via `ChargebackReader::listForOrder` / `listForCustomer` or `$vatly-\u003eorder($localOrder)-\u003echargebacks()`.\n\n**Checkout events** are dispatched only — no built-in reaction. The `checkout.*` deliveries carry the full Checkout resource (status, `customerId`, `orderId`, `metadata`) with no sparse money/tax fields, so they need no enriching API GET and are built straight from the payload. Use `CheckoutPaid` for an analytics/receipt handoff at the earliest \"customer paid\" moment — before `order.paid`'s tax-summary enrichment — 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.\n\n**`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 \u003c 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.\n\n`additionalWebhookReactions` (on `WebhookProcessorFactory::create`) lets you append driver-specific reactions without losing the built-ins.\n\n### 1. Implement `ConfigurationInterface`\n\nRead your framework's config and return values.\n\n```php\nnamespace Acme\\VatlySymfony;\n\nuse Vatly\\Fluent\\Concerns\\DerivesTestmodeFromApiKey;\nuse Vatly\\Fluent\\Contracts\\ConfigurationInterface;\n\nfinal class SymfonyVatlyConfig implements ConfigurationInterface\n{\n    use DerivesTestmodeFromApiKey;  // free isTestmode() from key prefix\n\n    public function __construct(\n        private string $apiKey,\n        private string $apiUrl = 'https://api.vatly.com',\n        private string $apiVersion = 'v1',\n        private ?string $webhookSecret = null,\n        private string $successUrl = '',\n        private string $canceledUrl = '',\n    ) {}\n\n    public function getApiKey(): string { return $this-\u003eapiKey; }\n    public function getApiUrl(): string { return $this-\u003eapiUrl; }\n    public function getApiVersion(): string { return $this-\u003eapiVersion; }\n    public function getWebhookSecret(): ?string { return $this-\u003ewebhookSecret; }\n    public function getDefaultRedirectUrlSuccess(): string { return $this-\u003esuccessUrl; }\n    public function getDefaultRedirectUrlCanceled(): string { return $this-\u003ecanceledUrl; }\n}\n```\n\n### 2. Implement `CustomerBindingRepository`\n\nThe 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.\n\n```php\nuse Vatly\\Fluent\\Contracts\\CustomerBindingRepository;\n\nfinal class SymfonyCustomerBindingRepository implements CustomerBindingRepository\n{\n    public function __construct(private Connection $db) {}\n\n    public function bind(string $vatlyCustomerId, string $hostCustomerId): void\n    {\n        $this-\u003edb-\u003eexecuteStatement(\n            'UPDATE users SET vatly_id = ? WHERE id = ?',\n            [$vatlyCustomerId, $hostCustomerId],\n        );\n    }\n\n    public function record(string $vatlyCustomerId): void\n    {\n        // No-op: rows arrive with a null host customer id via the\n        // anonymous-checkout flow and get attributed later. Override if\n        // your driver tracks unattributed customers in a join table instead.\n    }\n\n    public function hostCustomerIdFor(string $vatlyCustomerId): ?string\n    {\n        return $this-\u003edb-\u003efetchOne('SELECT id FROM users WHERE vatly_id = ?', [$vatlyCustomerId]) ?: null;\n    }\n\n    public function vatlyCustomerIdFor(string $hostCustomerId): ?string\n    {\n        return $this-\u003edb-\u003efetchOne('SELECT vatly_id FROM users WHERE id = ?', [$hostCustomerId]) ?: null;\n    }\n}\n```\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cstrong\u003eWhat if I can't add a \u003ccode\u003evatly_id\u003c/code\u003e column?\u003c/strong\u003e — vendor user model, third-party identity provider, multi-tenant…\u003c/summary\u003e\n\nImplement the same four methods against a dedicated join table. The host class itself stays untouched.\n\n```sql\nCREATE TABLE vatly_customer_bindings (\n    host_customer_id  VARCHAR(255) NOT NULL,\n    vatly_customer_id VARCHAR(255) NOT NULL,\n    created_at        TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    PRIMARY KEY (host_customer_id),\n    UNIQUE      (vatly_customer_id)\n);\n```\n\n`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).\n\n**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.\n\n\u003c/details\u003e\n\n### 3. Implement your `SubscriptionInterface` model\n\nState accessors + the derived predicates. Use `DerivesSubscriptionState` to get the six predicates for free.\n\n```php\nuse DateTimeInterface;\nuse Vatly\\Fluent\\Concerns\\DerivesSubscriptionState;\nuse Vatly\\Fluent\\Contracts\\SubscriptionInterface;\n\nclass Subscription implements SubscriptionInterface\n{\n    use DerivesSubscriptionState;  // isActive, isCancelled, isOnGracePeriod,\n                                   // isValid, isRecurring, isEnded\n\n    public function getVatlyId(): string { /* ... */ }\n    public function getType(): string { /* ... */ }\n    public function getPlanId(): string { /* ... */ }\n    public function getName(): string { /* ... */ }\n    public function getQuantity(): int { /* ... */ }\n    public function getEndsAt(): ?DateTimeInterface { /* ... */ }\n\n    // Mandate summary on file — persist these alongside the rest so portals\n    // render \"card ending in 4242\" without a per-request API roundtrip.\n    public function getMandateMethod(): ?string { /* 'card', 'sepa_debit', null, ... */ }\n    public function getMandateMaskedIdentifier(): ?string { /* '4242', 'NL91****4300', null */ }\n}\n```\n\n### 4. Implement your `OrderInterface` model\n\n```php\nuse Vatly\\Fluent\\Contracts\\OrderInterface;\n\nclass Order implements OrderInterface\n{\n    public function getVatlyId(): string { /* ... */ }\n    public function getStatus(): string { /* ... */ }\n    public function getInvoiceNumber(): ?string { /* ... */ }\n    public function getTotal(): int { /* ... */ }\n    public function getCurrency(): string { /* ... */ }\n    public function getPaymentMethod(): ?string { /* ... */ }\n    public function isPaid(): bool { return $this-\u003estatus === 'paid'; }\n}\n```\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cstrong\u003eWhat if my host already has Order / Subscription tables?\u003c/strong\u003e — bolting onto an ecosystem plugin (FluentCart, PMPro, MemberPress, EDD…)\u003c/summary\u003e\n\nThis 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:\n\n```php\nuse Vatly\\Fluent\\Contracts\\OrderInterface;\n\nfinal class FluentCartOrder implements OrderInterface\n{\n    public function __construct(private OrderTransaction $txn) {}\n\n    public function getVatlyId(): string       { return $this-\u003etxn-\u003evatly_id; }\n    public function getStatus(): string        { return $this-\u003etxn-\u003estatus; }\n    public function getInvoiceNumber(): ?string{ return $this-\u003etxn-\u003einvoice_no; }\n    public function getTotal(): int            { return (int) $this-\u003etxn-\u003etotal; }\n    public function getCurrency(): string      { return $this-\u003etxn-\u003ecurrency; }\n    public function getPaymentMethod(): ?string{ return $this-\u003etxn-\u003epayment_method; }\n    public function isPaid(): bool             { return $this-\u003etxn-\u003estatus === 'paid'; }\n}\n```\n\nYour `OrderRepositoryInterface::store` then routes the incoming `StoreOrderData` to the right host record — typically by reading `$data-\u003emetadata` 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`:\n\n```php\npublic function store(StoreOrderData $data): ?OrderInterface\n{\n    $txnId = $data-\u003emetadata['fluentcart_transaction_id'] ?? null;\n    if ($txnId === null) {\n        return null; // anonymous / audit-only — nothing to attach to\n    }\n\n    $txn = OrderTransaction::find($txnId);\n    if ($txn === null) {\n        return null;\n    }\n\n    $txn-\u003evatly_id = $data-\u003evatlyId;\n    $txn-\u003esave();\n\n    return new FluentCartOrder($txn);\n}\n```\n\nSame shape for `SubscriptionRepositoryInterface::store`. Built-in reactions tolerate null returns.\n\n**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()`.\n\n\u003c/details\u003e\n\n### 5. Implement the three repository contracts\n\nEach entity-side contract has three methods. See [src/Contracts](src/Contracts) for signatures.\n\n- `SubscriptionRepositoryInterface` — `findByVatlyId`, `store`, `update`\n- `OrderRepositoryInterface` — `findByVatlyId`, `store`, `update`\n- `RefundRepositoryInterface` — `findByVatlyId`, `listForOrder`, `listForCustomer`, `store`, `update` (**optional** — only needed to persist `refund.*` webhooks)\n- `ChargebackRepositoryInterface` — `findByVatlyId`, `listForOrder`, `listForCustomer`, `store`, `update` (**optional** — only needed to persist `order.chargeback_*` webhooks)\n- `WebhookCallRepositoryInterface` — record received webhook calls (audit log)\n\n`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.\n\n```php\npublic function store(StoreSubscriptionData $data): SubscriptionInterface\n{\n    $attrs = [\n        'vatly_id' =\u003e $data-\u003evatlyId,\n        'type'     =\u003e $data-\u003etype,\n        'plan_id'  =\u003e $data-\u003eplanId,\n        'name'     =\u003e $data-\u003ename,\n        'quantity' =\u003e $data-\u003equantity,\n    ];\n\n    if ($data-\u003ehostCustomerId !== null) {\n        $attrs['owner_id'] = $data-\u003ehostCustomerId;\n    }\n\n    return Subscription::create($attrs);\n}\n```\n\n\u003e 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.\n\n\u003e **If your repo needs to call back into the SDK** — e.g. `GetOrder` to read fresh metadata from a partial webhook payload — 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.\n\u003e\n\u003e ```php\n\u003e // ✗ Circular — $vatly doesn't exist yet at Wiring-construction time:\n\u003e new Vatly(new Wiring(orders: new MyOrderRepository($vatly), …));\n\u003e\n\u003e // ✓ Closure resolver — the repo only touches Vatly at call time:\n\u003e new Vatly(new Wiring(orders: new MyOrderRepository(fn () =\u003e $container-\u003eget(Vatly::class)), …));\n\u003e\n\u003e // ✓ Singleton accessor — same idea via a static entry point:\n\u003e new Vatly(new Wiring(orders: new MyOrderRepository(Plugin::vatly(...)), …));\n\u003e ```\n\u003e\n\u003e Inside the repo, resolve lazily: `($this-\u003evatly)()-\u003egetOrder()-\u003eexecute($vatlyId)` for the closure form, or `Plugin::vatly()-\u003egetOrder()-\u003eexecute($vatlyId)` for the accessor form.\n\n### 6. Implement `EventDispatcherInterface`\n\n```php\nuse Vatly\\Fluent\\Contracts\\EventDispatcherInterface;\n\nclass SymfonyEventDispatcher implements EventDispatcherInterface\n{\n    public function __construct(private \\Symfony\\Contracts\\EventDispatcher\\EventDispatcherInterface $bus) {}\n\n    public function dispatch(object $event): void\n    {\n        $this-\u003ebus-\u003edispatch($event);\n    }\n}\n```\n\nIf you don't need events, use `Vatly\\Fluent\\Events\\NullEventDispatcher` (no-op).\n\n### 7. Construct `Vatly` from a `Wiring`\n\n```php\nuse Vatly\\Fluent\\Vatly;\nuse Vatly\\Fluent\\Wiring;\n\n$vatly = new Vatly(new Wiring(\n    config:           $config,                  // your ConfigurationInterface impl\n    subscriptions:    $subscriptionsRepo,\n    orders:           $ordersRepo,\n    webhookCalls:     $webhookCallsRepo,\n    events:           $eventDispatcher,\n    customerBindings: $customerBindingsRepo,\n));\n```\n\nRegister the `Vatly` instance as a singleton in your framework's container. Everything else resolves through it.\n\nIf your driver ships plugin-specific webhook reactions (e.g. assigning a membership level on `subscription.started`), pass them as `additionalWebhookReactions`:\n\n```php\n$vatly = new Vatly(new Wiring(\n    config:           $config,\n    // ... repos + events + bindings ...\n    additionalWebhookReactions: [\n        new AssignMembershipLevelOnStarted(...),\n        new RevokeMembershipLevelOnCanceled(...),\n    ],\n));\n```\n\nThey run after fluent's built-in reactions (subscription sync, order persistence, cancellation handling).\n\n### 8. Use the SDK — two paths\n\n**Action-driven.** For drivers that want consumers to reach the SDK explicitly. Reach `Vatly` through your container:\n\n```php\n$vatly = $container-\u003eget(Vatly::class);\n\n// Create a checkout — pass in a CustomerProfile carrying whatever the host knows.\nuse Vatly\\Fluent\\CustomerProfile;\n\n$checkout = $vatly\n    -\u003echeckoutBuilder(new CustomerProfile(vatlyId: $user-\u003evatly_id))\n    -\u003ewithRedirectUrlSuccess('https://app.example.com/done')\n    -\u003ewithRedirectUrlCanceled('https://app.example.com/oops')\n    -\u003ecreate([['id' =\u003e 'plan_premium', 'quantity' =\u003e 1]], '...', '...');\n\n// Subscribe\n$checkout = $vatly\n    -\u003esubscriptionBuilder(new CustomerProfile(vatlyId: $user-\u003evatly_id))\n    -\u003etoPlan('plan_premium')\n    -\u003ecreate();\n\n// Subscribe with a free trial. withTrialDays() is the whole-day form;\n// withTrialEndsAt() takes a DateTimeInterface and rounds up to whole days\n// (Vatly's trial input is day-granular) so the trial never ends early.\n$checkout = $vatly\n    -\u003esubscriptionBuilder(new CustomerProfile(vatlyId: $user-\u003evatly_id))\n    -\u003etoPlan('plan_premium')\n    -\u003ewithTrialDays(14)\n    -\u003ecreate();\n\n// Operate on a stored Subscription / Order\n$vatly-\u003esubscription($localSubscription)-\u003ecancel();\n$vatly-\u003eorder($localOrder)-\u003einvoiceUrl();\n\n// Billing address / VAT / company name changes go through a hosted Vatly\n// flow. Returns a fresh redirect URL per call — don't cache.\n// `redirectUrlSuccess` and `redirectUrlCanceled` are filled in from the\n// config defaults when omitted; pass them in $prefillData to override.\n$url = $vatly-\u003esubscription($localSubscription)-\u003eupdateBilling();\n\n// Optionally prefill the billing address:\n$url = $vatly-\u003esubscription($localSubscription)-\u003eupdateBilling([\n    'billingAddress' =\u003e [\n        'streetAndNumber' =\u003e 'Damrak 1',\n        'city'            =\u003e 'Amsterdam',\n        'country'         =\u003e 'NL',\n    ],\n]);\n```\n\n**Customer helper.** For host-first flows where you create a Vatly customer for a known host entity and want the link recorded automatically:\n\n```php\n$customer = $vatly-\u003ecustomers()-\u003ecreateFor(\n    hostCustomerId: (string) $user-\u003eid,\n    profile:        new CustomerProfile(email: $user-\u003eemail, name: $user-\u003ename),\n);\n// $customer-\u003eid is now bound to $user-\u003eid via your CustomerBindingRepository.\n\n// Forward extra create-customer API keys via $additionalPayload (locale,\n// metadata, or anything else the create-customer endpoint accepts).\n$customer = $vatly-\u003ecustomers()-\u003ecreateFor(\n    hostCustomerId:    (string) $user-\u003eid,\n    profile:           new CustomerProfile(email: $user-\u003eemail, name: $user-\u003ename),\n    additionalPayload: ['locale' =\u003e 'nl_NL', 'metadata' =\u003e ['internal_id' =\u003e $user-\u003eid]],\n);\n\n// Look up later\n$existing = $vatly-\u003ecustomers()-\u003efindByHostCustomerId((string) $user-\u003eid);\n```\n\nDrivers commonly wrap these calls in idiomatic shortcuts — e.g. a Laravel trait that adds `$user-\u003esubscribe()-\u003etoPlan(...)-\u003ecreate()` on top of `subscriptionBuilder($user-\u003ecustomerProfile())`.\n\n### 9. Wire the webhook receiver\n\nVatly POSTs webhooks to a URL of your choice. Your driver:\n\n1. Defines an HTTP route in your framework.\n2. The route handler reads the raw request body and the `Vatly-Signature` header.\n3. Passes both into `$vatly-\u003ewebhookProcessor()-\u003ehandle($payload, $signature)`.\n4. Returns `201` on success, `403` on `InvalidWebhookSignatureException`.\n\nExample handler:\n\n```php\nuse Vatly\\Fluent\\Exceptions\\InvalidWebhookSignatureException;\nuse Vatly\\Fluent\\Webhooks\\SignatureVerifier;\n\npublic function handle(SomeRequest $request)\n{\n    try {\n        $this-\u003evatly-\u003ewebhookProcessor()-\u003ehandle(\n            payload:   $request-\u003egetRawBody(),\n            signature: $request-\u003eheaders-\u003eget(SignatureVerifier::SIGNATURE_HEADER_NAME, ''),\n        );\n    } catch (InvalidWebhookSignatureException) {\n        return new Response(status: 403);\n    }\n\n    return new Response(status: 201);\n}\n```\n\nThe 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.\n\n### 10. (Optional) Expose operations on your local models\n\nFor Cashier-style ergonomics, give your Eloquent / Doctrine entities operation methods that delegate to fluent:\n\n```php\nclass Subscription implements SubscriptionInterface\n{\n    // ... interface state accessors (getVatlyId, getMandateMethod, etc.) ...\n\n    public function cancel(): void\n    {\n        $container-\u003eget(Vatly::class)-\u003esubscription($this)-\u003ecancel();\n    }\n\n    public function swap(string $planId): self\n    {\n        $container-\u003eget(Vatly::class)-\u003esubscription($this)-\u003eswap($planId);\n        return $this;\n    }\n\n    public function billingUpdateUrl(array $prefillData = []): string\n    {\n        return $container-\u003eget(Vatly::class)\n            -\u003esubscription($this)\n            -\u003eupdateBilling($prefillData);\n    }\n}\n```\n\nThis makes `foreach ($user-\u003esubscriptions as $sub) $sub-\u003ecancel()` work naturally.\n\n---\n\n## What `Vatly` (the composition root) exposes\n\n```php\n$vatly-\u003egetApiClient();                            // raw VatlyApiClient\n$vatly-\u003egetSignatureVerifier();                    // raw webhook signature verifier\n$vatly-\u003egetWebhookEventFactory();                  // parses webhook payloads\n\n// Actions (lazy, cached)\n$vatly-\u003ecreateCustomer();    $vatly-\u003egetCustomer();\n$vatly-\u003egetOrder();          $vatly-\u003ecreateCheckout();\n$vatly-\u003egetSubscription();   $vatly-\u003ecancelSubscription();\n$vatly-\u003eresumeSubscription(); $vatly-\u003eswapSubscriptionPlan();\n$vatly-\u003eupdateSubscriptionBilling();\n\n// Composed services — require repos in Wiring\n$vatly-\u003ecustomers();                               // CustomerService (lazy, cached)\n$vatly-\u003echeckoutBuilder($profile);                 // CheckoutBuilder (per-call)\n$vatly-\u003esubscriptionBuilder($profile);             // SubscriptionBuilder (per-call)\n$vatly-\u003esubscription($localSubscription);          // SubscriptionHandle wrapping local state\n$vatly-\u003eorder($localOrder);                        // OrderHandle wrapping local state\n$vatly-\u003ewebhookProcessor();                        // WebhookProcessor (also needs events dispatcher)\n```\n\nCalling a composed-services method without the required repos in `Wiring` throws `IncompleteWiringException` with a message naming what's missing.\n\n## Contracts at a glance\n\nIn [src/Contracts](src/Contracts):\n\n- `SubscriptionInterface` — local subscription state + derived predicates\n- `OrderInterface` — local order state\n- `CustomerBindingRepository` — bidirectional mapping between Vatly customer ids and host ids\n- `SubscriptionRepositoryInterface` — subscription persistence (3 methods). Splits into `SubscriptionReader` (find) + `SubscriptionWriter` (store/update).\n- `OrderRepositoryInterface` — order persistence (3 methods). Splits into `OrderReader` (find) + `OrderWriter` (store/update).\n- `RefundRepositoryInterface` — refund persistence (optional). Splits into `RefundReader` (find + `listForOrder` / `listForCustomer`) + `RefundWriter` (store/update).\n- `ChargebackRepositoryInterface` — chargeback persistence (optional). Splits into `ChargebackReader` (find + `listForOrder` / `listForCustomer`) + `ChargebackWriter` (store/update).\n- `WebhookCallRepositoryInterface` — webhook audit log (write-only by nature)\n- `EventDispatcherInterface` — fire domain events\n- `ConfigurationInterface` — API key, URL, version, webhook secret, redirect defaults\n- `WebhookReactionInterface` — extension point for adding your own webhook reactions. Compose multiple via `Webhooks\\Reactions\\WebhookReactionChain` (variadic constructor; the chain itself implements the same interface).\n\n## Domain events\n\nDispatched by webhook reactions through your `EventDispatcherInterface`. Subscribe to them in your framework's event bus.\n\n- `WebhookReceived` — raw webhook envelope (typed shape; `object` is the resource payload)\n- `OrderPaid` — order with full `taxSummary` breakdown, ready to materialize local invoices\n- `OrderCanceled`\n- `OrderChargebackReceived` / `OrderChargebackReversed` — dispute signals carrying the affected `orderId`\n- `RefundCompleted` / `RefundFailed` / `RefundCanceled` — each with full `taxSummary` breakdown\n- `SubscriptionStarted`\n- `SubscriptionBillingUpdated` — billing/mandate changed; carries the refreshed mandate summary\n- `SubscriptionResumed`\n- `SubscriptionCanceledImmediately`\n- `SubscriptionCanceledWithGracePeriod`\n- `LocalSubscriptionCreated`\n- `UnsupportedWebhookReceived`\n\n## Testing\n\n```bash\ncomposer test\n```\n\n### Test helpers for consumers\n\n`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):\n\n```php\nuse Vatly\\Fluent\\Testing\\FakeVatly;\nuse Vatly\\Fluent\\Testing\\FakeCheckout;\n\n$fake = (new FakeVatly())-\u003eonSubscriptionCreate(\n    fn (string $planId) =\u003e FakeCheckout::make('https://checkout.vatly.test/chk_1'),\n);\n$this-\u003eapp-\u003einstance(Vatly::class, $fake); // drop-in: FakeVatly extends Vatly\n\n$this-\u003eget('/vatly/subscription-checkout/plan_pro')\n    -\u003eassertRedirect('https://checkout.vatly.test/chk_1');\n\n$fake-\u003eassertSubscriptionCreated('plan_pro');\n$fake-\u003eassertNothingCanceled();\n```\n\n- **`FakeVatly`** — drop-in `Vatly` that hands out recording builders/handles and returns scriptable `Checkout`s (`onSubscriptionCreate` / `onCheckoutCreate` / `withDefaultCheckout`).\n- **`FakeCheckout::make($url)`** — a minimal `Checkout` with a working `links-\u003echeckoutUrl-\u003ehref`.\n- **Assertions** — `assertSubscriptionCreated($planId)`, `assertCheckoutCreated(productId:)`, `assertSubscriptionSwapped(from:, to:)`, `assertSubscriptionCanceled($id)`, `assertNothingCanceled()`, `assertNothingCreated()`.\n\nSwap/cancel/resume routed through `$fake-\u003esubscription($localSub)` are recorded too. Ships in-package (like Cashier's helpers); the PHPUnit dependency is only touched from the `assert*` methods.\n\n## Contributing\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md).\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fvatly%2Fvatly-fluent-php","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fvatly%2Fvatly-fluent-php","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fvatly%2Fvatly-fluent-php/lists"}