Going Further

Platform Apps

How the project wraps every third-party vendor — Wathq, Yaqeen, payment gateways, AI providers — behind a uniform Connector/Request/Contract shape under `src/Domain/Integration/{Vendor}/`.

Every call out to a third-party API — a commercial-register lookup at Wathq, a driver-data inquiry at Yaqeen, a payment at Moyasar, a chat completion at OpenAI — goes through the same shape: a row in platform_apps for credentials and an on/off toggle, a Connector that owns the base URL and auth, one Request class per endpoint, and a single Contract that the rest of the codebase talks to. The folder is src/Domain/Integration/{Vendor}/.

The canonical reference is Wathq. Open src/Domain/Integration/Wathq/ and you can read every file this chapter describes. To add a vendor, copy that shape and substitute names. Single-vendor integrations (Wathq, Yaqeen, Athr, Bayan, Fatoora, GoogleMaps, OpenAi, Tamm, Moyasar, …) all follow it; the multi-vendor façades — PaymentGateway, AiGateway — are a Connector-less variant covered at the end.

What a Platform App is

A Platform App is a row in platform_apps representing one vendor. It carries:

ColumnPurpose
codeStable string identifier (wathq), matching a PlatformAppCode enum case.
is_activeRuntime toggle. When false, every UI button, route, and helper check for the vendor short-circuits — the integration becomes invisible.
is_requiredAdmin-UI flag for vendors the system cannot run without (cannot be disabled).
configCredentials, URLs, timeouts. Encrypted at rest via an AsEncryptedArrayObject cast.
last_used_atTouched after every successful call (informational).

The config column is never read directly. All access goes through the platformConfig($code) helper, which returns a Domain\PlatformApp\Helpers\PlatformAppConfig instance and layers an in-memory memo over Redis over MySQL — decryption happens only on a cache miss.

When to reach for it

Use this pattern for any outbound call to a system you do not own. Going through it gets you, for free: transaction logging to platform_transactions, credential scrubbing, fake-mode in development, rate limiting, and automatic Telegram alerts on failure. A raw Http::post('https://vendor.sa/...') inside an Action bypasses all of that — it is the headline anti-pattern.

The two layers

Domain\Integration\Http\        Connector, Request, Pipeline, Response,
                                Authenticators, Bodies — shared foundation.
Domain\PlatformApp\             PlatformAppCode enum, PlatformAppConfig /
                                PlatformAppStatus helpers, the models.
        │  you extend these, never modify them
        ▼
Domain\Integration\Wathq\       Connectors/, Requests/, DataTransferObjects/,
                                Contracts/, Services/, Providers/ — what you build.

Cross-domain consumers (an Order action, a controller, a job) talk to a vendor only through app(WathqContract::class). They never import anything from Connectors/, Requests/, or Services/ — those are internal to the integration.

The directory

src/Domain/Integration/Wathq/
├── Actions/              # cached-lookup orchestration (optional)
├── Connectors/           # WathqCommercialConnector, WathqAddressConnector
├── Contracts/            # WathqContract — the single public interface
├── DataTransferObjects/  # input + output DTOs (use Arrayable)
├── Enums/                # cache-key enums for cached lookups (optional)
├── Providers/            # WathqServiceProvider — binds Contract → Service
├── Requests/             # one per endpoint, extends Integration\Http\Request
└── Services/             # WathqService — implements the Contract

The recipe

Build it in this order: enum case → seeder row → DTOs → Connector → Request → Contract → Service → Provider.

1. Enum case and seeder row

Add the case to src/Domain/PlatformApp/Enums/PlatformAppCode.php:

case WATHQ = 'wathq';

The enum already exposes ->value ('wathq') and ->middleware() ('platformApp:wathq'). Never write helpers for those; use the enum.

Add a row to database/seeders/PlatformAppSeeder.php and run it. Credentials come from env() — never hard-code them, and add the keys to .env.example.

[
    'code'        => 'wathq',                  // ⇄ PlatformAppCode::WATHQ->value
    'is_active'   => true,
    'is_required' => false,
    'names'       => ['ar' => 'واثق', 'en' => 'Wathq'],
    'config'      => [                          // encrypted at rest
        'commercial_url'     => env('WATHQ_PLATFORM_COMMERCIAL_URL'),
        'commercial_api_key' => env('WATHQ_PLATFORM_COMMERCIAL_API_KEY'),
    ],
],

