flex/express
Flex Express brings Express-style routing and middleware to Laravel/PHP, making it easy to define clean endpoints and build small APIs or web apps with minimal boilerplate. Its expressive syntax keeps setup light and development fast.
composer require flex/express:^1.0.2
Pin to ^1.0.2 at minimum — that release (see Gotchas) refreshes the bundled 快递公司代码 table, and older pins will send stale carrier codes to the vendors.
The package is a thin SDK around two upstream aggregators — 快递鸟 (KdNiao) and 快递100 (Kuaidi100) — so the first thing to do is decide which provider you actually have credentials for. They are not interchangeable: 快递鸟 needs an EBusinessID + AppKey (and pays per query unless you're on the free 即时查询 quota), while 快递100 needs a customer + secret (MD5-signed) or the newer 授权 key. You will typically only configure one.
php artisan vendor:publish and pick the flex/express entry):php artisan vendor:publish --provider="Flex\Express\ExpressServiceProvider"
.env / config/express.php:return [
'default' => env('EXPRESS_DRIVER', 'kdniao'),
'kdniao' => [
'ebusiness_id' => env('KDNIAO_EBUSINESS_ID'),
'app_key' => env('KDNIAO_APP_KEY'),
'sandbox' => env('KDNIAO_SANDBOX', false),
],
'kuaidi100' => [
'customer' => env('KUAIDI100_CUSTOMER'),
'secret' => env('KUAIDI100_SECRET'),
],
];
use Flex\Express\Facades\Express;
$result = Express::query('SF', 'SF1234567890', '13800000000');
The carrier code (SF, YTO, ZTO, JD, …) must match the vendor's expected string. The package ships a company-code table (which 1.0.2 updated) — grep src/ and any bundled codes/company array or JSON before assuming it's absent, and read the driver class (KdNiao, Kuaidi100) next; that file is the real documentation, since the README is sparse.
Bind a thin interface, not the SDK. Because the package talks to two vendors with different response shapes, wrap it immediately:
interface TrackingService
{
public function track(string $carrier, string $number, ?string $phone = null): TrackingResult;
}
Map both drivers' payloads into one DTO (status, events[], delivered_at). This is the single highest-value thing you can do — it lets you swap providers when one's quota runs out, and it stops vendor JSON from leaking into your controllers.
Normalise carrier codes at the boundary. Even though the package carries a code list, put your own lookup in front of it — an enum or carrier_codes config keyed by your internal carrier slug, whose values are validated against the package's table:
// config/carriers.php
'sf' => ['vendor' => 'SF', 'phone_required' => true],
'jd' => ['vendor' => 'JD', 'phone_required' => true],
This gives you a single place to re-map when the vendor renames a company (百世/极兔/德邦 have all churned codes historically) and means a package upgrade — like 1.0.2 — can't silently change what you send. Re-diff your mappings against the package table after every bump.
Query-through vs. push (subscribe). Both vendors offer 订阅推送, which is what you want in production — you register a tracking number once and the vendor POSTs status changes to your webhook. Use the SDK for the subscribe call, but implement the callback endpoint yourself (validate their signature, then dispatch a job). Do not let the vendor callback do DB writes inline; 快递鸟 retries and you will get duplicates.
Polling as a fallback. For one-off admin lookups ("where is this order?") a synchronous Express::query() is fine. For anything covering many orders, push the query into a queued job:
class RefreshTracking implements ShouldQueue
{
public function handle(TrackingService $tracking): void
{
$this->order->tracking = $tracking->track($this->order->carrier, $this->order->tracking_no);
$this->order->save();
}
}
Cache terminal states (delivered, returned) so you never burn a paid query on a parcel that already arrived.
Where to wire it in. Typical integration points: an OrderObserver/OrderPaid listener that subscribes the tracking number right after fulfilment, a scheduled command (everyThirtyMinutes) that polls non-terminal shipments, and a webhook route excluded from CSRF.
Multi-driver resolution. If the config exposes a default key, you can usually select a driver per-call. Read the driver factory in the package source before assuming the method name — releases around 2022 exposed it inconsistently as Express::driver('kdniao') or via the facade's __call.
SF-Express/shunfeng-style strings you copied out of src/. Diff the code table between 1.0.1 and 1.0.2 before rolling out, and re-run one live query per carrier you actually support.strlen(null) are likely). Pin the version in composer.json and consider forking if you need more than code-table updates.requestData with MD5 and then URL-encodes it before base64, in that order. 快递100 signs param + customer + key with MD5 and uppercases it. If you're getting errCode 100 / status 500, it's almost always the sign, not your credentials.Success: false + Reason; 快递100 returns result: false + message. If your SDK wrapper only checks the HTTP status you will silently store empty tracking data. Log the raw response body on every non-terminal path.http://sandboxapi.kdniao.com:8080/...) — make sure the flag actually flips the base URL; some versions hard-code the production host in the driver. Verify with a known test number before going live, and re-verify after each bump, since code-list patches occasionally ride along with other small edits.timeout by default; Guzzle's default is unlimited. Set 5–10s, retry with backoff in your job, not in the SDK (a retried subscribe is fine, a retried query is money).KdNiao/Kuaidi100 classes are a useful reference for the signing logic, which is the tedious part, and its company-code table is a reasonable starting point for your own.carrier → vendor_code table; shipping providers (Shopify, 店匠, custom WMS) emit codes like shunfeng / SF-Express, and neither the SDK nor the bundled table will normalise those for you.How can I help you explore Laravel packages today?