ptondereau/laravel-ups-api
Laravel package that wraps the UPS API, letting you easily add shipping rates, tracking, address validation, and label creation to your Laravel applications through a clean, configurable integration.
composer require ptondereau/laravel-ups-api
php artisan vendor:publish --tag=ups # publishes config/ups.php
Fill config/ups.php (or the matching env vars) with the five values you get from the UPS Developer Portal: access key, user id, password, shipper number, and account number. Note that shipper number and account number are not interchangeable — negotiated rates need the account number plus the shipper entity on the request.
Start with the rate sandbox. Point the package at the UPS CIE (test) environment via the integration/environment config flag, then:
use Ups\Rate;
use Ups\Entity\Address;
use Ups\Entity\Package;
use Ups\Entity\UnitOfMeasurement;
$shipper = new Address();
$shipper->setPostalCode('90210');
$shipper->setCountryCode('US');
$shipper->setCity('Beverly Hills');
$shipTo = new Address();
$shipTo->setPostalCode('10001');
$shipTo->setCountryCode('US');
$shipTo->setCity('New York');
$package = new Package();
$package->getPackagingType()->setCode(Package::PKG_TYPE_CUSTOMER_SUPPLIED);
$package->getPackageWeight()->setWeight(2.5);
$package->getPackageWeight()->getUnitOfMeasurement()->setCode(UnitOfMeasurement::PROD_KILOGRAMS);
$rates = Rate::getRate($shipper, $shipper, $shipTo, $package);
Where to look first:
config/ups.php — credentials, environment toggle, debug/logging.src/Facades/ — one facade per UPS service (Rate, Tracking, Shipping, Address, TimeInTransit, Tradeability, Locator, QuantumView). In v5 each declares @mixin to its Ups\* root class, so hovering a facade call in PhpStorm now shows the real return type.vendor/ptondereau/ups-api — the underlying SDK. The entities (Address, Package, Shipment, PackageWeight, …) and their magic getters/setters live here, not in the Laravel package. This is the actual API surface you'll spend your time in.If you're on v4, bump deliberately: v5.0.0 is a major tag that adds Laravel 13 / PHP 8.5 support, so confirm the illuminate/* and php constraints in composer.json before assuming your current Laravel 10/11 app is still supported. Run composer why-not ptondereau/laravel-ups-api 5.0 first.
Wrap the facades; don't sprinkle them. UPS entities are verbose and mutable, and mapping them into your own DTOs at the boundary keeps the rest of the app free of Ups\Entity\*. A UpsRateService that accepts a plain ShipmentQuoteRequest and returns a Collection of ShippingOption values is the pattern that ages best, and it gives you one place to add caching, timeouts and fallbacks.
Checkout rate quoting. Cache aggressively keyed on (postal code, country, weight bucket, residential flag) — UPS rate calls are 200–800 ms and will dominate your checkout latency if uncached. Always wrap the call in a try/catch with a hardcoded fallback rate so a CIE/production outage doesn't take down checkout. Bucket weights (round up to 0.5 kg) to raise cache hit rate.
Label purchase should be queued and idempotent. Ups\Shipping::shop() returns rates and a label in one call, which is what you usually want for "buy cheapest label". Do it in a job, not in the request cycle. UPS has no idempotency key, so before retrying a failed job, look up whether a shipment with your reference number already exists — retries are a real source of duplicate labels and double charges. Persist GraphicImage (base64) to your storage disk immediately and keep the tracking number in a column, not in a log.
Tracking is poll-based. There are no webhooks for the standard Tracking API, so schedule a job over open shipments and back off on delivery (M/D status codes) versus in-transit. QuantumView exists for bulk file-based updates but requires a separate UPS subscription — only reach for it at high volume.
Validate addresses before rating or shipping. Address validation rejects bad postal codes cheaply; a bad address at label time is a hard failure after you've already quoted the customer.
Test by swapping the container binding. Bind your own UpsRateService interface to a fake in tests rather than hitting CIE. For the few integration tests that do call UPS, group them (@group ups) and skip when credentials are absent.
Response->Error / the Alert collection on every response. A silently empty rate list is almost always this, not a config problem.@mixin helps tooling, not runtime. If you mock a facade with Facade::shouldReceive(...), the method name still has to match the root class method, and static analysis of a shouldReceive expectation is only as good as the mixin. After upgrading to v5, re-index your IDE — stale caches will still show mixed and you'll wrongly conclude the annotations don't work./** @var mixed $rates */ casts or PHPStan baseline entries around facade calls. v5 makes those redundant and level-10 analysis will now flag the redundant cast or the assumption that hid a type error. Clean them up while the diff is small.Address/Shipper on the request, and the negotiated-rates request option set. Miss one and you get list rates that look fine.use Ups; for the root facade in a file that also imports Ups\Entity\* — the alias collides with the namespace. Import the specific facades (use Ups\Rate;).config('ups.*'). A stray env('UPS_ACCESS_KEY') outside a config file returns null the moment you run config:cache in production.How can I help you explore Laravel packages today?