SimpleClient

Fluent cURL-based HTTP client with retry, file download, and concurrent pool support.

Alpha — v0.1.0. This module is in early testing. The API may change before a stable release. Feedback and bug reports are welcome.

A modern, fluent HTTP client for ProcessWire that makes consuming APIs effortless. Send HTTP requests, handle responses, upload files, manage authentication, execute concurrent requests, and download remote files - all with an elegant, chainable interface that feels natural in ProcessWire.

Features


  • Full HTTP Methods: GET, POST, PUT, PATCH, DELETE, HEAD with fluent API
  • Request Pooling: Execute multiple requests concurrently for better performance
  • Authentication: Built-in support for Bearer tokens and Basic Auth
  • File Operations: Upload files with multipart/form-data and download remote files
  • Smart Retry: Automatic retry logic with configurable attempts
  • Flexible Timeouts: Separate connection and request timeout settings
  • URL Templating: Dynamic URL parameters with {placeholder} syntax
  • Rich Responses: JSON parsing with dot notation, status helpers, header access
  • Error Handling: Comprehensive exception system with context and response data
  • Content Negotiation: Accept header helpers for JSON, HTML, and custom types
  • Security: URL scheme validation and sanitization built-in
  • Status Reference: Complete HTTP status code dictionary included

Installation


Install as a standalone ProcessWire module:

  1. Copy the SimpleClient folder to /site/modules/
  2. Go to Modules → Refresh in the ProcessWire admin
  3. Install SimpleClient

Quick Access


// Global function (recommended)
client('https://api.example.com')->get('/users');

// Module instance
wire()->simpleclient->newClient('https://api.example.com');

// Via module function
simpleclient()->newClient('https://api.example.com');

Quick Start


After installation, use the global client() function to make HTTP requests:

Basic Usage

<?php
// Simple GET request
$response = client('https://api.github.com')->get('/users/octocat');
echo $response->body();

// POST with JSON data
$response = client('https://api.example.com')
    ->withBody(json_encode(['name' => 'John', 'email' => 'john@example.com']))
    ->post('/users');

// Check response status
if ($response->successful()) {
    $data = $response->json();
    echo $data['id'];
}

// With authentication
$response = client('https://api.example.com')
    ->withToken('your-api-token')
    ->get('/protected-resource');

// Download a file
client()->download(
    'https://example.com/file.pdf',
    $config->paths->files . 'downloads/file.pdf'
);

With SimpleRouter Integration

<?php
// /site/templates/api-proxy.php
namespace ProcessWire;

$page->route("get:github/user/{username}", function($username) {
    try {
        $response = client('https://api.github.com')
            ->get("/users/{$username}");

        if ($response->notFound()) {
            return response()->json(['error' => 'User not found'], 404);
        }

        return response()->json($response->json());

    } catch (ClientException $e) {
        return response()->json(['error' => $e->getMessage()], 500);
    }
});

$result = $page->dispatchRoutes();
if ($result !== null) {
    echo $result;
}

Making Requests


HTTP Methods

// GET request
$response = client('https://api.example.com')->get('/users');

// POST request
$response = client('https://api.example.com')
    ->post('/users', ['name' => 'John', 'email' => 'john@example.com']);

// PUT request
$response = client('https://api.example.com')
    ->put('/users/123', ['name' => 'John Updated']);

// PATCH request
$response = client('https://api.example.com')
    ->patch('/users/123', ['email' => 'newemail@example.com']);

// DELETE request
$response = client('https://api.example.com')
    ->delete('/users/123');

// HEAD request (only checks if resource exists)
$response = client('https://api.example.com')->head('/users/123');

Base URL

// Set base URL to avoid repetition
$client = client('https://api.github.com');

$users = $client->get('/users');
$repos = $client->get('/repositories');
$orgs = $client->get('/organizations');

// Each request uses the base URL automatically

Request Body

// JSON body
$response = client('https://api.example.com')
    ->withBody(json_encode(['name' => 'Product', 'price' => 99.99]))
    ->post('/products');

// Form data
$response = client('https://api.example.com')
    ->asForm()
    ->post('/contact', [
        'name' => 'John Doe',
        'email' => 'john@example.com',
        'message' => 'Hello!'
    ]);

// Can also use withBody() directly
$response = client('https://api.example.com')
    ->withBody(['name' => 'John'])
    ->post('/users');

Headers


Setting Headers

// Add single header
$response = client('https://api.example.com')
    ->withHeaders(['X-Custom-Header' => 'value'])
    ->get('/data');

