Eine regionale Payment Gateway. Ausgeliefert als Plugin. Pull-Modell — kein Webhook.

Eine Payment Gateway in AcelleMail ist die Brücke zwischen einem PaymentIntent auf dem Host und dem Checkout- / Subscription-Zustand eines Vendors. Der Host liefert acht Gateways im gebündelten Cashier-Paket aus (Stripe, Stripe Subscription, Braintree, Braintree Subscription, PayPal, Paystack, Razorpay, Offline); jeder andere Vendor — Paddle, Lemon Squeezy, regionale Anbieter, Krypto-Rails — wird als Plugin ausgeliefert. Die Architektur ist pull-basiert: Der Host holt sich den Zustand vom Vendor on demand, kein Webhook-Listener nötig. Diese Seite ist das ausgearbeitete Beispiel mit storage/app/plugins/acelle/paddle/ als Referenz.

Warum Plugin, nicht Kern

Einen Vendor durch Bearbeiten von <code>app/Cashier/Services/</code>, des gebündelten Pakets <code>vendor/acelle/cashier/</code> und <code>app/Providers/CheckoutServiceProvider.php</code> hinzuzufügen, funktioniert im Prinzip. Der Plugin-Pfad wurde aus vier konkreten Gründen stattdessen gewählt:

  • Der Kern bleibt versiegelt. Ein Kern-Upgrade, das einen Vendor hinzufügt, bindet jede Installation an dessen Boot-Kosten. Ein Händler, der niemals über Paddle verkauft, zahlt trotzdem die Kosten und sieht ein Konfigurationsfeld, das er nicht nutzen kann.
  • Unabhängiges Ausliefern. Ein Plugin kann im Tempo der Vendor-API iterieren — Paddle Billing v2, Razorpay Routes, Adyen-Checkout-API-Revisionen — ohne auf ein Kern-Release zu warten.
  • Uninstall ist sauber. php artisan plugin:delete acelle/paddle entfernt den Gateway-Typ vollständig. Es bleibt kein toter Zweig case 'paddle': in Kern-Switch-Statements zurück.
  • Per-Tenant-Policy. Das Deaktivieren des Plugins lässt die Gateway sofort überall aus der Auswahlliste „Select type" des Admins verschwinden. Stripe und Offline sind in den Kern gebündelt, weil sie „immer an" sind; alles andere passt besser in die Plugin-Form.

Trade-off: Der Plugin-Autor besitzt den Gateway-Service, den Redirect-Controller, die Admin-Form-View und die Read-Side-Mapper (getRemotePlan / getRemoteSubscription / getRemotePaymentMethod). Die Foundation-Verträge machen das überschaubar — vollständiges Paddle umfasst grob 500-700 Zeilen.

Das Pull-Modell — kein Webhook

Die Gateway-Architektur von AcelleMail ist pull-basiert: Der Host holt sich Zustand vom Vendor on demand. Drei Trigger lösen Reads aus:

  • Lazy-Fetch beim Seitenaufruf — die Subscription- / Invoice-Seite des Kunden ruft getRemoteSubscription zur Renderzeit auf, wenn der lokale Zustand älter ist als der Freshness-Schwellwert.
  • „Refresh"-Button — Admin / Kunde kann einen sofortigen Read erzwingen.
  • Periodischer RemoteSubscriptionSyncService-Cron — durchläuft jede aktive Remote-Subscription in konfigurierbarer Kadenz.

Kein Webhook-Listener erforderlich. Plugin-Autoren müssen keinen öffentlichen Webhook-Endpoint hosten, HMAC-Signaturen verifizieren, At-least-once-Delivery deduplizieren oder Replay-Angriffe behandeln. Der Trade-off ist Zustandsverzögerung — typischerweise Minuten — zwischen der Bestätigung einer Zustandsänderung durch den Vendor und ihrer Bemerkung durch den Host. Für SaaS-Billing ist das in Ordnung: Der Kunde sieht den neuen Subscription-Status nicht im Mikrosekundentakt, in dem Paddle ihn bestätigt; er sieht ihn beim nächsten Seitenrender. Harte Anforderungen an Sub-Sekunden-Propagation passen ohne Erweiterung des Vertrags nicht zu dieser Architektur.