2. DTOs

Input and output DTOs follow the project DTO idiom: final class with per-property readonly, and use Arrayable; (the Domain\Core\Traits\Arrayable trait, which supplies toArray(), only(), fromArray(), and the chainable filters). No class-level docblock — inline // WHY comments only.

Use final class with readonlyproperties, not final readonly class. The class-level modifier breaks the trait's private static reflection state. This holds for every Integration DTO, whether or not it currently uses the trait.

The input DTO is named after the operation:

final class CommercialInfoRequest
{
    use Arrayable;

    public function __construct(
        public readonly string $crEntityNumber,
    ) {}
}

The response DTO always carries public readonly array $raw (the original upstream payload) and a static fromArray() factory with defensive casts, so an upstream shape change never throws:

final class CommercialInfoResponse
{
    use Arrayable;

    public function __construct(
        public readonly ?string $name,
        public readonly ?string $crNumber,
        public readonly ?int $statusId,
        public readonly array $raw,   // original upstream payload
    ) {}

    public static function fromArray(array $data): self
    {
        return new self(
            name: isset($data['name']) ? (string) $data['name'] : null,
            crNumber: isset($data['crNumber']) ? (string) $data['crNumber'] : null,
            statusId: isset($data['status']['id']) ? (int) $data['status']['id'] : null,
            raw: $data,
        );
    }
}

Keeping $raw matters: controllers return $response?->raw ?? [] so the JSON contract to legacy frontends stays identical. All post-processing — key remapping, Hijri→Gregorian conversion, fallback resolution — lives inside fromArray(). When upstream keys map 1:1 to property names, drop the custom factory and let the trait's generic fromArray() do the work.

Two patterns are allowed. (A) Noun-phrase XxxRequest for the DTO, verb-prefixed HTTP class (CommercialInfoRequest + GetCommercialInfoRequest) — the Wathq default. (B) Verb+noun XxxData when the verb is part of the operation's identity (CreateTripData + CreateTripRequest) — the Bayan style. Value objects (plate, address, location) use a bare noun or XxxResult. Don't mix A and B inside one vendor folder.

3. Connector

A Connector describes one logical endpoint group: base URL, auth, retries. A vendor can have several when endpoint groups use different credentials — Wathq has a Commercial and an Address connector for exactly that reason.

use Domain\Integration\Http\Authenticators\HeaderAuth;
use Domain\Integration\Http\Connector;
use Domain\Integration\Http\Contracts\Authenticator;
use Domain\PlatformApp\Enums\PlatformAppCode;

final class WathqCommercialConnector extends Connector
{
    public function __construct(
        private readonly string $apiKey,
        private readonly string $url,
    ) {}

    public static function fromConfig(): self
    {
        return new self(
            apiKey: (string) platformConfig(PlatformAppCode::WATHQ)->get('commercial_api_key'),
            url: (string) platformConfig(PlatformAppCode::WATHQ)->get('commercial_url'),
        );
    }

    public function baseUrl(): string { return $this->url; }

    public function platformAppCode(): PlatformAppCode { return PlatformAppCode::WATHQ; }

    public function authenticator(): Authenticator
    {
        return new HeaderAuth(['apiKey' => $this->apiKey]);
    }
}

baseUrl() and platformAppCode() are the only required methods. Reach for the Authenticators\ classes — BearerAuth, HeaderAuth, QueryAuth, BasicAuth, OAuthClientCredentialsAuth — rather than hand-rolling headers(); the auth header is then scrubbed from stored payloads automatically. Other optional overrides include headers(), query(), config() (timeouts), retries(), and the failure hooks below.

fromConfig() is the convention. Services call WathqCommercialConnector::fromConfig() — never new-up a Connector with raw credentials in domain code. Tests may construct with explicit args.
Keep secrets out of stored payloads. Every request and response is persisted to platform_transactions and is visible to operators. RequestScrubber (src/Domain/Integration/Http/Helpers/RequestScrubber.php) is the single gate that redacts secrets. The Authenticators\ classes are already covered. If your vendor passes a secret under a new header, query key, body key, or in the URL path, add the canonical spelling to the matching list in RequestScrubber and confirm the value renders as [REDACTED]. Matching is case- and separator-insensitive, so app-id covers app_id and appId.

4. Request