// Add multiple headers
$response = client('https://api.example.com')
    ->withHeaders([
        'X-API-Key' => 'your-key',
        'X-API-Version' => '2.0',
        'User-Agent' => 'MyApp/1.0'
    ])
    ->get('/data');

// Replace all headers
$response = client('https://api.example.com')
    ->replaceHeaders(['Content-Type' => 'application/json'])
    ->post('/data');

// Accept header helpers
$response = client('https://api.example.com')
    ->acceptJson()
    ->get('/data');

$response = client('https://api.example.com')
    ->acceptHtml()
    ->get('/page');

// Custom accept type
$response = client('https://api.example.com')
    ->accept('application/xml')
    ->get('/data');

Authentication


Bearer Token

// Bearer token authentication
$response = client('https://api.example.com')
    ->withToken('your-access-token')
    ->get('/protected-resource');

// Automatically adds: Authorization: Bearer your-access-token

Basic Authentication

// Basic auth
$response = client('https://api.example.com')
    ->withBasicAuth('username', 'password')
    ->get('/protected');

// Automatically adds: Authorization: Basic base64(username:password)

URL Parameters


Query Parameters

// Add query parameters
$response = client('https://api.example.com')
    ->withQueryParameters(['page' => 2, 'limit' => 50])
    ->get('/users');

// Results in: /users?page=2&limit=50

// Merge with an existing query string already in the URL
$response = client('https://api.example.com')
    ->withQueryParameters(['sort' => 'name', 'order' => 'asc'])
    ->get('/users?active=true');

// Results in: /users?active=true&sort=name&order=asc

URL Template Parameters

// Dynamic URL segments with placeholders
$response = client('https://api.example.com')
    ->withUrlParameters(['id' => 123, 'action' => 'activate'])
    ->get('/users/{id}/{action}');

// Results in: /users/123/activate

// Useful for RESTful APIs
$userId = 456;
$response = client('https://api.example.com')
    ->withUrlParameters(['userId' => $userId])
    ->get('/users/{userId}/posts');

File Operations


File Uploads

// Upload single file
$response = client('https://api.example.com')
    ->attach('avatar', '/path/to/avatar.jpg', 'image/jpeg', 'avatar.jpg')
    ->post('/users/123/avatar');

// Upload multiple files
$response = client('https://api.example.com')
    ->attach('document', '/path/to/doc.pdf', 'application/pdf', 'document.pdf')
    ->attach('image', '/path/to/img.png', 'image/png', 'image.png')
    ->post('/upload');

// Upload file with additional form data
$response = client('https://api.example.com')
    ->attach('file', '/path/to/file.zip', 'application/zip', 'archive.zip')
    ->withBody(['description' => 'Backup archive', 'category' => 'backups'])
    ->post('/files/upload');

File Downloads

// Download file
$success = client()->download(
    'https://example.com/large-file.zip',
    $config->paths->files . 'downloads/file.zip'
);

if ($success) {
    echo "File downloaded successfully!";
} else {
    echo "Download failed: " . client()->getError();
}

// Download with authentication
$success = client()
    ->withToken('api-token')
    ->download(
        'https://api.example.com/secure-file.pdf',
        '/path/to/save/file.pdf'
    );

// Directory is created automatically if it doesn't exist
$success = client()->download(
    'https://example.com/report.pdf',
    $config->paths->files . 'reports/2024/january/report.pdf'
);

Timeouts & Retry


Setting Timeouts

// Request timeout (total time for request)
$response = client('https://slow-api.com')
    ->timeout(60)  // 60 seconds
    ->get('/large-dataset');

// Connection timeout (time to establish connection)
$response = client('https://api.example.com')
    ->connectTimeout(5)  // 5 seconds
    ->get('/data');

// Both timeouts
$response = client('https://api.example.com')
    ->timeout(30)
    ->connectTimeout(10)
    ->get('/data');

Retry Logic

// Retry failed requests
$response = client('https://unreliable-api.com')
    ->retry(3)  // Retry up to 3 times
    ->get('/data');

// Combine with timeout
$response = client('https://api.example.com')
    ->timeout(10)
    ->retry(2)
    ->get('/data');

// If request fails, it will retry automatically
// Total attempts = retry_attempts + 1 initial attempt

Response Handling


Response Body

// Get raw body
$response = client('https://api.example.com')->get('/data');
$body = $response->body();

// Parse JSON
$data = $response->json();

// Get specific JSON key
$name = $response->json('name');

// Get nested value with dot notation
$city = $response->json('user.address.city');

// With default value
$country = $response->json('user.address.country', 'USA');

// Parse as object
$obj = $response->object();
echo $obj->name;

// Get as collection (array)
$items = $response->collect();
$users = $response->collect('users');  // Get specific key as array

