Ein brandneues MTA-Backend. Ausgeliefert als Plugin. Ohne den Kern zu forken.

Ein Sending Driver in AcelleMail ist die Klasse, die einen Vendor besitzt — Amazon SES, Postal, SendGrid, Ihr eigenes SMTP-Backend. Die Host-Anwendung reserviert einen einzigen REGISTRY-Hook (register_sending_server_driver) plus eine kleine Menge Capability-Marker-Interfaces; alles andere — die Picker-Seite, das Connection-Formular, die Validierungs-Pipeline, der Webhook-Controller, der Sender-Identity-Tab, der Warmup-Tab — wird im Host ausgeliefert. Diese Seite ist das ausgearbeitete Beispiel, um von plugin:init bis zu einem erfolgreichen Live-Send über Ihren Driver zu gelangen, destilliert aus dem statischen Review des Postal-MTA-Plugins (storage/app/plugins/rencontru/postal/).

Warum einen Driver als Plugin ausliefern

AcelleMail wird mit einem stabilen Satz an First-Party-Drivern ausgeliefert — Amazon SES, generisches SMTP, sendmail, Postmark, SendGrid, Mailgun und einigen weiteren. Jeder andere Vendor — regionale Anbieter, selbstgehostete MTAs, Nischen-Transaktionsdienste, Custom Backends — benötigt dieselben fünf Dinge im Host verdrahtet: eine Zeile auf der Picker-Seite, ein Connection-Formular, einen Validierungsschritt, einen Webhook-Listener für Bounces und Beschwerden sowie eine Laufzeit-Implementierung von send(). Das in einem Fork zu tun bedeutet, Host-Upgrades für immer nachzuziehen; es als Plugin zu tun bedeutet, einen Ordner in storage/app/plugins/{vendor}/{name}/ abzulegen und den Host sich um jeden hostseitigen Belang kümmern zu lassen.

Der Plugin-Vertrag ist bewusst klein. Ein REGISTRY-Hook, um den Driver anzumelden. Eine Driver-Klasse mit fünf erforderlichen Methoden (send, test, setupBeforeSend, validationRules, plus die Standard-Service-Name-Accessoren). Ein Blade-Partial für das Connection-Formular. Optionale Capability-Marker-Interfaces für alles andere — Webhooks, Identity-Sync, Custom-Verification-Email. Das war's. Picker-Rendering, Formular-Layout, Save-Action, Validierungs-Pipeline, Webhook-Routing — alles im Host.

Der Vertrag — was ein Plugin liefert

Der vollständige Dateibaum eines Sending-Driver-Plugins:

storage/app/plugins/<vendor>/<name>/
├── composer.json                 # PSR-4 + Laravel provider hook
├── routes.php                    # icon route only (CRUD + webhook handled by host)
├── icon.svg                      # picker page icon, served by routes.php
├── src/
│   ├── ServiceProvider.php       # ONE hook + view namespace + lifecycle
│   └── <Vendor>Driver.php        # the driver class
└── resources/
    ├── views/sending-servers/
    │   └── _fields_connection.blade.php   # Connection-tab form fields
    └── lang/en/messages.php       # labels + help text

Das Skeleton ist bewusst dünn. routes.php registriert genau eine Route — die das icon.svg des Plugins von der Platte ausliefert, sodass die Picker-Seite etwas zum Rendern hat. CRUD-Endpoints, die Webhook-URL und die Form-Save-Action leben alle im hostseitigen Refactor\Admin\SendingServerController; das Plugin trägt nur die treiberspezifische Oberfläche bei.

Vier Dinge, die das Plugin tatsächlich beim Host registriert:

  1. Driver-Klasse + Metadaten — ein einzelner Hook::add('register_sending_server_driver', ...)-Payload, der den Type-Slug, den FQCN der Driver-Klasse, die Vendor-Config-Keys und die Picker-Card-Metadaten transportiert.
  2. View-Namespace$this->loadViewsFrom($path, 'myvendor'), sodass view('myvendor::...') in die Templates des Plugins auflöst.
  3. Übersetzungsdatei — ein Hook::add('add_translation_file', ...)-Payload, der auf resources/lang/ des Plugins für den Master + den Dump-Clone-Pfad zeigt.
  4. Connection-Tab-Blade — implementiert den Capability-Marker ProvidesConnectionFieldsView auf dem Driver und gibt den Partial-Pfad zurück, den das hostseitige Formular rendert.