Warum Pull bei SaaS-Billing besser ist als Push. Ein öffentlicher Endpoint weniger zu sichern (kein HMAC-Verify, kein Replay-Fenster, keine Anforderung an eine öffentliche IP in der Entwicklung). Ein Integrationsschritt weniger für den Admin beim Gateway-Setup (kein „Endpoint anlegen, Secret kopieren, auf den Test-Webhook warten"-Tanz). Kündigungen und Plan-Wechsel aus dem Kundenportal des Vendors propagieren weiterhin — der nächste Sync-Lauf greift sie ab. Die Sync-Kadenz ist im Kern konfigurierbar, sodass Installationen mit strengeren Freshness-Anforderungen das Intervall verringern.

Foundation-Verträge im Kern

Der Host liefert vier Foundation-Bausteine, die das Plugin konsumiert. Plugins implementieren diese nicht — sie rufen sie auf.

Die BillingManager-Registry

app/Library/BillingManager.php ist ein DI-gebundenes Singleton, das die Map Gateway-Typ → Presentation-Metadata + Service-Factory hält. Der boot() des Plugin-Service-Providers ruft einmal pro Vendor Billing::register(...) auf; das kundenseitige Select-Type-Dropdown, der Admin-Form-Picker und Billing::resolveService($gateway) lesen alle aus dieser Registry.

Billing::register(
    string  $type,                 // 'paddle' — discriminator slug
    string  $name,                 // shown in admin select-type
    string  $description,          // 1-2 sentences shown alongside the name
    \Closure $serviceFactory,      // fn(PaymentGateway $gw): IntentGatewayInterface
    string  $icon = 'payment',     // Material Symbols Rounded ligature
    bool    $isRemoteSubscription = false,
    string  $formView = '',        // namespaced blade view for the admin gateway-config form
);

Der Callback CheckoutHandlerInterface

vendor/acelle/cashier/src/Contracts/CheckoutHandlerInterface.php deklariert die hostseitigen Callbacks, die die Sync-Schicht feuert, wenn der Remote-Zustand vom lokalen Intent-Zustand divergiert. Plugins rufen sie nicht direkt auf. Der RemoteSubscriptionSyncService des Hosts konsumiert die Read-Methoden des Plugins und dispatcht selbst auf CheckoutHandler::onPaymentSuccess / onSubscriptionCreated / onPaymentFailed / onPaymentRequiresAuth / onOfflineClaimReceived.

Die PaymentIntent-State-Machine

Fünf terminale oder ausstehende Zustände; das Plugin überführt den Intent-Zustand niemals direkt. Die Sync-Schicht des Hosts liest den Vendor-Zustand über das Plugin und schaltet den Intent über CheckoutHandler um.

ZustandBedeutung
PENDINGIntent erstellt, der Kunde hat noch nicht bezahlt
REQUIRES_ACTION3DS- / SCA-Challenge wartet auf den Kunden (nur Karten)
AWAITING_ADMIN_APPROVALOffline-Claim wartet auf Admin-Prüfung
SUCCEEDEDTerminal — Zahlung bestätigt
FAILED / CANCELLEDTerminal — dem Kunden mit dem Grund des Vendors angezeigt

Das Cashier-Paket — acht eingebaute Gateways als Referenz

Das geforkte Cashier-Paket unter /Users/luan/apps/cashier/src/Services/ liefert acht eingebaute Gateway-Implementierungen aus. Sie zu lesen ist der schnellste Weg, die gesamte Oberfläche zu sehen, die ein Payment-Gateway-Plugin implementieren könnte: StripePaymentGateway, StripeSubscriptionGateway, BraintreePaymentGateway, BraintreeSubscriptionGateway, PaypalPaymentGateway, PaystackPaymentGateway, RazorpayPaymentGateway, OfflinePaymentGateway. Die ersten beiden sind subscription-förmig; der Rest ist einmalig per Karte oder Überweisung.

Vier Capability-Interfaces

Alle vier leben im Cashier-Paket unter /Users/luan/apps/cashier/src/Contracts/. Die Gateway-Klasse des Plugins implementiert nur die Interfaces, die ihr Vendor tatsächlich unterstützt — der Host führt an jeder Callsite instanceof-Checks durch.

InterfaceZweckErforderlich für
IntentGatewayInterface Basis-Vertrag — getCheckoutUrl(intent, returnUrl) + getMethodTitle/getMethodInfo für die Anzeige gespeicherter Methoden Jede Gateway
SupportsAutoChargeInterface autoCharge(intent, pmData) für Off-Session-Kartenbelastung ohne Redirect Gateways mit One-Tap-Rebill (Stripe einmalig; Paddle nicht)
SupportsSubscriptionInterface createSubscription(intent, pmData) für headless Subscription-Erstellung Gateways, die einen headless Subscription-Flow unterstützen (Stripe Subscription); Paddles createSubscription wirft, weil Paddle ausschließlich Hosted-Checkout ist
RemoteSubscriptionGatewayInterface getRemotePlans / getRemoteSubscription / getRemoteSubscriptions / cancelRemoteSubscription / updateRemoteSubscriptionPlan / getRemotePaymentMethod — die Read-/Sync-Seite Gateways, bei denen der Vendor den Subscription-Zustand besitzt (Paddle, Stripe Subscription)

Plugin-Scaffold

Das vollständige Dateilayout eines Payment-Gateway-Plugins:

storage/app/plugins/{author}/{name}/
├── composer.json                              ← plugin metadata + autoload + provider
├── routes.php                                 ← icon route + /cashier/{vendor}/checkout/{intent_uid}
├── icon.svg                                   ← admin Plugins page icon
├── src/
│   ├── ServiceProvider.php                    ← Billing::register + lifecycle hooks
│   ├── Services/
│   │   └── {Vendor}Gateway.php                ← implements IntentGateway[+ optional capability ifaces]
│   ├── Controllers/
│   │   └── {Vendor}CheckoutController.php     ← redirect to vendor's hosted checkout
│   └── Support/
│       └── {Vendor}Api.php                    ← Guzzle wrapper around vendor REST API
├── database/migrations/                       ← empty unless plugin owns its own table
├── resources/
│   ├── views/
│   │   └── form.blade.php                     ← admin gateway-config form (api keys, environment)
│   └── lang/
│       └── en/messages.php                    ← gateway name, description, form labels
└── tests/Unit/                                ← unit tests (capability contract)

Beachten Sie, was nicht da ist: kein Webhook-Controller, kein Signature-Verifier, keine Replay-Protection-Tabelle. Der Zustands-Sync wird vom Host gepullt, nicht vom Vendor gepusht.

ServiceProvider — ein einzelner Billing::register-Aufruf registriert alles

Das vollständige Skeleton des Service Providers für ein Payment-Gateway-Plugin (paraphrasiert von acelle/paddle):

namespace Acelle\Paddle;

class ServiceProvider extends Base
{
    public function register(): void
    {
        // Translation file — MUST be in register(), not boot. See /developers/translations.
        Hook::add('add_translation_file', fn () => [
            'translation_prefix' => 'paddle',
            'master_translation_file' => realpath(__DIR__.'/../resources/lang/en/messages.php'),
            // ...
        ]);
    }

    public function boot(): void
    {
        $this->loadViewsFrom(__DIR__.'/../resources/views', 'paddle');
        $this->loadRoutesFrom(__DIR__.'/../routes.php');

        // The single registry call.
        Billing::register(
            'paddle',
            trans('paddle::messages.gateway.name'),
            trans('paddle::messages.gateway.description'),
            fn ($gw) => new Services\PaddleGateway(
                apiKey:      (string) $gw->getGatewayData('api_key'),
                environment: (string) ($gw->getGatewayData('environment') ?: 'sandbox'),
            ),
            icon: 'rocket_launch',
            isRemoteSubscription: true,
            formView: 'paddle::form',
        );

        // Lifecycle — same pattern as every plugin.
        Hook::on('activate_plugin_acelle/paddle', fn () => \Artisan::call('migrate', [
            '--path'  => 'storage/app/plugins/acelle/paddle/database/migrations',
            '--force' => true,
        ]));
        Hook::on('delete_plugin_acelle/paddle', fn () => \Artisan::call('migrate:rollback', [
            '--path'  => 'storage/app/plugins/acelle/paddle/database/migrations',
            '--force' => true,
        ]));
    }
}

Die Closure innerhalb von Billing::register liest Credentials aus der JSON-Spalte gatewayData der PaymentGateway über getGatewayData('key'). So fließen die Felder des Admin-Formulars in den Service-Konstruktor.

Gateway-Service — getCheckoutUrl gibt die URL des Plugins zurück

Der Host ruft getCheckoutUrl in dem Moment auf, in dem der Kunde sich auf einen Checkout festlegt. Die Implementierung muss billig und nebenwirkungsfrei sein — keine Vendor-API-Calls während des Renderns:

public function getCheckoutUrl(PaymentIntent $intent, string $returnUrl): string
{
    return route('paddle.checkout', ['intent_uid' => $intent->uid])
        . '?return_url=' . urlencode($returnUrl);
}

Die URL zeigt auf den eigenen Controller des Plugins, nicht direkt auf die Vendor-URL. Drei Gründe:

  • Der Vendor-API-Call zum Erstellen des tatsächlichen Hosted-Checkout (Paddle: POST /transactions) muss hinter einer Controller-Grenze leben, sodass Fehler gefangen werden können und der Kunde mit einer Flash-Fehlermeldung zurück zur Invoice-Seite redirected wird.
  • Logging und Throttling gehören in den Controller, nicht in den Gateway-Service.
  • Der Gateway-Service bleibt „pure" — keine HTTP-Seiteneffekte, wenn getCheckoutUrl zur Intent-Erstellungszeit läuft. getCheckoutUrl kann spekulativ aufgerufen werden, auch in Tests.

Checkout-Controller — Vendor aufrufen + 302 auf den Hosted-Checkout

Der CheckoutController::redirect() des Plugins ist dort, wo der Vendor-API-Call tatsächlich stattfindet. Der Kunde trifft /cashier/paddle/checkout/{uid}, der Controller ruft Paddles POST /transactions-Endpoint auf und 302t den Browser zur Hosted-Checkout-URL, die Paddle zurückgibt:

public function redirect(Request $request, string $intentUid)
{
    $intent  = PaymentIntent::where('uid', $intentUid)->firstOrFail();
    $service = Billing::resolveService($intent->paymentGateway);
    $returnUrl = (string) $request->query('return_url', url('/'));

    try {
        $tx = $service->createCheckoutTransaction(
            intentUid:     $intent->uid,
            currency:      (string) $intent->currency,
            amountMajor:   (float)  $intent->amount,
            customerEmail: $intent->invoice->billing_email,
            remotePriceId: $intent->metadata['remote_plan_id'] ?? null,
            returnUrl:     $returnUrl,
        );
    } catch (\Throwable $e) {
        \Log::error('Paddle checkout: createTransaction failed', [
            'intent_uid' => $intent->uid,
            'error'      => $e->getMessage(),
        ]);
        return redirect()->away($returnUrl)
            ->with('alert-error', trans('paddle::messages.checkout.create_failed', [
                'error' => $e->getMessage(),
            ]));
    }

    return redirect()->away($tx['data']['checkout']['url']);
}

createCheckoutTransaction ist eine plugin-interne Methode auf PaddleGateway (auf keinem Interface). Sie kapselt den POST /transactions des Vendors mit der Paddle-spezifischen JSON-Form.

Kritisch: custom_data.intent_uid wird an den Vendor gesendet und in nachfolgenden Reads zurückgespiegelt — GET /subscriptions/{id} gibt dasselbe custom_data zurück. So mappt die Sync-Schicht eine Paddle-Subscription zurück auf einen lokalen PaymentIntent. Geht das verloren, scheitert der Sync still.

Read-Side-Mapper — Sync-Schicht füttern

Das Plugin exponiert den Vendor-Zustand über die Methoden von RemoteSubscriptionGatewayInterface. Der RemoteSubscriptionSyncService des Hosts (Cron + on demand) ruft diese auf, um lokale DTOs zu aktualisieren, und dispatcht dann auf CheckoutHandler, wenn der Zustand divergiert:

public function getRemoteSubscription(string $remoteSubId): RemoteSubscriptionDTO
{
    $response = $this->api->get("/subscriptions/{$remoteSubId}");
    return $this->subscriptionToDto($response['data'] ?? []);
}

public function getRemoteSubscriptions(?string $startingAfter = null, int $limit = 100): array
{
    $query = ['per_page' => min($limit, 200)];
    if ($startingAfter) {
        $query['after'] = $startingAfter;
    }
    $response = $this->api->get('/subscriptions', $query);
    return [
        'data'        => array_map([$this, 'subscriptionToDto'], $response['data'] ?? []),
        'has_more'    => $response['has_more'] ?? false,
        'next_cursor' => $response['next_cursor'] ?? null,
    ];
}

Paginierungsvertrag: Geben Sie {data, has_more, next_cursor} zurück. Der Sync-Service durchläuft Seiten, bis has_more => false. DTO-Mapper (priceToDto, subscriptionToDto) übersetzen die JSON-Form des Vendors in die neutrale DTO-Form des Hosts — das ist das per-Vendor-Wissen, das am schwersten zu teilen ist, weil sich die Response-Formen jedes Vendors unterscheiden.

Capability-Matrix — vier Vendor-Muster

Die Interfaces, die ein Plugin implementiert, hängen vom Zahlungsmodell des Vendors ab. Die vier Muster, die der Host bereits unterstützt:

Vendor-MusterImplementiertBeispiele
Einmalige Kartenbelastung mit Token IntentGatewayInterface + SupportsAutoChargeInterface Stripe einmalig, Square, Razorpay einmalig
Hosted-Checkout-Subscription IntentGatewayInterface + RemoteSubscriptionGatewayInterface + SupportsSubscriptionInterface (createSubscription wirft) Paddle, Lemon Squeezy
Headless Subscription IntentGatewayInterface + SupportsSubscriptionInterface + RemoteSubscriptionGatewayInterface Stripe Subscription, Braintree Subscription
Manuell / Überweisung Nur IntentGatewayInterface Offline (Banküberweisung, Bargeld)

Implementieren Sie keine ungenutzten Interfaces. Billing::supportsRemoteSubscription($gw) liest das zur Registrierungszeit gesetzte Flag isRemoteSubscription, nicht per instanceof — aber das Flag muss zu dem passen, was Ihr Service tatsächlich tut, also halten Sie beides synchron.

Den Capability-Vertrag testen

Ohne Webhook-Code gibt es keine Sicherheitsgrenze, die per Unit-Test geprüft werden müsste. Konzentrieren Sie sich auf den Capability-Vertrag — die Dinge, auf die Aufrufer sich als Garantien der Form der Gateway verlassen.

Was man per Unit-Test prüfen sollte

  • createSubscription wirft für ausschließlich Hosted-Checkout-Vendors (Paddle) — Aufrufer wissen dann, dass sie stattdessen getCheckoutUrl verwenden müssen.
  • getCheckoutUrl liefert eine Route auf den Controller Ihres Plugins zurück, nicht eine Vendor-URL — belegt, dass die Controller-Grenze steht.
  • getRemoteSubscription liefert ein befülltes RemoteSubscriptionDTO, wenn der Vendor eine bekannte Form zurückgibt — testen Sie subscriptionToDto mit einer aufgezeichneten Fixture.
  • Paginierungsvertrag — getRemoteSubscriptions mit mehreren Seiten läuft bis has_more => false und liefert die zusammengefügten Daten.

Was man nicht per Unit-Test prüfen sollte

  • Vendor-API-Responses live — diese brauchen einen echten Sandbox-Account und liegen außerhalb des Unit-Test-Scopes. Verifizieren Sie live vor dem Ausliefern (curl gegen die Endpoints mit einem Sandbox-Key).
  • DTO-Mapper, wenn sie privat und eng an die Vendor-JSON-Form gekoppelt sind — decken Sie sie indirekt über Ende-zu-Ende-Tests mit aufgezeichneten Fixtures ab, oder exponieren Sie sie nur zum Testen, wenn die Mapping-Logik nicht-trivial ist.
  • Routing — Laravel deckt loadRoutesFrom ab; der Route-Name löst zur Laufzeit auf, solange die Closure ihn einfängt.
  • Billing::register-Happy-Path — der hostseitige BillingManagerTest deckt das ab.

Registrieren Sie die Testsuite des Plugins in der hostseitigen phpunit.xml gemäß dem Testing-Deep-Dive: <testsuite name="Plugin: acelle/paddle"> ... </testsuite>. Führen Sie die Suite mit ./vendor/bin/pest --testsuite="Plugin: acelle/paddle" aus.

Aktivierungs-Lifecycle

EventWas passiert
php artisan plugin:init author/nameDateien werden unter storage/app/plugins/... generiert. DB-Zeile mit status=inactive eingefügt. Service Provider auto-geladen.
Admin klickt ActivateFeuert activate_plugin_author/name → der Hook des Plugins führt Migrationen aus. DB-Status wechselt auf active. Die Gateway ist jetzt im Select-Type-Dropdown sichtbar.
Admin klickt DeactivateDB-Status wechselt auf inactive. Der Service Provider bleibt geladen. Routen lösen weiterhin auf. Der Gateway-Typ ist noch im BillingManager, bis der nächste Prozess bootet.
Admin klickt DeleteFeuert delete_plugin_author/name → der Hook des Plugins rollt Migrationen zurück. Dateien entfernt, DB-Zeile gelöscht, Master-Datei-Eintrag bereinigt.

Schutz für die Deactivate-Semantik: Wenn „deactivate = Gateway verschwindet sofort" für Ihre Installation zählt, prüfen Sie Plugin::getByName('myvendor/myplugin')->isActive() innerhalb der Service-Factory-Closure von Billing::register und werfen Sie, falls nicht aktiv. Das gebündelte Plugin acelle/paddle tut das aktuell nicht — die Gateway bleibt verfügbar, auch wenn ein Admin sie deaktiviert (bis zum nächsten Prozess-Neustart). Künftige Härtung; für jetzt lebt das Muster in Plugin-Architektur § Warum inaktive Plugins die App weiterhin beeinflussen.

Vendor-Boundary-Disziplin

Das sind die Muster, die lokale Zustand-only-Tests übersehen. Jedes stammt aus einem realen Bug, der ausgeliefert wurde, bevor er von einer Sichtprüfung gefangen wurde. Lesen Sie diesen Abschnitt bevor Sie den Gateway-Service schreiben.

1. Einheitenbehaftete Felder explizit übergeben — niemals auf Vendor-Defaults verlassen

Amount allein reicht nicht. Vendors interpretieren Amount als Minor Units irgendeiner Währung; übergibt das Plugin Currency nicht, fällt der Vendor auf den Default des Terminals oder Accounts zurück. Das TBANK-Plugin (Geschwister-Referenz) ließ ursprünglich Currency in seinem Init-Payload weg — der Default des Terminals war RUB, der Plan war USD, der Kunde sah „₽49", während der Händler glaubte, er habe „$49" belastet. Der Bug war für lokale DB-Assertions unsichtbar, weil der lokale Intent brav currency='USD' aufzeichnete; nur die Anzeige des Vendors sagte die Wahrheit.

// ❌ Silent default
$payload = ['Amount' => $amountMinor, 'OrderId' => $intentUid];

// ✅ Explicit, fail-loud on unknown currency
private const ISO4217_NUMERIC = ['RUB' => '643', 'USD' => '840', 'EUR' => '978', /* ... */];

if (!isset(self::ISO4217_NUMERIC[$iso = strtoupper($currency)])) {
    throw new \InvalidArgumentException(
        "Unsupported currency '{$currency}'. Supported: " .
        implode(', ', array_keys(self::ISO4217_NUMERIC))
    );
}
$payload['Currency'] = self::ISO4217_NUMERIC[$iso];

2. custom_data über jeden Read-Endpoint zurückmappen

Das Plugin sendet custom_data.intent_uid beim Checkout-Erstellen. Der Vendor spiegelt es auf jedem zugehörigen Read zurück — Subscription-Detail, Transaction-Detail, Payment-Method-Detail. Das Plugin muss es aus jeder Form zurücklesen, weil sich der Ort von custom_data beim Vendor zwischen Read-Endpoints unterscheiden kann. Geht das Mapping bei nur einem verloren, lässt der Sync diesen Intent für immer in PENDING hängen.

3. Niemals eine unbehandelte Exception aus getCheckoutUrl werfen

getCheckoutUrl wird beim Seitenrender aufgerufen. Ein unbehandeltes Werfen erzeugt eine 500-Seite, wo der Kunde einen Checkout-Button erwartet. Halten Sie die Methode nebenwirkungsfrei; lassen Sie den Controller Vendor-Call-Fehler behandeln und den Fehler in die Flash-Session des Kunden überführen.

4. Jeden Vendor-Call im Controller fangen und den Fehler flashen

Kunden müssen sehen, was der Vendor sagte, damit sie eine andere Gateway wählen oder den Support kontaktieren können. Eine 500-Seite sagt ihnen nichts. Der try / catch + redirect()->away($returnUrl)->with('alert-error', ...) des Paddle-Controllers ist das kanonische Muster.

5. Jeden Vendor-Call mit der Intent-UID loggen

Ohne die Intent-UID in der Log-Zeile bedeutet das Debuggen eines kundengemeldeten Fehlers das Scrollen durch hunderte unverbundener Log-Zeilen. \Log::error('Paddle checkout: createTransaction failed', ['intent_uid' => $intent->uid, 'error' => $e->getMessage()]) ist die minimal nützliche Form; Teams, die mehrere Gateways betreiben, ergänzen ein 'gateway' => 'paddle'-Tag.

Wie es weitergeht

Sending Driver (Push-Modell mit Webhooks) und Payment Gateways (Pull-Modell mit Reads) sind die zwei schwergewichtigsten ausgearbeiteten Beispielseiten. Zusammen decken sie beide Seiten des Vendor-Boundary-Spektrums ab, das der Host heute unterstützt. Die nächste Seite — die acelle/ai-Showcase — behandelt das kanonische komplexe Plugin Ende-zu-Ende als Verständnis-Übung: acht Eloquent-Modelle, vierzehn Migrationen, achtzehn Locales, die Chatbox-UI-Oberfläche, jedes Hook-Muster in Produktion. Verwenden Sie es als Referenz-Codebasis, wenn Sie etwas Größeres als einen Driver oder eine Gateway bauen.

Wenn die Gateway ausgeliefert und live ist, fahren Sie den activate → test → delete-Zyklus aus dem Testing-Deep-Dive — er fängt eine Klasse von delete_plugin_*-Hook-Bugs ab, die Unit-Tests nicht erwischen. Für die breiteren übergreifenden Referenzen hat der Überblick Plugin-Architektur die vollständige Lifecycle-Karte.