Status Checking

// Get status code
$status = $response->status();  // 200, 404, etc.

// Status categories
if ($response->successful()) {  // 2xx
    echo "Success!";
}

if ($response->redirect()) {  // 3xx
    echo "Redirected";
}

if ($response->failed()) {  // 4xx or 5xx
    echo "Request failed";
}

if ($response->clientError()) {  // 4xx
    echo "Client error";
}

if ($response->serverError()) {  // 5xx
    echo "Server error";
}

Specific Status Codes

// Convenience methods for common status codes
if ($response->ok()) {  // 200
    echo "OK!";
}

if ($response->created()) {  // 201
    echo "Resource created";
}

if ($response->accepted()) {  // 202
    echo "Accepted for processing";
}

if ($response->noContent()) {  // 204
    echo "No content";
}

if ($response->notFound()) {  // 404
    echo "Not found";
}

if ($response->unauthorized()) {  // 401
    echo "Authentication required";
}

if ($response->forbidden()) {  // 403
    echo "Access denied";
}

if ($response->unprocessableEntity()) {  // 422
    echo "Validation failed";
}

if ($response->tooManyRequests()) {  // 429
    echo "Rate limit exceeded";
}

if ($response->internalServerError()) {  // 500
    echo "Server error";
}

if ($response->serviceUnavailable()) {  // 503
    echo "Service unavailable";
}

// See ClientResponse for complete list

Response Headers

// Get specific header (case-insensitive)
$contentType = $response->header('Content-Type');
$rateLimit = $response->header('X-RateLimit-Remaining');

// Get all headers
$headers = $response->headers();

// Convenience methods
$contentType = $response->getContentType();

// Check if response is JSON
if ($response->isJson()) {
    $data = $response->json();
}

// Get cookies from response
$cookies = $response->getCookies();
// Returns: ['session_id' => 'abc123', 'user_pref' => 'dark']

Request Pooling (Concurrent Requests)


Basic Pool Usage

// Execute multiple requests concurrently
$responses = client('https://api.example.com')
    ->pool(function($pool) {
        $pool->get('/users');
        $pool->get('/posts');
        $pool->get('/comments');
        return $pool->getHandles();
    });

// Responses are keyed by request order
$users = $responses[0]->json();
$posts = $responses[1]->json();
$comments = $responses[2]->json();

Named Pool Requests

// Name your requests for easier access
$responses = client('https://api.example.com')
    ->pool(function($pool) {
        $pool->as('users')->get('/users');
        $pool->as('posts')->get('/posts');
        $pool->as('comments')->get('/comments');
        return $pool->getHandles();
    });

// Access by name
$users = $responses['users']->json();
$posts = $responses['posts']->json();
$comments = $responses['comments']->json();

Pool with Options

// Pool with shared options
$responses = client('https://api.example.com')
    ->withToken('api-token')
    ->pool(function($pool) {
        // All requests inherit the token
        $pool->withQueryParameters(['limit' => 10])
            ->as('users')
            ->get('/users');

        $pool->withQueryParameters(['published' => true])
            ->as('posts')
            ->get('/posts');

        $pool->as('stats')
            ->get('/stats');

        return $pool->getHandles();
    });

// Different methods in pool
$responses = client('https://api.example.com')
    ->withToken('token')
    ->pool(function($pool) {
        $pool->as('list')->get('/items');
        $pool->as('create')->post('/items', ['name' => 'New Item']);
        $pool->as('update')->put('/items/123', ['name' => 'Updated']);
        $pool->as('delete')->delete('/items/456');
        return $pool->getHandles();
    });

Pool Performance Benefits

// Sequential (slow) - 3 seconds if each takes 1 second
$users = client('https://api.example.com')->get('/users');
$posts = client('https://api.example.com')->get('/posts');
$comments = client('https://api.example.com')->get('/comments');
// Total time: ~3 seconds

// Concurrent (fast) - 1 second total
$responses = client('https://api.example.com')
    ->pool(function($pool) {
        $pool->as('users')->get('/users');
        $pool->as('posts')->get('/posts');
        $pool->as('comments')->get('/comments');
        return $pool->getHandles();
    });
// Total time: ~1 second (all execute in parallel)

Status Utilities


Quick Status Check

// Get just the status code
$status = client()->status('https://example.com');
echo $status;  // 200, 404, 0 if failed

// Get status with text
$statusText = client()->statusText('https://example.com');
echo $statusText;  // "200 OK", "404 Not Found", "0 Error"

// Check if URL is accessible
if (client()->status('https://example.com/page') === 200) {
    echo "Page is accessible";
}