ServiceProvider — das Boot-Muster

Das vollständige Skeleton des Service Providers für ein Sending-Driver-Plugin (paraphrasiert vom Postal-Plugin):

namespace MyVendor\Sending;

use App\Library\Facades\Hook;
use Illuminate\Support\ServiceProvider as Base;

class ServiceProvider extends Base
{
    public const PLUGIN_NAME = 'myvendor/sending';   // MUST match composer.json#name

    public function register(): void
    {
        // Translation file registration — see /developers/translations for
        // the full contract. MUST be in register(), never in boot(), or the
        // host's collect loop misses it.
        Hook::add('add_translation_file', fn () => [
            'id'                      => '#myvendor/sending_translation_file',
            'plugin_name'             => self::PLUGIN_NAME,
            'file_title'              => 'Translation for myvendor/sending plugin',
            'translation_folder'      => storage_path('app/data/plugins/myvendor/sending/lang/'),
            'translation_prefix'      => 'myvendor',
            'file_name'               => 'messages.php',
            'master_translation_file' => realpath(__DIR__ . '/../resources/lang/en/messages.php'),
        ]);
    }

    public function boot(): void
    {
        // (1) View namespace + plugin's own routes.
        $this->loadViewsFrom(__DIR__ . '/../resources/views', 'myvendor');
        $this->loadRoutesFrom(__DIR__ . '/../routes.php');

        // (2) The single REGISTRY hook that the host's SendingServerServiceProvider
        //     collects in its app->booted() phase. The closure body runs lazily
        //     at collect time — route() resolves correctly because all providers
        //     have already booted.
        Hook::add('register_sending_server_driver', fn () => [
            'type'         => MyVendorDriver::TYPE,            // slug -> DriverRegistry
            'driver'       => MyVendorDriver::class,
            'config_keys'  => ['my_api_key', 'my_region'],     // -> JSON config column
            'name'         => 'My Vendor',
            'description'  => 'Send via My Vendor API',
            'icon_url'     => route('plugin.myvendor.sending.icon'),
            // create_url omitted -> main app derives from `type`
        ]);

        // (3) Lifecycle — only if the plugin needs cleanup on uninstall.
        Hook::on('delete_plugin_' . self::PLUGIN_NAME, function () {
            \App\Model\SendingServer::where('type', MyVendorDriver::TYPE)->forceDelete();
        });
    }
}

Zwei nicht offensichtliche Regeln, die der Host durchsetzt:

  • Jedes Hook::add außer add_translation_file gehört in boot(), niemals in register(). Der hostseitige SendingServerServiceProvider verzögert seine Driver-Registry-Sammlung über $this->app->booted(...) genau, damit Plugins Zeit haben, sich über ihren eigenen boot() zu registrieren; register_sending_server_driver in register() zu legen, bedeutet, dass die Closure möglicherweise läuft, bevor ihre eigenen Abhängigkeiten (insbesondere route()) verfügbar sind.
  • Vermeiden Sie asset('plugins/myvendor/sending/icon.svg') aus dem Hook-Payload. Für Sending-Driver-Plugins gibt es keinen Auto-Publish-Schritt, der Plugin-Assets nach public/plugins/... kopiert; dieser Pfad liefert in Produktion 404. Das Plugin besitzt seine eigene Route für das Icon (definiert in routes.php), und der Hook-Payload referenziert diese Route per Name. Self-contained — Plugin-Ordner abgelegt, das Icon ist ohne irgendeinen hostseitigen Code-Pfad erreichbar.

Die Driver-Klasse

Der minimal lauffähige Driver erbt von App\SendingServers\Drivers\AbstractDriver und implementiert den Marker ProvidesConnectionFieldsView (der dem Host signalisiert, dass dieser Driver sein eigenes Connection-Blade hat):

namespace MyVendor\Sending;

use App\SendingServers\Capabilities\ProvidesConnectionFieldsView;
use App\SendingServers\Drivers\AbstractDriver;
use App\SendingServers\Drivers\SendResult;
use App\SendingServers\Drivers\TestResult;

class MyVendorDriver extends AbstractDriver implements ProvidesConnectionFieldsView
{
    public const TYPE = 'myvendor-api';

    public function getServiceName(): string  { return 'My Vendor'; }
    public function getServiceIcon(): string  { return 'send'; }       // Material Symbols ligature
    public function getServiceColor(): string { return 'var(--chart-2)'; }

