smart-dato / gls-italy
Fluent PHP SDK for the GLS Italy carrier web services: label service, pickups, tracking, stock release and address check
Fund package maintenance!
Requires
- php: ^8.4
- ext-libxml: *
- ext-simplexml: *
- illuminate/contracts: ^11.0||^12.0||^13.0
- nesbot/carbon: ^2.72||^3.0
- saloonphp/saloon: ^4.0
- smalot/pdfparser: ^2.7
- spatie/laravel-data: ^4.7
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^11.0.0||^10.0.0||^9.0.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- spatie/laravel-ray: ^1.35
README
A fluent PHP SDK for the GLS Italy carrier web services, built on Saloon and spatie/laravel-data.
The wire format is preserved byte for byte from the battle-tested integration
this package was extracted from — including the GLS field formatters (alfa,
alfaStreetCity, alfaEmail), the SOAP-1.2-envelope-around-XML-string
protocol, the CDATA-wrapped inner <Info> documents and the literal Italian
success strings GLS answers with. The request builders and result objects
never touch your models, storage paths or database — you map in, you persist
out.
Coverage
| GLS service | Manual | Status |
|---|---|---|
Label service — AddParcel, CloseWorkDay, DeleteSped, ListSped |
MU162 | ✅ since 0.0.1 |
Pickups — addpickup.php / deletepickup.php |
MU302 | ✅ since 0.0.1 |
Tracking — get_xml_track.php |
MU40 | ✅ since 0.0.3 |
Stock release (svincolo giacenze) — redelivery_parcel.php |
MU276 | ✅ since 0.0.4 |
Address validation — wscheckaddress.asmx |
— | ✅ since 0.0.4 |
The label service and pickup wire formats are verified byte for byte against recorded production calls.
Track a shipment or pickup
Tracking needs no password — just the depot and client/contract codes:
$result = GlsItaly::trackByShipmentNumber('BZ', '620873098', '1234567'); foreach ($result->events() as $event) { $event->datetime; // Carbon, or null when the feed value was unparseable $event->code; // e.g. '901'; '906' is delivered $event->subsidiary; // the depot the event happened at $event->warning; // true when the datetime could not be parsed } $result->shipmentNumber(); $result->destinationSede(); $result->retourReference(); // ['sede' => 'VE', 'number' => '123456789'] when GLS created a retour $pickup = GlsItaly::trackPickup('BB', '9660004359', '2557'); $pickup->pickupEvents(); // TRACKINGRITIRO events $pickup->shipmentEvents(); // events of the shipment the pickup generated
trackByBda() and trackRetour() cover the BDA and retour lookups.
Requirements
- PHP
^8.4 - Laravel
^11.0 || ^12.0 || ^13.0
Installation
composer require smart-dato/gls-italy
Optionally publish the config file:
php artisan vendor:publish --tag="gls-italy-config"
return [ 'endpoints' => [ 'label_service' => 'https://labelservice.gls-italy.com/ilswebservice.asmx', // legacy, stock_release, tracking, check_address ... ], 'http' => [ 'timeout' => 30, 'verify' => true, 'label_service_content_type' => 'text/xml', ], ];
Every endpoint can be swapped, e.g. to route the label service through an HTTP
proxy: the SOAP method is selected by the request body's root element, never by
the URL, so a bare URL swap is enough. http.verify disables TLS peer
verification and http.label_service_content_type overrides the POST content
type for proxies that expect something other than text/xml.
Getting started
Credentials are passed per call — the package holds no account state, so one application can serve any number of GLS accounts:
use SmartDato\GlsItaly\Data\Credentials; use SmartDato\GlsItaly\Facades\GlsItaly; $gls = GlsItaly::withCredentials(new Credentials( sede: 'BZ', // two-character depot code clientCode: '1234567', // codice cliente password: 'secret', ));
Outside Laravel-facade contexts, new \SmartDato\GlsItaly\GlsItaly() works the
same way — withCredentials() returns a configured clone.
Create a shipment and store its label
use SmartDato\GlsItaly\Data\ParcelData; use SmartDato\GlsItaly\Data\RecipientData; use SmartDato\GlsItaly\Enums\LabelFormat; $result = $gls->shipment() ->contractCode('2557') // CodiceContrattoGls ->recipient(new RecipientData( name: 'Mario Rossi', street: 'Via Roma 1', city: 'Bolzano', zipcode: '39100', province: 'BZ', email: 'mario.rossi@example.com', phone: '+390471123456', // last 10 digits are sent as Cellulare1 )) ->parcel(new ParcelData(weight: 1.5, volume: 0.01)) ->cashOnDelivery(25.50, collectionMode: 'CONT') // ImportoContrassegno + ModalitaIncasso ->note('Mario Rossi tel +390471123456') // NoteSpedizione, printed on the label ->additionalNote('handle with care') // NoteAggiuntive, shown on the driver device ->clientReference('ORDER-4242') // RiferimentoCliente ->bda('O2600004242') ->services(['01', '36']) // accessory service codes, see below ->labelFormat(LabelFormat::Pdf) // or LabelFormat::Zpl ->deliveryTimeNote('Sig. Bianchi 9:00 - 17:00') // OrarioNoteGDO, only when set ->create();
All recipient and note values are passed raw; the serializer applies the GLS field formatters (ASCII transliteration, whitespace collapsing, symbol stripping, length limits) exactly like the original integration, so what GLS receives does not depend on the caller pre-cleaning anything.
The result exposes everything GLS returned:
$result->shipmentNumber(); // '620873098' — '' when GLS returned none $result->hasShipmentNumber(); $result->barcode(); // 'BZ620873098010M5' — assembled from the response; // 'GLS Check' routings drop the destination suffix $result->extractedLabelBarcode(); // barcode read back from the label PDF text $result->pdf(); // binary PDF label (LabelFormat::Pdf) $result->zpl(); // raw ZPL commands (LabelFormat::Zpl) $result->destinationDescription(); // e.g. 'MILANO', or 'GLS Check' on failed routing $result->errorDescription(); // the soap:Text error, when GLS rejected the call $result->storePdf('labels/620873098.pdf', disk: 's3') // chainable; throws ->storeZpl('labels/620873098.zpl', disk: 's3'); // StorageException on failure $result->rawRequest(); // exact bytes sent — for your API-call audit log $result->rawResponse(); // exact bytes received
When hasShipmentNumber() is false, GLS accepted the parcel but returned no
number — look it up among the shipments waiting for close-of-day, matched by
recipient exactly like the legacy fallback:
$number = $gls->listPendingShipments() ->findPendingByRecipient('Mario Rossi', 'Bolzano', 'BZ', 'Via Roma 1');
Close the work day
Closing transmits the day's shipments to GLS. Each shipment is identified by repeating its full parcel record:
$closing = $gls->closeWorkDay(); foreach ($shipmentRecords as $record) { $closing->addShipment($record); // ShipmentData — e.g. ShipmentBuilder::toData() } $result = $closing->send(); $result->succeeded(); // the literal <DescrizioneErrore>OK</DescrizioneErrore> marker $result->manifestPdf(); // the DistintaPDF manifest, when GLS returns one
ShipmentBuilder::toData() returns the validated ShipmentData DTO, so the
same record you created the shipment with can be replayed into the close-of-day
call. Mind the MU162 payload limits (~400 parcel records per call when PDF
labels are requested, ~1000 without).
List and delete shipments
foreach ($gls->listPendingShipments()->shipments() as $pending) { $pending->shipmentNumber; // plus date, recipientName, city, province, street, // parcelCount, weight, status, routing, ... } $deletion = $gls->deleteShipment('620873098'); $deletion->succeeded(); // GLS confirms with an Italian sentence, not a status code $deletion->resultText(); // the raw <DeleteSpedResult> for logging
Request a pickup
Pickups go through the legacy MU302 channel (addpickup.php). Note the
credential difference: this channel authenticates with the web profile
code (CodiceProfiloGls), which is usually not the label-service client code.
use SmartDato\GlsItaly\Data\PickupCancellationData; use SmartDato\GlsItaly\Data\RecipientData; $result = GlsItaly::withCredentials($pickupProfileCredentials)->pickup() ->requesterName('Your Company') ->bda('P2600044261') ->pickupAddress(new RecipientData( name: 'Mario Rossi', street: 'Via Roma 1', city: 'Bolzano', zipcode: '39100', province: 'BZ', )) ->pickupDate(new DateTimeImmutable('2026-08-24')) ->parcelCount(2) ->weight(12.0) ->deliveryAddress(null) // null blanks the destination block (aggregated/detour) ->window('08', '13', '13', '18') // the allowed morning/afternoon hour ranges ->notifyEmail('ops@example.com') ->phone('3288046977') ->create(); $result->pickupNumber(); // 'BB9660004359', null on failure $result->error(); // GLS error text, or null on success $result->ensureCreated(); // pickup number, or throws ApiErrorException $cancellation = GlsItaly::withCredentials($pickupProfileCredentials)->cancelPickup(new PickupCancellationData( contractCode: '2557', pickupNumber: 'BB9660004359', requesterName: 'Mario Rossi', email: 'ops@example.com', )); $cancellation->cancelled('BB9660004359');
Accessory service codes (MU162)
services() takes the two-character GLS codes, for example:
| Code | Service | Code | Service |
|---|---|---|---|
01 |
Entro ore 12 | 21 |
Servizio al sabato (Venezia) |
02 |
Ora fissa | 22 |
Express 12 |
03 |
Anticipato | 23 |
Document return |
05 |
Consegna al piano | 24 |
Exchange |
06 |
Mezzo idoneo | 25 |
Preavviso telefonico |
07 |
Servizio al sabato | 27 |
Timbro |
08 |
Verifica contenuto | 28 |
e-ROD |
14–16 |
Venezia laguna variants | 29 |
Sabato pomeriggio |
36 |
Saturday express |
Error handling
GLS does not speak HTTP status codes — failures come back as error text inside
a 200 response, so results are always returned and inspected via
succeeded() / errorDescription(). The package throws only for problems on
your side of the wire, all extending GlsItalyException (except the builder
validation):
| Exception | Thrown when |
|---|---|
Exceptions\ValidationException |
a builder is sent incomplete — the message names the missing call ("A recipient is required, call recipient() before create().") |
Exceptions\RequestException |
the connection itself failed (DNS, timeout, TLS) |
Exceptions\ResponseParseException |
a store*() helper is called but the response holds no label |
Exceptions\StorageException |
a store*() write returned false (disks configured with throw => false) |
Faking in your tests
Saloon's MockClient intercepts every request the package sends:
use Saloon\Http\Faking\MockClient; use Saloon\Http\Faking\MockResponse; MockClient::global([ MockResponse::make('<InfoLabel><Parcel><NumeroSpedizione>620873098</NumeroSpedizione>...</Parcel></InfoLabel>'), ]); // exercise your code, then assert on the outgoing bytes: MockClient::getGlobal()->getLastPendingRequest()->body()->all(); MockClient::destroyGlobal();
Pass ->progressiveCounter(999999999) in tests to make the emitted XML fully
deterministic — without it, ContatoreProgressivo defaults to a random
nine-digit number, exactly like the original integration.
Development
composer test # pest — includes byte-for-byte wire-format snapshot tests composer analyse # phpstan composer format # pint
The wire snapshots in tests/Fixtures/requests were recorded from the
original production integration; the suite also reads real GLS label PDFs to
verify barcode extraction. Treat the snapshots as the contract — a diff there
means GLS receives different bytes.
Changelog
Please see CHANGELOG for more information on what has changed recently.
Credits
License
The MIT License (MIT). Please see License File for more information.