One Request per endpoint, extending Domain\Integration\Http\Request. endpoint() is the only required override.

use Domain\Integration\Http\Request;
use Domain\Integration\Http\Response;
use Domain\PlatformApp\Enums\PlatformTransactionAction;

final class GetCommercialInfoRequest extends Request
{
    public function __construct(public readonly CommercialInfoRequest $data) {}

    public function endpoint(): string
    {
        return "/fullinfo/{$this->data->crEntityNumber}";
    }

    public function action(): PlatformTransactionAction
    {
        return PlatformTransactionAction::IMPORT;
    }

    public function dto(Response $response): CommercialInfoResponse
    {
        return CommercialInfoResponse::fromArray($response->json() ?? []);
    }

    public function fake(): ?array   // shaped exactly like the real upstream
    {
        return [
            'name'     => fake()->company(),
            'crNumber' => $this->data->crEntityNumber,
            'status'   => ['id' => 1],
        ];
    }
}

Name the class for its HTTP intent: Get{X}Request, List{X}Request, Create{X}Request, Update{X}Request, Cancel{X}Request, Verify{X}Request. Optional overrides include method() (defaults GET), body(), bodyType(), action() (stored on the transaction; defaults INQUIRY), and dto().

For requests that carry a body, use a body trait instead of overriding bodyType() by hand: HasJsonBody, HasFormBody, or HasMultipartBody from Domain\Integration\Http\Bodies\. The trait sets the body type and Content-Type; you just supply body() (usually return $this->data->toBody();).

5. Contract and Service

One interface per vendor, with method names in domain language — importCommercialInfo, not get. This Contract is the only way external code reaches the vendor, so keep it narrow.

interface WathqContract
{
    public function importCommercialInfo(CommercialInfoRequest $data): CommercialInfoResponse;

    public function importAddress(AddressRequest $data): ?AddressResponse;

    /** @return list<LookupResponse> */
    public function listActivities(LookupRequest $data = new LookupRequest): array;
}

The Service implements it, and every method is a three-line one-liner: build the Connector, send the Request, take the DTO.

final class WathqService implements WathqContract
{
    public function importCommercialInfo(CommercialInfoRequest $data): CommercialInfoResponse
    {
        return WathqCommercialConnector::fromConfig()
            ->send(new GetCommercialInfoRequest($data))
            ->dto();
    }
}
If a Service method grows past about five lines — or contains Log::, json_decode, body composition, or response shaping — the logic is in the wrong place. It belongs in the Request's body trait, the Response's fromArray(), or a dedicated Action in the calling domain.

6. ServiceProvider

Bind the Contract to the Service, then append the provider to the umbrella registry. That is the whole wiring.

final class WathqServiceProvider extends ServiceProvider
{
    public array $bindings = [
        WathqContract::class => WathqService::class,
    ];
}
// Domain\Integration\Providers\IntegrationServiceProvider
private array $integrationProviders = [
    WathqServiceProvider::class,
    YaqeenServiceProvider::class,
    // ...
];

IntegrationServiceProvider is itself registered in bootstrap/providers.php. Never add an individual vendor provider there — go through the umbrella.

Cached lookups (optional)

Some vendors ship slow-changing lookup tables (Wathq has business types, activities, CR statuses). Store them in the vendor's own encrypted config, keyed by a cache-key enum. The read path is $contract->cachedActivities() returning a Collection; the write path is an artisan command calling $contract->refreshCachedLookups(), which fetches via the API and calls platformConfig(PlatformAppCode::WATHQ)->set($key, …). The set() call persists the encrypted column and flushes the config cache for you — no manual invalidation.

Reading credentials

Always through platformConfig($code). The accessors are instance methods, except the static mask() / flush().

// one value (optional 2nd-arg default)
$apiKey = (string) platformConfig(PlatformAppCode::WATHQ)->get('commercial_api_key');

// write a value — persists to the encrypted column and flushes the cache
platformConfig(PlatformAppCode::WATHQ)->set('commercial_api_key', $value);

// masked bag for display — sensitive values redacted
$safe = platformConfig(PlatformAppCode::WATHQ)->masked();
❌ Bad✅ Good
config('services.wathq.api_key')platformConfig(PlatformAppCode::WATHQ)->get('commercial_api_key')
$model->config['api_key']platformConfig($code)->get('api_key')