// Handle errors
$status = client()->status('https://invalid-domain.example');
if ($status === 0) {
    echo "Error: " . client()->getError();
}

HTTP Status Code Reference

// Get all HTTP status codes
$codes = client()->getHttpCodes();
// Returns: [100 => 'Continue', 200 => 'OK', 404 => 'Not Found', ...]

// Useful for documentation or status pages
foreach (client()->getHttpCodes() as $code => $description) {
    echo "{$code}: {$description}";
}

Error Handling


Namespace note: ClientException lives in the SimpleWire\Client namespace. In ProcessWire templates (which run in the ProcessWire namespace) use the fully-qualified class name \SimpleWire\Client\ClientException, or add use SimpleWire\Client\ClientException; at the top of your file.

Exception Handling

use SimpleWire\Client\ClientException;

try {
    $response = client('https://api.example.com')
        ->get('/data');

    echo $response->json('message');

} catch (ClientException $e) {
    // Get error message
    echo "Error: " . $e->getMessage();

    // Get error code
    echo "Code: " . $e->getCode();

    // Check if response is available
    if ($e->hasResponse()) {
        $response = $e->getResponse();
        echo "Status: " . $response->status();
        echo "Body: " . $response->body();
    }
}

Error Types

try {
    $response = client('https://api.example.com')->get('/data');

} catch (ClientException $e) {
    // Get error context
    $context = $e->getContext();
    $errorType = $context['type'] ?? 'unknown';

    // $context['type'] is either 'curl_error' or 'http_error'
    match ($errorType) {
        'curl_error' => handleCurlError($e),
        'http_error' => handleHttpError($e),
        default      => handleUnknownError($e)
    };
}

function handleHttpError($e) {
    $status = $e->getResponseStatus();
    $body = $e->getResponseBody();

    if ($status === 404) {
        echo "Resource not found";
    } elseif ($status === 401) {
        echo "Authentication required";
    } else {
        echo "HTTP Error {$status}: {$body}";
    }
}

Getting Last Error

// For methods that return bool (download, status)
$success = client()->download('https://example.com/file.zip', '/tmp/file.zip');

if (!$success) {
    $error = client()->getError();
    echo "Download failed: {$error}";
}

// Clear error
client()->clearError();

// Check status
$status = client()->status('https://example.com');
if ($status === 0) {
    echo client()->getError();
}

Security


URL Scheme Validation

// By default, only http and https are allowed
$response = client()->get('https://example.com');  // ✓ OK
$response = client()->get('http://example.com');   // ✓ OK
$response = client()->get('ftp://example.com');    // ✗ Exception

// Configure allowed schemes
client()
    ->setAllowSchemes(['http', 'https', 'ftp'])
    ->get('ftp://example.com');  // ✓ Now OK

// Get current allowed schemes
$schemes = client()->getAllowSchemes();
// Returns: ['http', 'https']

URL Validation

// URLs are validated using ProcessWire's sanitizer
// Configure validation options
client()
    ->setValidateURLOptions([
        'allowRelative' => false,
        'allowIDN' => true
    ])
    ->get('/api/data');

// Invalid URLs throw exceptions automatically

Complete Examples


GitHub API Integration

<?php
// Fetch user repositories from GitHub
function getGithubRepos(string $username): array
{
    try {
        $response = client('https://api.github.com')
            ->withHeaders(['User-Agent' => 'ProcessWire-App'])
            ->get("/users/{$username}/repos");

        if ($response->notFound()) {
            return ['error' => 'User not found'];
        }

        $repos = $response->collect();

        return array_map(function($repo) {
            return [
                'name' => $repo['name'],
                'description' => $repo['description'] ?? '',
                'stars' => $repo['stargazers_count'],
                'url' => $repo['html_url']
            ];
        }, $repos);

    } catch (ClientException $e) {
        return ['error' => $e->getMessage()];
    }
}

// Usage
$repos = getGithubRepos('torvalds');
foreach ($repos as $repo) {
    echo "{$repo['name']}: {$repo['stars']} stars\n";
}
?>

REST API CRUD Operations

<?php
class ProductAPI
{
    private \SimpleWire\Client\Client $client;

    public function __construct(string $apiKey)
    {
        $this->client = client('https://api.example.com')
            ->withToken($apiKey)
            ->acceptJson()
            ->timeout(30);
    }

    public function listProducts(int $page = 1): array
    {
        $response = $this->client
            ->withQueryParameters(['page' => $page, 'limit' => 20])
            ->get('/products');

        return $response->json('data', []);
    }

