agenziasmart / swotto
PHP SDK for Swotto API
Requires
- php: >=8.3
- ext-mbstring: *
- guzzlehttp/guzzle: ^7.15.1
- guzzlehttp/psr7: ^2.12.3
- psr/http-client: ^1.0
- psr/http-message: ^1.0 || ^2.0
- psr/log: ^3.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.0
- mockery/mockery: ^1.6
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Official PHP client library for integrating with the SW4 API - a comprehensive B2B/ERP platform providing centralized access to:
- Customer & Supplier Management (Master Data/Anagrafiche)
- Inventory & Stock Management (Magazzino)
- Product Information Management (PIM)
- Document Management (Orders, Invoices, DDT, Agreements)
Swotto simplifies API integration with built-in authentication, error handling, file operations, and smart response handling.
Why Swotto?
- Type-safe - PHPStan Level 8 compliant
- Resilient - Built-in Retry with Exponential Backoff
- Immutable - Fully stateless, worker-safe (FrankenPHP/Swoole)
- Flexible - Dual authentication (DevApp + Bearer tokens)
- Smart responses - Auto-detect JSON, CSV, PDF formats
- Tested - 302 tests, 895 assertions
Table of Contents
- Installation
- Quick Start
- Authentication
- Basic Usage
- Advanced Features
- File Uploads
- Error Handling
- Configuration Reference
- Testing
- FAQ
- Support
- License
Installation
Install via Composer:
composer require agenziasmart/swotto
Requirements
- PHP 8.3 or higher
- Composer
- A valid SW4 API account with DevApp credentials
Quick Start
<?php require_once 'vendor/autoload.php'; use Swotto\SwottoClient; // Initialize the client $client = new SwottoClient([ 'url' => 'https://api.sw4.it', 'key' => 'YOUR_DEVAPP_TOKEN', ]); // Make your first API call $customers = $client->get('customer'); print_r($customers);
Authentication
Swotto supports dual authentication to identify both your application and end users.
DevApp Token (Application Authentication)
Identifies your third-party application to SW4. Required for all requests.
$client = new SwottoClient([ 'url' => 'https://api.sw4.it', 'key' => 'YOUR_DEVAPP_TOKEN', ]);
Security Note: Never commit DevApp tokens to version control. Use environment variables:
$client = new SwottoClient([ 'url' => $_ENV['SW4_API_URL'], 'key' => $_ENV['SW4_DEVAPP_TOKEN'], ]);
Bearer Token (User Authentication)
Authenticates specific end users within your application. Can be set as default or per-call.
// Option A: Config default (applied to every request) $client = new SwottoClient([ 'url' => 'https://api.sw4.it', 'key' => 'YOUR_DEVAPP_TOKEN', 'bearer_token' => $userBearerToken, ]); // Option B: Per-call (overrides default for this request) $orders = $client->get('salesorder', [ 'bearer_token' => $userBearerToken, ]);
Complete Authentication Flow
// 1. Initialize with DevApp token and user context $client = new SwottoClient([ 'url' => 'https://api.sw4.it', 'key' => 'YOUR_DEVAPP_TOKEN', 'client_ip' => $_SERVER['REMOTE_ADDR'], 'client_user_agent' => $_SERVER['HTTP_USER_AGENT'], ]); // 2. User login $loginResponse = $client->post('auth', [ 'username' => 'user@example.com', 'password' => 'YOUR_PASSWORD', ]); // 3. Create authenticated client with Bearer token $authClient = new SwottoClient([ 'url' => 'https://api.sw4.it', 'key' => 'YOUR_DEVAPP_TOKEN', 'bearer_token' => $loginResponse['data']['access_token'], 'client_ip' => $_SERVER['REMOTE_ADDR'], 'client_user_agent' => $_SERVER['HTTP_USER_AGENT'], ]); // 4. All requests are now authenticated $profile = $authClient->get('me'); $customers = $authClient->get('customer'); // 5. End the session $authClient->post('auth/logout');
The login response carries data.access_token and data.expires_at; the token is what
bearer_token expects.
For FrankenPHP/Swoole workers, use per-call options instead:
$client = new SwottoClient([ 'url' => 'https://api.sw4.it', 'key' => 'YOUR_DEVAPP_TOKEN', ]); // Each request carries its own context - no state leakage $profile = $client->get('me', [ 'bearer_token' => $userToken, 'client_ip' => $_SERVER['REMOTE_ADDR'], 'client_user_agent' => $_SERVER['HTTP_USER_AGENT'], 'language' => 'it', ]);
How It Works:
- DevApp token determines which organization's data you can access
- Bearer token identifies which user is making the request
- Data isolation: All responses are automatically filtered by organization ID
Basic Usage
HTTP Methods
SW4 resources are named in the singular, and records are addressed by UUID:
// GET request $data = $client->get('customer'); $data = $client->get('customer', ['query' => ['limit' => 10]]); // POST request $result = $client->post('customer', [ 'name' => 'ACME Srl', 'business_code' => 'ACME01', 'tax_code' => 'CMEXXX00X00X000X', ]); // PUT request (full update) $result = $client->put("customer/{$uuid}", [ 'name' => 'ACME Holding Srl', ]); // PATCH request (partial update) $result = $client->patch("customer/{$uuid}", [ 'email' => 'info@example.com', ]); // DELETE request $result = $client->delete("customer/{$uuid}");
Pagination
Every list endpoint answers with data plus a meta.pagination block:
$response = $client->get('customer', ['query' => ['page' => 1, 'limit' => 50]]); $customers = $response['data']; $pagination = $response['meta']['pagination']; echo "Page {$pagination['current_page']} of {$pagination['total_pages']}"; echo "Total: {$pagination['total']} customers";
Advanced Features
Multi-Format Responses
Handle JSON, CSV, PDF, and binary content:
// Get smart response wrapper $response = $client->getResponse('customer/export/csv'); // Content type detection if ($response->isJson()) { $data = $response->asArray(); } elseif ($response->isCsv()) { $rows = $response->asArray(); // one entry per record, keyed by header $csv = $response->asString(); // or the raw payload } elseif ($response->isPdf() || $response->isBinary()) { $response->saveToFile('/path/to/report.pdf'); } // Direct file download $client->downloadToFile('customer/export/csv', '/path/to/customers.csv');
Most SW4 collections expose {resource}/export/csv — customer, product, supplier,
salesorder, purchaseorder, invoice, ddt and others. The delimiter is detected from
the payload, so a semicolon-separated export parses correctly without configuration.
Retry with Exponential Backoff
Automatic retry for transient errors with configurable backoff:
$client = new SwottoClient([ 'url' => 'https://api.sw4.it', 'key' => 'YOUR_DEVAPP_TOKEN', // Retry configuration (opt-in) 'retry_enabled' => true, 'retry_max_attempts' => 3, // Total attempts (1 + 2 retries) 'retry_initial_delay_ms' => 100, // First retry delay 'retry_max_delay_ms' => 10000, // Maximum delay cap 'retry_multiplier' => 2.0, // Exponential factor 'retry_jitter' => true, // +/-25% randomization ]); // Automatic retry on: // - Network errors (NetworkException, ConnectionException) // - Server errors (5xx status codes) // - Rate limits (429 - Retry-After is honoured, capped at retry_max_delay_ms) // NO retry on client errors: // - 401 Unauthorized // - 403 Forbidden // - 404 Not Found // - 422 Validation Error
Only safe and idempotent methods are retried automatically: GET, HEAD, PUT, DELETE,
OPTIONS, TRACE. A network error is ambiguous — the request may well have reached the
server — so replaying a POST or PATCH could duplicate an order, a document or an upload.
Accept that risk per request when the endpoint is safe to repeat — cancelling an already
cancelled batch, for instance, changes nothing the second time:
$client->post("batch/{$uuid}/cancel", [], ['retry_non_idempotent' => true]);
Per-Call Options
Pass request-specific parameters directly in options:
$client = new SwottoClient([ 'url' => 'https://api.sw4.it', 'key' => 'YOUR_DEVAPP_TOKEN', ]); // Each request carries its own context $ordersA = $client->get('salesorder', [ 'bearer_token' => $userAToken, 'client_ip' => $requestA->getClientIp(), 'language' => 'it', ]); // No state leakage between requests $ordersB = $client->get('salesorder', [ 'bearer_token' => $userBToken, 'client_ip' => $requestB->getClientIp(), 'language' => 'en', ]);
Available per-call options:
| Option | Header | Description |
|---|---|---|
bearer_token |
Authorization |
Bearer token for this request |
language |
Accept-Language |
Response language |
session_id |
x-sid |
Session ID |
client_ip |
Client-Ip |
Original client IP |
client_user_agent |
X-Client-User-Agent |
Original client User-Agent |
Default Options Pattern
Set context options in config as defaults. Per-call options override defaults.
$client = new SwottoClient([ 'url' => 'https://api.sw4.it', 'key' => 'YOUR_DEVAPP_TOKEN', 'bearer_token' => 'default-token', // applied to every request 'language' => 'it', // applied to every request ]); // Uses defaults: bearer_token=default-token, language=it $data = $client->get('customer'); // Override language for this request only $data = $client->get('customer', ['language' => 'en']); // Next request uses default 'it' again (immutable) $other = $client->get('product');
File Uploads
Each SW4 upload endpoint expects a specific field name — logo for a customer logo,
document for a product attachment, file for a batch import. Pass it as the third
argument; it is not guessed from the filename.
// Customer logo — field name "logo" $fileHandle = fopen('/path/to/logo.png', 'r'); $result = $client->postFile("customer/{$uuid}/logo", $fileHandle, 'logo'); // Product attachment — field name "document", with metadata $fileHandle = fopen('/path/to/datasheet.pdf', 'r'); $result = $client->postFile("product/{$uuid}/documents", $fileHandle, 'document', [ 'title' => 'Technical datasheet', ]); // CSV batch import — field name "file" $fileHandle = fopen('/path/to/customers.csv', 'r'); $result = $client->postFile('customer/batch', $fileHandle, 'file'); // Several files in one request $files = [ 'document' => fopen('/path/to/first.pdf', 'r'), 'attachment' => fopen('/path/to/second.jpg', 'r'), ]; $result = $client->postFiles("product/{$uuid}/documents", $files, [ 'title' => 'Product pack', ]); // Replace or amend an existing record with a file $result = $client->putFile("customer/{$uuid}/logo", fopen('/path/to/new-logo.png', 'r'), 'logo'); $result = $client->patchFile("product/{$uuid}/documents/{$docUuid}", $fileHandle, 'document');
Error Handling
Exception Hierarchy
SwottoExceptionInterface (interface)
+-- SwottoException (base class)
+-- ApiException (HTTP 400-599)
| +-- AuthenticationException (401)
| +-- ForbiddenException (403)
| +-- NotFoundException (404)
| +-- ValidationException (400, 422)
| +-- RateLimitException (429)
+-- NetworkException (connection issues)
| +-- ConnectionException
+-- SecurityException (security violations)
| +-- FileOperationException
| +-- MemoryException
+-- StreamingException
Best Practices
use Swotto\Exception\{ AuthenticationException, NotFoundException, ValidationException, RateLimitException, NetworkException, SwottoException }; try { $result = $client->post('customer', $data); } catch (ValidationException $e) { // Handle validation errors (400 and 422) // SW4 reports the offending fields under error.details, keyed by field name $details = $e->getErrorData()['error']['details'] ?? []; } catch (AuthenticationException $e) { // Token expired or invalid (401) } catch (NotFoundException $e) { // Resource doesn't exist (404) } catch (RateLimitException $e) { // Too many requests (429) $retryAfter = $e->getRetryAfter(); // seconds } catch (NetworkException $e) { // Network connectivity issues } catch (SwottoException $e) { // Catch-all for other API errors error_log(sprintf('API failure: %s status=%d', $e::class, $e->getStatusCode())); }
Messages returned by the API remain available for validation/business outcomes only:
400, 402, 409 and 422. Authentication, authorization, lookup, throttling, server and
transport failures use constant messages so upstream internals cannot cross the SDK boundary.
The complete parsed response stays in getErrorData() for application handling.
“Public to application code” is not the same as “safe to log”: validation text and
getErrorData() can contain submitted values or personal data. Log the exception class,
status and your own request/correlation ID; do not log exception objects, messages or response
data wholesale.
Configuration Reference
Required Options
| Option | Type | Description |
|---|---|---|
url |
string |
SW4 API base URL (e.g., https://api.sw4.it) |
Authentication Options
| Option | Type | Default | Description |
|---|---|---|---|
key |
string |
null |
DevApp token for application authentication |
bearer_token |
string |
null |
Bearer token for user authentication |
session_id |
string |
null |
Session ID |
HTTP Client Options
| Option | Type | Default | Description |
|---|---|---|---|
timeout |
int |
10 |
Request timeout in seconds |
verify_ssl |
bool |
true |
Verify SSL certificates |
Retry Options
| Option | Type | Default | Description |
|---|---|---|---|
retry_enabled |
bool |
false |
Enable automatic retry with backoff |
retry_max_attempts |
int |
3 |
Total attempts (1-10) |
retry_initial_delay_ms |
int |
100 |
Initial delay in milliseconds |
retry_max_delay_ms |
int |
10000 |
Maximum delay cap in milliseconds, Retry-After included |
retry_multiplier |
float |
2.0 |
Exponential backoff multiplier (1.0-5.0) |
retry_jitter |
bool |
true |
Add +/-25% randomization |
retry_non_idempotent is a per-call option, not a config key: pass it in the options of
a single POST or PATCH to allow that request to be retried.
Client Metadata
| Option | Type | Default | Description |
|---|---|---|---|
client_user_agent |
string |
null |
End-user User-Agent (sent as X-Client-User-Agent) |
client_ip |
string |
null |
Client IP address |
language |
string |
null |
Preferred response language |
App Identification
| Option | Type | Default | Description |
|---|---|---|---|
app_name |
string |
null |
Your application name (included in User-Agent) |
app_version |
string |
null |
Your application version (included in User-Agent) |
Complete Example
$client = new SwottoClient([ // Required 'url' => 'https://api.sw4.it', // Authentication 'key' => $_ENV['SW4_DEVAPP_TOKEN'], 'bearer_token' => $userToken, // HTTP 'timeout' => 60, 'verify_ssl' => true, // App identification (optional) 'app_name' => 'MyERP', 'app_version' => '1.0.0', // Retry (handles transient errors) 'retry_enabled' => true, 'retry_max_attempts' => 3, // Client context (default for all requests) 'language' => 'en', 'client_ip' => $_SERVER['REMOTE_ADDR'], 'client_user_agent' => $_SERVER['HTTP_USER_AGENT'], ]);
Testing
Run the test suite:
# Run all tests composer test # Run specific tests composer test -- --filter SwottoClientTest # Code style check composer cs # Fix code style composer cs-fix # Static analysis composer phpstan
FAQ
How do I get DevApp credentials?
Contact SW4 support or visit your organization dashboard at https://app.sw4.it/settings/devapps.
What's the difference between DevApp token and Bearer token?
- DevApp token: Identifies your application and determines data scope (organization)
- Bearer token: Identifies the end user making requests through your app
Can I use this SDK without authentication?
No. SW4 API requires at least a DevApp token for all requests.
What PHP versions are supported?
PHP 8.3 or higher.
How do I debug API requests?
Inject a PSR-3 logger in the constructor:
$client = new SwottoClient($config, $yourPsr3Logger);
The SDK logs bounded transport metadata only. Request bodies, headers, query/auth/proxy/cookie options, certificates and upstream response text are deliberately unavailable in logs; use a correlation ID to inspect the authoritative API-side event.
Can I use this with Laravel/Symfony/other frameworks?
Yes! Swotto is framework-agnostic and works with any PHP application.
Is Swotto thread-safe?
Yes. The client is fully immutable - no mutable state. A single client instance can be safely shared across requests in FrankenPHP/Swoole workers using per-call options.
How do I handle large file downloads?
Use downloadToFile() for memory-safe streaming to disk:
// Direct download to disk (memory-safe) $client->downloadToFile('product/export/csv', '/path/to/products.csv');
downloadToFile() streams straight to disk and never buffers the whole body.
asString() and asArray() read the body in 8 KB chunks and count the bytes actually
received, throwing MemoryException past 50 MB. The limit applies whether or not the
response carries a Content-Length header, so a chunked response cannot bypass it.
saveToFile() rewinds the stream when it can, so saving after inspecting the response
still writes the full content. A non-seekable stream that has already been consumed
raises StreamingException instead of writing an empty file, and a body shorter than the
advertised Content-Length is rejected rather than saved truncated.
Support
- Issues: GitHub Issues
- Email: support@sw4.it
Getting Help
- Check the FAQ
- Search existing issues
- Create a new issue with:
- SDK version (
composer show agenziasmart/swotto) - PHP version (
php -v) - Minimal code example
- Expected vs actual behavior
- SDK version (
License
MIT License. See LICENSE file for details.
Copyright 2025 AgenziaSmart