Integration credentials live only in platform_apps.config. config/services.php is for non-platform settings (cookie domains, SDK keys that don't fit the platform-app model). Reading the column directly skips the cache and the display-path masking.

Gating: directive and middleware

A disabled app's platformConfig() returns empty, so an ungated call builds an empty-URL Connector and the Pipeline throws. Every call path needs exactly one gate.

In Blade, wrap any element that triggers a vendor call. When is_active is false, the element disappears.

@can('cp.companies.import')
    @platformApp(\Domain\PlatformApp\Enums\PlatformAppCode::WATHQ)
        <button onclick="importCompanyData()">{{ trans('buttons.import_cr') }}</button>
    @endplatformApp
@endcan

The directive sits inside @can(...) — it handles the vendor toggle, not authorization — and takes a PlatformAppCode case, never a string.

For routes, use the middleware. It runs per-request, so it survives php artisan route:cache.

Route::middleware(PlatformAppCode::WATHQ->middleware())->group(function () {
    Route::post('import_company_data', [ImportCompanyDataController::class, 'crData']);
});

// any-of — enabled if ANY listed app is on
Route::middleware('platformApp:wathq,yaqeen')->group(/* … */);

For automatic callers with no route or view — a Job, Observer, Command, or Listener — guard at the top and return a safe empty value:

if (! platformApp(PlatformAppCode::WATHQ)) {
    return;
}
One gate per path. Once route middleware is in place, remove any inline abort_unless(platformApp(...), 404) from the controller. Inline platformApp() checks are only for non-route contexts — FormRequest rules, view composers, automatic callers. is_active is the single source of truth; never branch on a config.enabled flag.

Controllers

Inject the Contract, never the Service or a Connector. Pass typed input DTOs, not arrays. Return $response?->raw ?? [] to preserve the JSON contract that legacy frontend JS depends on, overlaying derived keys when the DTO resolved them differently.

class ImportCompanyDataController extends Controller
{
    public function __construct(
        private readonly WathqContract $wathq,
    ) {}

    public function crData(Request $request): array
    {
        $request->validate(['cr_entity_number' => 'required|digits:10']);

        $response = $this->wathq->importCommercialInfo(
            new CommercialInfoRequest($request->input('cr_entity_number')),
        );

        return ['parties' => $response->parties] + $response->raw;
    }
}

Don't wrap the call in try/catch for transport errors. The Pipeline already logs to platform_transactions and throws HttpIntegrationException, which the global handler maps to a 502.

Fake mode

Any Request that runs in development should implement fake(): ?array, returning a payload shaped exactly like the real upstream so dto() produces the same DTO. Outside production, the Pipeline short-circuits with that payload before any HTTP call — this replaces the old if (isProduction()) { real } else { fake } branching that used to live in every action.

To hit the real upstream for one app in staging, an operator turns on its is_early_work flag; the Pipeline then bypasses fake() for that app only and makes real calls with the real config. No per-Request guard is needed.

Failure handling

Three hooks override on both Connector and Request (the Request-level override wins):

HookDefaultOverride when
failed()! $response->successful()The vendor signals failure inside a 2xx body — e.g. HTTP 200 with status: 2. Return null on real HTTP errors to defer.
onError()no-opA failure needs a side-effect (log, metric, alert) but the default exception is fine. Never throw here.
withCustomException()HttpException::fromResponse()You want a vendor-specific Throwable bubbling up — most often to wrap the vendor error in a translation key. Return null to defer to the next layer.

Override failed() only when failure isn't signalled by an HTTP status — return null for genuine non-2xx so the default still applies, and true only on the application-level error flag:

public function failed(Response $response): ?bool
{
    if (! $response->successful()) {
        return null;   // defer to the default for real HTTP errors
    }

    return (int) ($response->json()['status'] ?? 0) === 2;
}

For a one-shot token refresh on a 401, override Connector::recover(): mutate the PendingRequest with the new token and return true to retry once. It is called exactly once per send — never re-send the request yourself inside it.

Connector::middleware() and ResponseMiddleware stay for true response transformation — body normalization, header rewriting, cache-key extraction. A middleware class whose only job was to throw on failure should become a withCustomException() hook instead (this is how the Moyasar and AlrajhiBank throw-on-error classes were removed).

The TelegramOnFailure response middleware, applied by the Pipeline, reports every non-2xx and every transport failure to the api channel. Never add sendTelegramNotification(...) calls inside a service or action — that was the legacy per-vendor pattern this replaced.

Rate limits

The platform_app_rate_limits table and the counter infrastructure are live. To opt a vendor in, seed limit rows — periods are daily | weekly | monthly — and enforcement is automatic: the Pipeline checks the counter before each request and increments it on success. You only insert the rules; you never call the check or increment yourself. Counters go through the Cache facade (following CACHE_STORE), and a flushed cache rebuilds the period count from platform_transactions rather than silently resetting the limit.

Cross-vendor gateways

When a decision routes between two vendors — OpenAI vs DeepSeek, or HyperPay vs Moyasar vs AlrajhiBank — the routing does not belong inside the caller. Build a gateway integration: a Connector-less folder at Domain/Integration/{Name}/ (AiGateway, PaymentGateway) holding Actions, a Contract, and a Service. The gateway owns the platformApp() guard and the match between vendors; each vendor keeps its own Integration/{Vendor}/ folder. Callers depend only on the gateway Contract and pass model: null, letting the routing layer resolve the default from the platform enum or config.

❌ Bad✅ Good
A caller running its own match to pick OpenAi vs DeepSeekDepend on the AiGateway Contract; it owns the routing.
A new top-level Domain/Ai/ domain just to switch vendorsA gateway is still an integration — Domain/Integration/AiGateway/.
match (...) with a default => $fallback armExhaustive match with no default, so an unknown value throws \UnhandledMatchError.

Anti-patterns

❌ Bad✅ Good
Http::post('https://wathq.sa/...') in an ActionBuild a Request + Connector, call through the Contract.
app(WathqService::class)app(WathqContract::class) — bind the interface.
Importing a Connector, Request, or DTO outside the integrationReach only for the Contract; everything else is internal.
try/catch swallowing transport errorsLet HttpIntegrationException bubble — it is already logged and alerted.
$order->wathq_response = $response->raw['name']Store the whole $response->raw; extract fields later.
Inline if (isProduction()) { real } else { fake }Request::fake(); for live staging calls, the is_early_work flag.
A Response DTO whose fromArray() returns null on a missing fieldAlways return self with the field null and $raw populated — losing the upstream error message ("Plate not found") is a silent regression.
Replacing a legacy upstream message with a 'UNKNOWN' literalExpose public readonly ?string $message; callers read $response?->message ?? $response?->raw.

Verification checklist

After wiring a vendor, confirm the round-trip before opening the PR:

# Contract resolves to the Service
php artisan tinker
>>> app(\Domain\Integration\Wathq\Contracts\WathqContract::class)

# Config is readable and decrypted (not null, not the encrypted blob)
>>> platformConfig(\Domain\PlatformApp\Enums\PlatformAppCode::WATHQ)->get('commercial_api_key')

# Smoke the round-trip — in dev this uses fake()
>>> app(\Domain\Integration\Wathq\Contracts\WathqContract::class)
        ->importCommercialInfo(new \Domain\Integration\Wathq\DataTransferObjects\CommercialInfoRequest('1010123456'))

# A transaction was logged (app_id matches, action = import, status = success)
>>> \Domain\PlatformApp\Models\Entities\PlatformTransaction::latest()->first()

Then flip is_active off in the admin UI and confirm the @platformApp button disappears and the gated route 404s; flip it back and confirm both restore. Run ./vendor/bin/pint and ./vendor/bin/pest tests/Unit. The rule file also ships grep checks that mechanise the DTO conventions (no final readonly class, Arrayable present, no class-level docblock, no DTO/HTTP name clash) — run them before committing.

For tests: factory-create the PlatformApp row with is_active = true, call PlatformAppStatus::flush() so the cache doesn't serve a stale result, and choose your layer — the default fake() for unit tests, or is_early_work = true plus Http::fake() for the real HTTP path. Test the DTO's fromArray() mapping separately from the round-trip; they are independent units. Never hit a real upstream from an automated test.

  • Services and contracts — the Contract-binding pattern every integration follows.
  • DTOs — the Arrayable idiom the input and response objects use.
  • Actions — where orchestration belongs when a Service method would otherwise grow.
  • Feature flags — the other runtime toggle in the codebase, alongside the is_active gate here.
  • Anti-patterns — the project's review vocabulary; "go through the Contract" is the recurring instinct.