    public function getProduct(int $id): ?array
    {
        try {
            $response = $this->client->get("/products/{$id}");
            return $response->json();
        } catch (ClientException $e) {
            if ($e->getResponseStatus() === 404) {
                return null;
            }
            throw $e;
        }
    }

    public function createProduct(array $data): array
    {
        $response = $this->client
            ->withBody(json_encode($data))
            ->post('/products');

        if ($response->created()) {
            return $response->json();
        }

        throw new \Exception('Failed to create product');
    }

    public function updateProduct(int $id, array $data): array
    {
        $response = $this->client
            ->withBody(json_encode($data))
            ->put("/products/{$id}");

        return $response->json();
    }

    public function deleteProduct(int $id): bool
    {
        $response = $this->client->delete("/products/{$id}");
        return $response->successful();
    }
}

// Usage
$api = new ProductAPI('your-api-key');

// List products
$products = $api->listProducts(1);

// Get single product
$product = $api->getProduct(123);

// Create product
$newProduct = $api->createProduct([
    'name' => 'New Product',
    'price' => 99.99,
    'category' => 'Electronics'
]);

// Update product
$updated = $api->updateProduct(123, ['price' => 89.99]);

// Delete product
$api->deleteProduct(123);
?>

Parallel Data Fetching

<?php
// Fetch data from multiple sources concurrently
function getDashboardData(): array
{
    $responses = client('https://api.example.com')
        ->withToken('api-token')
        ->pool(function($pool) {
            $pool->as('users')->get('/stats/users');
            $pool->as('revenue')->get('/stats/revenue');
            $pool->as('orders')->get('/stats/orders');
            $pool->as('traffic')->get('/stats/traffic');
            return $pool->getHandles();
        });

    return [
        'users' => $responses['users']->json('total', 0),
        'revenue' => $responses['revenue']->json('total', 0),
        'orders' => $responses['orders']->json('count', 0),
        'traffic' => $responses['traffic']->json('visits', 0)
    ];
}

// This executes all 4 requests concurrently
// If each request takes 500ms, total time is ~500ms instead of ~2000ms
$data = getDashboardData();
echo "Users: {$data['users']}\n";
echo "Revenue: {$data['revenue']}\n";
?>

File Upload with Progress

<?php
$page->route("post:upload/document", function() {
    // Validate file
    if (!request()->hasFile('document')) {
        return response()->json(['error' => 'No file uploaded'], 400);
    }

    $file = request()->file('document');
    $tempPath = $file['tmp_name'];

    try {
        // Upload to external storage API
        $response = client('https://storage.example.com')
            ->withToken('storage-api-key')
            ->attach('file', $tempPath, $file['type'], $file['name'])
            ->withBody([
                'folder' => 'documents',
                'public' => false
            ])
            ->post('/upload');

        if ($response->created()) {
            $data = $response->json();

            return response()->json([
                'success' => true,
                'file_id' => $data['id'],
                'url' => $data['url']
            ]);
        }

        return response()->json(['error' => 'Upload failed'], 500);

    } catch (ClientException $e) {
        return response()->json(['error' => $e->getMessage()], 500);
    }
});
?>

Webhook Receiver with Verification

<?php
$page->route("post:webhooks/stripe", function() {
    // Verify webhook signature
    $signature = request()->header('Stripe-Signature');
    $payload = request()->body();

    if (!verifyStripeSignature($signature, $payload)) {
        return response()->json(['error' => 'Invalid signature'], 401);
    }

    $event = request()->json();

    // Process event
    match ($event['type']) {
        'payment_intent.succeeded' => handlePaymentSuccess($event['data']),
        'payment_intent.failed' => handlePaymentFailure($event['data']),
        'customer.created' => handleCustomerCreated($event['data']),
        default => null
    };

    // Acknowledge receipt
    return response()->json(['received' => true]);
});

function handlePaymentSuccess($data): void
{
    $paymentId = $data['object']['id'];
    $amount = $data['object']['amount'] / 100;

    // Notify internal system
    try {
        client('https://internal-api.example.com')
            ->withToken('internal-token')
            ->post('/payments/confirm', [
                'payment_id' => $paymentId,
                'amount' => $amount,
                'status' => 'completed'
            ]);
    } catch (ClientException $e) {
        wire('log')->error("Failed to notify internal system: " . $e->getMessage());
    }
}
?>

Data Synchronization

