engeni/api-client-2
Engeni API Client 2 is a fluent, Guzzle-powered HTTP client for Laravel and PHP applications, simplifying request building, authentication, error handling, and JSON response parsing so you can integrate third-party services with clean, testable code.
This is a private, single-consumer package (Bitbucket-hosted, zero dependents), so treat the first hour as reconnaissance rather than a normal composer require.
Get access before you get code. Add the Bitbucket repo to composer.json and stash credentials in auth.json (or CI secrets), never in the committed manifest:
{
"repositories": [
{ "type": "vcs", "url": "https://bitbucket.org/engeni/api-client-2.git" }
],
"require": { "engeni/api-client-2": "^2.0" }
}
For CI, either use a Bitbucket app password / access token or mirror the package into your own Satis/Private Packagist.
Read four files in this order. They tell you more than the README will:
composer.json — PHP and illuminate/* constraints. This is the single biggest integration risk; a stale ^8.0 constraint will fight your Laravel 11 app.src/*ServiceProvider.php — what gets bound into the container, whether config is publishable, whether a facade alias is registered.config/*.php — base URL, credentials, timeout, retry, and any endpoint map.src/ entry class (usually a Client or Api class) — the public method surface. Skim the tests, if any exist, for the real usage contract.Publish and wire config:
php artisan vendor:publish --provider="Engeni\ApiClient\ApiClientServiceProvider"
Then set the env vars the config file references (base URL, client ID/secret, and any tenant/company key). Run php artisan config:clear before assuming anything is broken.
First use case: a read-only smoke test. Don't start with writes. Resolve the client from the container and hit the cheapest authenticated GET the API exposes (health/ping, or a single-resource find($id)), then dump() the raw response. This validates credentials, base URL, and the response envelope shape in one shot. Write that as a throwaway artisan command or tinker session and keep the output — you'll reference it constantly.
Confirm the transport. Determine whether the package wraps Laravel's Http facade or talks to Guzzle directly. This decides your entire testing strategy (see Gotchas).
Inject, don't resolve inline. Bind once and let the container hand it to you — constructor injection in a job or a thin wrapper service beats app(Client::class) scattered through controllers.
Wrap it in your own service layer. Given the package's age and lack of community, put a narrow interface between your domain and engeni/api-client-2:
interface BillingGateway {
public function invoice(string $id): InvoiceData;
}
final class EngeniBillingGateway implements BillingGateway
{
public function __construct(private \Engeni\ApiClient\Client $client) {}
public function invoice(string $id): InvoiceData
{
return InvoiceData::from($this->client->invoices()->get($id));
}
}
This is not ceremony — it's the only thing that makes the package swappable when V3 lands or the repo goes dark.
Hydrate into DTOs at the boundary. The client will hand back arrays or stdClass. Convert to your own value objects immediately so a shape change upstream surfaces in one place instead of 40 views.
Keep pagination in one helper. Most clients of this style expose either a cursor or page/per-page pair. Wrap iteration in a generator so callers never hand-roll while ($hasMore) loops, and so you can add a page cap that prevents runaway syncs.
Push writes onto the queue. Anything that creates/updates remote state should be a job with $tries, backoff(), and idempotency keys if the API supports them. Interactive requests that block on a third-party API are how you get 30-second page loads during the vendor's incidents.
Multi-tenant apps: register the client scoped, not singleton. If credentials or a tenant header vary per request, bind with ->scoped() (or build a per-tenant client via a factory) so Octane and queued workers don't leak one tenant's credentials into another's request.
Test with the transport's native fake. If it uses Http, Http::fake([...]) in feature tests is enough. If it uses raw Guzzle, inject a MockHandler through the client's constructor or a container binding, and record fixtures from the smoke test in step 4 above. Fixture-based tests are your regression net for upstream changes.
Log around the boundary. A single structured log line per outbound call — method, path (not full body), status, duration, correlation ID — pays for itself the first time the vendor asks "did you send it?"
2.x.y or a commit hash) and treat every bump as a code review, because there's no changelog discipline to rely on. Add a composer outdated check to CI so you notice drift.composer require refuses to resolve, don't --ignore-platform-reqs blindly; check whether the package's illuminate/support range actually covers your version, and prefer a fork with a widened constraint over a global ignore.Http::fake() silently does nothing if the package uses Guzzle directly — your "mocked" tests will hit the real API. Verify by asserting a ConnectionException when you fake a 500. This is the #1 source of accidental live calls from test suites.expires_in. Clock skew, config:cache, and shared Redis across environments all bite here. Check whether the cache key is namespaced per credential set; if not, add your own prefix or you'll get cross-environment 401s.null, or return ['error' => ...]. Normalize to your own exception types in the wrapper so callers have one thing to catch and you have one place to add Sentry breadcrumbs.extend(), and any Macroable trait on the client. A middleware hook is the cleanest place to inject correlation IDs, logging, and retry policy without forking.How can I help you explore Laravel packages today?