    public function send($message, array $params = []): SendResult
    {
        // Call vendor API to deliver $message.
        // MUST throw on failure — never return SendResult with a "failed" status.
        $vendorMessageId = $this->callVendorApi($message);
        return new SendResult(runtimeMessageId: $vendorMessageId);
    }

    public function test(): TestResult
    {
        try {
            // See pitfall §6.1 — must hit a REAL endpoint that requires auth.
            return TestResult::success();
        } catch (\Throwable $e) {
            return TestResult::failure($e->getMessage());
        }
    }

    public function setupBeforeSend(string $fromEmailAddress): void
    {
        // No-op for most drivers. Implement if vendor needs per-batch
        // setup — SNS topic subscribe, identity feedback enable, etc.
    }

    public function validationRules(): array
    {
        $r = parent::validationRules();
        $r['cols'] = [
            'my_api_key' => 'required|string|max:128',
            'my_region'  => 'required|in:us,eu,ap',
        ];
        return $r;
    }

    public function connectionFieldsView(): string
    {
        return 'myvendor::sending-servers._fields_connection';
    }
}

Die vier Service-Name-Accessoren (getServiceName, getServiceIcon, getServiceColor) sind alles, was der Host braucht, um die Picker-Card und den Chosen-Server-Header in der Sending-Servers-UI zu rendern. send() und test() sind die produktiven Hot-Paths — jede Kampagne, die über einen Server dieses Typs versendet wird, ruft send() einmal pro Empfänger auf; jeder „Test connection"-Klick auf der Admin-Seite ruft test() auf. setupBeforeSend() läuft einmal zu Beginn einer Kampagnen-Batch — die meisten Driver lassen sie leer.

Capability-Marker-Interfaces

Über die Mindestoberfläche hinaus stellt der Host eine Reihe von Capability-Marker-Interfaces bereit. Der Driver implementiert nur die Marker, die zutreffen — der Host führt an jeder Callsite instanceof-Checks durch, sodass ein Driver, der ReceivesWebhooks nicht implementiert, einfach die Webhook-Routen-Registrierung überspringt, ohne zu werfen.

MarkerWas der Driver implementiert
ProvidesConnectionFieldsViewCustom Connection-Tab-Blade — connectionFieldsView(): string liefert den namespaced View-Pfad.
ReceivesWebhooksverifyWebhook + parseWebhook + webhookUrl — der Vendor sendet POST auf /webhook/{type}/{uid}; der Host routet das Payload an Ihren Driver.
SupportsIdentitySyncsyncIdentities + verifyIdentity — der Host rendert einen Sender-Identity-Tab für diesen Driver.
SupportsRemoteDomainVerifyaddDomain + validateDomain + checkDomainVerificationStatus — der Vendor übernimmt die DNS-Verifizierung, nicht der Host.
SignsDkimOnServerServer signiert DKIM — der Host überspringt seine eigene Signing-Schicht.
SupportsCustomReturnPathBerücksichtigt einen custom Return-Path-Header auf ausgehenden Mails.
AllowsLocalDomainVerify / AllowsLocalEmailVerify / AllowsCrossSendingDomainGenerische SMTP-Flexibilitäts-Flags.
SendsCustomVerificationEmailsendVerificationEmail(Sender) — der Driver rendert + sendet seine eigene Verification-Mail statt der Default-Mail des Hosts.

Connection-Tab-Blade

Das Connection-Partial unter resources/views/sending-servers/_fields_connection.blade.php rendert ausschließlich die Formularfelder. Der Host umschließt es mit dem <form>, dem Submit-Button, dem Validierungs-Alert und dem Vier-Tab-Seiten-Chrome:

<div class="mc-form-group">
    <label class="mc-form-label">
        {{ trans('myvendor::messages.fields.api_key') }}
        <span class="mc-form-required">*</span>
    </label>
    <input type="password"
           name="my_api_key"
           value="{{ old('my_api_key', $server->getConfig('my_api_key')) }}"
           class="mc-form-input @error('my_api_key') mc-form-input-error @enderror"
           id="myvendor-key-input">
    @error('my_api_key') <div class="mc-form-error">{{ $message }}</div> @enderror
    <div class="mc-form-help">{{ trans('myvendor::messages.fields.api_key_help') }}</div>
</div>

@
<div class="mc-form-group">
    <label class="mc-form-label">{{ trans('myvendor::messages.fields.webhook_url') }}</label>
    <input type="text"
           value="{{ $server->id ? $server->driver()->webhookUrl() : trans('myvendor::messages.fields.webhook_url_after_save') }}"
           class="mc-form-input"
           readonly>
