Skip to content
agovena.
agovena.
Get started
Community

Developer guides

Building Extensions

Provider-neutral Extension manifests, registration, settings, lifecycle, and adapter contracts.

On this page

Extend a contract, not the whole platform

An Extension connects an existing Agovena domain to a provider or contributes to an existing integration point. Core owns the commerce behavior; a Module may own the optional domain; the Extension owns the adapter. This guide covers the development contract, not merchant credentials or provider-specific onboarding.

The public Extension interface declares id(): string and register(ExtensionContext $context): void. The typical Laravel service provider exposes extension(): Extension. Keep its Laravel register(): void separate from the Extension object's context registration method.

Create and inspect a scaffold

From Core with the sibling repository configured:

bash
php artisan agovena:make-extension acme-pay --category=payment_gateway

The generator writes to optional-packages/extensions/payments/acme-pay when AGOVENA_OPTIONAL_PACKAGES_PATH resolves to the sibling checkout. The category value is payment_gateway, while its preferred directory is payments. Package identity comes from the manifest ID, not the directory category.

IDs use lowercase letters and numbers separated by single hyphens, starting with a letter. Existing scaffold targets require the explicit --force overwrite option. As with Modules, the current generator's agovena: "^0.1" constraint must be reviewed against the installed platform version. Without the configured optional root, the fallback Core extensions/ folder needs explicit discovery configuration.

Sources: MakeExtensionCommand and ScaffoldingGenerator.

Manifest schema

This is an illustrative custom adapter manifest, not an included provider:

json
{
  "id": "acme-pay",
  "name": "Acme Pay",
  "version": "0.1.0",
  "description": "A custom payment adapter.",
  "author": "Your team",
  "category": "payment_gateway",
  "agovena": "^0.0.1",
  "provider": "Acme\\Payments\\AcmePayServiceProvider",
  "autoload": {"psr-4": {"Acme\\Payments\\": "src/"}},
  "dependencies": [],
  "module_dependencies": [],
  "production_ready": false,
  "settings": [{
    "key": "api_key",
    "label": "API key",
    "type": "string",
    "secret": true,
    "required": true,
    "help": "Store the credential in encrypted Extension settings."
  }]
}

id, name, provider, and category are required. Optional fields include version (default 0.0.0), description, author, agovena (default *), dependencies, module_dependencies, settings, and autoload.psr-4.

dependencies lists Extension IDs. module_dependencies lists Module IDs. These lists do not express version constraints or install packages automatically. A provisioning adapter should declare the domain Module it actually requires.

production_ready must be a boolean and defaults to false. The manager only permits a false value in local or testing; other environments require true. Do not set this flag merely to bypass the environment gate before the adapter is verified.

The recognized category values are payment_gateway, provisioning, shipping, authentication, storage, notifications, analytics, tax, domain, and other. A category label does not itself create a registry or integration behavior.

Sources: ExtensionManifest and ExtensionCategory.

Registration surfaces

ExtensionContext exposes:

Method Purpose
extensionId() Current Extension ID
admin() Shared Admin registrar
settings() / getSetting($key, $default) Per-Extension settings access
setting($definition) Runtime settings-field definition
paymentGateway($gateway) Register a PaymentGateway
provisioner($provider) Register a Provisioner
shippingCarrier($carrier) Register a ShippingCarrier
cartRequirements($contributor) Add a CartRequirementContributor
invoiceDocument($view) Select invoice presentation
health($callback) Register a callback returning HealthResult

For a concrete registration pattern, the existing payment adapter uses:

php
$context->setting(new ExtensionSettingDefinition(
    key: 'api_key',
    label: 'mollie::messages.settings.api_key',
    type: 'string',
    secret: true,
    required: true,
    help: 'mollie::messages.settings.api_key_help',
));

$context->paymentGateway(app(MolliePaymentGateway::class));
$context->health(static fn () => app(MolliePaymentGateway::class)->health());

This excerpt demonstrates registration only. The imports and provider bindings live in MollieExtension and MollieServiceProvider. Use your own namespace, labels, and adapter implementation for another provider.

Provider contracts

