A modern, PSR-compliant HTTP client for PHP with middleware architecture, advanced retry strategies, and fluent API for building requests.
- ✅ PSR Standards - Built on PSR-7 (HTTP Messages), PSR-17 (HTTP Factories), PSR-18 (HTTP Client)
- ✅ No Hard Dependencies - Use any PSR-18 HTTP client (Guzzle, Symfony, or custom)
- ✅ Auto-Detection - Automatically discovers and uses available HTTP clients
- ✅ Middleware Architecture - Extensible plugin system for custom behavior
- ✅ Advanced Retry Strategies - Exponential backoff with jitter, custom retry conditions
- ✅ Fluent Request Builder - Chainable API for building requests
- ✅ Immutable Configuration - Thread-safe, predictable behavior
- ✅ Type-Safe - Full PHP 8.1+ type hints and strict types
- ✅ JSON Helpers - Parse JSON with proper error handling and dot-notation access
- ✅ Custom Exceptions - Detailed error context implementing PSR standards
- ✅ Easy to Swap HTTP Clients - Switch between Guzzle, Symfony, or any PSR-18 client
- ✅ File Upload & Multipart - RFC 7578 compliant multipart/form-data with file upload support
- ✅ Cookie Management - RFC 6265 compliant automatic cookie handling with session support
- PHP 8.1 or higher
You can install the package via composer:
composer require farzai/transportThe library will auto-detect any available PSR-18 HTTP client. If you don't have one installed, we recommend:
# Recommended: Modern HTTP client with async support
composer require symfony/http-client
# Alternative: Popular and widely-used
composer require guzzlehttp/guzzleTransport PHP automatically detects available HTTP clients (Symfony, Guzzle, etc.) - no configuration needed!
use Farzai\Transport\TransportBuilder;
// Just works! Auto-detects your HTTP client
$transport = TransportBuilder::make()
->withBaseUri('https://api.example.com')
->build();
$response = $transport->get('/users')->send();
echo $response->json('data.0.name'); // Dot notation support!use Farzai\Transport\TransportBuilder;
// Create a transport client with configuration
$transport = TransportBuilder::make()
->withBaseUri('https://api.example.com')
->withHeaders([
'Authorization' => 'Bearer token123',
'Accept' => 'application/json',
])
->withTimeout(30)
->build();
// Make requests using fluent API
$response = $transport->get('/users/123')->send();
// Access response data
echo $response->statusCode(); // 200
echo $response->body(); // Raw response body
$data = $response->json(); // Parsed JSON as array// GET request with query parameters
$response = $transport
->get('/users')
->withQuery(['page' => 1, 'limit' => 10])
->withHeader('X-Custom-Header', 'value')
->send();
// POST with JSON body
$response = $transport
->post('/users')
->withJson([
'name' => 'John Doe',
'email' => 'john@example.com'
])
->send();
// POST with form data
$response = $transport
->post('/login')
->withForm([
'username' => 'john',
'password' => 'secret'
])
->send();
// With authentication
$response = $transport
->get('/protected')
->withBearerToken('your-token')
->send();
// Or basic auth
$response = $transport
->get('/protected')
->withBasicAuth('username', 'password')
->send();// Parse JSON with automatic error handling
$data = $response->json(); // ['id' => 123, 'name' => 'John Doe']
// Get specific field using dot notation
$name = $response->json('name'); // 'John Doe'
$city = $response->json('user.address.city'); // 'New York'
// Get as array
$array = $response->toArray();
// Safe JSON parsing (returns null on error instead of throwing)
$data = $response->jsonOrNull();
// Check if response is successful
if ($response->isSuccessful()) {
// Handle success (2xx status codes)
}use Farzai\Transport\Retry\ExponentialBackoffStrategy;
use Farzai\Transport\Retry\RetryCondition;
use Farzai\Transport\Exceptions\NetworkException;
// Configure retry with exponential backoff
$transport = TransportBuilder::make()
->withRetries(
maxRetries: 3,
strategy: new ExponentialBackoffStrategy(
baseDelayMs: 1000, // Start with 1 second
multiplier: 2.0, // Double each retry
maxDelayMs: 30000, // Cap at 30 seconds
useJitter: true // Add randomization
),
condition: (new RetryCondition())
->onExceptions([NetworkException::class])
)
->build();
// Retries automatically with exponential backoff + jitter
$response = $transport->get('/unreliable-endpoint')->send();
// Or use default retry condition (retries on any exception)
$transport = TransportBuilder::make()
->withRetries(
maxRetries: 3,
condition: RetryCondition::default() // Retries on ANY exception
)
->build();Retry Conditions:
RetryCondition::default()- Retries on any exception(new RetryCondition())->onExceptions([...])- Retry only specific exceptions(new RetryCondition())->onStatusCodes([500, 502, 503])- Retry specific HTTP status codes
use Farzai\Transport\Middleware\MiddlewareInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
// Create custom middleware
class AuthMiddleware implements MiddlewareInterface
{
public function handle(RequestInterface $request, callable $next): ResponseInterface
{
// Add auth token to all requests
$request = $request->withHeader('Authorization', 'Bearer ' . $this->getToken());
return $next($request);
}
private function getToken(): string
{
// Your token logic
return 'your-token';
}
}
// Add to transport
$transport = TransportBuilder::make()
->withMiddleware(new AuthMiddleware())
->build();$transport = TransportBuilder::make()
->withBaseUri('https://api.example.com')
->withHeaders(['Accept' => 'application/json'])
->withTimeout(30) // Seconds
->withRetries(3) // Max retry attempts
->withMiddleware($customMiddleware)
->withoutDefaultMiddlewares() // Disable logging, timeout, retry middlewares
->setClient($customPsrClient) // Use custom PSR-18 client
->setLogger($customLogger) // Use custom PSR-3 logger
->build();use Farzai\Transport\Factory\ClientFactory;
// Auto-detect (recommended - uses Symfony > Guzzle > Others)
$transport = TransportBuilder::make()->build();
// Explicitly use Guzzle
$transport = TransportBuilder::make()
->setClient(ClientFactory::createGuzzle(['timeout' => 30]))
->build();
// Explicitly use Symfony HTTP Client
$transport = TransportBuilder::make()
->setClient(ClientFactory::createSymfony(['max_redirects' => 5]))
->build();
// Check which client is being used
echo ClientFactory::getDetectedClientName(); // e.g., "Symfony\Component\HttpClient\Psr18Client"// Simple file upload
$response = $transport->post('/upload')
->withFile(
name: 'document',
path: '/path/to/file.pdf',
filename: 'report.pdf',
additionalFields: ['title' => 'Monthly Report']
)
->send();
// Multiple files with form data
$response = $transport->post('/upload')
->withMultipart([
// Text fields
['name' => 'title', 'contents' => 'My Upload'],
['name' => 'description', 'contents' => 'File description'],
// File uploads
[
'name' => 'avatar',
'contents' => file_get_contents('photo.jpg'),
'filename' => 'avatar.jpg',
'content-type' => 'image/jpeg'
],
[
'name' => 'document',
'contents' => fopen('/path/to/file.pdf', 'r'),
'filename' => 'document.pdf'
]
])
->send();
// Advanced: Using MultipartStreamBuilder
use Farzai\Transport\Multipart\MultipartStreamBuilder;
$builder = new MultipartStreamBuilder();
$builder->addField('username', 'john_doe')
->addFile('avatar', '/path/to/avatar.jpg', 'profile.jpg')
->addFileContents('data', $jsonData, 'data.json', 'application/json');
$response = $transport->post('/api/upload')
->withMultipartBuilder($builder)
->send();
// Memory-efficient streaming for large files
use Farzai\Transport\Multipart\StreamingMultipartBuilder;
$streamBuilder = new StreamingMultipartBuilder();
$stream = $streamBuilder
->addFile('video', '/path/to/large-video.mp4', 'video.mp4')
->addField('title', 'My Video')
->build();
// Streams file without loading entire content into memory
$response = $transport->request()
->withBody($stream)
->withHeader('Content-Type', $streamBuilder->getContentType())
->post('/upload')
->send();Note: The library automatically selects StreamingMultipartBuilder for large files (>1MB by default) to optimize memory usage. You can also manually use it for memory-efficient uploads of any size.
// Automatic cookie handling
$transport = TransportBuilder::make()
->withBaseUri('https://api.example.com')
->withCookies() // Enable automatic cookie management
->build();
// Login - cookies are automatically stored
$transport->post('/login')
->withJson(['username' => 'user', 'password' => 'pass'])
->send();
// Subsequent requests automatically include cookies
$response = $transport->get('/profile')->send();
// Advanced: Manual cookie management
use Farzai\Transport\Cookie\CookieJar;
use Farzai\Transport\Cookie\Cookie;
$cookieJar = new CookieJar();
// Add cookies manually
$cookieJar->setCookie(new Cookie(
name: 'session_id',
value: 'abc123',
expiresAt: time() + 3600,
domain: 'example.com',
path: '/',
secure: true,
httpOnly: true
));
$transport = TransportBuilder::make()
->withCookieJar($cookieJar)
->build();
// Inspect cookies
echo "Cookies: {$cookieJar->count()}\n";
foreach ($cookieJar->getAllCookies() as $cookie) {
echo "{$cookie->getName()}: {$cookie->getValue()}\n";
}
// Export/Import cookies for persistence
$data = $cookieJar->toArray();
file_put_contents('cookies.json', json_encode($data));
// Later...
$newJar = new CookieJar();
$newJar->fromArray(json_decode(file_get_contents('cookies.json'), true));Performance Note: The cookie management system automatically optimizes for different workloads. For applications with many cookies (50+), it uses indexed collections with O(1) domain lookups. For smaller cookie counts, it uses simpler collections to minimize overhead.
Monitor HTTP requests lifecycle with event listeners:
use Farzai\Transport\Events\RequestSendingEvent;
use Farzai\Transport\Events\ResponseReceivedEvent;
use Farzai\Transport\Events\RequestFailedEvent;
use Farzai\Transport\Events\RetryAttemptEvent;
$transport = TransportBuilder::make()
->withBaseUri('https://api.example.com')
// Track successful responses
->addEventListener(ResponseReceivedEvent::class, function ($event) {
printf(
"[SUCCESS] %s %s → %d (%.2fms)\n",
$event->getMethod(),
$event->getUri(),
$event->getStatusCode(),
$event->getDuration()
);
})
// Track failed requests
->addEventListener(RequestFailedEvent::class, function ($event) {
printf(
"[ERROR] %s %s failed: %s\n",
$event->getMethod(),
$event->getUri(),
$event->getExceptionMessage()
);
})
// Monitor retry attempts
->addEventListener(RetryAttemptEvent::class, function ($event) {
printf(
"[RETRY] Attempt %d/%d (delay: %dms)\n",
$event->getAttemptNumber(),
$event->getMaxAttempts(),
$event->getDelay()
);
})
->withRetries(3)
->build();
// Events are automatically dispatched during request lifecycle
$response = $transport->get('/api/endpoint')->send();Available Events:
RequestSendingEvent- Before a request is sentResponseReceivedEvent- After successful response (includes duration metrics)RequestFailedEvent- When a request fails with exception detailsRetryAttemptEvent- Before each retry attempt with delay information
Use Cases:
- Performance monitoring and metrics collection
- Logging and debugging request/response cycles
- Custom retry notifications
- Request/response instrumentation
use Farzai\Transport\Exceptions\ClientException;
use Farzai\Transport\Exceptions\ServerException;
use Farzai\Transport\Exceptions\RetryExhaustedException;
use Farzai\Transport\Exceptions\JsonParseException;
// Throw exception on non-2xx responses
try {
$response->throw();
} catch (ClientException $e) {
// 4xx errors
echo "Client error: {$e->getStatusCode()}\n";
echo "Request: {$e->getRequest()->getUri()}\n";
var_dump($e->getContext()); // Rich debugging context
} catch (ServerException $e) {
// 5xx errors
echo "Server error: {$e->getStatusCode()}\n";
}
// Custom error handling callback
$response->throw(function ($response, $exception) {
if ($response->statusCode() === 404) {
throw new \Exception('Resource not found!');
}
throw $exception;
});
// Handle retry exhaustion
try {
$response = $transport->get('/flaky-endpoint')->send();
} catch (RetryExhaustedException $e) {
echo "Failed after {$e->getAttempts()} attempts\n";
echo "Delays used: " . implode(', ', $e->getDelaysUsed()) . "ms\n";
foreach ($e->getRetryExceptions() as $attempt => $exception) {
echo "Attempt $attempt: {$exception->getMessage()}\n";
}
}
// Handle JSON parse errors
try {
$data = $response->json();
} catch (JsonParseException $e) {
echo "Invalid JSON: {$e->getMessage()}\n";
echo "JSON string: {$e->jsonString}\n";
echo "Error code: {$e->jsonErrorCode}\n";
}use Farzai\Transport\TransportBuilder;
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
// Create custom logger
$logger = new Logger('http-client');
$logger->pushHandler(new StreamHandler('path/to/your.log'));
// Use any PSR-18 compliant client
$client = new \Your\Custom\Psr18Client();
$transport = TransportBuilder::make()
->setClient($client)
->setLogger($logger)
->build();use Farzai\Transport\ResponseBuilder;
$response = ResponseBuilder::create()
->statusCode(200)
->withHeader('Content-Type', 'application/json')
->withBody('{"success": true}')
->build();
// Or use the fluent builder methods
$response = ResponseBuilder::create()
->statusCode(404)
->withHeaders([
'Content-Type' => 'application/json',
'X-Request-ID' => '12345'
])
->withBody('{"error": "Not found"}')
->withVersion('1.1')
->withReason('Not Found')
->build();- Examples - Practical usage examples:
Transport PHP v2.x uses PSR standards and auto-detection:
- PSR-7 - HTTP Message Interface
- PSR-17 - HTTP Factories for creating requests/responses
- PSR-18 - HTTP Client Interface
This means you can use any PSR-18 compliant HTTP client:
- ✅ Symfony HTTP Client (modern, async, HTTP/2)
- ✅ Guzzle (popular, stable)
- ✅ Any custom PSR-18 implementation
Request → Middleware Stack → HTTP Client → Response
↓
[LoggingMiddleware]
[TimeoutMiddleware]
[RetryMiddleware]
[CustomMiddleware...]
Default middlewares (can be disabled with withoutDefaultMiddlewares()):
- LoggingMiddleware: Logs requests and responses
- TimeoutMiddleware: Enforces request timeouts
- RetryMiddleware: Handles retry logic with configurable strategies
All configuration is immutable and set during the build phase:
// ✅ Correct - configuration during build
$transport = TransportBuilder::make()
->withTimeout(30)
->withRetries(3)
->build();This makes the Transport instance:
- Thread-safe - Can be safely shared across threads
- Predictable - Configuration can't change unexpectedly
- Easier to test - No hidden state changes
composer test# Run tests with coverage
composer test-coverage
# Fix code style
composer formatPlease see CHANGELOG for more information on what has changed recently.
Please see CONTRIBUTING for details.
Please review our security policy on how to report security vulnerabilities.
The MIT License (MIT). Please see License File for more information.