Tại sao plugin, không phải core
Thêm một vendor bằng cách edit <code>app/Cashier/Services/</code>, package <code>vendor/acelle/cashier/</code> bundle, và <code>app/Providers/CheckoutServiceProvider.php</code> hoạt động về nguyên tắc. Đường plugin được chọn thay thế vì bốn lý do cụ thể:
- Core stays sealed. Một core upgrade thêm vendor sẽ trói mọi install vào boot cost của vendor đó. Một merchant không bao giờ bán qua Paddle vẫn phải trả cost và thấy field config họ không dùng được.
- Independent shipping. Một plugin có thể iterate theo tốc độ API của vendor — Paddle Billing v2, Razorpay Routes, revision Adyen Checkout API — mà không phải đợi core release.
- Uninstall sạch.
php artisan plugin:delete acelle/paddle xóa hoàn toàn gateway type. Không còn nhánh case 'paddle': chết trong switch ở core.
- Per-tenant policy. Disable plugin làm gateway biến mất khỏi list select-type Sending Servers của admin ở mọi nơi, ngay lập tức. Stripe và Offline được bundle trong core vì chúng "always-on"; còn lại fit shape plugin hơn.
Trade-off: plugin author owns gateway service, redirect controller, admin form view, và read-side mapper (getRemotePlan / getRemoteSubscription / getRemotePaymentMethod). Foundation contract làm cái này nhỏ — full Paddle khoảng 500-700 dòng.
Pull model — không webhook
Kiến trúc gateway của AcelleMail là pull-based: host fetch state từ vendor theo nhu cầu. Ba trigger chạy read:
- Page-load lazy fetch — page subscription / invoice của customer gọi
getRemoteSubscription tại render time khi local state cũ hơn ngưỡng freshness.
- Nút "Refresh" — admin / customer có thể force một read ngay lập tức.
- Cron
RemoteSubscriptionSyncService định kỳ — sweep mọi active remote subscription theo cadence configurable.
Không cần webhook listener. Plugin author không cần host public webhook endpoint, verify HMAC signature, dedupe at-least-once delivery, hay handle replay attack. Trade-off là state lag — thường vài phút — giữa lúc vendor confirm state change và lúc host notice. Với SaaS billing là OK: customer không thấy subscription status mới đúng cái microsecond Paddle confirm; họ thấy nó ở lần render page kế tiếp. Hard requirement cho sub-second propagation không fit với kiến trúc này nếu không mở rộng contract.
Tại sao pull beat push cho SaaS billing. Bớt một public endpoint cần secure (không HMAC verify, không replay window, không yêu cầu public-IP trên dev). Bớt một bước integration cho admin lúc setup gateway (không có dance "create endpoint, copy secret, đợi test webhook"). Cancellation và plan change đến từ customer portal của vendor vẫn propagate — sync pass kế tiếp sẽ pick up. Sync cadence configurable trong core, nên install có yêu cầu freshness ngặt hơn sẽ hạ interval xuống.
Foundation contract trong core
Host ship bốn mảnh foundation mà plugin consume. Plugin không implement chúng — plugin gọi chúng.
Registry BillingManager
app/Library/BillingManager.php là DI-bound singleton giữ map gateway-type → presentation-metadata + service-factory. Hàm boot() của service-provider plugin gọi Billing::register(...) một lần mỗi vendor; dropdown select-type customer-facing, picker form admin, và Billing::resolveService($gateway) đều read từ registry này.
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
);
Callback CheckoutHandlerInterface
vendor/acelle/cashier/src/Contracts/CheckoutHandlerInterface.php declare host-side callback mà sync layer fire khi remote state diverge khỏi local intent state. Plugin không gọi nó trực tiếp. RemoteSubscriptionSyncService của host consume read method của plugin và dispatch tới CheckoutHandler::onPaymentSuccess / onSubscriptionCreated / onPaymentFailed / onPaymentRequiresAuth / onOfflineClaimReceived tự nó.
State machine PaymentIntent
Năm state terminal-hoặc-pending; plugin không bao giờ transition intent state trực tiếp. Sync layer của host read vendor state qua plugin và flip intent qua CheckoutHandler.
| State | Ý nghĩa |
PENDING | Intent đã tạo, customer chưa thanh toán |
REQUIRES_ACTION | Challenge 3DS / SCA đang đợi customer (chỉ thẻ) |
AWAITING_ADMIN_APPROVAL | Offline claim đang đợi admin review |
SUCCEEDED | Terminal — thanh toán đã confirm |
FAILED / CANCELLED | Terminal — surface lên customer kèm lý do từ vendor |
Package Cashier — tám gateway built-in để reference
Package Cashier fork tại /Users/luan/apps/cashier/src/Services/ ship tám implementation gateway built-in. Đọc chúng là cách nhanh nhất để thấy full surface mà một plugin payment-gateway có thể muốn implement: StripePaymentGateway, StripeSubscriptionGateway, BraintreePaymentGateway, BraintreeSubscriptionGateway, PaypalPaymentGateway, PaystackPaymentGateway, RazorpayPaymentGateway, OfflinePaymentGateway. Hai cái đầu subscription-shaped; còn lại one-off thẻ hoặc wire-style.
Bốn capability interface
Cả bốn nằm trong package Cashier dưới /Users/luan/apps/cashier/src/Contracts/. Gateway class của plugin chỉ implement interface mà vendor thật sự support — host check instanceof tại mọi callsite.
| Interface | Mục đích | Required cho |
IntentGatewayInterface |
Base contract — getCheckoutUrl(intent, returnUrl) + getMethodTitle/getMethodInfo để hiện saved-method |
Mọi gateway |
SupportsAutoChargeInterface |
autoCharge(intent, pmData) cho off-session card charging không redirect |
Gateway có one-tap re-bill (Stripe one-off; Paddle không) |
SupportsSubscriptionInterface |
createSubscription(intent, pmData) cho headless subscription create |
Gateway support flow headless subscription (Stripe Subscription); createSubscription của Paddle throw vì Paddle hosted-checkout-only |
RemoteSubscriptionGatewayInterface |
getRemotePlans / getRemoteSubscription / getRemoteSubscriptions / cancelRemoteSubscription / updateRemoteSubscriptionPlan / getRemotePaymentMethod — phía read/sync |
Gateway mà vendor own subscription state (Paddle, Stripe Subscription) |
Plugin scaffold
Full file layout cho một plugin payment-gateway:
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)
Chú ý cái không có ở đó: không webhook controller, không signature verifier, không bảng replay-protection. State sync được pull bởi host, không push bởi vendor.
ServiceProvider — chỉ một call Billing::register register mọi thứ
Full skeleton service provider cho một plugin payment-gateway (paraphrase từ 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,
]));
}
}
Closure trong Billing::register read credential từ column JSON gatewayData của PaymentGateway qua getGatewayData('key'). Đó là cách field form admin flow vào service constructor.
Gateway service — getCheckoutUrl return URL của plugin
Host gọi getCheckoutUrl đúng lúc customer commit vào checkout. Implementation phải rẻ và side-effect-free — không gọi vendor API lúc render:
public function getCheckoutUrl(PaymentIntent $intent, string $returnUrl): string
{
return route('paddle.checkout', ['intent_uid' => $intent->uid])
. '?return_url=' . urlencode($returnUrl);
}
URL trỏ về controller của plugin, không trỏ thẳng vào URL vendor. Ba lý do:
- Call vendor API để create hosted-checkout thật (Paddle:
POST /transactions) cần sống sau controller boundary để error có thể catch và customer có thể redirect về invoice page kèm flash error.
- Logging và throttling thuộc về controller, không thuộc gateway service.
- Gateway service stays "pure" — không HTTP side effect khi
getCheckoutUrl chạy ở intent-create time. getCheckoutUrl có thể được call speculatively, kể cả trong tests.
Checkout controller — gọi vendor + 302 đến hosted checkout
CheckoutController::redirect() của plugin là nơi call vendor API thật sự xảy ra. Customer hit /cashier/paddle/checkout/{uid}, controller gọi endpoint POST /transactions của Paddle, và 302 browser tới hosted checkout URL mà Paddle trả về:
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 là plugin-internal method trên PaddleGateway (không nằm trên bất kỳ interface nào). Nó wrap POST /transactions của vendor với shape JSON đặc thù Paddle.
Critical: custom_data.intent_uid được gửi tới vendor và echo ngược lại trên các read kế tiếp — GET /subscriptions/{id} return cùng custom_data. Đó là cách sync layer map một Paddle subscription ngược về một PaymentIntent local. Mất cái này thì sync silently fail.
Read-side mapper — feed sync layer
Plugin expose vendor state qua các method của RemoteSubscriptionGatewayInterface. RemoteSubscriptionSyncService của host (cron + on-demand) gọi các method này để refresh DTO local, rồi dispatch tới CheckoutHandler khi state diverge:
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,
];
}
Pagination contract: return {data, has_more, next_cursor}. Sync service walk qua các page tới khi has_more => false. DTO mapper (priceToDto, subscriptionToDto) translate shape JSON của vendor về shape DTO neutral của host — đó là per-vendor knowledge khó share nhất, vì shape response của mỗi vendor đều khác.
Capability matrix — bốn pattern vendor
Interface mà plugin implement phụ thuộc payment model của vendor. Bốn pattern mà host đã support:
| Pattern vendor | Implements | Ví dụ |
| Charge thẻ one-off với token |
IntentGatewayInterface + SupportsAutoChargeInterface |
Stripe one-off, Square, Razorpay one-off |
| Subscription hosted-checkout |
IntentGatewayInterface + RemoteSubscriptionGatewayInterface + SupportsSubscriptionInterface (createSubscription throw) |
Paddle, Lemon Squeezy |
| Subscription headless |
IntentGatewayInterface + SupportsSubscriptionInterface + RemoteSubscriptionGatewayInterface |
Stripe Subscription, Braintree Subscription |
| Manual / wire transfer |
Chỉ IntentGatewayInterface |
Offline (chuyển khoản, tiền mặt) |
Đừng implement interface không dùng. Billing::supportsRemoteSubscription($gw) read flag isRemoteSubscription set lúc registration, không qua instanceof — nhưng flag phải khớp với cái service của bạn thật sự làm, nên giữ chúng aligned.
Testing capability contract
Không có code webhook, không có security boundary để unit-test. Focus vào contract capability — những cái caller depend vào như guarantee về shape của gateway.
Cái nên unit-test
createSubscription throw cho vendor hosted-checkout-only (Paddle) — caller biết dùng getCheckoutUrl thay vào đó.
getCheckoutUrl return route tới controller của plugin, không phải URL vendor — chứng minh controller boundary đặt đúng chỗ.
getRemoteSubscription return RemoteSubscriptionDTO populate khi vendor return shape đã biết — exercise subscriptionToDto với fixture đã record.
- Pagination contract —
getRemoteSubscriptions với nhiều page walk tới khi has_more => false và return data nối lại.
Cái không unit-test
- Vendor API response live — cần sandbox account thật và out-of-scope của unit-test. Verify live trước khi ship (curl endpoint với sandbox key).
- DTO mapper khi chúng private và tight-couple với shape JSON vendor — cover gián tiếp qua end-to-end test với fixture đã record, hoặc expose chúng cho testing chỉ khi logic mapping non-trivial.
- Routing — Laravel lo
loadRoutesFrom; tên route resolve tại runtime miễn closure capture nó.
- Happy path
Billing::register — BillingManagerTest của host lo rồi.
Register testsuite của plugin trong phpunit.xml của host theo deep-dive Testing: <testsuite name="Plugin: acelle/paddle"> ... </testsuite>. Run suite với ./vendor/bin/pest --testsuite="Plugin: acelle/paddle".
Activation lifecycle
| Event | Xảy ra gì |
php artisan plugin:init author/name | File generate dưới storage/app/plugins/.... Row DB insert với status=inactive. Service provider auto-load. |
| Admin click Activate | Fire activate_plugin_author/name → hook của plugin chạy migration. Status DB flip về active. Gateway giờ visible trong dropdown select-type. |
| Admin click Deactivate | Status DB flip về inactive. Service provider vẫn loaded. Route vẫn resolve. Gateway type vẫn ở trong BillingManager tới process boot kế tiếp. |
| Admin click Delete | Fire delete_plugin_author/name → hook của plugin rollback migration. File xóa, row DB delete, entry master-file clear. |
Guard cho semantic deactivate: nếu "deactivate = gateway biến mất ngay" quan trọng với install của bạn, check Plugin::getByName('myvendor/myplugin')->isActive() bên trong closure service-factory của Billing::register và throw nếu không. Plugin acelle/paddle bundle hiện không làm thế — gateway vẫn available kể cả khi admin deactivate (tới process restart kế tiếp). Hardening tương lai; hiện pattern sống ở Plugin architecture § Tại sao plugin inactive vẫn affect app.
Discipline vendor-boundary
Đây là các pattern mà testing local-state-only miss. Cái nào cũng đến từ bug thật đã ship trước khi bị catch bởi check humans-eyes-on-screen. Đọc section này trước khi viết gateway service.
1. Pass field mang đơn vị tường minh — đừng dựa vào default của vendor
Amount một mình không đủ. Vendor diễn giải amount như minor unit của một currency nào đó; nếu plugin không pass Currency, vendor fall back về default của terminal hoặc account. Plugin TBANK (reference sibling) ban đầu omit Currency khỏi Init payload — default terminal là RUB, plan là USD, customer thấy "₽49" trong khi merchant tin họ đã charge "$49". Bug invisible với assertion DB local vì local intent dutifully record currency='USD'; chỉ display của vendor mới nói sự thật.
// ❌ 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. Map custom_data ngược qua mọi read endpoint
Plugin gửi custom_data.intent_uid lúc checkout create. Vendor echo nó ngược lại trên mọi read liên quan — subscription detail, transaction detail, payment-method detail. Plugin phải read nó ngược về từ mỗi shape vì vị trí custom_data của vendor có thể khác nhau giữa các read endpoint. Mất mapping ở bất kỳ một cái và sync silently để intent đó trong PENDING mãi mãi.
3. Đừng bao giờ throw unhandled exception ra khỏi getCheckoutUrl
getCheckoutUrl được gọi trong lúc render page. Một throw unhandled produce trang 500 chỗ mà customer mong đợi nút checkout. Giữ method side-effect free; để controller handle failure của call vendor và surface error tới flash session của customer.
4. Catch mọi call vendor trong controller và flash error
Customer cần thấy vendor nói gì để họ có thể pick gateway khác hoặc liên hệ support. Trang 500 không nói cho họ điều gì. Pattern chính tắc là try / catch + redirect()->away($returnUrl)->with('alert-error', ...) của controller Paddle.
5. Log mọi call vendor kèm intent UID
Không có intent UID trong log line, debug một failure customer-reported nghĩa là scroll qua hàng trăm log line không liên quan. \Log::error('Paddle checkout: createTransaction failed', ['intent_uid' => $intent->uid, 'error' => $e->getMessage()]) là shape minimum hữu ích; team chạy nhiều gateway thêm tag 'gateway' => 'paddle' nữa.
Đi tiếp đâu
Sending drivers (push model với webhook) và payment gateway (pull model với read) là hai trang worked-example nặng nhất. Cùng nhau chúng cover cả hai phía của vendor-boundary spectrum mà host support hôm nay. Trang kế tiếp — showcase acelle/ai — walk plugin phức tạp chính tắc end-to-end như một bài tập reading-comprehension: tám Eloquent model, mười bốn migration, mười tám locale, surface UI chatbox, mọi hook pattern trong production. Dùng nó làm reference codebase khi bạn sẵn sàng build cái gì lớn hơn driver hay gateway.
Khi gateway đã ship và live, run cycle activate → test → delete từ deep-dive Testing — nó catch một class bug hook delete_plugin_* mà unit test không thể. Cho reference cross-page rộng hơn, overview Plugin architecture có full map lifecycle.