Building Payment Plugins
A payment plugin is a Joomla plugin in the alfa-payments group. The component boots it with
bootPlugin($type, 'alfa-payments') and calls your hooks across the storefront, checkout, the gateway
return, and the admin order screen.
standardThe bundled plg_alfapayments_standard plugin (offline: bank transfer / cash on delivery) is the
heavily-documented reference — copy it as your skeleton. Being offline, it has no onPaymentResponse.
Real gateway integrations are premium and distributed separately — they are not in the core package.
Lifecycle
Anatomy
plugins/alfa-payments/<gateway>/
├── <gateway>.xml # manifest — group="alfa-payments"
├── services/provider.php # DI: boots the plugin from PluginHelper::getPlugin()
├── src/Extension/<Gateway>.php # extends PaymentsPlugin
├── params/{params.xml, logs.xml} # admin config + plugin log-table schema
├── tmpl/ # layout fragments (default_item_view.php, …)
└── language/en-GB/plg_alfa-payments_<gateway>.ini (+ .sys.ini)
- Manifest:
group="alfa-payments"(mandatory) and<namespace path="src">Alfa\Plugin\AlfaPayments\<Gateway></namespace>. - Class:
final class <Gateway> extends \Alfa\Component\Alfa\Administrator\Plugin\PaymentsPlugin. - Provider: copy the standard
services/provider.phpand swap the class import + thegetPlugin('alfa-payments', '<gateway>')strings.
Hooks by capability tier
Capability is enforced by the event class each hook receives (GeneralEvent → RedirectEvent → LayoutEvent).
A data-tier event has no setLayout() / setRedirectUrl() at all.
| Tier | Hooks | Render layout? | Redirect? |
|---|---|---|---|
| View | onItemView, onCartView, onOrderProcessView, onOrderCompleteView | ✅ | ✅ |
| Data | onOrderBeforePlace, onOrderAfterPlace | ❌ | ❌ |
| Gateway return | onPaymentResponse | ❌ | ✅ (redirect-only) |
| Admin | onGetActions, onExecuteAction | ✅ (modal) | ✅ |
Each view hook carries $event->getMethod() (the payment-method record) + a subject (getItem()/getCart()/getOrder()).
onItemView/onCartView are abstract on the base Plugin; onOrderProcessView/onOrderCompleteView are abstract on
PaymentsPlugin — implement all four (an empty setLayout is fine).
The gateway-return flow
The part most integrations get wrong — there are three distinct entry points, keep them separate:
onOrderProcessView(has a view) — sends the buyer to the gateway with$event->setRedirectUrl($gatewayUrl). On the buyer's return it also renders the result layout by reading a?<plugin>_result=flag.onPaymentResponse(PaymentController::response(), no view) — verify the return and redirect only:Neverpublic function onPaymentResponse($event): void
{
$order = $event->getOrder();
if ($paid) {
$this->payment($order)->completed()->transactionId($txn)->save();
$event->setRedirectUrl($this->getCompletePageUrl()); // success → complete page
return;
}
$event->setRedirectUrl($this->getProcessPageUrl() . '&mygw_result=cancelled'); // cancel/err → process page
}setLayout()here —PaymentResponseEventis redirect-only and the controller has no view, so a layout is silently dropped. To show a page, redirect to a view page (the process page) and letonOrderProcessViewrender it.- Webhook —
task=plugin.trigger&type=alfa-payments&name=<plugin>&func=notifyruns with no session/view and is the source of truth for payment confirmation. Treat the browser return as UX only.
getCompletePageUrl() / getProcessPageUrl() come from PaymentsPlugin (raw internal URLs are auto-SEF-routed).
The payment record
PaymentsPlugin exposes a fluent builder (an OrderPaymentHelper):
$this->payment($order)->pending()->save(); // create (amount auto-resolved from the order)
$this->paymentUpdate($id)->completed()->transactionId($t)->processedAt($now)->save(); // capture / mark paid
Status setters: pending() authorized() completed() failed() cancelled() refunded(). save() writes only the
changed columns and records the change to the order activity log automatically. A refund is two steps: flip the
original ->refunded(), then create a ->refund()->refundedPayment($id)->fullRefund() audit record. Full API:
Order Payment Helper.
Admin action buttons
The admin order screen renders buttons your plugin registers. onGetActions adds them (conditional on the payment
state); onExecuteAction handles the click.
public function onGetActions($event): void
{
if (($event->getPayment()->status ?? 'pending') !== 'completed') {
$event->add('mark_paid', Text::_('PLG_ALFA_PAYMENTS_YOURGATEWAY_MARK_PAID'))
->icon('check')->css('btn-success')->confirm('Mark this payment as paid?')->priority(200);
}
}
public function onExecuteAction($event): void
{
match ($event->getAction()) {
'mark_paid' => $this->paymentUpdate($event->getPayment()->id)->completed()->save(),
default => $event->setError('Unknown action: ' . $event->getAction()),
};
// respond via $event->setMessage() / setError() / setRefresh(true); or a modal via setLayout() + setModalTitle()
}
The fluent button API: ->icon() ->css() ->confirm() ->modal('layout', 'Title', 'lg') ->priority().
Minimal example (online gateway)
namespace Alfa\Plugin\AlfaPayments\YourGateway\Extension;
use Alfa\Component\Alfa\Administrator\Plugin\PaymentsPlugin;
use Joomla\CMS\Factory;
defined('_JEXEC') or die;
final class YourGateway extends PaymentsPlugin
{
public function onItemView($event): void { $event->setLayout('default_item_view'); $event->setLayoutData(['method' => $event->getMethod(), 'item' => $event->getSubject()]); }
public function onCartView($event): void { $event->setLayout('default_cart_view'); $event->setLayoutData(['method' => $event->getMethod(), 'item' => $event->getSubject()]); }
public function onOrderCompleteView($event): void { $event->setLayout('default_order_completed'); $event->setLayoutData(['order' => $event->getOrder(), 'method' => $event->getMethod()]); }
public function onOrderProcessView($event): void
{
$result = Factory::getApplication()->getInput()->getString('yourgateway_result', '');
if ($result === '') { // first arrival → go pay
$event->setRedirectUrl($this->buildGatewayUrl($event->getOrder()));
return;
}
$event->setLayout('default_order_process_' . $result); // came back → render result
$event->setLayoutData(['order' => $event->getOrder()]);
}
public function onOrderAfterPlace($event): void // DATA: create the record
{
$order = $event->getOrder();
if ($order && !empty($order->id)) {
$this->payment($order)->authorized()->save(); // offline standard uses ->pending()
}
}
public function onPaymentResponse($event): void // REDIRECT-ONLY
{
$order = $event->getOrder();
if ($this->confirmedAtGateway($order)) {
$this->payment($order)->completed()->transactionId($txn)->save();
$event->setRedirectUrl($this->getCompletePageUrl());
return;
}
$event->setRedirectUrl($this->getProcessPageUrl() . '&yourgateway_result=cancelled');
}
}