</div>

Drei Regeln gelten für das Partial:

  • Nur Felder, kein <form>, kein Submit-Button. Der Host besitzt den Form-Wrapper. Ein eigener Submit feuert den falschen Save-Endpoint.
  • Feld-name stimmt mit dem config_keys-Payload + den Keys aus validationRules()['cols'] überein. Der Host routet $server->fill($request->all()) automatisch durch die JSON-Spalte config basierend auf den deklarierten Keys.
  • Bestehende Werte lesen Sie über $server->getConfig('my_api_key'), nicht über $server->my_api_key. Letzteres funktioniert zufällig über einen Legacy-getAttribute-Fallback, ist aber unklarer und vertraglich nicht stabil.

Die Validierungs-Pipeline

Wenn ein Admin auf Save im Sending-Server-Formular klickt, führt der Host die Validierung Ihres Drivers in zwei Phasen aus:

[admin clicks Save]
    │
    ▼
Refactor\Admin\SendingServerController::store
    │
    ▼
$server->validConnection($request->all())              # SendingServer.php
    ├─ Phase 1: Laravel validator with $this->getRules()
    │             └─ → $this->driver()->validationRules()['cols']  (your plugin)
    └─ Phase 2: $validator->after(fn() => $this->driver()->test())
                  ↓ if !ok → adds 'connection' error
    │
    ▼
fails? redirect back with errors → blade `@if($errors->any())` renders
otherwise $server->save() → row in DB

Ihr Driver kontrolliert zwei Fehlermodi:

  • Feldebene (Phase 1) — Regeln in validationRules()['cols']. Der Host mappt automatisch jeden Regel-Fehlschlag auf das entsprechende name=...-Feld in Ihrem Blade, wo @error('my_api_key') inline rendert.
  • Connection-Ebene (Phase 2) — alles, was aus test() geworfen oder als TestResult::failure(...) zurückgegeben wird. Der Host zeigt es auf einem synthetischen connection-Feld an, das im Validierungs-Summary-Alert oben im Formular gerendert wird.

Fünf Stolperfallen aus dem Postal-Plugin

Das sind reale Bugs, die im Postal-MTA-Plugin auftraten. Sie im Voraus zu kennen, erspart dem nächsten Driver-Autor Stunden des Debuggens.

1. test() muss einen echten Endpoint treffen

Die erste test()-Implementierung des Postal-Plugins rief client->makeRequest('servers', 'list', []) auf — URL /api/v1/servers/list. Schaut man in die tatsächliche Postal-API, gibt es nur die Controller messages und send; es gibt keinen servers-Endpoint. Postal lieferte jedes Mal HTTP 404 zurück, und der Admin sah „Status code returned by Postal server: 404" in Rot, selbst mit gültigen Credentials.

Fix: Gleichen Sie immer die API-Dokumentation des Vendors ab und finden Sie einen existierenden Read-Endpoint, der Auth verlangt und keine Seiteneffekte hat. Typische Kandidaten: GET /me, GET /account, GET /domains. Der Probe-Aufruf muss zwischen 200-bei-gültigem-Key und 401/403-bei-falschem-Key unterscheiden können — 404-bei-fehlendem-Pfad ist bedeutungslos.

2. Webhook-Payload-Form ändert sich zwischen Vendor-Versionen

Vendors entwickeln ihre Webhook-Formate weiter. Das Postal-Plugin wurde mit drei hartkodierten Format-Guards ausgeliefert, die „sehr alt", „legacy" und „aktuell" abdeckten — und verfehlte trotzdem das moderne Format. Modernes Postal umschließt alles in {event, timestamp, payload, uuid}; bei MessageBounced ist das Payload {original_message: {token, ...}, bounce: {...}}. Das Token liegt unter payload.original_message.token, nicht unter payload.message.token — das Plugin verfehlte den Unterschied und ließ jeden Bounce still verloren gehen.

Fix: Ziehen Sie sich den Quellcode des Vendors und finden Sie jede webhook.trigger(...)-Callsite. Listen Sie die exakten Payload-Formen auf, die der Vendor tatsächlich sendet. Lassen Sie parseWebhook für unbekannte Event-Namen ein IgnorableWebhookEvent zurückgeben statt sie stillschweigend zu droppen — Observability zählt.

