ux2dev/econt
The Econt e-delivery API. All 25 RPC operations across seven service groups, generated from Econt's own OpenAPI spec so upstream changes are a regeneration.
Framework-agnostic PHP SDK for the Econt e-deliveryexpress API. Covers all 25 RPC operations across 7 service groups (Nomenclatures, Address, Labels, Shipments, Profile, ThreeWayLogistics, PaymentReport) as resource methods that take typed Input DTOs and return typed Result DTOs.
Ships with three opt-in layers: framework-agnostic core, Laravel adapter (multi-account manager + facade), and a persistence module (Eloquent models, repository contracts, sync/polling actions, queueable jobs, Form Requests).
The full DTO + resource catalogue is generated from Econt's openapi.yaml — re-run the generator to pick up upstream changes.
Requirements
- PHP 8.2 or higher
- JSON extension
- A PSR-18 HTTP client and PSR-17 request/stream factories (Guzzle provides both)
Installation
composer require ux2dev/econt
Quick start
The SDK takes a PSR-18 HTTP client and two PSR-17 factories — no specific implementation is required. Any PSR-18 client works (Guzzle, Symfony HttpClient, Buzz, kriswallsmith/Buzz, your own…). The example below uses Guzzle because it satisfies both contracts in a single package; bring whatever HTTP stack your project already has.
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Ux2Dev\Econt\Config\EcontConfig;
use Ux2Dev\Econt\Dto\Input\Nomenclatures\GetCountriesInput;
use Ux2Dev\Econt\Econt;
use Ux2Dev\Econt\Enum\Environment;
/** @var ClientInterface $httpClient */ // any PSR-18 client
/** @var RequestFactoryInterface $requestFactory */ // any PSR-17 factory
/** @var StreamFactoryInterface $streamFactory */ // any PSR-17 factory
$config = new EcontConfig(
username: 'your-econt-user',
password: 'your-econt-password',
environment: Environment::Demo,
);
$econt = new Econt($config, $httpClient, $requestFactory, $streamFactory);
$result = $econt->nomenclatures()->getCountries(new GetCountriesInput());
foreach ($result->countries as $country) {
echo $country->data['code2'] . PHP_EOL;
}
Concrete setup with Guzzle (one extra dep, satisfies PSR-18 + both PSR-17 factories):
composer require guzzlehttp/guzzle
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\HttpFactory;
$factory = new HttpFactory();
$econt = new Econt($config, new Client(), $factory, $factory);
Or — if you're on Symfony — pair symfony/http-client (PSR-18) with nyholm/psr7 (PSR-17 factories). The SDK doesn't care which.
Laravel
The package auto-discovers Ux2Dev\Econt\Laravel\EcontServiceProvider. Publish the config:
php artisan vendor:publish --tag=econt-config
config/econt.php defines accounts. Set the default account's credentials in .env:
ECONT_USERNAME=your-econt-user
ECONT_PASSWORD=your-econt-password
ECONT_ENVIRONMENT=demo
Use the facade:
use Ux2Dev\Econt\Dto\Input\Nomenclatures\GetCountriesInput;
use Ux2Dev\Econt\Laravel\Facades\Econt;
$result = Econt::nomenclatures()->getCountries(new GetCountriesInput());
Multiple accounts: add entries to accounts and switch at runtime:
// config/econt.php
'accounts' => [
'main' => [...],
'eu' => [...],
],
// at runtime
Econt::account('eu')->labels()->createLabel(...);
Each account is resolved once and cached for the request lifetime. The container also resolves app(Econt::class) to the default account.
If your app already binds Psr\Http\Client\ClientInterface, RequestFactoryInterface, or StreamFactoryInterface, the provider respects those bindings. Otherwise it falls back to Guzzle.
Persistence module (opt-in)
The package ships an optional Laravel module that mirrors Econt's nomenclatures (countries, cities, offices, streets, quarters) into local tables for fast offline lookups. It is not auto-discovered.
Enable:
-
Publish the migrations:
php artisan vendor:publish --tag=econt-migrations php artisan migrate -
Toggle the gate in
.env:ECONT_PERSISTENCE_ENABLED=true -
Register the provider in
bootstrap/providers.php:return [ App\Providers\AppServiceProvider::class, Ux2Dev\Econt\Laravel\Persistence\PersistenceServiceProvider::class, ];
Sync:
# All nomenclatures
php artisan econt:sync-nomenclatures
# Specific areas, on a non-default account
php artisan econt:sync-nomenclatures --account=eu --only=countries --only=cities
Or queue it:
use Ux2Dev\Econt\Laravel\Persistence\Jobs\SyncNomenclaturesJob;
SyncNomenclaturesJob::dispatch(account: 'eu', only: ['countries', 'cities']);
Shipment tracking:
After a successful Econt::labels()->createLabel(...), persist the shipment so subsequent polls find it:
use Ux2Dev\Econt\Dto\Input\Shipments\CreateLabelInput;
use Ux2Dev\Econt\Laravel\Facades\Econt;
use Ux2Dev\Econt\Laravel\Persistence\Actions\RecordShipmentCreationAction;
$result = Econt::labels()->createLabel(new CreateLabelInput(...));
app(RecordShipmentCreationAction::class)('main', $result);
Schedule the polling command in routes/console.php:
use Illuminate\Support\Facades\Schedule;
Schedule::command('econt:poll-statuses')->everyFifteenMinutes();
Or queue it on demand:
use Ux2Dev\Econt\Laravel\Persistence\Jobs\PollShipmentStatusesJob;
PollShipmentStatusesJob::dispatch(account: 'main', limit: 500, since: '30 days');
econt_shipment_statuses only grows when the latest status differs from the most recent stored row, so repeated polls are cheap.
Repositories:
Each aggregate has a contract-bound repository:
use Ux2Dev\Econt\Laravel\Persistence\Repositories\Contracts\CityRepository;
use Ux2Dev\Econt\Laravel\Persistence\Repositories\Contracts\CountryRepository;
$bg = app(CountryRepository::class)->findByEcontId(1);
$matches = app(CityRepository::class)->search('Sofia', limit: 10);
Override any binding via config('econt.persistence.repositories'):
'repositories' => [
\Ux2Dev\Econt\Laravel\Persistence\Repositories\Contracts\CountryRepository::class
=> App\Econt\Repositories\CachedCountryRepository::class,
],
Form Requests:
Five publishable Form Requests validate inbound HTTP payloads against Econt's required fields and produce typed core-SDK Input DTOs:
use Ux2Dev\Econt\Laravel\Facades\Econt;
use Ux2Dev\Econt\Laravel\Persistence\Http\Requests\CreateLabelRequest;
use Ux2Dev\Econt\Laravel\Persistence\Actions\RecordShipmentCreationAction;
public function store(CreateLabelRequest $request)
{
$result = Econt::labels()->createLabel($request->toInput());
app(RecordShipmentCreationAction::class)('main', $result);
return back()->with('awb', $result->label?->data['shipmentNumber'] ?? null);
}
Available: CreateLabelRequest, UpdateLabelRequest, DeleteLabelsRequest, RequestCourierRequest, ValidateAddressRequest.
Validation messages live under the econt translation namespace. Publish to override:
php artisan vendor:publish --tag=econt-translations
Configuration
EcontConfig is a final readonly value object. Validates at construction; the password is private and redacted from var_dump(); serialization is blocked.
new EcontConfig(
username: 'KZ12345',
password: '...',
environment: Environment::Production, // or Environment::Demo
timeout: 30, // optional, seconds
);
The Environment enum carries the base URL — no free-form URLs to typo. A baseUrlOverride parameter exists for tests/proxies only.
Resources
Seven resources, one method per RPC action:
| Resource | Methods |
|---|---|
nomenclatures() |
getCountries, getCities, getOffices, getStreets, getQuarters |
address() |
validateAddress, addressServiceTimes, getNearestOffices |
labels() |
createLabel, createLabels, deleteLabels, updateLabel, updateLabels, checkPossibleShipmentEditions, grouping, groupingCancelation |
shipments() |
requestCourier, getShipmentStatuses, getRequestCourierStatus, getMyAWB, setITUCode |
profile() |
getClientProfiles, createCDAgreement |
threeWayLogistics() |
threeWayLogistics |
paymentReport() |
paymentReport |
Every method takes a single *Input DTO and returns the matching *Result DTO. Nested objects share Dto/Input/Common/ and Dto/Result/Common/ sub-DTOs.
Exceptions
All SDK exceptions extend Ux2Dev\Econt\Exception\EcontException:
| Exception | Trigger |
|---|---|
ConfigurationException |
Invalid EcontConfig (empty creds, non-positive timeout, etc.) |
TransportException |
PSR-18 client throws (DNS, socket, TLS, timeout) |
AuthenticationException |
HTTP 401 — Econt rejected credentials |
ApiException |
HTTP 4xx/5xx, or 200 with an error envelope |
InvalidResponseException |
2xx but body is not valid JSON object |
Security
This package is a wrapper, not an application. The host app is responsible for the surrounding controls.
- Form Requests as scaffolding.
Ux2Dev\Econt\Laravel\Persistence\Http\Requests\*ship withauthorize(): trueso they are usable in any host. Do not bind them to public routes unmodified. Either subclass and implement an ownership / policy check (auth()->user()->can('econt.manage')) or wrap the corresponding routes inauth+can:middleware. CallingEcont::shipments()->createLabel(...)costs money — treat it as a privileged action. - PII at rest.
econt_shipmentsstores sender/receiver name, phone, and address. SetECONT_PERSISTENCE_ENCRYPT_PII=trueto enable Laravel'sencryptedcast on those columns and onraw_response_json. RequiresAPP_KEYto be present and stable. On an existing populated database, you must re-write rows after enabling. - Logging exceptions.
AuthenticationExceptionexposes only the path and the response body size — the body itself is dropped at construction time so accidentallyLog::error($e)can't dump credential hints.InvalidResponseException::rawBody()is truncated to 500 chars but still returns server output; prefer logginggetMessage()+path()only. The transport masksAuthorizationheaders in any wrapped client exception message. - Credentials in queue payloads.
EcontConfigblocksserialize()/unserialize()outright, so it can never end up in a job payload. Jobs in this package (PollShipmentStatusesJob,SyncNomenclaturesJob) accept only scalar arguments and validate them in the constructor.
Updating to a new Econt API version
curl -sSL https://ee.econt.com/services/openapi.yaml > bin/openapi.yaml
php bin/generate.php
git diff
git commit
The generator wipes and rewrites src/Dto/ and src/Resources/. Hand-written transport, exceptions, and config are untouched. The snapshot test (tests/Bin/GeneratorSnapshotTest.php) fails if anyone hand-edits a generated file.
Tests
vendor/bin/pest
Contributing
- Specs live in
docs/superpowers/specs/ - Plans live in
docs/superpowers/plans/
License
MIT