Skip to main content

Architecture

Promotes understanding vs over-abstraction - Five patterns, one stable platform

Core Principle

Abstract the Abstractable (CRUD): Pleni supports CRUD where it fits but doesn't pretend it covers everything. It offers multiple integration patterns to match different API shapes - each built on Laravel's native tooling and proven libraries like Saloon for HTTP.

How Plenipotentiary Works: The Flow

Your Application    →   Gateway     →   Adapter         →   External API
(You write) (Provided) (You write) (Third-party)

Your Application Domain

Controllers, Jobs, Commands You write this

Gateway

Stable, consistent contracts ✓ Plenipotentiary provides

Adapter

API integration logic You write this

External API

Stripe, Google, etc. Third-party service

What You Write vs What Plenipotentiary Provides

What You Write

Your application code and the Adapter (actual API integration logic). This is NOT a magic wrapper; you still implement the integration, but with structure and safety guardrails.

What Plenipotentiary Provides

The Gateway layer (stable contracts, validation, policies) and scaffolding commands to generate boilerplate.

Your Application Domain

Use any Laravel pattern you know - all return Result<T>

Consistent Result Interface

Every pattern returns a consistent Result<T> interface - whether Result<CanonicalDTO> (CRUD) or Result<\{UseCase\}DTO> (Operation). Predictable, testable, transport-agnostic. From simplest to most complex syntax:

1. Simplest: Basic Success Check

$result = $gateway->create($dto);

if ($result->isOk()) {
// Success! Use the canonical DTO
$campaign = $result->unwrap();
echo $campaign->externalId; // '12345'
}

2. Error Handling

$result = $gateway->update($dto);

if ($result->isErr()) {
// Provider error (network, API limit, etc.)
$error = $result->error();
Log::error('Update failed', $error);
return response()->json($error, 500);
}

3. Complete: Provider Errors + Raw Response Access

$result = $gateway->create($dto);
// Gateway already validated $dto against INPUT_SPEC before calling adapter
// If INPUT_SPEC failed, we wouldn't reach the adapter at all

// Provider rejected our data (Google's validation, not ours)
if ($result->isInvalid()) {
// Google Ads rejected the campaign (budget too low, invalid name, etc.)
$rawResponse = $result->rawResponse(); // Check Google's actual error
return response()->json([
'message' => 'Provider rejected data',
'violations' => $result->violations()
], 422);
}

// Provider error (network, API limit, auth failure, etc.)
if ($result->isErr()) {
return response()->json($result->error(), 500);
}

// Success: Get canonical DTO AND raw provider response
$campaign = $result->unwrap(); // Canonical DTO (consistent)
$rawResponse = $result->rawResponse(); // Provider response (for debugging)

Log::info('Campaign created', [
'externalId' => $campaign->externalId,
'resourceName' => $rawResponse->getResults()[0]->getResourceName(),
]);

What is rawResponse()?

unwrap() returns your domain DTO (CanonicalDTO for CRUD, {UseCase}DTO for Operation - consistent across providers).