<?php
// Sync products from external API to ProcessWire
function syncProducts(): array
{
    $results = ['success' => 0, 'failed' => 0, 'errors' => []];

    try {
        // Fetch products from API
        $response = client('https://supplier-api.example.com')
            ->withToken('supplier-key')
            ->withQueryParameters(['updated_since' => date('Y-m-d', strtotime('-1 day'))])
            ->get('/products');

        if (!$response->successful()) {
            $results['errors'][] = 'Failed to fetch products';
            return $results;
        }

        $products = $response->json('products', []);

        foreach ($products as $productData) {
            try {
                // Check if product exists
                $product = wire('pages')->get("template=product, sku={$productData['sku']}");

                if (!$product->id) {
                    // Create new product
                    $product = new Page();
                    $product->template = 'product';
                    $product->parent = wire('pages')->get('/products/');
                }

                // Update product data
                $product->title = $productData['name'];
                $product->sku = $productData['sku'];
                $product->price = $productData['price'];
                $product->description = $productData['description'];
                $product->stock = $productData['stock'];
                $product->save();

                // Download product image if URL provided
                if (!empty($productData['image_url'])) {
                    $imagePath = wire('config')->paths->files . $product->id . '/';
                    $imageFile = $imagePath . basename($productData['image_url']);

                    $downloaded = client()->download($productData['image_url'], $imageFile);

                    if ($downloaded) {
                        $product->images->add($imageFile);
                        $product->save();
                    }
                }

                $results['success']++;

            } catch (\Exception $e) {
                $results['failed']++;
                $results['errors'][] = "Product {$productData['sku']}: " . $e->getMessage();
            }
        }

    } catch (ClientException $e) {
        $results['errors'][] = 'API Error: ' . $e->getMessage();
    }

    return $results;
}

// Run sync
$results = syncProducts();
echo "Synced {$results['success']} products, {$results['failed']} failed\n";
if (!empty($results['errors'])) {
    foreach ($results['errors'] as $error) {
        echo "Error: {$error}\n";
    }
}
?>

Rate-Limited API Client

<?php
class RateLimitedAPIClient
{
    private \SimpleWire\Client\Client $client;
    private string $cacheKey = 'api_rate_limit';
    private int $maxRequests = 100;
    private int $perSeconds = 60;

    public function __construct(string $apiKey)
    {
        $this->client = client('https://api.example.com')
            ->withToken($apiKey)
            ->acceptJson();
    }

    private function checkRateLimit(): bool
    {
        $requests = wire('cache')->get($this->cacheKey) ?: 0;

        if ($requests >= $this->maxRequests) {
            return false;
        }

        wire('cache')->save($this->cacheKey, $requests + 1, $this->perSeconds);
        return true;
    }

    public function request(string $method, string $url, array $data = []): ClientResponse
    {
        if (!$this->checkRateLimit()) {
            throw new \Exception('Rate limit exceeded. Please try again later.');
        }

        return match(strtoupper($method)) {
            'GET' => $this->client->get($url),
            'POST' => $this->client->post($url, $data),
            'PUT' => $this->client->put($url, $data),
            'DELETE' => $this->client->delete($url),
            default => throw new \Exception('Unsupported method')
        };
    }

    public function getRemainingRequests(): int
    {
        $requests = wire('cache')->get($this->cacheKey) ?: 0;
        return max(0, $this->maxRequests - $requests);
    }
}

// Usage
$api = new RateLimitedAPIClient('api-key');

if ($api->getRemainingRequests() > 0) {
    $response = $api->request('GET', '/data');
    echo $response->json('message');
} else {
    echo "Rate limit exceeded";
}
?>

Multi-Source Data Aggregation

<?php
// Aggregate data from multiple APIs
function getWeatherData(string $city): array
{
    // Fetch from multiple weather APIs concurrently
    $responses = client()
        ->pool(function($pool) use ($city) {
            $pool->as('openweather')
                ->withQueryParameters(['q' => $city, 'appid' => 'key1'])
                ->get('https://api.openweathermap.org/data/2.5/weather');

            $pool->as('weatherapi')
                ->withQueryParameters(['key' => 'key2', 'q' => $city])
                ->get('https://api.weatherapi.com/v1/current.json');

            return $pool->getHandles();
        });

    // Combine results
    $aggregated = [
        'city' => $city,
        'sources' => [],
        'average_temp' => 0
    ];

    $temps = [];

    // OpenWeather data
    if ($responses['openweather']->successful()) {
        $data = $responses['openweather']->json();
        $temp = $data['main']['temp'] - 273.15; // Convert Kelvin to Celsius
        $temps[] = $temp;

        $aggregated['sources'][] = [
            'name' => 'OpenWeather',
            'temp' => round($temp, 1),
            'conditions' => $data['weather'][0]['description']
        ];
    }

    // WeatherAPI data
    if ($responses['weatherapi']->successful()) {
        $data = $responses['weatherapi']->json();
        $temp = $data['current']['temp_c'];
        $temps[] = $temp;

        $aggregated['sources'][] = [
            'name' => 'WeatherAPI',
            'temp' => round($temp, 1),
            'conditions' => $data['current']['condition']['text']
        ];
    }

    // Calculate average
    if (!empty($temps)) {
        $aggregated['average_temp'] = round(array_sum($temps) / count($temps), 1);
    }

    return $aggregated;
}

