TruPayments is the payment hub that Booking and TruCommerce share. A plugin uses it in one of two ways: it takes payments for its own records, or it adds a gateway that every consumer can then offer.
How the pieces fit
A consumer (Booking, TruCommerce, your plugin) owns something payable. TruPayments turns it into a Payment row, asks the chosen gateway for a payment request, and records every response in the payment history. Gateways never know which consumer they are serving, and consumers never talk to a gateway directly.
Your record (implements Payable)
| PaymentManager::createFromPayable($record, $method)
v
payments row -- PaymentManager::requestFor($payment) --> gateway builds a PaymentRequest
| (redirect, inline form or offline note)
| customer pays; gateway calls back:
| GET|POST payments/{hash}/return or POST payments/webhook/{gateway}
v
PaymentManager::applyResponse() -> payment_history row, Vanilo payment events,
status copied onto your recordYour plugin cannot work without TruPayments, so say so in module.json. The Plugin Manager then refuses to activate your plugin until TruPayments is active.
module.json
{
"requires": { "trupayments": "^1.0" },
"payments": true
}"payments": true lists your plugin in Module::paymentModules(), the same way Booking and TruCommerce are listed. It also adds your plugin to the Scope list on the payment-method form, so a site owner can create methods that only your plugin offers.
The record you charge for must implement Vanilo\Contracts\Payable. TruCommerce uses a checkout intent for this; Booking makes the booking itself payable.
| Method | Return |
|---|---|
getPayableId(), getPayableType() | The key and the morph type of your record, so the payment can find it again |
getAmount(), getCurrency() | Amount as a float and an ISO 4217 currency code |
getTitle(), getNumber() | What the customer sees on the payment page and in history |
getBillpayer() | A Billpayer or null. When it has an e-mail address, TruPayments stores it on the payment so the customer finds it in their account history |
getPayableRemoteId(), setPayableRemoteId() | An optional id the gateway assigns |
hasItems(), getItems() | Line items, if you have any |
Give the record a status column
After every status change PaymentManager writes a status straight onto the payable model: paid, partially_paid, failed or pending, and confirmed when the record was pending_payment and money arrived. It uses forceFill()->save(), so the column must exist and accept those strings. If your own workflow needs different states, keep them in another column and react to the payment events below.
Ask the manager which payment methods the customer may use. It filters by currency, by billing zone and by context, and it drops gateways that are not configured.
use Modules\TruPayments\Services\PaymentManager;
$methods = app(PaymentManager::class)->availableMethods(
currency: 'EUR',
billingAddress: ['country_code' => 'DE'], // or null when you do not know it yet
context: 'testimonials', // your module name, lower-cased
);any shows everywhere; a method scoped to booking shows only when the context is booking. The scope keys are the lower-cased names of the modules marked "payments": true, so pass yours.country_code (or country_id), province_id or province_code, and city. When the visitor's IP resolves to a country, that location wins and the address you pass is ignored.For a ready-made selector use renderAvailableMethodsList($currency, $billingAddress, $context), which returns the HTML list TruCommerce and Booking use.
php — in your checkout controller
use Modules\TruPayments\Models\PaymentMethod;
use Modules\TruPayments\Services\PaymentManager;
$manager = app(PaymentManager::class);
$method = PaymentMethod::query()->findOrFail($request->integer('payment_method_id'));
$payment = $manager->createFromPayable($order, $method, [
'source' => 'testimonials', // stored with the payment, handy in reports
]);
// Simplest: let TruPayments render its hosted payment page.
return redirect()->route('trupayments.pay', $payment);If you want the payment step inside your own page, ask for the request yourself and render it. The URLs you pass are where the gateway sends the customer back:
$paymentRequest = $manager->requestFor($payment, null, [
'returnUrl' => route('testimonials.checkout.return', $payment),
'cancelUrl' => route('testimonials.checkout.cancel', $payment),
'submitUrl' => route('testimonials.checkout.submit', $payment),
]);
if ($paymentRequest->willRedirect()) {
// hosted checkout (PayPal, Mollie): the snippet is a form that leaves the site
}
return view('testimonials::checkout.pay', [
'paymentHtml' => $paymentRequest->getHtmlSnippet(),
]);TruCommerce's CheckoutPaymentService passes both camelCase and snake_case keys (returnUrl and return_url) because gateways read either. Do the same if you support every bundled gateway.
A request can throw GatewayNotReadyException when the gateway lost its credentials or does not support the currency. Catch it and show the message; the hosted trupayments.pay page already does.
Every response goes through PaymentManager::applyResponse(), which writes the history, updates the payment and fires the standard payment events. Listen to them to fulfil your order:
Event (Vanilo\Payment\Events\…) | When |
|---|---|
PaymentCreated | A payment row was created |
PaymentCompleted | The full amount was received |
PaymentPartiallyReceived | Part of the amount was received |
PaymentDeclined | The gateway declined the payment |
PaymentTimedOut | The payment expired |
Modules/Testimonials/app/Listeners/FulfilAfterPayment.php
use Vanilo\Payment\Events\PaymentCompleted;
class FulfilAfterPayment
{
public function handle(PaymentCompleted $event): void
{
$payable = $event->getPayment()->getPayable();
if (! $payable instanceof SponsoredTestimonial) {
return; // another module's payment
}
$payable->publish();
}
}Register the listener in your module's EventServiceProvider ($listen map) or with Event::listen() in boot(). Always check the payable type first: the events fire for every consumer. Refunds have their own events, Modules\TruPayments\Events\RefundCreated and RefundStatusChanged, both dispatched after the database transaction commits.
Stripe, PayPal, Braintree and Mollie are each a small plugin with no migrations. Copy that shape. A gateway plugin has four parts:
Modules/Acme/
module.json "requires": {"trupayments": "^1.0"}
config/config.php defaults, merged into config('trupayments.gateways.acme')
app/Providers/AcmeServiceProvider.php
app/Gateways/Acme/AcmePaymentGateway.php
app/Helpers/AcmeSettings.php credentials screen, registered on AdminBooting
resources/views/gateways/request.blade.php (optional) the payment form
lang/en/payments.phpModules/Acme/app/Providers/AcmeServiceProvider.php (shape of StripeServiceProvider)
use App\Events\AdminBooting;
use Illuminate\Support\Facades\Event;
use Modules\Acme\Gateways\Acme\AcmePaymentGateway;
use Modules\Acme\Helpers\AcmeSettings;
use Modules\TruPayments\Services\GatewaySettings;
use Nwidart\Modules\Support\ModuleServiceProvider;
use Vanilo\Payment\PaymentGateways;
class AcmeServiceProvider extends ModuleServiceProvider
{
protected string $name = 'Acme';
protected string $nameLower = 'acme';
public function register(): void
{
parent::register();
$this->mergeConfigFrom(module_path($this->name, 'config/config.php'), 'trupayments.gateways.acme');
$this->app->bind(AcmePaymentGateway::class, function (): AcmePaymentGateway {
$settings = $this->app->make(GatewaySettings::class);
return new AcmePaymentGateway(
(string) $settings->credential('acme', 'api_key', ''),
);
});
}
public function boot(): void
{
parent::boot();
$this->registerTranslations(); // namespaced override, see "The service provider"
GatewaySettings::registerGateway('acme', [
'label' => 'Acme Pay',
'class' => AcmePaymentGateway::class,
'required_credentials' => ['api_key'],
'supported_currencies' => config('trupayments.gateways.acme.supported_currencies', []),
]);
PaymentGateways::register(
(string) app(GatewaySettings::class)->gatewayValue('acme', 'id', AcmePaymentGateway::DEFAULT_ID),
AcmePaymentGateway::class
);
Event::listen(AdminBooting::class, function (): void {
AcmeSettings::register();
});
}
}GatewaySettings::registerGateway() tells TruPayments' admin screens about your gateway (label, required credentials, currencies). PaymentGateways::register() puts the class in the gateway registry that payment methods point at. Both run in boot() because the public payment pages and webhooks need them, not only the admin.
Implement two interfaces: Vanilo\Payment\Contracts\PaymentGateway (the payment flow) and Modules\TruPayments\Gateways\Contracts\ConfigurableGateway (readiness and presentation). The UsesGatewayPresentation trait supplies the currency helpers and lets an administrator rename the gateway or swap its icon.
| Method | What to return |
|---|---|
static getName(), static svgIcon() | Display name and inline SVG. Wrap them with configuredGatewayName() / configuredGatewayIcon() from the trait so admin overrides apply |
key() | Your settings key (acme) |
isConfigured(), readinessMessage(?string $currency) | Whether the credentials are present, and a customer-safe reason when they are not |
supportedCurrencies(), supportsCurrency() | From the trait; an empty list means "any currency" |
createPaymentRequest(Payment $payment, ?Address $shippingAddress, array $options) | A PaymentRequest: getHtmlSnippet(), willRedirect(), getRemoteId() |
processPaymentResponse(Request $request, array $options) | A PaymentResponse built from the return or webhook request. $options['webhook'] is true for webhooks |
isRedirect(), isOffline() | Whether the customer leaves the site; whether no money moves online |
paymentRequestView() | Your namespaced request view (acme::gateways.request) or null |
transactionHandler() | A handler for refunds and captures, or null |
The built-in OfflinePaymentGateway in Modules/TruPayments/app/Gateways/Offline is the shortest complete implementation; read it first, then a real one such as Modules/PayPal (redirect) or Modules/Stripe (inline).
Gateway settings follow one naming rule, which GatewaySettings relies on:
trupayments_{gateway}_{key}, with the default from config('trupayments.gateways.{gateway}.{key}'). Read it with gatewayValue('acme', 'key').mode is sandbox or live. credential('acme', 'api_key') first looks for trupayments_acme_{mode}_api_key, then for trupayments_acme_api_key. Ship separate sandbox and live fields.env group (for example TRUPAYMENTS_STRIPE_LIVE_SECRET_KEY), mirrored into settings. Copy StripeSettings: it adds the mode select, a read-only webhook URL field and one field per credential.TruPayments already exposes POST payments/webhook/{gateway} (route trupayments.webhook). It calls your processPaymentResponse() with ['webhook' => true], finds the payment, reconciles refunds and applies the response. You do not add a route; show the URL in your settings screen as the bundled gateways do. Verify the provider's signature inside processPaymentResponse() and throw when it does not match.
composer dump-autoload, then activate it (TruPayments must be active).Http::preventStrayRequests()), so fake your provider's API with Http::fake().createFromPayable(), then updateStatus($payment, 'paid') and assert on your record and your listener.Modules/{Gateway}/tests; they are good templates for request and webhook parsing.