3. Webhook-Signatur-Verifizierung

Die meisten Vendors signieren ihre Webhooks (HMAC oder RSA). Plugin-Autoren lassen verifyWebhook in v1 oft als No-op — ein Sicherheitsrisiko in Produktion, da jeder, der die Webhook-URL kennt, einen gefälschten Bounce posten kann.

Fix für v1: Lassen Sie verifyWebhook als No-op + protokollieren Sie eine Warnung, dokumentieren Sie das als FOLLOW-UP. Die echte Implementierung speichert den Public Key des Vendors pro Server (im JSON-config) und verifiziert die Signatur gegen den Request-Body. Postal signiert mit RSA SHA256 über die Header X-Postal-Signature-KID + X-Postal-Signature-256.

4. Auswahl von runtimeMessageId

SendResult.runtimeMessageId ist das, was der Host in tracking_logs.runtime_message_id speichert. Der Webhook-Listener korreliert eingehende Bounces und Beschwerden über diese ID zurück zur ursprünglichen Tracking-Zeile. Sie muss zu dem passen, was der Vendor in Webhook-Payloads ablegt.

Die Antwort von Postals /api/v1/send/raw liefert sowohl eine global eindeutige message_id als auch ein per-Empfänger-token zurück. Postals MessageBounced-Webhook enthält payload.original_message.token — per Empfänger. Die Driver der Plattform senden einen Empfänger pro send()-Aufruf, also ist der richtige Wert zum Speichern der per-Empfänger-token, nicht die globale message_id.

Fix: Wenn der Vendor per-Empfänger-Identifier in seinen Webhooks sendet, speichern Sie den per-Empfänger-Identifier in runtimeMessageId. Die falsche Wahl bedeutet, dass jeder BounceLog mit NULL-tracking_log_id endet — der Bounce kommt an, aber nichts in der Host-UI wird ihn jemals anzeigen.

5. Der Race zwischen send() und tracking_logs-INSERT

Der SendMessage-Job des Hosts ruft zuerst driver->send() auf, dann fügt er die TrackingLog-Zeile ein. Vendors können einen Bounce- oder Beschwerde-Webhook liefern, bevor das INSERT committet — ein Race im Millisekundenbereich, der in Produktion real ist.

Der Host handhabt das bereits auf Listener-Ebene: RecordBounce und RecordComplaint wiederholen den Lookup bis zu 5 Sekunden lang, bevor sie aufgeben. Plugin-Autoren müssen nichts Besonderes tun, aber sollten NICHT:

  • Ein TrackingLog vor send() per Pre-INSERT erstellen — jeder Send wird selbst auf dem Fast-Path zu zwei DB-Roundtrips.
  • send() innerhalb einer äußeren Transaktion ausführen — das TrackingLog-INSERT wird bis zum äußeren Commit unsichtbar, was das Race-Fenster vergrößert.

Activate-und-verifizieren-Rezept

Nach dem Ablegen des Plugin-Ordners unter storage/app/plugins/<vendor>/<name>/ registrieren und aktivieren Sie es über tinker, dann führen Sie fünf Smoke-Checks aus:

# 1. Register the plugin DB row
php artisan tinker --execute="App\Model\Plugin::register('vendor/name')"

# 2. Activate (fires activate_plugin_ hook)
php artisan tinker --execute="App\Model\Plugin::where('name','vendor/name')->first()->activate()"

# 3. Verify the driver registered
php artisan tinker --execute="
  \$registry = App\SendingServers\DriverRegistry::all();
  echo isset(\$registry['myvendor-api']) ? 'YES → '.\$registry['myvendor-api'] : 'NO';
"

# 4. Verify config keys auto-routed
php artisan tinker --execute="
  \$keys = App\Model\SendingServer::getVendorConfigKeys();
  echo in_array('my_api_key', \$keys, true) ? 'YES' : 'NO';
"

# 5. Verify the connection-blade view is namespaced
php artisan tinker --execute="
  echo Illuminate\Support\Facades\View::exists('myvendor::sending-servers._fields_connection') ? 'YES' : 'NO';
"

UI-Smoke nach den fünf bestandenen Checks:

  1. Login als Admin → Sending Servers → Create. Der Block „Plugin Servers" sollte jetzt Ihre Card zeigen.
  2. Klicken Sie auf Ihre Card. Das Formular rendert mit den in validationRules()['cols'] deklarierten Feldern.
  3. Save mit gültigen Credentials. Der Host führt Phase 1 (Regeln) und dann Phase 2 (driver->test()) aus; beide bestehen und die Zeile wird committet.
  4. Edit-Seite. Vier Tabs: Connection (Ihr Blade) + Configuration / Sender Identity / Warmup (vom Host gerendert).

