cristal/php-api-wrapper
cristal/php-api-wrapper is a Laravel/PHP package for building clean, reusable API clients. It offers a fluent interface for requests and responses, supports caching, retries, logging, and testing, and helps keep external API integrations organized and maintainable.
cristal/php-api-wrapper gives you an Eloquent-shaped model layer over any REST API. You define PHP classes that map to endpoints, annotate their properties, and then query/write them with the same vocabulary you use for Eloquent (where, first, get, save, relations).
1. Install
composer require cristal/php-api-wrapper
Laravel 9/10/11 will auto-discover Cristal\ApiWrapper\Bridges\Laravel\ApiWrapperServiceProvider. For non-Laravel apps you can bootstrap the Wrapper manually, but the Laravel bridge is where ~90% of the ergonomics live.
2. Publish and edit the config
php artisan vendor:publish --tag=api-wrapper-config
config/api-wrapper.php is the first file you should read. It defines a default connection and a connections map. Each connection has a driver (Guzzle by default), options (anything your HTTP client accepts — base_uri, headers, timeout), optional auth, and a cache block. This is the single source of truth for base URLs and credentials; do not hardcode them in models.
'default' => 'acme',
'connections' => [
'acme' => [
'driver' => 'guzzle',
'options' => [
'base_uri' => env('ACME_API_URL'),
'headers' => ['Accept' => 'application/json'],
],
'auth' => ['type' => 'bearer', 'token' => env('ACME_API_TOKEN')],
'cache' => ['enabled' => true, 'store' => 'redis', 'ttl' => 300],
],
],
3. Define your first model
use Cristal\ApiWrapper\Api\Model;
use Cristal\ApiWrapper\Attributes\Endpoint;
use Cristal\ApiWrapper\Attributes\Property;
#[Endpoint('users')]
class User extends Model
{
#[Property('id')] public int $id;
#[Property('email')] public string $email;
#[Property('name')] public string $name;
}
4. First use case — wrap a read-only listing endpoint
$user = User::query()->where('email', '[email protected]')->first();
$actives = User::query()
->where('status', 'active')
->orderBy('created_at', 'desc')
->limit(50)
->get();
That's the hook: you stop hand-rolling Http::get(...)->json()['data'] and start treating the remote API like a database. Look next at Cristal\ApiWrapper\Api\Builder (the fluent query object), Api\Model (hydration, casting, serialization), and the Attributes namespace for relations and endpoint metadata.
Models as schema, not as business logic. Treat each API model as a thin DTO with endpoint metadata. Put orchestration, retries, and cross-resource workflows into services or actions that use the models. The package makes it tempting to put everything in the model; resist it, because API models are harder to unit-test than plain classes.
One connection per upstream. If you talk to multiple APIs, define multiple connections and pin each model to one:
#[Endpoint('invoices')]
#[Connection('billing')]
class Invoice extends Model {}
Keeping connections separate means auth, base URIs, and cache stores stay isolated, and swapping a sandbox URL is a one-line config change.
Relations mirror Eloquent, but call them deliberately.
#[Relation]
public function company(): BelongsTo
{
return $this->belongsTo(Company::class, 'company_id');
}
#[Relation]
public function invoices(): HasMany
{
return $this->hasMany(Invoice::class, 'user_id');
}
Load them with with(['invoices']) when you can, and be prepared to hand-roll a whereIn fallback (see Gotchas) because remote relation loading often translates to one HTTP call per parent.
Centralize writes.
$user = User::query()->create(['name' => 'Jane', 'email' => '[email protected]']);
$user->name = 'Jane Doe';
$user->save();
$user->delete();
Wrap these in an Action or repository so you can add optimistic-lock checks, audit logging, or cache busting in one place. The wrapper gives you save()/create()/delete(), but it will not know that your API also requires X-Idempotency-Key on POST — that's your options or middleware.
Cast aggressively. API payloads are strings and nested arrays; cast on the model so downstream code sees real types:
#[Property('created_at')]
#[Cast(DateTimeInterface::class)]
public ?Carbon $createdAt;
Prefer explicit casts over sprinkling Carbon::parse() through your codebase — you get consistency and a single place to fix when the upstream changes format.
Cache at the connection, override per query. The config-level cache block is a blunt instrument. For hot listings, wrap the builder call in a Laravel Cache::remember keyed by the query parameters. This gives you real invalidation (via tags or keys) that connection-level caching cannot provide, because the wrapper caches by URL and never learns about your write operations.
Testing with a fake transport. Do not hit the real API in tests. The cleanest pattern is to bind a Guzzle MockHandler into options.handler for a testing connection, or implement the driver interface with a fake that returns fixtures. Keep integration tests narrow (one per endpoint) and unit-test your services against stubbed models or a fake driver.
Standalone (non-Laravel) use. You can construct a Wrapper with a connection array and set it as the default, but you lose the service provider, the config publisher, and Laravel's cache. If you're outside Laravel, budget an afternoon for bootstrapping and consider a thin adapter layer so you can swap the wrapper out later.
Pagination shape is not standardized. Every API returns metadata differently — meta.total, pagination.next_cursor, X-Total-Count headers. paginate() will work if the upstream matches the package's expectations; otherwise override the pagination handling in your model or map the response yourself. Verify on one endpoint before you build listings on top of it.
Relations are lazy and can hide N+1 HTTP calls. with(['invoices']) reduces this when supported, but a "hasMany across a remote API" fundamentally requires either a join-style endpoint or a query per parent. For collections larger than a handful, batch manually: collect IDs, then Invoice::query()->whereIn('user_id', $ids)->get()->groupBy('user_id').
Writes do not invalidate the cache. Connection-level caching will happily serve stale data after a save() or delete(). Either disable caching on writable models or flush the relevant keys in your service layer. The package will not warn you about this.
Auth refresh is not built in. Bearer tokens work; OAuth client-credentials refresh does not happen automatically. The pragmatic fix is a Guzzle middleware that catches 401, refreshes the token, and retries once — register it via the connection's options or a custom driver. Never store long-lived tokens in the config file for production; pull them from the environment or a secret store and pass them in at boot.
Attribute syntax only on modern PHP. The package moved to PHP 8 attributes; older READMEs and blog posts still show docblock annotations. If examples don't work, check the PHP version guard and the version you installed (composer show cristal/php-api-wrapper). PHP 8.1+ is the safe assumption.
Property metadata must match the API contract exactly. Misspell a #[Property('first_name')] and hydration silently drops the value or leaves the typed property uninitialized — you'll get a TypeError on access rather than a helpful "field not found." Write one smoke test per model that asserts hydration against a fixture payload.
Error handling is opt-in-ish. HTTP 4xx/5xx need to be surfaced explicitly via the connection's options (e.g., http_errors) or driver config. Default Guzzle behavior throws, which is usually what you want, but the exception is a GuzzleHttp\Exception\ClientException leaking through your domain. Add a small mapping layer so your services throw UserNotFoundException rather than exposing transport exceptions.
find() assumes a REST convention. User::query()->find(1) expects GET /users/1. If your API uses GET /users?id=1 or a slug, use where() + first() instead and document why.
Debugging. Turn on Guzzle request/response logging via options.handler or a middleware in the connection config for staging. When a query returns nothing, dump $builder->toArray() / the underlying URL before assuming a code defect — usually it's a mismatched query parameter name or an unexpected response envelope.
Serialization is on you. Models implement array/JSON conversion, but the shape won't match your API's envelope. If you're round-tripping payloads (e.g., proxying or re-POSTing), keep the raw response around rather than reconstructing from model properties.
Maintenance risk. Last tagged release is April 2023 and the dependents count is effectively zero, so expect to own fixes. Pin a version in composer.json, keep the wrapper behind your own repository/interface, and avoid putting its base classes in your public API surface. That way, if the package stalls, swapping to a maintained alternative or a hand-rolled client is a mechanical change rather than a rewrite.
Extension points worth knowing. Custom drivers (implement the driver contract for signed requests or gRPC), custom casts for value objects, and model lifecycle hooks for audit logging. These are the seams that let you keep the package while adapting to the quirks of a specific upstream — lean on them instead of forking.
How can I help you explore Laravel packages today?