なぜコアではなくプラグインか
<code>app/Cashier/Services/</code>、同梱の <code>vendor/acelle/cashier/</code> パッケージ、<code>app/Providers/CheckoutServiceProvider.php</code> を編集してベンダーを追加することは原理的には動作します。プラグインパスが代わりに選ばれた具体的な理由は 4 つあります。
- コアは封印されたまま。 ベンダーを追加するコアアップグレードは、すべてのインストールをそのベンダーのブートコストに縛り付けます。Paddle で売らないマーチャントもコストを支払い、使えない設定フィールドを目にします。
- 独立した出荷。 プラグインはベンダーの API ペースで反復できます — Paddle Billing v2、Razorpay Routes、Adyen Checkout API のリビジョン — コアリリースを待つ必要なく。
- アンインストールがクリーン。
php artisan plugin:delete acelle/paddle はゲートウェイタイプを完全に削除します。コアの switch に case 'paddle': の死んだブランチが残りません。
- テナントごとのポリシー。 プラグインを無効化するとゲートウェイは管理画面の Sending Servers select-type リストから即座にどこでも消えます。Stripe と Offline は「常時オン」なのでコアに同梱されていますが、それ以外はプラグインの形がより適しています。
トレードオフ: プラグイン作成者はゲートウェイサービス、リダイレクトコントローラー、管理フォームビュー、読み込み側マッパー (getRemotePlan / getRemoteSubscription / getRemotePaymentMethod) を所有します。基盤契約のおかげでこれは小さくなり — Paddle 全体でおよそ 500-700 行です。
プルモデル — Webhook なし
AcelleMail のゲートウェイアーキテクチャはプルベースです: ホストがオンデマンドでベンダーから状態を取得します。3 つのトリガーが読み込みを実行します。
- ページ読み込み時の遅延取得 — 顧客のサブスクリプション / 請求書ページがレンダリング時に、ローカル状態が鮮度閾値より古い場合
getRemoteSubscription を呼びます。
- 「Refresh」ボタン — 管理者 / 顧客が即座の読み込みを強制できます。
- 定期的な
RemoteSubscriptionSyncService cron — 設定可能な頻度でアクティブなリモートサブスクリプションを掃引します。
Webhook リスナーは不要です。プラグイン作成者は公開 Webhook エンドポイントをホストする必要なく、HMAC 署名を検証する、at-least-once 配信を重複排除する、リプレイ攻撃を処理する必要もありません。トレードオフは状態のラグです — 通常は数分 — ベンダーが状態変更を確認してからホストが気づくまでの間。SaaS 請求にとってこれで十分です: 顧客は Paddle が確認したマイクロ秒単位で新しいサブスクリプションステータスを見るわけではなく、次のページレンダリング時に見ます。サブ秒の伝播を強く要求するケースは、契約を拡張しない限り、このアーキテクチャに合いません。
SaaS 請求でプッシュよりプルが優れる理由。 保護すべき公開エンドポイントが 1 つ少なくなります (HMAC 検証なし、リプレイウィンドウなし、開発時の公開 IP 要件なし)。ゲートウェイセットアップ中の管理者の統合手順が 1 つ少なくなります (「エンドポイントを作成、シークレットをコピー、テスト Webhook を待つ」ダンスなし)。ベンダーの顧客ポータルからのキャンセルとプラン変更も伝播します — 次の同期パスがそれらを拾います。同期頻度はコアで設定可能なので、より厳しい鮮度要求のインストールは間隔を下げられます。
コアの基盤契約
ホストはプラグインが消費する 4 つの基盤を出荷します。プラグインはこれらを実装しません — 呼び出します。
BillingManager レジストリ
app/Library/BillingManager.php はゲートウェイタイプ → 表示メタデータ + サービスファクトリのマップを保持する DI バインド済みシングルトンです。プラグインの Service Provider boot() はベンダーごとに 1 度 Billing::register(...) を呼びます。顧客向け select-type ドロップダウン、管理者フォームの選択肢、Billing::resolveService($gateway) はすべてこのレジストリから読みます。
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
);
CheckoutHandlerInterface コールバック
vendor/acelle/cashier/src/Contracts/CheckoutHandlerInterface.php は、リモート状態がローカルの意図状態から乖離したときに同期レイヤーが発火するホスト側コールバックを宣言します。プラグインはこれを直接呼びません。 ホストの RemoteSubscriptionSyncService がプラグインの読み込みメソッドを消費し、自身で CheckoutHandler::onPaymentSuccess / onSubscriptionCreated / onPaymentFailed / onPaymentRequiresAuth / onOfflineClaimReceived にディスパッチします。
PaymentIntent ステートマシン
5 つの終端または保留状態。プラグインは意図状態を直接遷移させません。ホストの同期レイヤーがプラグイン経由でベンダー状態を読み、CheckoutHandler 経由で意図をフリップします。
| 状態 | 意味 |
PENDING | 意図作成済み、顧客はまだ支払っていない |
REQUIRES_ACTION | 3DS / SCA チャレンジが顧客待ち (カードのみ) |
AWAITING_ADMIN_APPROVAL | オフラインクレームが管理者レビュー待ち |
SUCCEEDED | 終端 — 支払い確認済み |
FAILED / CANCELLED | 終端 — ベンダーの理由とともに顧客に表示 |
Cashier パッケージ — 参考用の 8 つの組み込みゲートウェイ
fork された Cashier パッケージは /Users/luan/apps/cashier/src/Services/ にあり、8 つの組み込みゲートウェイ実装を搭載しています。決済ゲートウェイプラグインが実装したい全体面を見る最速の方法はこれらを読むことです: StripePaymentGateway、StripeSubscriptionGateway、BraintreePaymentGateway、BraintreeSubscriptionGateway、PaypalPaymentGateway、PaystackPaymentGateway、RazorpayPaymentGateway、OfflinePaymentGateway。最初の 2 つはサブスクリプション型、残りは単発カードまたは送金型です。
4 つの capability インターフェース
4 つすべてが Cashier パッケージの /Users/luan/apps/cashier/src/Contracts/ 配下にあります。プラグインのゲートウェイクラスはベンダーが実際にサポートするインターフェースのみを実装し — ホストは各呼び出し箇所で instanceof チェックを行います。
| インターフェース | 用途 | 必須となる場面 |
IntentGatewayInterface |
基本契約 — getCheckoutUrl(intent, returnUrl) + 保存済みメソッド表示用の getMethodTitle/getMethodInfo |
すべてのゲートウェイ |
SupportsAutoChargeInterface |
リダイレクトなしでのオフセッションカード課金用の autoCharge(intent, pmData) |
ワンタップ再課金のゲートウェイ (Stripe 単発。Paddle は非対応) |
SupportsSubscriptionInterface |
ヘッドレスサブスクリプション作成用の createSubscription(intent, pmData) |
ヘッドレスサブスクリプションフローをサポートするゲートウェイ (Stripe Subscription)。Paddle の createSubscription は Paddle がホスト型チェックアウト専用のため例外を投げます |
RemoteSubscriptionGatewayInterface |
getRemotePlans / getRemoteSubscription / getRemoteSubscriptions / cancelRemoteSubscription / updateRemoteSubscriptionPlan / getRemotePaymentMethod — 読み込み / 同期側 |
ベンダーがサブスクリプション状態を所有するゲートウェイ (Paddle、Stripe Subscription) |
プラグインの scaffold
決済ゲートウェイプラグインの完全なファイルレイアウト:
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)
そこにないものに注目してください: Webhook コントローラーなし、署名検証なし、リプレイ保護テーブルなし。状態同期はベンダーがプッシュするのではなく、ホストがプルします。
Service Provider — 単一の Billing::register 呼び出しがすべてを登録
決済ゲートウェイプラグインの完全なスケルトン Service Provider (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,
]));
}
}
Billing::register 内のクロージャは、getGatewayData('key') 経由で PaymentGateway の gatewayData JSON カラムから認証情報を読み込みます。これが管理者のフォームフィールドがサービスコンストラクタに流れ込む方法です。
ゲートウェイサービス — getCheckoutUrl はプラグインの URL を返す
ホストは顧客がチェックアウトにコミットした瞬間に getCheckoutUrl を呼びます。実装は安価で副作用フリーである必要があります — レンダリング中にベンダー API 呼び出しを行ってはいけません:
public function getCheckoutUrl(PaymentIntent $intent, string $returnUrl): string
{
return route('paddle.checkout', ['intent_uid' => $intent->uid])
. '?return_url=' . urlencode($returnUrl);
}
URL はベンダーの URL ではなく、プラグイン自身のコントローラーを指します。3 つの理由:
- 実際のホスト型チェックアウトを作成するベンダー API 呼び出し (Paddle:
POST /transactions) はコントローラー境界の背後に置く必要があり、エラーをキャッチして顧客を flash エラーと共に請求書ページにリダイレクトできるようにします。
- ログとスロットリングはゲートウェイサービスではなくコントローラーに属します。
- ゲートウェイサービスは「純粋」のまま —
getCheckoutUrl が意図作成時に走るときに HTTP 副作用なし。getCheckoutUrl はテストを含めて投機的に呼び出せます。
チェックアウトコントローラー — ベンダー呼び出し + ホスト型チェックアウトへの 302
プラグインの CheckoutController::redirect() が実際のベンダー API 呼び出しが起きる場所です。顧客が /cashier/paddle/checkout/{uid} にアクセスし、コントローラーが Paddle の POST /transactions エンドポイントを呼び、Paddle が返したホスト型チェックアウト URL に 302 でブラウザをリダイレクトします:
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 は PaddleGateway のプラグイン内部メソッドです (どのインターフェースにもありません)。Paddle 固有の JSON 形状で POST /transactions をラップします。
重要: custom_data.intent_uid はベンダーに送信され、後続の読み込みで echo back されます — GET /subscriptions/{id} は同じ custom_data を返します。これが同期レイヤーが Paddle サブスクリプションをローカルの PaymentIntent にマップする方法です。これを失うと同期はサイレントに失敗します。
読み込み側マッパー — 同期レイヤーへの供給
プラグインは RemoteSubscriptionGatewayInterface メソッドを通じてベンダー状態を公開します。ホストの RemoteSubscriptionSyncService (cron + オンデマンド) がこれらを呼んでローカル DTO を更新し、状態が乖離したときに CheckoutHandler にディスパッチします:
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,
];
}
ページネーション契約: {data, has_more, next_cursor} を返します。同期サービスは has_more => false までページを歩きます。DTO マッパー (priceToDto、subscriptionToDto) はベンダーの JSON 形状をホストの中立 DTO 形状に翻訳します — これがベンダーごとの最も共有しにくい知識で、すべてのベンダーのレスポンス形状が異なるためです。
capability マトリクス — 4 つのベンダーパターン
プラグインが実装するインターフェースはベンダーの決済モデルに依存します。ホストがすでにサポートする 4 つのパターン:
| ベンダーパターン | 実装 | 例 |
| トークンによる単発カード課金 |
IntentGatewayInterface + SupportsAutoChargeInterface |
Stripe 単発、Square、Razorpay 単発 |
| ホスト型チェックアウトサブスクリプション |
IntentGatewayInterface + RemoteSubscriptionGatewayInterface + SupportsSubscriptionInterface (createSubscription が例外を投げる) |
Paddle、Lemon Squeezy |
| ヘッドレスサブスクリプション |
IntentGatewayInterface + SupportsSubscriptionInterface + RemoteSubscriptionGatewayInterface |
Stripe Subscription、Braintree Subscription |
| 手動 / 銀行振込 |
IntentGatewayInterface のみ |
Offline (銀行振込、現金) |
使わないインターフェースは実装しないでください。Billing::supportsRemoteSubscription($gw) は登録時にセットされる isRemoteSubscription フラグを読み、instanceof 経由ではありません — しかしフラグはサービスが実際に行うことと一致する必要があるので、整合させ続けてください。
capability 契約のテスト
Webhook コードがないので、ユニットテストするセキュリティ境界はありません。capability 契約 — 呼び出し元がゲートウェイの形の保証として依存するもの — に焦点を当ててください。
ユニットテストすべきこと
- ホスト型チェックアウト専用ベンダー (Paddle) で
createSubscription が例外を投げる — 呼び出し元は代わりに getCheckoutUrl を使うことを知る。
getCheckoutUrl がベンダー URL ではなくプラグインのコントローラーへのルートを返す — コントローラー境界が機能していることを証明。
- ベンダーが既知の形を返したとき、
getRemoteSubscription が populate された RemoteSubscriptionDTO を返す — 記録されたフィクスチャで subscriptionToDto を実行。
- ページネーション契約 — 複数ページでの
getRemoteSubscriptions が has_more => false まで歩き、連結データを返す。
ユニットテストすべきでないこと
- ライブのベンダー API レスポンス — これらは実際のサンドボックスアカウントが必要で、ユニットテスト範囲外です。出荷前にライブで検証してください (サンドボックスキーでエンドポイントを curl)。
- ベンダー JSON 形状と密結合のプライベートな DTO マッパー — 記録されたフィクスチャでの end-to-end テストで間接的にカバー、またはマッピングロジックが非自明な場合のみテスト用に公開する。
- ルーティング — Laravel が
loadRoutesFrom をカバーします。クロージャがルート名を捕捉する限り、ランタイムで解決されます。
Billing::register ハッピーパス — ホスト自身の BillingManagerTest がカバーします。
プラグインの testsuite をホストの phpunit.xml に Testing 深掘りに従って登録してください: <testsuite name="Plugin: acelle/paddle"> ... </testsuite>。./vendor/bin/pest --testsuite="Plugin: acelle/paddle" でスイートを実行します。
アクティベーションライフサイクル
| イベント | 起きること |
php artisan plugin:init author/name | storage/app/plugins/... 配下にファイル生成。DB 行が status=inactive で挿入。Service Provider が自動ロード。 |
| 管理者が Activate をクリック | activate_plugin_author/name を発火 → プラグインのフックが Migration を実行。DB ステータスが active にフリップ。ゲートウェイが select-type ドロップダウンで可視に。 |
| 管理者が Deactivate をクリック | DB ステータスが inactive にフリップ。Service Provider はロードされたまま。 ルートは依然として解決されます。ゲートウェイタイプは次のプロセスブートまで BillingManager に残ります。 |
| 管理者が Delete をクリック | delete_plugin_author/name を発火 → プラグインのフックが Migration をロールバック。ファイル削除、DB 行削除、マスターファイルエントリクリア。 |
deactivate セマンティクスのためのガード: 「deactivate = ゲートウェイが即座に消える」がインストールにとって重要なら、Billing::register のサービスファクトリクロージャ内で Plugin::getByName('myvendor/myplugin')->isActive() をチェックし、そうでなければ例外を投げてください。同梱の acelle/paddle プラグインは現在これをしていません — 管理者が無効化してもゲートウェイは (次のプロセス再起動まで) 利用可能のままです。将来の強化項目。現時点でのパターンは プラグインアーキテクチャ § 非アクティブなプラグインがアプリに影響を与え続ける理由 にあります。
ベンダー境界の規律
これらはローカル状態のみのテストが見逃すパターンです。各々は実際にデプロイされた後、人の目によるチェックで発見されたバグから来ています。ゲートウェイサービスを書く前にこのセクションを読んでください。
1. 単位を持つフィールドを明示的に渡す — ベンダーのデフォルトに依存しない
Amount だけでは十分ではありません。ベンダーは金額をある通貨の最小単位として解釈します。プラグインが Currency を渡さない場合、ベンダーは端末またはアカウントのデフォルトにフォールバックします。TBANK プラグイン (姉妹リファレンス) は当初 Init ペイロードから Currency を省略していました — 端末デフォルトは RUB、プランは USD、顧客は 「₽49」を見ているのにマーチャントは 「$49」を課金したと信じていました。バグはローカル DB アサーションには見えませんでした — ローカルの意図は律儀に currency='USD' を記録していたからです。真実を語ったのはベンダーの表示だけでした。
// ❌ 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 をマップバックする
プラグインはチェックアウト作成時に custom_data.intent_uid を送信します。ベンダーはすべての関連読み込み — サブスクリプション詳細、トランザクション詳細、決済方法詳細 — でそれを echo back します。プラグインは各形からそれを読み戻す必要があります — ベンダーの custom_data の場所が読み込みエンドポイント間で異なる可能性があるためです。どれかひとつでマッピングを失うと、同期はその意図を PENDING に永遠にサイレントに残します。
3. getCheckoutUrl から未処理の例外を投げない
getCheckoutUrl はページレンダリング中に呼ばれます。未処理の throw は、顧客がチェックアウトボタンを期待した場所に 500 ページを生成します。メソッドを副作用フリーに保ち、コントローラーにベンダー呼び出しの失敗を処理させ、顧客の flash セッションにエラーを表示させてください。
4. コントローラーでベンダー呼び出しをすべてキャッチし、エラーを flash する
顧客はベンダーが何と言ったかを見る必要があり、別のゲートウェイを選ぶかサポートに連絡できるようにしてください。500 ページは何も伝えません。Paddle コントローラーの try / catch + redirect()->away($returnUrl)->with('alert-error', ...) が正規パターンです。
5. すべてのベンダー呼び出しを intent UID 付きでログ
ログ行に intent UID がなければ、顧客報告の失敗をデバッグするには何百もの無関係なログ行をスクロールすることになります。\Log::error('Paddle checkout: createTransaction failed', ['intent_uid' => $intent->uid, 'error' => $e->getMessage()]) が最低限有用な形です。複数ゲートウェイを運用するチームは 'gateway' => 'paddle' タグも追加します。
次に読むべきページ
送信ドライバー (Webhook 付きプッシュモデル) と決済ゲートウェイ (読み込み付きプルモデル) は最も重い実例 2 ページです。両者でホストが今日サポートするベンダー境界スペクトルの両側をカバーします。次のページ — acelle/ai ショーケース — は読解演習として正規の複雑なプラグインを end-to-end で通します: 8 つの Eloquent モデル、14 の Migration、18 のロケール、チャットボックス UI 面、本番で使われるすべてのフックパターン。ドライバーやゲートウェイより大きいものを構築する準備ができたら、リファレンスコードベースとして使ってください。
ゲートウェイが出荷されライブになったら、Testing 深掘りからの activate → test → delete サイクル を実行してください — ユニットテストでは捕捉できない delete_plugin_* フックバグのクラスを捕捉します。より広いクロスページリファレンスとしては、プラグインアーキテクチャ の概要に完全なライフサイクルマップがあります。