// Usage
$weather = getWeatherData('Miami');
echo "Temperature in {$weather['city']}: {$weather['average_temp']}°C\n";
foreach ($weather['sources'] as $source) {
    echo "{$source['name']}: {$source['temp']}°C - {$source['conditions']}\n";
}
?>

Module Configuration


Configuration Options

Configured in the SimpleClient module settings.

  • client_timeout — Maximum time for request completion (default: 30 seconds)
  • client_connectTimeout — Maximum time to establish connection (default: 10 seconds)
  • client_retryAttempts — Number of automatic retries on failure (default: 0)
  • client_verifySsl — Enable SSL certificate verification (default: true)
  • client_followRedirects — Automatically follow HTTP redirects (default: true)
  • client_maxRedirects — Maximum redirects to follow (default: 5)
  • client_userAgent — User agent string sent with requests (default: SimpleWire/Client 1.0)
  • client_allowedSchemes — Comma-separated list of URL schemes allowed in requests (default: http, https)

Setting Defaults

Configure defaults in the module settings, or override per-request:

// Use module defaults
$response = client('https://api.example.com')->get('/data');

// Override for specific request
$response = client('https://api.example.com')
    ->timeout(60)
    ->connectTimeout(15)
    ->retry(3)
    ->get('/data');

Best Practices


  1. Use Base URLs: Set base URL once to avoid repetition
  2. Handle Exceptions: Always wrap requests in try-catch blocks
  3. Check Response Status: Use successful(), failed() before processing
  4. Use Request Pooling: Execute concurrent requests for better performance
  5. Set Appropriate Timeouts: Configure timeouts based on expected response times
  6. Validate URLs: Use scheme validation for security
  7. Use Typed Methods: Prefer json() over manual parsing
  8. Log Errors: Log failed requests for debugging
  9. Cache Responses: Cache API responses when appropriate to reduce calls
  10. Use Retry Wisely: Enable retry for unreliable endpoints, disable for critical operations

Performance Tips


  • Use Request Pooling: Dramatically reduce total request time with concurrent execution
  • Set Realistic Timeouts: Don't wait longer than necessary
  • Cache API Responses: Use ProcessWire's WireCache to cache responses
  • Reuse Client Instances: Create client once with base URL and reuse
  • Disable SSL Verification (Development Only): Only for local development
  • Monitor Rate Limits: Respect API rate limits to avoid throttling

Security Considerations


Important Security Notes:

  • Validate URLs: Always validate and sanitize user-provided URLs
  • Use HTTPS: Prefer HTTPS for sensitive data transmission
  • Store Tokens Securely: Never hardcode API tokens, use environment variables or config
  • Verify Webhooks: Always verify webhook signatures before processing
  • Sanitize Input: Sanitize data before sending to external APIs
  • Handle Errors Gracefully: Don't expose sensitive error details to users
  • Rate Limiting: Implement rate limiting for outbound requests
  • Timeout Protection: Always set timeouts to prevent hanging requests
  • SSL Verification: Keep SSL verification enabled in production

Troubleshooting


Request timing out:

  • Increase timeout with timeout() method
  • Check if remote server is responding slowly
  • Verify network connectivity
  • Check if firewall is blocking outbound requests

SSL certificate errors:

  • Update CA certificates on your server
  • Verify the remote server's SSL certificate is valid
  • For development only: disable SSL verification via client()->setValidateURLOptions([]) or the module config
  • In production: SSL verification is enabled by default — keep it that way

File upload failing:

  • Check file permissions on the source file
  • Verify file size doesn't exceed server limits
  • Ensure correct MIME type is specified
  • Check remote API's file upload requirements

Response parsing errors:

  • Verify response is valid JSON with isJson()
  • Check response status before parsing
  • Use json() with default values to handle missing keys
  • Log raw response body for debugging

Authentication failing:

  • Verify token/credentials are correct
  • Check if token has expired
  • Ensure proper Authorization header format
  • Test authentication separately before complex requests

Debugging

