A Laravel API wrapper package is what you build the third time you paste the same HTTP client into a new project. Three apps all talk to the same shipping provider, and each one has its own slightly different copy of the client, its own error handling, its own idea of what the response looks like. They drift. A bug fixed in one never reaches the other two. The fix is to extract that client once into a small Composer package, require it everywhere, and version it. One tested wrapper, one place to change the base URL, one place to fix a retry bug. I have shipped a few of these for payment and logistics APIs, and the structure below is what I keep coming back to.
What goes in the package, and what stays in the app?
The package owns everything that is the same across every consumer: the HTTP client, the typed methods, the DTOs, the error handling, and a default config. The app owns the secrets and any per-project overrides, which live in its own .env and a published config file. Get that boundary right and upgrading the package never touches a consumer's credentials. The skeleton is three things: a composer.json that autoloads your namespace and tells Laravel which service provider to discover, the provider itself, and the client class.
- composer.json: PSR-4 autoload mapping your namespace to src/, plus extra.laravel.providers so package auto-discovery registers your provider without the app editing config/app.php.
- A service provider: binds the client as a singleton, merges the default config, and publishes the config file so consumers can override it.
- The client: configured from config (never from env() directly), with typed methods that return DTOs instead of raw arrays.
- An optional facade: a thin static accessor for people who prefer ShippingApi::rate(...) over injecting the client.
- A fake: a drop-in test double you ship so consumers can assert against your package without hitting the network.
{
"name": "acme/shipping-client",
"description": "Typed Laravel wrapper for the Acme shipping API",
"type": "library",
"license": "MIT",
"require": {
"php": "^8.3",
"illuminate/support": "^11.0|^12.0",
"illuminate/http": "^11.0|^12.0"
},
"require-dev": {
"orchestra/testbench": "^9.0",
"pestphp/pest": "^3.0"
},
"autoload": {
"psr-4": {
"Acme\\Shipping\\": "src/"
}
},
"extra": {
"laravel": {
"providers": [
"Acme\\Shipping\\ShippingServiceProvider"
],
"aliases": {
"ShippingApi": "Acme\\Shipping\\Facades\\ShippingApi"
}
}
}
}The extra.laravel.providers block is the whole point of package auto-discovery: once a consumer runs composer require acme/shipping-client, Laravel finds and registers ShippingServiceProvider on its own. Nobody edits config/app.php, and nobody forgets to. The aliases entry does the same for the facade. Pin illuminate/support to the framework majors you actually test against (^11.0|^12.0 here), not to a single version, or you will block every consumer the day they upgrade Laravel.
How does the service provider wire it all up?
The provider does two jobs. In register() it merges your default config and binds the client into the container as a singleton, reading from config so the consumer's published values win. In boot() it publishes the config file so consumers can run vendor:publish and override it. Binding as a singleton matters: you want one configured client per request, not a fresh object with a fresh connection pool on every resolve.
<?php
namespace Acme\Shipping;
use Illuminate\Support\ServiceProvider;
class ShippingServiceProvider extends ServiceProvider
{
public function register(): void
{
// Default config ships inside the package; the app's published
// values override these keys, the rest fall back to the default.
$this->mergeConfigFrom(__DIR__ . '/../config/shipping.php', 'shipping');
$this->app->singleton(ShippingClient::class, function ($app) {
$config = $app['config']['shipping'];
return new ShippingClient(
baseUrl: $config['base_url'],
apiKey: $config['api_key'],
timeout: $config['timeout'],
);
});
}
public function boot(): void
{
// Lets a consumer run:
// php artisan vendor:publish --tag=shipping-config
$this->publishes([
__DIR__ . '/../config/shipping.php' => config_path('shipping.php'),
], 'shipping-config');
}
}Note the client is constructed from config values, not from env() calls inside the package. env() returns null once a consumer runs config:cache, so reading env() anywhere outside a config file is the single most common reason a package mysteriously stops authenticating in production. The default config file maps the env names to config keys exactly once, and the client only ever sees the resolved config array.
<?php
return [
'base_url' => env('SHIPPING_BASE_URL', 'https://api.acme-shipping.test/v1'),
'api_key' => env('SHIPPING_API_KEY'),
'timeout' => (int) env('SHIPPING_TIMEOUT', 10),
];Why return DTOs instead of raw arrays?
A raw array from ->json() is an untyped bag of strings. Every consumer then guesses at key names ($response['rate_amount'] or was it ['amount']?), and a renamed upstream field becomes three silent bugs in three apps. A DTO makes the shape explicit and the failure loud: the client maps the response once, in the package, and consumers get a typed object with named properties and autocomplete. When the provider renames a field, you fix the mapping in one method and ship a patch release. This is the same separation I argue for in testable service classes: the boundary between your code and someone else's JSON should be exactly one typed object.
<?php
namespace Acme\Shipping;
use Acme\Shipping\Data\ShippingRate;
use Acme\Shipping\Exceptions\ShippingException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
class ShippingClient
{
public function __construct(
private string $baseUrl,
private ?string $apiKey,
private int $timeout = 10,
) {}
private function request(): PendingRequest
{
// Centralized: auth, timeout, and retry/backoff live here once,
// so every method inherits the same resilient behaviour.
return Http::baseUrl($this->baseUrl)
->withToken($this->apiKey)
->timeout($this->timeout)
->retry(3, 200, throw: false)
->acceptJson();
}
public function rate(string $from, string $to, int $grams): ShippingRate
{
$response = $this->request()->get('/rates', [
'from' => $from,
'to' => $to,
'weight' => $grams,
]);
if ($response->failed()) {
throw ShippingException::fromResponse($response);
}
// One mapping, in one place. Consumers never touch raw keys.
return ShippingRate::fromArray($response->json());
}
}The retry, backoff, and the single ShippingException are the resilience layer, and it belongs in the package so every consumer inherits it for free. I covered the why and the edge cases (which status codes to retry, how to back off, when to give up) in my resilient third-party API client post; the package is simply where those patterns live permanently instead of being re-derived per project. If you also cache the slow or metered calls, the wrapper is the natural home for that too, following the approach in caching third-party API responses.
If three apps each carry their own copy of the same API client, you do not have one client with bugs. You have three clients, and only one of them ever gets the fix.
How do consumers test against the package?
Because the client uses Laravel's Http facade, any consumer can already fake it with Http::fake() in their own tests, no real network call and no credentials needed. That works out of the box. But making each consumer hand-build a fake response that matches your exact JSON shape just re-creates the drift you were trying to kill. So ship a fake from the package: a small static helper that registers a canned response and returns the typed DTO consumers expect. Now the response shape lives in your package, versioned alongside the real client.
<?php
use Acme\Shipping\ShippingClient;
use Illuminate\Support\Facades\Http;
it('returns a typed rate from a faked response', function () {
Http::fake([
'api.acme-shipping.test/*' => Http::response([
'rate_amount' => 1299,
'currency' => 'AUD',
'service_code' => 'EXPRESS',
], 200),
]);
$rate = app(ShippingClient::class)->rate('2000', '3000', 500);
expect($rate->amountCents)->toBe(1299)
->and($rate->currency)->toBe('AUD');
Http::assertSent(fn ($request) => str_contains($request->url(), '/rates'));
});The consumer never touches the real API, the test runs in milliseconds, and the assertion proves both the request that went out and the DTO that came back. For the canonical reference on Http::fake() and assertSent(), the Laravel HTTP client docs are the source I keep open. Ship a similar fake helper inside your package so consumers get the canned shape for free instead of copying my example by hand.
How do you version and release it?
Tag with semantic versioning and mean it, because consumers pin to it. A renamed config key, a removed method, or a changed DTO property is a breaking change and demands a major bump. A new typed method or a new optional config key is a minor. A retry tweak or a bug fix is a patch. Tag the release in git, push the tag, and either publish to Packagist or point consumers at a private VCS or Satis repository. The contract you are selling is stability: a consumer should be able to run composer update inside a major version and never have their build break.
- MAJOR: a removed or renamed public method, a changed DTO property, a renamed config key. Anything a consumer's code or config references.
- MINOR: a new method, a new optional config key with a sane default, a new DTO field. Additive only.
- PATCH: retry tuning, error-message wording, an internal bug fix. No surface change.
- Pin illuminate/support to the framework majors you test (^11.0|^12.0), and run those in CI so you find a break before a consumer does.
The payoff shows up the first time the provider changes something. A header gets renamed, a 429 starts arriving where a 503 used to, an amount switches from dollars to cents. You fix it once in the package, tag a patch, and every app picks it up with one composer update instead of three separate copy-paste sessions you will inevitably do at three different levels of care. That is the entire argument for the wrapper: turn three drifting clients into one you can actually maintain.

