A fluent, exception-less PHP HTTP client library that uses a callback-based approach for handling responses. Built on cURL.

Maintainers

Details

github.com/Phpmystic/kai

Source

Issues

Installs: 1

Dependents: 0

Suggesters: 0

Security: 0

Stars: 1

Watchers: 0

Forks: 0

Open Issues: 0

pkg:composer/phpmystic/kai

0.5.0 2025-11-15 10:57 UTC

This package is auto-updated.

Last update: 2025-12-20 07:46:25 UTC


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:

  1. More Explicit - Each status code is handled explicitly
  2. No Try-Catch Blocks - Cleaner code flow
  3. Fluent Interface - Chain handlers naturally
  4. Selective Handling - Only handle the statuses you care about
  5. 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 parameters
  • withHeaders(array $headers): self - Set request headers

HTTP Methods

  • get(string $url): self - Send GET request
  • post(string $url, array $data = []): self - Send POST request
  • put(string $url, array $data = []): self - Send PUT request
  • patch(string $url, array $data = []): self - Send PATCH request
  • delete(string $url): self - Send DELETE request

Response Handlers

  • on(int $statusCode, callable $callback): self - Handle specific status code
  • onSuccess(callable $callback): self - Handle 2xx responses
  • onClientError(callable $callback): self - Handle 4xx responses
  • onServerError(callable $callback): self - Handle 5xx responses
  • onError(callable $callback): self - Handle cURL errors
  • always(callable $callback): self - Always execute callback

Status Code Aliases

  • ok(callable $callback): self - Handle 200
  • created(callable $callback): self - Handle 201
  • accepted(callable $callback): self - Handle 202
  • noContent(callable $callback): self - Handle 204
  • badRequest(callable $callback): self - Handle 400
  • unauthorized(callable $callback): self - Handle 401
  • forbidden(callable $callback): self - Handle 403
  • notFound(callable $callback): self - Handle 404
  • unprocessable(callable $callback): self - Handle 422
  • serverError(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

Links