// Log request details
try {
    $response = client('https://api.example.com')
        ->withHeaders(['X-Debug' => 'true'])
        ->get('/data');

    // Log response
    wire('log')->save('api-calls', sprintf(
        'URL: %s, Status: %d, Body: %s',
        'https://api.example.com/data',
        $response->status(),
        $response->body()
    ));

} catch (ClientException $e) {
    // Log error with full context
    wire('log')->error(sprintf(
        'API Error: %s, Code: %d, Context: %s',
        $e->getMessage(),
        $e->getCode(),
        json_encode($e->getContext())
    ));

    // If response available, log it
    if ($e->hasResponse()) {
        $response = $e->getResponse();
        wire('log')->error(sprintf(
            'Response Status: %d, Body: %s',
            $response->status(),
            $response->body()
        ));
    }
}

// Debug download issues
$success = client()->download('https://example.com/file.zip', '/tmp/file.zip');
if (!$success) {
    wire('log')->error('Download failed: ' . client()->getError());
}

// Test URL accessibility
$status = client()->status('https://api.example.com/health');
wire('log')->message("API Health Check: " . client()->statusText('https://api.example.com/health'));

API Reference


Global Function

client(string $baseUrl = '', array $config = []): \SimpleWire\Client\Client

Request Methods

  • get(string $url, array $options = []): ClientResponse
  • post(string $url, $body = null, array $options = []): ClientResponse
  • put(string $url, $body = null, array $options = []): ClientResponse
  • patch(string $url, $body = null, array $options = []): ClientResponse
  • delete(string $url, array $options = []): ClientResponse
  • status(string $url): int
  • statusText(string $url): string
  • download(string $fromURL, string $toFile): bool

Request Configuration

  • withBody($body): self
  • asForm(): self
  • attach(string $name, string $path, string $mime, string $filename): self
  • withHeaders(array $headers): self
  • replaceHeaders(array $headers): self
  • accept(string $type): self
  • acceptJson(): self
  • acceptHtml(): self
  • withQueryParameters(array $params): self
  • withUrlParameters(array $params): self
  • timeout(int $seconds): self
  • connectTimeout(int $seconds): self
  • retry(int $attempts): self

Authentication

  • withToken(string $token): self
  • withBasicAuth(string $username, string $password): self

Request Pooling

  • pool(callable $callback): array

Utilities

  • getHttpCodes(): array
  • getError(): ?string
  • clearError(): self
  • setAllowSchemes(array $schemes): self
  • getAllowSchemes(): array
  • setValidateURLOptions(array $options): self

Response Methods

  • body(): string
  • json($key = null, $default = null): mixed
  • object(): object
  • collect($key = null): array
  • status(): int
  • header(string $header): string
  • headers(): array
  • successful(): bool - 2xx
  • redirect(): bool - 3xx
  • failed(): bool - 4xx or 5xx
  • clientError(): bool - 4xx
  • serverError(): bool - 5xx
  • ok(): bool - 200
  • created(): bool - 201
  • accepted(): bool - 202
  • noContent(): bool - 204
  • notFound(): bool - 404
  • unauthorized(): bool - 401
  • forbidden(): bool - 403
  • unprocessableEntity(): bool - 422
  • tooManyRequests(): bool - 429
  • internalServerError(): bool - 500
  • serviceUnavailable(): bool - 503
  • isJson(): bool
  • getContentType(): string
  • getCookies(): array

Exception Methods

  • getResponse(): ?ClientResponse
  • hasResponse(): bool
  • getResponseBody(): string
  • getResponseStatus(): int
  • getResponseHeaders(): array
  • getContext(): array

Comparison with Other Clients


FeatureSimpleWire ClientGuzzlecURL
InstallationSimpleClient ModuleComposer RequiredPHP Extension
Fluent API
Request PoolingManual
JSON Dot Notation
ProcessWire Integration✅ NativeManualManual
File Download HelperManualManual
Learning CurveLowMediumHigh

License


This module is released under the MIT License.

More modules by WireCodex

  • SimpleRouter

    URL routing with pattern matching and caching for ProcessWire.
  • SimpleClient

    Fluent cURL-based HTTP client with retry, file download, and concurrent pool support.
  • SimpleRequest

    HTTP request abstraction with input handling, validation, and content negotiation.
  • SimpleResponse

    HTTP response builder with HTMX support, redirects, and content negotiation.
  • SimpleQuery

    GraphQL-like query engine for ProcessWire pages with caching, rate limiting, and write support.
  • SimpleQueue

    Background job queue with priority, delayed execution, retry, and LazyCron-based processing.
  • SimpleAsset

    Asset management for ProcessWire. Resolves, groups, and renders CSS/JS assets from CDN sources or local paths with cache-busting, SRI, and inline threshold support.

All modules by WireCodex

Install and use modules at your own risk. Always have a site and database backup before installing new modules.