Testing-Checkliste

TestWie
Driver-Klasse lädt ohne Syntaxfehlerphp artisan tinker --execute="new MyVendor\Sending\MyVendorDriver(new App\Model\SendingServer(['type' => 'myvendor-api']))"
test() gelingt mit gültigen CredentialsFühren Sie einen curl gegen denselben Probe-Endpoint mit demselben Key aus — beide sollten dieselbe Form liefern
test() scheitert sauber bei schlechten CredentialsFalschen my_api_key setzen → erwarten Sie TestResult::failure mit der tatsächlichen Fehlermeldung des Vendors, nicht mit einem Laravel-Exception-Trace
Formularfelder werden korrekt übermitteltSave mit gültigen Credentials → die config-JSON der DB-Zeile enthält jeden in config_keys gelisteten Key
Webhook-Intake parst die Bounce-FormPOSTen Sie ein echtes Beispiel-Bounce-Payload → parseWebhook liefert BounceReceived mit der korrekten runtimeMessageId
Webhook-Signatur-Verifizierung (falls implementiert)POST mit einer ungültigen Signatur → verifyWebhook wirft
Plugin-Uninstall räumt aufApp\Model\Plugin::find($id)->delete() → keine verwaisten sending_servers-Zeilen dieses type
validationRules() deckt jeden Feldnamen in config_keys abDiff array_keys(validationRules()['cols']) gegen das config_keys-Payload — muss exakt übereinstimmen

Für Ende-zu-Ende-Live-Tests liefert der Host ein Test-Harness aws-e2e/ aus, das eine echte SendingServer-Zeile aufsetzt, eine Test-Liste erstellt, eine Kampagne ausführt und gegen den Bounce-/Beschwerde-Flow prüft. Spiegeln Sie sein Drei-Skript-Muster (10-bootstrap-emma-{vendor}.php + 11-create-test-list.php + 12-run-test-campaign.php) für eine Live-Regressionsabdeckung.

Dateisystem-Template

Der schnellste Weg zu einem lauffähigen Plugin ist, das Postal-Plugin zu klonen und umzubenennen. Sechs Edits und ein paar Search-and-replaces:

cp -r storage/app/plugins/rencontru/postal storage/app/plugins/<vendor>/<name>
cd storage/app/plugins/<vendor>/<name>

# 1. Edit composer.json — name, namespace, autoload.psr-4
# 2. Rename src/PostalDriver.php → <Vendor>Driver.php, update class name + TYPE
# 3. Update src/ServiceProvider.php — PLUGIN_NAME, view namespace, hook payload
# 4. Adjust resources/views/sending-servers/_fields_connection.blade.php
# 5. Adjust resources/lang/en/messages.php
# 6. Replace the Postal HTTP client under src/Postal/* with your vendor client

Das Postal-Plugin ist eine nützliche Referenz für die Form, aber sein API-Client ist Postal-spezifisch — ersetzen, nicht adaptieren. Die acelle/ai-Showcase behandelt das kanonische komplexe Plugin, falls Sie eine andere Referenz für Testmuster, Sidebar-UI oder Admin-Seiten brauchen.

Wie es weitergeht

Sending Driver und Payment Gateways sind die zwei schwergewichtigsten „Feature-Plugin ausliefern"-Beispiele. Die Formen sind ähnlich — beide liefern einen einzelnen REGISTRY-Hook, eine Klasse mit einer kleinen Pflichtmethoden-Oberfläche und ein Connection-Blade — aber die Lifecycles unterscheiden sich deutlich. Sending Driver empfangen Webhooks (Push); Payment Gateways pullen Zustand nach Sync-Plan (kein Webhook). Die nächste Seite behandelt das Payment-Gateway-Muster mit Paddle als ausgearbeitetem Beispiel.

Wenn der Driver ausgeliefert und live ist, behandelt Testing das Rezept für den Lifecycle-Integrationstest (activate → test-send → delete), das beweist, dass Ihr delete_plugin_*-Listener sauber aufräumt. Die acelle/ai-Showcase behandelt das kanonische komplexe Plugin, falls Sie eine schwergewichtigere Referenz-Codebasis brauchen.