Why ship a driver as a plugin
AcelleMail ships with a stable set of first-party drivers — Amazon SES, generic SMTP, sendmail, Postmark, SendGrid, Mailgun, and a handful of others. Every other vendor — regional providers, self-hosted MTAs, niche transactional services, custom backends — needs the same five things wired into the host: a row in the picker page, a connection form, a validation step, a webhook listener for bounces and complaints, and a runtime send() implementation. Doing that in a fork means tracking host upgrades forever; doing it as a plugin means dropping a folder into storage/app/plugins/{vendor}/{name}/ and letting the host take care of every host-side concern.
The plugin contract is small on purpose. One REGISTRY hook to declare the driver. One driver class with five required methods (send, test, setupBeforeSend, validationRules, plus the standard service-name accessors). One Blade partial for the connection form. Optional capability marker interfaces for everything else — webhooks, identity sync, custom verification email. That's it. Picker rendering, form layout, save action, validation pipeline, webhook routing — all in the host.
The contract — what a plugin ships
The full file tree of a sending-driver plugin:
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
The skeleton is intentionally thin. routes.php registers exactly one route — serving the plugin's icon.svg from disk so the picker page has something to render. CRUD endpoints, the webhook URL, the form save action all live in the host's SendingServerController; the plugin contributes only the driver-specific surface area.
Four things the plugin actually registers with the host:
- Driver class + metadata — a single
Hook::add('register_sending_server_driver', ...) payload carrying the type slug, the driver class FQCN, the vendor config keys, and the picker card metadata.
- View namespace —
$this->loadViewsFrom($path, 'myvendor') so view('myvendor::...') resolves to the plugin's templates.
- Translation file — a
Hook::add('add_translation_file', ...) payload pointing at the plugin's resources/lang/ for the master + the dump-clone path.
- Connection-tab blade — implements the
ProvidesConnectionFieldsView capability marker on the driver, returns the partial path the host's form renders.
ServiceProvider — the boot pattern
The full skeleton service provider for a sending-driver plugin (paraphrased from the 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();
});
}
}
Two non-obvious rules the host enforces:
- Every
Hook::add except add_translation_file goes in boot(), never in register(). The host's SendingServerServiceProvider defers its driver-registry collection via $this->app->booted(...) exactly so plugins have time to register through their own boot(); putting register_sending_server_driver in register() means the closure may run before its own dependencies (route() in particular) are available.
- Do not
asset('plugins/myvendor/sending/icon.svg') from the hook payload. There is no auto-publish step that copies plugin assets into public/plugins/... for sending-driver plugins; that path 404s in production. The plugin owns its own route for the icon (defined in routes.php), and the hook payload references that route by name. Self-contained — drop the plugin folder in, the icon is reachable without any host code path.
The driver class
The minimum-viable driver inherits from App\SendingServers\Drivers\AbstractDriver and implements the ProvidesConnectionFieldsView marker (which gives the host a hint that this driver has its own connection blade):
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';
}
}
The four service-name accessors (getServiceName, getServiceIcon, getServiceColor) are all the host needs to render the picker card and the chosen-server header in the Sending Servers UI. send() and test() are the production hot paths — every campaign sent through a server of this type calls send() once per recipient; every "Test connection" click on the admin page calls test(). setupBeforeSend() runs once at the start of a campaign batch — most drivers leave it empty.
Capability marker interfaces
Beyond the minimum surface, the host exposes a set of capability marker interfaces. The driver implements only the markers that apply — the host does instanceof checks at every callsite, so a driver that does not implement ReceivesWebhooks simply skips the webhook route registration without throwing.
| Marker | What the driver implements |
ProvidesConnectionFieldsView | Custom Connection-tab blade — connectionFieldsView(): string returns the namespaced view path. |
ReceivesWebhooks | verifyWebhook + parseWebhook + webhookUrl — the vendor POSTs feedback to /webhook/{type}/{uid}; the host routes the payload to your driver. |
SupportsIdentitySync | syncIdentities + verifyIdentity — the host renders a Sender Identity tab for this driver. |
SupportsRemoteDomainVerify | addDomain + validateDomain + checkDomainVerificationStatus — the vendor handles DNS verification, not the host. |
SignsDkimOnServer | Server signs DKIM — the host skips its own signing layer. |
SupportsCustomReturnPath | Honors a custom Return-Path header on outgoing mail. |
AllowsLocalDomainVerify / AllowsLocalEmailVerify / AllowsCrossSendingDomain | Generic SMTP-style flexibility flags. |
SendsCustomVerificationEmail | sendVerificationEmail(Sender) — the driver renders + sends its own verification message instead of the host's default. |
Connection-tab blade
The connection partial under resources/views/sending-servers/_fields_connection.blade.php renders only the form fields. The host wraps it in the <form>, the submit button, the validation alert, and the four-tab page 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>
Three rules govern the partial:
- Only fields, no
<form>, no submit button. The host owns the form wrapper. Adding your own submit fires the wrong save endpoint.
- Field
name matches the config_keys payload + validationRules()['cols'] keys. The host auto-routes $server->fill($request->all()) through the JSON config column based on the keys you declared.
- Read existing values via
$server->getConfig('my_api_key'), not $server->my_api_key. The latter happens to work through a legacy getAttribute fallback but is muddier and not contractually stable.
The validation pipeline
When an admin clicks Save on the Sending Server form, the host runs your driver's validation in two phases:
[admin clicks Save]
│
▼
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
Your driver controls two failure modes:
- Field-level (Phase 1) — rules in
validationRules()['cols']. The host auto-maps each rule failure to its corresponding name=... field in your blade, where @error('my_api_key') renders inline.
- Connection-level (Phase 2) — anything thrown or
TestResult::failure(...)-returned from test(). The host surfaces it on a synthetic connection field rendered in the validation-summary alert at the top of the form.
Five pitfalls from the Postal plugin
These are real bugs the Postal MTA plugin hit. Knowing them up front saves the next driver author hours of debugging.
1. test() must hit a real endpoint
The Postal plugin's first test() implementation called client->makeRequest('servers', 'list', []) — URL /api/v1/servers/list. Looking at Postal's actual API, only messages and send controllers exist; there is no servers endpoint. Postal returned HTTP 404 every time, and the admin saw "Status code returned by Postal server: 404" in red even with valid credentials.
Fix: always cross-check the vendor's API documentation for an existing read endpoint that requires auth and has zero side effects. Typical candidates: GET /me, GET /account, GET /domains. The probe must distinguish 200-with-valid-key from 401/403-with-bad-key — 404-on-missing-route is meaningless.
2. Webhook payload shape changes between vendor versions
Vendors evolve their webhook formats. The Postal plugin shipped with three hardcoded format guards covering "very old", "legacy", and "current" — and still missed the modern format. Modern Postal wraps everything in {event, timestamp, payload, uuid}; for MessageBounced, the payload is {original_message: {token, ...}, bounce: {...}}. The token is at payload.original_message.token, not payload.message.token — the plugin missed the difference and silently dropped every bounce.
Fix: pull the vendor's source code and find every webhook.trigger(...) call site. Enumerate the exact payload shapes the vendor actually sends. Have parseWebhook return IgnorableWebhookEvent for unknown event names rather than silent dropping — observability matters.
3. Webhook signature verification
Most vendors sign their webhooks (HMAC or RSA). Plugin authors often leave verifyWebhook as a no-op for v1 — security risk in production, because anyone who knows the webhook URL can POST a fake bounce.
Fix for v1: leave verifyWebhook as no-op + log a warning, document it as FOLLOW-UP. Real implementation stores the vendor's public key per-server (in the config JSON) and verifies the signature against the request body. Postal signs with RSA SHA256 across X-Postal-Signature-KID + X-Postal-Signature-256 headers.
4. runtimeMessageId selection
SendResult.runtimeMessageId is what the host stores in tracking_logs.runtime_message_id. The webhook listener correlates inbound bounces and complaints back to the originating tracking row via this id. It must match what the vendor puts in webhook payloads.
Postal's /api/v1/send/raw response returns both a globally-unique message_id and a per-recipient token. Postal's MessageBounced webhook contains payload.original_message.token — per-recipient. Acelle drivers send one recipient per send() call, so the right value to store is the per-recipient token, not the global message_id.
Fix: if the vendor sends per-recipient identifiers in their webhooks, store the per-recipient identifier in runtimeMessageId. Picking the wrong one means every BounceLog ends up with NULL tracking_log_id — the bounce arrives but nothing in the host's UI will ever show it.
5. The race between send() and tracking_logs INSERT
The host's SendMessage job calls driver->send() first, then inserts the TrackingLog row. Vendors can deliver a bounce or complaint webhook before the INSERT commits — milliseconds-scale race that is real in production.
The host already handles this at the listener level: RecordBounce and RecordComplaint retry the lookup for up to 5 seconds before giving up. Plugin authors do not need to do anything special, but should NOT:
- Pre-INSERT a TrackingLog before
send() — every send becomes two DB round-trips even on the fast path.
- Run
send() inside an outer transaction — the TrackingLog INSERT becomes invisible until the outer commit, widening the race window.
Activate + verify recipe
After dropping the plugin folder under storage/app/plugins/<vendor>/<name>/, register and activate it through tinker, then run five smoke checks:
# 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 after the five checks pass:
- Login as admin → Sending Servers → Create. The "Plugin Servers" block should now show your card.
- Click your card. The form renders with the fields declared in
validationRules()['cols'].
- Save with valid credentials. The host runs Phase 1 (rules) then Phase 2 (
driver->test()); both pass and the row commits.
- Edit page. Four tabs: Connection (your blade) + Configuration / Sender Identity / Warmup (host-rendered).
Testing checklist
| Test | How |
| Driver class loads without syntax error | php artisan tinker --execute="new MyVendor\Sending\MyVendorDriver(new App\Model\SendingServer(['type' => 'myvendor-api']))" |
test() succeeds with valid credentials | Run a curl against the same probe endpoint with the same key — both should return the same shape |
test() fails gracefully with bad credentials | Set wrong my_api_key → expect TestResult::failure with the vendor's actual error message, not a Laravel exception trace |
| Form fields submit correctly | Save with valid creds → DB row's config JSON has every key listed in config_keys |
| Webhook intake parses bounce shape | POST a real sample bounce payload → parseWebhook yields BounceReceived with the correct runtimeMessageId |
| Webhook signature verification (if implemented) | POST with an invalid signature → verifyWebhook throws |
| Plugin uninstall cleans up | App\Model\Plugin::find($id)->delete() → no orphan sending_servers rows of this type |
validationRules() covers every field name in config_keys | Diff array_keys(validationRules()['cols']) against config_keys payload — must match exactly |
For end-to-end live tests, the host ships an aws-e2e/ test harness that bootstraps a real SendingServer row, creates a test list, runs a campaign, and asserts on the bounce/complaint flow. Mirror its three-script pattern (10-bootstrap-emma-{vendor}.php + 11-create-test-list.php + 12-run-test-campaign.php) for live regression coverage.
Filesystem template
The fastest path to a working plugin is cloning the Postal plugin and renaming. Six edits and a few 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
The Postal plugin is a useful reference for shape but its API client is Postal-specific — replace, do not adapt. The acelle/ai showcase covers the canonical complex plugin if you need a different reference for testing patterns, sidebar UI, or admin pages.
Where to go next
Sending drivers and payment gateways are the two heaviest "ship a feature plugin" worked examples. The shapes are similar — both ship a single REGISTRY hook, a class with a small required-method surface, and a connection blade — but the lifecycles differ significantly. Sending drivers receive webhooks (push); payment gateways pull state on a sync schedule (no webhook). The next page covers the payment-gateway pattern with Paddle as the worked example.
When the driver is shipped and live, Testing covers the lifecycle-integration test recipe (activate → test-send → delete) that proves your delete_plugin_* listener cleans up correctly. The acelle/ai showcase walks the canonical complex plugin if you need a heavier reference codebase.