rawResponse() returns the actual provider response (Google's MutateCampaignsResponse, Stripe's Charge object, eBay's SearchResponse, etc.).

Use it for: Debugging, logging provider-specific metadata, accessing fields not in your domain DTO.

Laravel Integration Examples

Every pattern returns Result<T> with the same methods: isOk(), isErr(), isInvalid(), unwrap(), rawResponse()

Controller

Option 1: Direct Gateway Usage

public function store(Request $req, CampaignGateway $gateway) {
$dto = CampaignCreateDTO::fromArray($req->validated());
$result = $gateway->create($dto);

return $result->isOk()
? response()->json($result->unwrap())
: response()->json($result->error(), 400);
}

Option 2: Via Action (recommended)

public function store(Request $req, CreateCampaignAction $action) {
$result = $action->handle($req->validated());

return $result->isOk()
? response()->json($result->unwrap())
: response()->json($result->error(), 400);
}

// The Action internally uses the Gateway:
// class CreateCampaignAction {
// public function __construct(private CampaignGateway $gateway) {}
// public function handle(array $data): Result {
// $dto = CampaignCreateDTO::fromArray($data);
// return $this->gateway->create($dto);
// }
// }

Job

class SyncCampaignsJob implements ShouldQueue {
public function handle(CampaignGateway $gateway) {
$result = $gateway->readMany(['status' => 'ENABLED']);

if ($result->isErr()) {
$this->fail($result->error());
return;
}

$campaigns = $result->unwrap();
// Sync to database...
}
}

Command

class CreateCampaignCommand extends Command {
public function handle(CampaignGateway $gateway) {
$dto = CampaignCreateDTO::fromArray([
'name' => $this->argument('name'),
'budget' => $this->option('budget'),
]);

$result = $gateway->create($dto);
// Gateway already checked INPUT_SPEC before calling adapter

// Provider (Google Ads) rejected our data
if ($result->isInvalid()) {
$this->error('Google Ads rejected the campaign:');

// Check raw response for Google's actual error details
$raw = $result->rawResponse();
$googleError = $raw->getPartialFailureError();

foreach ($result->violations() as $v) {
$this->line(" {$v['field']}: {$v['message']}");
}
return 1;
}

// Provider error (network, auth, rate limit, etc.)
if ($result->isErr()) {
$this->error($result->error()['message']);
return 1;
}

// Success: canonical DTO + raw response
$campaign = $result->unwrap();
$this->info("Created: {$campaign->externalId}");

// Access raw Google Ads response for detailed logging
$raw = $result->rawResponse();
$this->comment("Resource: {$raw->getResults()[0]->getResourceName()}");

return 0;
}
}

Action

Implementation:

class CreateCampaignAction extends Action {
public function __construct(
private CampaignGateway $gateway
) {}

public function handle(array $data): Result {
$dto = CampaignCreateDTO::fromArray($data);
return $this->gateway->create($dto);
}
}

Usage:

$result = CreateCampaignAction::run(['name' => 'Black Friday']);

// Same Result<T> interface everywhere
if ($result->isOk()) {
$campaign = $result->unwrap();
$rawResponse = $result->rawResponse();
}

Five Gateway/Adapter Patterns

Different patterns for different use cases. Pick the pattern that matches your use case, not a one-size-fits-all wrapper. You can use multiple patterns with the same API. These patterns help you handle heterogeneous integrations (SDKs, REST, SOAP) with a consistent interface.

1. CRUD Pattern - Abstractable Resource Lifecycle

Full lifecycle management with Create/Read/Update/Delete operations on resource-based APIs (campaigns, invoices, customers, products).

Transport: SDK or REST (Saloon)

Examples: Google Ads Campaigns, Stripe Customers, Shopify Products

Adapter Files:

  • CampaignCreate.php
  • CampaignRead.php
  • CampaignReadMany.php
  • CampaignUpdate.php
  • CampaignDelete.php

Gateway Methods:

  • create($dto)
  • read($id)
  • readMany($filter)
  • update($dto)
  • delete($id)

Repository: Optional Return Type: Result<CanonicalDTO>

2. Operation Pattern - Use Case Driven

Operations beyond CRUD that don't act on resource fields - search, generate, verify, calculate. If pausing a campaign (updating status field), use CRUD + Laravel Actions instead to avoid Gateway-calling-Gateway issues.

Transport: SDK or REST (Saloon)

Examples: eBay Browse Search, OpenAI Completions, Google Ads Reporting, Price Calculators

Adapter Files:

  • DTO/SearchItemsDTO.php
  • Adapter/SearchItems/SearchItemsOperation.php
  • Adapter/CreateCompletion/CreateCompletionOperation.php

Gateway Methods:

  • search($dto)
  • createCompletion($dto)

Repository: Optional/swappable Return Type: Result<\{UseCase\}DTO> Highlight: For operations that don't map to resource field changes

3. REST Pattern - Native Saloon

Clean RESTful APIs using Saloon's native Request/Response pattern. Two modes: (1) Operation-like use cases use {UseCase}DTO with Gateway for validation/policies, (2) Simple calls use pure Saloon without Gateway overhead. For CRUD operations, use the CRUD pattern instead.

Transport: REST (Saloon)

Examples: OpenAI Completions, Weather APIs, SendGrid Emails, GitHub API

Adapter Files:

  • Rest/Connector.php
  • Requests/CreateCompletionRequest.php
  • Requests/GetWeatherRequest.php
  • (Optional) DTO/CreateCompletionDTO.php

Gateway Methods:

  • Mode 1: $gateway->execute($completionDTO)
  • Mode 2: $connector->send(new GetWeatherRequest())

Repository: Flexible Return Type: Result<\{UseCase\}DTO> OR Saloon Response Highlight: For CRUD operations, use CRUD pattern. REST is for operations and simple calls.

4. Procedure Pattern - Rapid Prototyping

Quick prototyping with dynamic operation names for fast iteration and exploration.

Transport: SDK or REST (Saloon)

Examples: Admin Tools, Quick Scripts, Prototyping, One-off Tasks

Adapter Files:

  • Adapter/ProcedureAdapter.php (handles all)

Gateway Methods:

  • call($operation, $payload)

Repository: Optional/swappable Return Type: Result<mixed>

5. MCP Proxy Pattern - Controlled AI Agent Tool Access (Niche)

Proxy MCP servers through your Laravel app to add budget tracking, rate limiting, and audit trails when AI agents (Claude, ChatGPT) need controlled access to high-stakes tools (database, email, billing).

Transport: HTTP API → MCP (stdio/SSE)

Examples: Proxy Database MCP, Proxy Filesystem MCP, Proxy Slack MCP, Proxy Email MCP

Adapter Files:

  • Adapter/McpProxyAdapter.php
  • Support/McpServerConnector.php (stdio/SSE)
  • Http/Controllers/McpProxyController.php

Gateway Methods:

  • proxyToolCall($tool, $params)
  • forwardToMcpServer($request)

Repository: N/A (proxies existing MCP servers) Return Type: Result<McpToolResult> Highlight: Proxies existing MCP servers, doesn't create new ones

Gateway Layer: Your Stable Platform

Gateway = Stable platform. Your application calls the Gateway, never the vendor API directly. When provider APIs change, only the Adapter changes - your Gateway stays stable. All gateways automatically apply cross-cutting concerns through Laravel's native tools.

Cross-Cutting Concerns

The Gateway layer provides a consistent location to apply:

  1. Logging - Laravel Log facade
  2. Retries - Automatic backoff
  3. Idempotency - Laravel Cache
  4. Error Mapping - Domain exceptions
  5. Rate Limiting - Laravel RateLimiter
  6. Observability - Metrics & events

All applied via GatewayPolicy.

Team Collaboration via INPUT_SPEC

All adapters define INPUT_SPEC as their contract. When sharing adapters, INPUT_SPEC becomes an invaluable kickstart - self documenting errors ensures everyone knows exactly what fields are needed, validation rules, and defaults. This is what YOUR domain needs, not everything the API/SDK call supports (See step 4 in the developer workflow).

// CampaignCreate.php
public const INPUT_SPEC = [
'name' => [
'rules' => ['required', 'string', 'min:1', 'max:128'],
],
'status' => [
'rules' => ['nullable', 'in:ENABLED,PAUSED,REMOVED'],
],
'budgetMicros' => [
'rules' => ['nullable', 'numeric', 'min:0'],
],
'budgetResourceName' => [
'rules' => ['nullable', 'string'],
],
// customerId comes from providerContext - auto-injected
'providerContext.google.customerId' => [
'rules' => ['required', 'string'],
'source' => 'env:GOOGLE_ADS_LINKED_CUSTOMER_ID',
],
];

// Gateway validates automatically via INPUT_SPEC
// Teams immediately understand the contract

Adapter Layer

Gateway delegates to Adapters - Adapters contain your actual API integration code, implemented according to the pattern you selected (CRUD, Operation, REST, Procedure, or MCP Proxy). Each adapter translates between your domain (DTOs, INPUT_SPEC) and the provider's API (SDK calls, HTTP requests, SOAP).

When provider APIs change, you update the Adapter implementation - the Gateway contract and your application code stay stable.

You write this

External API

Adapters call External APIs - The third-party services your application integrates with: Stripe, Google Ads, Mailchimp, OpenAI, eBay, or any custom API. Your application never calls these directly - always through the Gateway → Adapter layer.

This indirection provides stability, testability, and consistent error handling across all your integrations.

Third-party service

Understanding the MCP Proxy Pattern: Controlled AI Tool Access

This is a niche pattern for when AI agents (Claude, ChatGPT) need access to high-stakes tools (database queries, email sending, billing operations) and you need budget tracking, rate limiting, and complete audit trails. Your Laravel app acts as a controlled proxy between the AI agent and existing MCP servers.

The Complete Flow

  1. User asks Claude Desktop: "Find all inactive customers and send re-engagement emails"
  2. Claude analyzes the request and decides it needs tools
  3. Claude calls YOUR Laravel API: POST /api/mcp/database/query_customers (configured to call your endpoint, not the MCP server directly)
  4. Your MCP Proxy Gateway checks budget, applies rate limit, logs request
  5. Gateway forwards to real MCP server → Database MCP executes query
  6. Results return to Claude via YOUR API (budget tracked: $0.01 spent)
  7. Claude analyzes: "Found 52 inactive customers, need to send emails"
  8. Claude calls YOUR endpoint 52 times: POST /api/mcp/email/send (all tracked)
  9. Gateway proxies to Email MCP server, tracks budget ($0.52 total), enforces rate limits
  10. Claude reports: "Sent 52 re-engagement emails" (complete audit trail logged)

When You Need MCP Proxy

Use MCP Proxy When:

  • AI agents need access to high-stakes tools (database, billing, customer data)
  • You need strict budget limits to prevent runaway costs
  • Compliance requires complete audit trails (GDPR, SOC2)
  • Rate limiting prevents system overload or provider blocking

Skip MCP Proxy When:

  • Tools are read-only and low-risk (documentation, logs)
  • Claude API's built-in token tracking is sufficient
  • You're comfortable with AI calling MCP servers directly
  • Simple logging at the conversation level is enough

Key Distinction: You're not building MCP servers (those already exist: @modelcontextprotocol/server-filesystem, server-slack, etc.). You're proxying them through Laravel HTTP endpoints to add budget tracking, rate limiting, and audit logging for high-stakes AI agent workflows. This is a niche pattern - most use cases can call Claude/ChatGPT APIs directly (Operation/REST patterns).