A payment adapter implements PaymentGateway:

  • id() and label() identify it.
  • capabilities() returns PaymentGatewayCapabilities.
  • initiate(PaymentInitiation) returns PaymentInitiationResult.
  • mapStatus(string) maps provider states to Core PaymentStatus.
  • verifyWebhook(Request) authenticates incoming provider traffic.
  • parseWebhook(Request) produces the normalized WebhookPayload.
  • refund(RefundRequest) returns RefundResult.
  • health() returns HealthResult.

Capability flags are refunds, partialRefunds, recurring, webhooks, redirect, statusSync, and cancelPending, all false by default. Advertise only supported behavior and implement any additional contract required by that behavior. Never use a successful browser redirect as proof of payment. Core's webhook handler verifies before parsing, persists replay identity, and applies normalized state.

The base Provisioner and ShippingCarrier contracts only require id() and label(). Operational features are separate optional interfaces. Do not infer capabilities from a provider's name.

Operational lifecycle interfaces

ProvisionerLifecycle accepts ServiceInstanceInfo rather than a vendor SDK model. It defines provision(), activate(), suspend(), unsuspend(), and terminate() returning void. poll() and syncStatus() return updated ServiceInstanceInfo. changePlan() accepts that instance plus a string or structured array describing the plan. Keep provider state translation inside the adapter and test repeated lifecycle requests without duplicating an external resource.

ProvisionerActions defines actions($instance): array, returning ProvisionerAction objects, and runAction($instance, $actionId): void. ProvisionerPanel returns nullable ProvisionerPanelData for safe display fields. Neither interface authorizes arbitrary customer-supplied provider commands.

Shipping separates shipment creation from tracking. CreatesCarrierShipments declares createShipment(Order $order, string $serviceCode): CarrierShipmentResult and cancelShipment(string $externalId): void. TracksShipments declares tracking(string $externalId): array, with status, nullable tracking_number, and nullable tracking_url. Shipping quotations have their own contracts and are not implicitly implemented by supporting tracking.

Settings and secret handling

Manifest settings normalize key, label, type, secret, required, default, and help. Runtime field definitions can also be registered through the context. Keep both definitions consistent. Installation seeds a non-null default only when no existing setting value is found.

ExtensionSettingsRepository encrypts values stored with secret: true. Non-secret values are JSON-encoded. A decryption failure marks a setting corrupt and returns the requested default rather than exposing ciphertext.

The environment naming convention is AGOVENA_EXT_<EXTENSION>_<KEY>, with non-alphanumeric separators converted to underscores. It is not a general credential override: lookup consults the environment only when no database row exists, and keys matching sensitive patterns such as API key, token, secret, password, credential, or DSN are refused. Use the encrypted settings flow for credentials. Existing stored values take precedence.

Lifecycle and dependency behavior

The ExtensionManager discovers installed storage, the optional checkout, then extra configured roots. Both flat extensions/<id> and grouped extensions/<category>/<id> layouts are supported. First ID wins.

Installation checks compatibility, discovered Extension dependencies, and installed Module dependencies; it records a disabled installation, runs migrations, and seeds defaults. Enablement additionally requires enabled dependencies, runs migrations, saves the enabled state, registers the provider, and calls the Extension contract.

Disabling preserves settings and data and disables dependent Extensions. The runtime rebuild clears payment, provisioning, shipping, and registered runtime provider registries before booting enabled Extensions again. Do not assume this is a universal undo operation for arbitrary Laravel listeners or external side effects. Keep registration idempotent and test disablement for every integration point you use.

Installed Extensions, including disabled ones, participate in migration upgrades. Uninstall removes the installation marker; generic data purging is not implemented and purgeData: true is rejected.

Test beyond a successful provider response

Use fakes for provider transport, assert normalized domain results, and cover malformed callbacks, duplicate delivery, wrong amounts or currencies where relevant, missing configuration, timeouts, and disabled dependencies. Keep live credentials out of the test harness.

bash
php artisan test tests/Feature/ExtensionRuntimeTest.php
php artisan test tests/Feature/ExtensionPluginSeamsTest.php
php artisan test tests/Feature/ExtensionModuleDependencyTest.php

These are existing Core suites. Add adapter-specific tests alongside the appropriate package integration coverage. Read Events and webhooks for incoming versus outgoing message contracts and contributing for the broader test gates.

Search documentation

Search guides, commands and API endpoints

What are you looking for?

Documentation