phpmystic / kai
A fluent, exception-less PHP HTTP client library that uses a callback-based approach for handling responses. Built on cURL.
Installs: 1
Dependents: 0
Suggesters: 0
Security: 0
Stars: 1
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/phpmystic/kai
Requires
- php: >=7.4
- ext-curl: *
- ext-json: *
- guzzlehttp/psr7: ^2.0
Requires (Dev)
- phpunit/phpunit: ^9.6
- symfony/var-dumper: ^7.3
README
A fluent, exception-less PHP HTTP client library that uses a callback-based approach for handling responses. Built on cURL.
Features
- Fluent Interface - Chain methods for readable, expressive code
- Callback-Based - Handle different response scenarios with callbacks
- Exception-Less - No try-catch blocks needed, handle errors gracefully with callbacks
- Type-Safe - Full type hints for better IDE support
- Lightweight - Minimal dependencies, just PHP and cURL
- Testable - Built-in support for mocking HTTP requests
Requirements
- PHP >= 7.4
- ext-curl
- ext-json
Installation
composer require phpmystic/kai
Quick Start
use Phpmystic\Kai\Client; $client = new Client('https://api.github.com'); $client->get('/users/octocat') ->onSuccess(function ($response) { echo "User: " . $response->body->login; }) ->notFound(function ($response) { echo "User not found"; }) ->onError(function ($response) { echo "Connection error: " . $response->error; });
Basic Usage
Creating a Client
use Phpmystic\Kai\Client; // Simple client $client = new Client('https://api.example.com'); // Client with default headers $client = new Client('https://api.example.com', [ 'Authorization' => 'Bearer your-token-here', 'Accept' => 'application/json' ]);
Making Requests
GET Request
$client->get('/users') ->onSuccess(function ($response) { print_r($response->body); });
POST Request
$client->post('/users', [ 'name' => 'John Doe', 'email' => 'john@example.com' ])->created(function ($response) { echo "User created with ID: " . $response->body->id; });
PUT Request
$client->put('/users/123', [ 'name' => 'Jane Doe' ])->onSuccess(function ($response) { echo "User updated successfully"; });
PATCH Request
$client->patch('/users/123', [ 'email' => 'newemail@example.com' ])->onSuccess(function ($response) { echo "Email updated"; });
DELETE Request
$client->delete('/users/123') ->noContent(function ($response) { echo "User deleted"; });
Advanced Features
Query Parameters
$client->query(['page' => 1, 'limit' => 10]) ->get('/users') ->onSuccess(function ($response) { // Handles: GET /users?page=1&limit=10 });
Custom Headers
$client->withHeaders([ 'X-Custom-Header' => 'value', 'Authorization' => 'Bearer token123' ])->get('/protected') ->onSuccess(function ($response) { // Request includes custom headers });
Fluent Chaining
Chain multiple operations together:
$client->query(['filter' => 'active']) ->withHeaders(['X-Request-ID' => uniqid()]) ->get('/users') ->onSuccess(function ($response) { echo "Found " . count($response->body) . " users"; }) ->always(function ($response) { echo "Request completed with status: " . $response->status; });
Response Handlers
Status-Specific Handlers
Handle specific HTTP status codes:
$client->get('/users/123') ->ok(function ($response) { // Handles 200 OK }) ->created(function ($response) { // Handles 201 Created }) ->accepted(function ($response) { // Handles 202 Accepted }) ->noContent(function ($response) { // Handles 204 No Content }) ->badRequest(function ($response) { // Handles 400 Bad Request }) ->unauthorized(function ($response) { // Handles 401 Unauthorized }) ->forbidden(function ($response) { // Handles 403 Forbidden }) ->notFound(function ($response) { // Handles 404 Not Found }) ->unprocessable(function ($response) { // Handles 422 Unprocessable Entity }) ->serverError(function ($response) { // Handles 500 Internal Server Error });
Generic Status Code Handler
$client->get('/endpoint') ->on(418, function ($response) { echo "I'm a teapot!"; });
Range Handlers
Handle ranges of status codes:
$client->get('/users') ->onSuccess(function ($response) { // Handles any 2xx status code echo "Success!"; }) ->onClientError(function ($response) { // Handles any 4xx status code echo "Client error: " . $response->status; }) ->onServerError(function ($response) { // Handles any 5xx status code echo "Server error: " . $response->status; });
Error Handling
Handle cURL-level errors (connection failures, timeouts, etc.):
$client->get('/endpoint') ->onError(function ($response) { echo "Connection error: " . $response->error; echo "Error code: " . $response->status; // Will be 0 for cURL errors });
Always Handler
Execute a callback regardless of the response status:
$client->get('/endpoint') ->onSuccess(function ($response) { // Handle success }) ->onClientError(function ($response) { // Handle client error }) ->always(function ($response) { // This runs regardless of success or failure echo "Request completed at " . date('Y-m-d H:i:s'); });
Response Object
All callbacks receive a Response object with the following properties and methods:
Properties
$response->status; // HTTP status code (int) $response->body; // Parsed JSON response (object|array|null) $response->raw_body; // Raw response body (string) $response->headers; // Response headers (array) $response->error; // cURL error message (string|null)
Methods
// Check response status $response->isSuccessful(); // Returns true for 2xx status codes $response->isClientError(); // Returns true for 4xx status codes $response->isServerError(); // Returns true for 5xx status codes $response->hasError(); // Returns true for cURL errors // Access response data $response->json(); // Get parsed JSON body (same as $response->body) $response->text(); // Get raw response body as string $response->toArray(); // Convert body to array // Get specific header (case-insensitive) $response->header('Content-Type'); // Returns header value or null $response->header('content-type'); // Same as above
Example Usage
$client->get('/users/123') ->onSuccess(function ($response) { // Access status code echo "Status: " . $response->status; // 200 // Access parsed JSON body echo "Name: " . $response->body->name; echo "Email: " . $response->body->email; // Or use helper methods $data = $response->toArray(); echo "Name: " . $data['name']; // Access raw body echo "Raw: " . $response->text(); // Access headers (case-insensitive) echo "Content-Type: " . $response->header('content-type'); // Check status if ($response->isSuccessful()) { echo "Success!"; } }) ->onError(function ($response) { // Access error information echo "Error: " . $response->error; echo "Status: " . $response->status; // 0 for cURL errors if ($response->hasError()) { echo "This is a cURL error"; } });
Working with Different Response Types
// JSON response $client->get('/api/user') ->onSuccess(function ($response) { // Access as object $name = $response->body->name; // Or convert to array $user = $response->toArray(); $name = $user['name']; // Or use json() helper $data = $response->json(); }); // Text/HTML response $client->get('/page.html') ->onSuccess(function ($response) { $html = $response->text(); echo $html; }); // Check headers $client->get('/file') ->onSuccess(function ($response) { $contentType = $response->header('Content-Type'); if ($contentType === 'application/pdf') { // Handle PDF } }); // Conditional logic based on status $client->get('/endpoint') ->always(function ($response) { if ($response->isSuccessful()) { echo "Request succeeded"; } elseif ($response->isClientError()) { echo "Client error: " . $response->status; } elseif ($response->isServerError()) { echo "Server error: " . $response->status; } elseif ($response->hasError()) { echo "Connection error: " . $response->error; } });
Real-World Examples
RESTful API Client
class UserService { private Client $client; public function __construct() { $this->client = new Client('https://api.example.com', [ 'Authorization' => 'Bearer ' . getenv('API_TOKEN'), 'Accept' => 'application/json' ]); } public function getUser(int $id): ?array { $user = null; $this->client->get("/users/{$id}") ->onSuccess(function ($response) use (&$user) { $user = (array) $response->body; }) ->notFound(function ($response) { // User doesn't exist, return null }) ->onError(function ($response) { error_log("API Error: " . $response->error); }); return $user; } public function createUser(array $data): ?int { $userId = null; $this->client->post('/users', $data) ->created(function ($response) use (&$userId) { $userId = $response->body->id; }) ->unprocessable(function ($response) { error_log("Validation errors: " . json_encode($response->body->errors)); }) ->onError(function ($response) { error_log("Connection error: " . $response->error); }); return $userId; } public function listUsers(int $page = 1, int $limit = 20): array { $users = []; $this->client->query(['page' => $page, 'limit' => $limit]) ->get('/users') ->onSuccess(function ($response) use (&$users) { $users = $response->body; }); return $users; } }
GitHub API Integration
$github = new Client('https://api.github.com', [ 'Accept' => 'application/vnd.github.v3+json', 'User-Agent' => 'MyApp/1.0' ]); // Get repository information $github->get('/repos/laravel/framework') ->onSuccess(function ($response) { echo "Repository: " . $response->body->full_name . "\n"; echo "Stars: " . $response->body->stargazers_count . "\n"; echo "Forks: " . $response->body->forks_count . "\n"; }) ->notFound(function ($response) { echo "Repository not found\n"; }) ->forbidden(function ($response) { echo "Access forbidden - check your token\n"; }); // Search repositories $github->query(['q' => 'language:php stars:>1000', 'sort' => 'stars']) ->get('/search/repositories') ->onSuccess(function ($response) { echo "Found " . $response->body->total_count . " repositories\n"; foreach ($response->body->items as $repo) { echo "- {$repo->full_name} ({$repo->stargazers_count} stars)\n"; } });
Weather API Client
$weather = new Client('https://api.openweathermap.org/data/2.5'); $apiKey = getenv('OPENWEATHER_API_KEY'); $weather->query(['q' => 'London', 'appid' => $apiKey, 'units' => 'metric']) ->get('/weather') ->onSuccess(function ($response) { $temp = $response->body->main->temp; $description = $response->body->weather[0]->description; echo "London: {$temp}°C, {$description}\n"; }) ->unauthorized(function ($response) { echo "Invalid API key\n"; }) ->notFound(function ($response) { echo "City not found\n"; }) ->onError(function ($response) { echo "Failed to fetch weather: " . $response->error . "\n"; });
Multi-Endpoint Workflow
$api = new Client('https://api.example.com', [ 'Authorization' => 'Bearer token123' ]); // Step 1: Create a post $postId = null; $api->post('/posts', ['title' => 'Hello World', 'content' => 'First post']) ->created(function ($response) use (&$postId) { $postId = $response->body->id; echo "Post created with ID: {$postId}\n"; }); // Step 2: Add a comment to the post if ($postId) { $api->post("/posts/{$postId}/comments", [ 'text' => 'Great post!' ])->created(function ($response) { echo "Comment added\n"; }); } // Step 3: Fetch the post with comments if ($postId) { $api->get("/posts/{$postId}?include=comments") ->onSuccess(function ($response) { echo "Post: " . $response->body->title . "\n"; echo "Comments: " . count($response->body->comments) . "\n"; }); }
Error Handling Pattern
$client = new Client('https://api.example.com'); $client->get('/data') ->onSuccess(function ($response) { // Everything went well processData($response->body); }) ->unauthorized(function ($response) { // Need to re-authenticate refreshAuthToken(); }) ->forbidden(function ($response) { // Don't have permission logSecurityEvent('Forbidden access attempt'); }) ->notFound(function ($response) { // Resource doesn't exist showNotFoundPage(); }) ->unprocessable(function ($response) { // Validation errors displayValidationErrors($response->body->errors); }) ->onServerError(function ($response) { // Server is having issues queueRetryJob(); notifyAdmins('API server error: ' . $response->status); }) ->onError(function ($response) { // Network/connection issues logError('Connection failed: ' . $response->error); showOfflineMessage(); }) ->always(function ($response) { // Cleanup, logging, metrics logRequest($response->status); updateMetrics(); });
Testing
Using Mocks in Tests
The library supports dependency injection for easy testing:
use PHPUnit\Framework\TestCase; use Phpmystic\Kai\Client; use Phpmystic\Kai\Tests\Mocks\MockCurlWrapper; class MyServiceTest extends TestCase { public function testApiCall(): void { // Create a mock $mockCurl = new MockCurlWrapper(); $mockCurl->setMockResponse( json_encode(['id' => 123, 'name' => 'Test User']), 200 ); // Inject mock into client $client = new Client('https://api.example.com', [], $mockCurl); // Test your code $result = null; $client->get('/users/123') ->onSuccess(function ($response) use (&$result) { $result = $response->body; }); $this->assertEquals(123, $result->id); $this->assertEquals('Test User', $result->name); } }
For more details on testing, see tests/README.md.
Running Tests
composer test
Design Philosophy
Why Callbacks Instead of Exceptions?
Traditional HTTP clients throw exceptions for error responses:
// Traditional approach try { $response = $client->get('/users/123'); $user = $response->json(); } catch (NotFoundException $e) { // Handle 404 } catch (UnauthorizedException $e) { // Handle 401 } catch (ServerException $e) { // Handle 5xx } catch (RequestException $e) { // Handle other errors }
Kai uses a callback-based approach:
// Kai approach $client->get('/users/123') ->onSuccess(function ($response) { $user = $response->body; }) ->notFound(function ($response) { // Handle 404 }) ->unauthorized(function ($response) { // Handle 401 }) ->onServerError(function ($response) { // Handle 5xx }) ->onError(function ($response) { // Handle connection errors });
Benefits:
- More Explicit - Each status code is handled explicitly
- No Try-Catch Blocks - Cleaner code flow
- Fluent Interface - Chain handlers naturally
- Selective Handling - Only handle the statuses you care about
- Better for Multiple Outcomes - HTTP responses aren't really "exceptions"
API Reference
Client Methods
Constructor
__construct(string $baseUrl = '', array $defaultHeaders = [], ?CurlWrapperInterface $curl = null)
Request Configuration
query(array $params): self- Set query parameterswithHeaders(array $headers): self- Set request headers
HTTP Methods
get(string $url): self- Send GET requestpost(string $url, array $data = []): self- Send POST requestput(string $url, array $data = []): self- Send PUT requestpatch(string $url, array $data = []): self- Send PATCH requestdelete(string $url): self- Send DELETE request
Response Handlers
on(int $statusCode, callable $callback): self- Handle specific status codeonSuccess(callable $callback): self- Handle 2xx responsesonClientError(callable $callback): self- Handle 4xx responsesonServerError(callable $callback): self- Handle 5xx responsesonError(callable $callback): self- Handle cURL errorsalways(callable $callback): self- Always execute callback
Status Code Aliases
ok(callable $callback): self- Handle 200created(callable $callback): self- Handle 201accepted(callable $callback): self- Handle 202noContent(callable $callback): self- Handle 204badRequest(callable $callback): self- Handle 400unauthorized(callable $callback): self- Handle 401forbidden(callable $callback): self- Handle 403notFound(callable $callback): self- Handle 404unprocessable(callable $callback): self- Handle 422serverError(callable $callback): self- Handle 500
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
License
This library is licensed under the MIT License. See the LICENSE file for details.
Author
Elmehdi el faithi - mehdi@elfatihi.me