1. The package
Start the Agora connector — composer.json, the service provider, a Connector class that binds nothing yet, configuration, and the HTTP client every port will share.
composer.json
{
"name": "acme/subscriby-connector-agora",
"description": "Sell access to private Agora boards through Subscriby.",
"license": "MIT",
"require": {
"php": "^8.4",
"illuminate/contracts": "^13.0",
"illuminate/http": "^13.0",
"illuminate/support": "^13.0",
"subscriby/connector-sdk": "^1.0"
},
"autoload": {
"psr-4": { "Acme\\Connectors\\Agora\\": "src/" }
},
"extra": {
"laravel": {
"providers": ["Acme\\Connectors\\Agora\\AgoraConnectorServiceProvider"]
}
}
}The SDK depends on illuminate/contracts, illuminate/http and illuminate/support only; a connector adds nothing else it does not need. extra.laravel.providers is how the application discovers the package.
The service provider
<?php
declare(strict_types=1);
namespace Acme\Connectors\Agora;
use Subscriby\Connector\ConnectorServiceProvider;
use Subscriby\Connector\Contracts\Connector;
final class AgoraConnectorServiceProvider extends ConnectorServiceProvider
{
protected function connector(): Connector
{
return $this->app->make(AgoraConnector::class);
}
}That is the whole provider. The base class finds the package root as the grandparent of src/, reads connector.json from it, registers the connector, and loads database/migrations, resources/views (as connector-agora::), lang/*.json, routes/inbound.php (behind connector.inbound:agora), routes/web.php and any config/*.php. Two hooks exist for later: packageCommands() for console commands and packageListeners() for the SDK's events.
The Connector class
Start with the seven required ports as stubs so the package boots; each chapter replaces one.
<?php
declare(strict_types=1);
namespace Acme\Connectors\Agora;
use Acme\Connectors\Agora\Ports;
use Subscriby\Connector\Contracts\Connector;
use Subscriby\Connector\Contracts\ConnectorRegistrar;
use Subscriby\Connector\Contracts\Ports\FailureClassifier;
use Subscriby\Connector\Contracts\Ports\IdentityResolver;
use Subscriby\Connector\Contracts\Ports\InboundGateway;
use Subscriby\Connector\Contracts\Ports\InstallationLifecycle;
use Subscriby\Connector\Contracts\Ports\SettingsSchema;
use Subscriby\Connector\Contracts\Ports\TextRenderer;
use Subscriby\Connector\Contracts\Ports\UiSlots;
final class AgoraConnector implements Connector
{
public const string KEY = 'agora';
public function __construct(
private readonly Ports\AgoraInstallationLifecycle $lifecycle,
private readonly Ports\AgoraIdentityResolver $identities,
private readonly Ports\AgoraInboundGateway $gateway,
private readonly Ports\AgoraFailureClassifier $failures,
private readonly Ports\AgoraTextRenderer $renderer,
private readonly Ports\AgoraUiSlots $slots,
) {}
public function register(ConnectorRegistrar $registrar): void
{
$registrar->port(InstallationLifecycle::class, $this->lifecycle);
$registrar->port(IdentityResolver::class, $this->identities);
$registrar->port(InboundGateway::class, $this->gateway);
$registrar->port(FailureClassifier::class, $this->failures);
$registrar->port(TextRenderer::class, $this->renderer);
$registrar->port(UiSlots::class, $this->slots);
}
}SettingsSchema is missing on purpose: the manifest will declare the install fields and the core binds the SDK's ManifestSettingsSchema for us. The other capability ports (Messenger, AccessController, SpaceCatalog, PortalLoginMethod, RecoverySupport) join in their chapters; remember that the registry refuses a declared capability without its port, so the manifest and this class grow together.
Configuration
Anything the connector needs that is not per installation lives in config/connector-agora.php, merged under its own name:
<?php
declare(strict_types=1);
return [
'timeout' => (int) env('CONNECTOR_AGORA_TIMEOUT', 10),
'webhook_path' => 'endpoints/connectors/agora',
];Read it as config('connector-agora.timeout'). Nothing secret goes here: API keys and webhook secrets are per forum and live in the core's encrypted CredentialBag.
The HTTP client
Every port talks to Agora through one client, so the failure reading, the base URL and the timeout live in one place. It takes the installation's forum URL and key per call, because a port receives them per call.
<?php
declare(strict_types=1);
namespace Acme\Connectors\Agora;
use Illuminate\Http\Client\Factory;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Subscriby\Connector\Data\CredentialBag;
use Subscriby\Connector\Data\InstallationRef;
final class Agora
{
public function __construct(private readonly Factory $http) {}
public function for(InstallationRef $installation, CredentialBag $credentials): PendingRequest
{
return $this->client((string) $credentials->get('forum_url'), (string) $credentials->get('api_key'));
}
public function client(string $forumUrl, string $apiKey): PendingRequest
{
return $this->http
->baseUrl(rtrim($forumUrl, '/').'/api')
->withToken($apiKey)
->acceptJson()
->timeout((int) config('connector-agora.timeout'));
}
/**
* Agora answers refusals inside a 200 with `ok: false`; treat those as failures too.
*/
public function refused(Response $response): bool
{
return $response->failed() || $response->json('ok') === false;
}
}forum_url and api_key are the two fields the install form will declare in the next chapter; complete() will add the webhook id and secret to the same bag.
The model and its table
Agora needs us to remember one thing per forum the core does not keep: the webhook Agora registered for us, so we can delete it on disconnect. That is a table of our own, prefixed with the key:
Schema::create('agora_forums', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->uuid('installation_id')->nullable()->index();
$table->string('forum_url');
$table->string('bot_user_id');
$table->string('webhook_id')->nullable();
$table->timestamps();
});No secret lives here: the webhook signing secret goes to the core with the credentials (chapter 3). installation_id points into the core, which the data rules allow; the core row will point back at ours through storage_ref. No Schema::table on anything of the core's, ever: the kit reads the file and fails the package otherwise.
Boot it
With composer install, a connector.json from the next chapter and the six stubs returning empty values, the package registers: the kit's manifest rules pass and the registry lists agora. Nothing is available to creators until Subscriby switches the connector on, which is exactly right while we build.
How is this guide?
From Zero to a Forum Connector
Build a complete Subscriby connector for an imaginary forum with private boards, chapter by chapter — package, manifest, installation, webhooks, messages, boards, portal sign-in, slots, tests, and shipping.
2. The manifest
Write connector.json for Agora — every block with the decision behind it, and a first test that validates the file exactly as the application will at boot.