Clients
Forrst clients provide a type-safe way to consume Forrst services from other Laravel applications or PHP projects.
Installation
Section titled “Installation”The Forrst package includes client capabilities built on Saloon:
composer require cline/forrstQuick Start
Section titled “Quick Start”Create a Connector
Section titled “Create a Connector”<?php
namespace App\Clients;
use Cline\Forrst\Requests\ForrstConnector;
class UserServiceConnector extends ForrstConnector{ public function __construct( private string $baseUrl, private string $apiToken, ) {}
public function resolveBaseUrl(): string { return $this->baseUrl; }
protected function defaultHeaders(): array { return [ 'Authorization' => 'Bearer ' . $this->apiToken, 'Content-Type' => 'application/json', ]; }}Create a Request
Section titled “Create a Request”<?php
namespace App\Clients\Requests;
use Cline\Forrst\Requests\ForrstRequest;
class ListUsersRequest extends ForrstRequest{ public function __construct( private int $page = 1, private int $perPage = 15, ) {}
public function getFunction(): string { return 'urn:acme:forrst:fn:users:list'; }
public function getVersion(): string { return '1.0.0'; }
public function getArguments(): array { return [ 'page' => $this->page, 'per_page' => $this->perPage, ]; }}Send the Request
Section titled “Send the Request”use App\Clients\UserServiceConnector;use App\Clients\Requests\ListUsersRequest;
$connector = new UserServiceConnector( baseUrl: config('services.user_service.url'), apiToken: config('services.user_service.token'),);
$response = $connector->send(new ListUsersRequest(page: 1, perPage: 25));
// Access the result$users = $response->result();
// Check for errorsif ($response->hasErrors()) { $errors = $response->errors();}Request Building
Section titled “Request Building”Basic Request
Section titled “Basic Request”class GetUserRequest extends ForrstRequest{ public function __construct( private int $userId, ) {}
public function getFunction(): string { return 'urn:acme:forrst:fn:users:get'; }
public function getArguments(): array { return ['id' => $this->userId]; }}Request with Extensions
Section titled “Request with Extensions”class CreateOrderRequest extends ForrstRequest{ public function __construct( private array $orderData, private string $idempotencyKey, ) {}
public function getFunction(): string { return 'urn:acme:forrst:fn:orders:create'; }
public function getArguments(): array { return $this->orderData; }
public function getExtensions(): array { return [ 'idempotency' => [ 'key' => $this->idempotencyKey, ], ]; }}Request with Tracing Context
Section titled “Request with Tracing Context”class ProcessPaymentRequest extends ForrstRequest{ public function __construct( private array $paymentData, private string $traceId, private string $spanId, ) {}
public function getFunction(): string { return 'urn:acme:forrst:fn:payments:process'; }
public function getArguments(): array { return $this->paymentData; }
public function getContext(): array { return [ 'trace_id' => $this->traceId, 'span_id' => $this->spanId, ]; }}Response Handling
Section titled “Response Handling”Basic Response Access
Section titled “Basic Response Access”$response = $connector->send(new GetUserRequest(userId: 123));
// Get the result$user = $response->result();
// Get specific fields$userName = $response->result('name');$userEmail = $response->result('email');
// Get the full response data$data = $response->json();Error Handling
Section titled “Error Handling”$response = $connector->send(new CreateOrderRequest($data, $key));
if ($response->hasErrors()) { foreach ($response->errors() as $error) { logger()->error('Forrst error', [ 'code' => $error['code'], 'message' => $error['message'], 'details' => $error['details'] ?? null, ]); }
throw new OrderCreationException($response->errors());}
return $response->result();Extension Response Data
Section titled “Extension Response Data”$response = $connector->send(new ListUsersRequest());
// Get caching extension data$caching = $response->extension('caching');$etag = $caching['etag'] ?? null;$cacheStatus = $caching['cache_status'] ?? null;
// Get rate limit data$rateLimit = $response->extension('rate_limit');$remaining = $rateLimit['remaining'] ?? null;Response DTO Mapping
Section titled “Response DTO Mapping”Map responses to Data Transfer Objects:
use Spatie\LaravelData\Data;
class UserData extends Data{ public function __construct( public int $id, public string $name, public string $email, public string $createdAt, ) {}}$response = $connector->send(new GetUserRequest(userId: 123));
// Map to DTO$user = UserData::from($response->result());
echo $user->name; // "Jane Doe"Collection Mapping
Section titled “Collection Mapping”$response = $connector->send(new ListUsersRequest());
// Map to collection of DTOs$users = UserData::collect($response->result());
foreach ($users as $user) { echo $user->email;}Connector Configuration
Section titled “Connector Configuration”Authentication
Section titled “Authentication”class UserServiceConnector extends ForrstConnector{ protected function defaultAuth(): ?Authenticator { return new TokenAuthenticator(config('services.user.token')); }}Middleware
Section titled “Middleware”class UserServiceConnector extends ForrstConnector{ public function __construct() { $this->middleware()->onRequest(function (PendingRequest $request) { $request->headers()->add('X-Request-ID', Str::uuid()); });
$this->middleware()->onResponse(function (Response $response) { logger()->info('Forrst response', [ 'status' => $response->status(), 'duration' => $response->getRequestTime(), ]); }); }}Retry Logic
Section titled “Retry Logic”class UserServiceConnector extends ForrstConnector{ public function __construct() { $this->sender( new RetrySender( maxAttempts: 3, delay: 1000, // ms multiplier: 2, ) ); }}Timeout Configuration
Section titled “Timeout Configuration”class UserServiceConnector extends ForrstConnector{ protected function defaultConfig(): array { return [ 'timeout' => 30, 'connect_timeout' => 5, ]; }}Service Abstraction
Section titled “Service Abstraction”Create a service class for cleaner API:
<?php
namespace App\Services;
class UserService{ public function __construct( private UserServiceConnector $connector, ) {}
public function list(int $page = 1, int $perPage = 15): Collection { $response = $this->connector->send( new ListUsersRequest($page, $perPage) );
return UserData::collect($response->result()); }
public function find(int $id): ?UserData { $response = $this->connector->send(new GetUserRequest($id));
if ($response->hasErrors()) { return null; }
return UserData::from($response->result()); }
public function create(array $data): UserData { $response = $this->connector->send( new CreateUserRequest($data, Str::uuid()) );
if ($response->hasErrors()) { throw new UserCreationException($response->errors()); }
return UserData::from($response->result()); }}Service Registration
Section titled “Service Registration”public function register(): void{ $this->app->singleton(UserServiceConnector::class, function ($app) { return new UserServiceConnector( baseUrl: config('services.user_service.url'), apiToken: config('services.user_service.token'), ); });
$this->app->singleton(UserService::class);}class OrderController extends Controller{ public function __construct( private UserService $users, ) {}
public function store(Request $request) { $user = $this->users->find($request->user_id);
if (!$user) { return response()->json(['error' => 'User not found'], 404); }
// Create order... }}Testing
Section titled “Testing”Mock Responses
Section titled “Mock Responses”use Saloon\Laravel\Facades\Saloon;
test('lists users from service', function () { Saloon::fake([ ListUsersRequest::class => MockResponse::make([ 'protocol' => ['name' => 'forrst', 'version' => '0.1.0'], 'id' => 'test-001', 'result' => [ ['id' => 1, 'name' => 'Jane', 'email' => 'jane@example.com'], ], ]), ]);
$service = app(UserService::class); $users = $service->list();
expect($users)->toHaveCount(1) ->and($users->first()->name)->toBe('Jane');});Assert Requests
Section titled “Assert Requests”test('sends correct request payload', function () { Saloon::fake([ CreateUserRequest::class => MockResponse::make(['result' => [...]]), ]);
$service = app(UserService::class); $service->create(['name' => 'John', 'email' => 'john@example.com']);
Saloon::assertSent(function (Request $request) { $body = json_decode($request->body()->all(), true);
return $body['call']['function'] === 'urn:acme:forrst:fn:users:create' && $body['call']['arguments']['name'] === 'John'; });});Error Response Testing
Section titled “Error Response Testing”test('handles not found error', function () { Saloon::fake([ GetUserRequest::class => MockResponse::make([ 'protocol' => ['name' => 'forrst', 'version' => '0.1.0'], 'id' => 'test-001', 'result' => null, 'errors' => [ ['code' => 'NOT_FOUND', 'message' => 'User not found'], ], ]), ]);
$service = app(UserService::class); $user = $service->find(999);
expect($user)->toBeNull();});Best Practices
Section titled “Best Practices”Centralize Configuration
Section titled “Centralize Configuration”return [ 'user_service' => [ 'url' => env('USER_SERVICE_URL'), 'token' => env('USER_SERVICE_TOKEN'), 'timeout' => env('USER_SERVICE_TIMEOUT', 30), ],];Handle Transient Failures
Section titled “Handle Transient Failures”class ResilientConnector extends ForrstConnector{ public function __construct() { $this->sender( new RetrySender( maxAttempts: 3, delay: 500, multiplier: 2, retryOnStatusCodes: [429, 500, 502, 503, 504], ) ); }}Circuit Breaker Pattern
Section titled “Circuit Breaker Pattern”use Staudenmeir\LaravelMigrationViews\Facades\Schema;
class UserServiceConnector extends ForrstConnector{ public function send(Request $request, MockClient $mockClient = null): Response { if ($this->isCircuitOpen()) { throw new ServiceUnavailableException('User service circuit is open'); }
try { $response = parent::send($request, $mockClient); $this->recordSuccess(); return $response; } catch (Throwable $e) { $this->recordFailure(); throw $e; } }}Next Steps
Section titled “Next Steps”- Servers - Build Forrst servers that clients consume
- Functions - Implement the functions clients call
- Extensions - Understand extension data in responses