arzcode / laravel-correos
Laravel package for integrating Correos shipping, labels, customs documents, and tracking via Saloon.
Fund package maintenance!
Requires
- php: ^8.4
- composer-runtime-api: ^2.2
- illuminate/contracts: ^11.0||^12.0||^13.0
- saloonphp/saloon: ^4.0
- spatie/laravel-data: ^4.19
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- driftingly/rector-laravel: ^2.6
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^11.1.0
- pestphp/pest: ^5.0
- pestphp/pest-plugin-arch: ^5.0
- pestphp/pest-plugin-laravel: ^5.0
- pestphp/pest-plugin-phpstan: ^5.2
- pestphp/pest-plugin-rector: ^5.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- rector/rector: ^2.6
- spatie/laravel-ray: ^1.35
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Laravel package for the Correos (Spanish postal service) APIs: shipment preregistration, label and customs document generation, and tracking. Built on Saloon 4.x for HTTP and Spatie Laravel Data 4.x for DTOs.
Requires PHP 8.4+ and Laravel 11, 12 or 13.
Collections (recogidas) are not covered: the API has no resource for them yet, so shipments are handed over at an office or picked up under a standing agreement.
Installation
composer require arzcode/laravel-correos
php artisan vendor:publish --tag="laravel-correos-config"
Add your credentials to .env:
CORREOS_OAUTH_CLIENT_ID=your-oauth-client-id CORREOS_OAUTH_CLIENT_SECRET=your-oauth-client-secret CORREOS_GATEWAY_CLIENT_ID=your-gateway-client-id CORREOS_GATEWAY_CLIENT_SECRET=your-gateway-client-secret
That is all production needs. Everything else in config/laravel-correos.php has a
working default — see Configuration reference for the rest.
Usage
Resolve the SDK from the container:
use Arzcode\LaravelCorreos\Correos; $correos = app(Correos::class); $correos->preregister()->createShipments($request);
Or reach the same instance through the facade:
use Arzcode\LaravelCorreos\Facades\Correos; Correos::preregister()->createShipments($request);
Outside Laravel, or with runtime credentials, build one by hand with
Arzcode\LaravelCorreos\Correos::make(['oauth_client_id' => ..., 'gateway_client_id' => ..., ...]).
Preregister a shipment
use Arzcode\LaravelCorreos\Data\Preregister\DeliveryRequestData; $request = DeliveryRequestData::from([ 'shipments' => [ [ 'product' => 'PAFXB', 'deliveryMethod' => 'DOUAOF', 'contractNumber' => '12345678', 'clientNumber' => '1234567890', 'labellerCode' => '0001', 'packagesNumber' => '1', 'sender' => [ 'name' => 'My Company', 'address' => 'Calle Sender 1', 'locality' => 'Madrid', 'province' => '28', 'cp' => '28001', 'country' => 'ESP', ], 'addressee' => [ 'name' => 'John Doe', 'address' => 'Calle Receiver 2', 'locality' => 'Barcelona', 'province' => '08', 'cp' => '08001', 'country' => 'ESP', ], 'packages' => [ ['packageWeightGrams' => '500'], ], ], ], ]); $correos->preregister()->validateShipments($request); // dry run, no shipment created $response = $correos->preregister()->createShipments($request); $response->fileIdentifier; // "FILE001" $response->shipments[0]->shipmentCode; // "PQXYZ1234567890" $response->shipments[0]->packages[0]->packageCode; // "PQ1DR4A0000012345678"
Print labels
use Arzcode\LaravelCorreos\Data\Labels\PrintLabelsRequestData; $labels = $correos->labels()->printLabels(PrintLabelsRequestData::from([ 'documentationType' => 1, // 0=All, 1=Label, 2=CN22/CN23 'print' => [ 'shipments' => ['PQXYZ1234567890'], 'labelFormat' => 2, // 1=XML, 2=PDF, 3=ZPL 'labelPrintMode' => 1, // 1=A4, 2=Labeler ], ])); $labels->pdf; // Base64-encoded PDF content $labels->decodedPdf(); // The same PDF as raw bytes, or null if there is none
labelPrintMode decides what that PDF contains, and the two modes are not interchangeable:
1 (A4) returns a full page with the labels already laid out on the sheet, 2 (labeler)
returns one label per page at label size.
To print from a package code rather than a shipment code, set preregisterInd to 1 and pass
the matching labelOrderType. Correos rejects the package code without it, because that flag is
what tells the API the codes belong to shipments preregistered earlier:
use Arzcode\LaravelCorreos\Enums\LabelOrderType; $labels = $correos->labels()->printLabels(PrintLabelsRequestData::from([ 'documentationType' => 1, 'print' => [ 'shipments' => ['PQ1DR4A0000012345678'], 'labelFormat' => 2, 'labelPrintMode' => 1, 'preregisterInd' => 1, // the codes are preregistered shipments 'labelOrderType' => LabelOrderType::PackageId->value, ], ]));
Composing your own A4 sheet with FPDI
To place labels yourself — starting at an arbitrary cell, or mixing carriers on one sheet —
ask for mode 2 and compose the page; mode 1 gives you a sheet you would have to cut up
again:
use setasign\Fpdi\Fpdi; use setasign\Fpdi\PdfParser\StreamReader; $pdf = new Fpdi; $pdf->AddPage(); $pages = $pdf->setSourceFile( StreamReader::createByString($labels->decodedPdf()) ); // 2 columns x 4 rows of 105mm x 74.25mm cells on A4. foreach (range(1, $pages) as $cell => $page) { $pdf->useTemplate( $pdf->importPage($page), x: ($cell % 2) * 105, y: intdiv($cell, 2) * 74.25, width: 105, ); }
Print customs documents (DCAF/DDP)
use Arzcode\LaravelCorreos\Data\Labels\PrintDocumentsRequestData; $document = $correos->labels()->printDocuments(PrintDocumentsRequestData::from([ 'documentationType' => 5, // 5=DCAF, 6=DDP 'documentData' => [ 'destinationName' => 'France', 'contractNumber' => '12345678', 'clientNumber' => '1234567890', ], ])); $document->pdf; // Base64-encoded PDF
Track a shipment
$tracking = $correos->tracking()->searchShipment('PQ1DR4A0000012345678'); $tracking->code; // "PQ1DR4A0000012345678" $tracking->codProduct; // "PQDOM" $tracking->remitName; // Sender name $tracking->destiName; // Addressee name foreach ($tracking->events as $event) { $event->eventDate; // "06/02/2026" $event->eventCode; // "P010000V" $event->summaryText; // "Shipment preregistered" $event->location; // "CTA MADRID" }
All available methods
Every method takes and returns typed DTOs from Arzcode\LaravelCorreos\Data\*.
$correos->preregister()
| Method | Purpose |
|---|---|
validateShipments(DeliveryRequestData) |
Validate without creating |
createShipments(DeliveryRequestData) |
Create shipments |
createCnShipments(DeliveryRequestData) |
Create shipments with CN22/CN23 customs data |
modifyShipment(DeliveryRequestData) |
Modify an existing shipment |
cancelShipment(AnnulmentRequestData) |
Cancel a shipment |
cancelExpedition(AnnulmentExpeditionRequestData) |
Cancel a whole expedition |
generateShipmentCode(GenerateShipmentCodeRequestData) |
Reserve codes without preregistering |
queryShipments(QueryRequestData) |
Query shipments by code |
queryShipmentsIris(QueryRequestData) |
Same query against the IRIS backend |
getExpeditionPackages(string $expeditionCode) |
Packages of an expedition |
getPackagesByReference(string $clientReference, ?string $contractNumber, ?string $clientNumber) |
Look up by your own reference |
searchLabelsInfo(SearchLabelsInfoRequestData) |
Label metadata for a set of shipments |
getBackofficeShipment(string $shipmentCode) |
Backoffice detail for one shipment |
getBackofficeErrors(?$contractNumber, ?$clientNumber, ?$dateFrom, ?$dateTo) |
Shipments rejected by the backoffice |
getBackofficeTotal(?$contractNumber, ?$clientNumber, ?$dateFrom, ?$dateTo) |
Totals for a period |
getBackofficeWaiting(?$contractNumber, ?$clientNumber, ?$dateFrom, ?$dateTo) |
Shipments waiting for admission |
$correos->labels()
| Method | Purpose |
|---|---|
printLabels(PrintLabelsRequestData) |
Labels as PDF, XML or ZPL |
printDocuments(PrintDocumentsRequestData) |
Customs documents (DCAF/DDP) |
getDocumentBackoffice(string $shipment) |
Documents already generated for a shipment |
$correos->tracking()
| Method | Purpose |
|---|---|
searchShipment(string $shippingCode) |
Shipment status and event history |
getExpedition(string $expeditionCode) |
Expedition with its clients and packages |
Enums
Typed enums cover the API's magic numbers. Each case carries a human readable label(), and
every enum exposes options() — value => label pairs, ready for a select input:
use Arzcode\LaravelCorreos\Enums\ProductCode; // PaqPremium, PaqEstandar, PaqToday, ... use Arzcode\LaravelCorreos\Enums\LabelPrintMode; // A4, Labeler ProductCode::PaqPremium->label(); // "Paq Premium" LabelPrintMode::options(); // [1 => 'A4 sheet', 2 => 'Labeler']
The full set: ProductCode, DocumentationType, LabelFormat, LabelPrintMode,
LabelOrderType, ShipmentType, DoiType, AdmissionMethod and ErrorCodeLanguage.
Error handling
API errors are thrown as CorreosApiException, which extends Saloon's RequestException:
use Arzcode\LaravelCorreos\Exceptions\CorreosApiException; try { $response = $correos->preregister()->createShipments($request); } catch (CorreosApiException $e) { $e->getMessage(); // Error message from the API $e->getCode(); // HTTP status code $e->errorCode; // Correos error code $e->moreInformation; // Additional error details $e->getResponse(); // The raw Saloon response, for logging }
Part of the Correos surface answers failures with HTTP 200 and an error field instead of an
error status — printing a label for an unknown shipment comes back as 200 with a null pdf
and a filled error. Those payloads raise the same exception, so a call that returns a DTO
has returned a usable one:
$labels = $correos->labels()->printLabels($labelRequest); // Never reached when Correos answered `{"pdf": null, "error": "El envío no existe"}`. $pdf = $labels->decodedPdf();
The check covers the top-level error/errors field of every response. Nested errors stay on
the DTO, because there they are the answer rather than a failure: validateShipments() still
returns its per-shipment validationErrorCount and error list without throwing.
The raw response of the last call — including a failed one — stays on the resource:
$correos->labels()->lastResponse()?->body();
Retries and idempotency
The API gateway rate limits, so transient failures are retried three times with exponential
backoff starting at 500 ms. Set CORREOS_RETRY_TIMES=1 to switch retries off.
What is retried is deliberately narrow, because a retried write can book the same shipment twice:
| Failure | Read (GET) |
Write (POST) |
|---|---|---|
429 Too Many Requests |
retried | retried — the gateway rejected it before Correos saw it |
401 Unauthorized |
retried | retried — the cached token is dropped and a fresh one fetched |
408, 5xx |
retried | not retried |
| Connection error, timeout | retried | not retried |
Any other 4xx |
not retried | not retried |
Making createShipments() safe to repeat
createShipments() is not idempotent: a request that times out after Correos has registered
the shipment leaves you unable to tell success from failure, and sending it again books a
duplicate. Guard it in your own service layer:
- Give every package a stable reference of your own (
clientReferenceonPackageData) and store it, with the resulting shipment and package codes, against your order. - Before creating, skip orders that already carry a shipment code.
- After a timeout or a
5xx, reconcile rather than retry — ask Correos what it holds under that reference:
$packages = $correos->preregister()->getPackagesByReference('ORDER-10231'); if ($packages->packageCodes) { // Already registered: store the codes instead of creating the shipment again. }
Configuration reference
Every environment variable
| Variable | Default | Purpose |
|---|---|---|
CORREOS_OAUTH_CLIENT_ID |
— | CorreosID OAuth client id |
CORREOS_OAUTH_CLIENT_SECRET |
— | CorreosID OAuth client secret |
CORREOS_GATEWAY_CLIENT_ID |
— | API gateway client id |
CORREOS_GATEWAY_CLIENT_SECRET |
— | API gateway client secret |
CORREOS_TOKEN_URL |
https://apioauthcid.correos.es/Api/Authorize/Token |
OAuth token endpoint |
CORREOS_OAUTH_SCOPE |
AP3 LBS RCG |
Requested scopes |
CORREOS_PREREGISTER_URL |
https://api1.correos.es/admissions/preregister/api/v1 |
Preregister base URL |
CORREOS_LABELS_URL |
https://api1.correos.es/support/labels/api/v1 |
Labels base URL |
CORREOS_TRACKING_URL |
https://api1.correos.es/support/trackpub/api/v2 |
Tracking base URL |
CORREOS_VERIFY_SSL |
true |
Verify TLS certificates |
CORREOS_FORCE_IP_RESOLVE |
— | v4 to force IPv4 |
CORREOS_RETRY_TIMES |
3 |
Attempts per request |
CORREOS_RETRY_INTERVAL |
500 |
Milliseconds before the first retry |
CORREOS_RETRY_EXPONENTIAL_BACKOFF |
true |
Double the interval each attempt |
CORREOS_TIMEOUT |
Saloon's 30s | Request timeout, seconds |
CORREOS_CONNECT_TIMEOUT |
Saloon's 10s | Connection timeout, seconds |
CORREOS_USER_AGENT |
Arzcode-LaravelCorreos/1.2.3 |
Override to identify your own app |
Pre-production environment
Override the four URLs:
CORREOS_TOKEN_URL=https://apioauthcid.correospre.es/Api/Authorize/Token CORREOS_PREREGISTER_URL=https://api1.correospre.es/admissions/preregister/api/v1 CORREOS_LABELS_URL=https://api1.correospre.es/support/labels/api/v1 CORREOS_TRACKING_URL=https://api1.correospre.es/support/trackpub/api/v2
PRE tends to use self-signed certificates and to answer on IPv4 only, so you may also need:
CORREOS_VERIFY_SSL=false CORREOS_FORCE_IP_RESOLVE=v4
Warning: never disable SSL verification in production.
Network access. Correos whitelists the client IP for PRE: connections from a
non-whitelisted address (and any IPv6 address, which CloudFront answers with a 403) are
rejected before they reach the API, and PRE is only up Monday to Friday, 08:00–20:00 CET.
Confirm with your Correos commercial contact whether your production contract carries the
same restriction; if it does, every host that calls the API — web servers, queue workers,
scheduled jobs — has to egress from a fixed, whitelisted IPv4 address, which usually means
pinning them to a static IP or routing them through a NAT gateway.
Using it from Filament (or any Livewire component)
Nothing special is needed to call the SDK from a Filament page or action — but four things are worth knowing.
Strip nulls before hydrating a DTO
Optional fields are typed string|Optional, and a Filament form submits null for the ones
the user left alone, which is a TypeError rather than a validation error:
$clean = fn (array $values) => collect($values) ->map(fn ($value) => is_array($value) ? $clean($value) : $value) ->reject(fn ($value) => $value === null || $value === '' || $value === []) ->all(); $request = DeliveryRequestData::from($clean($this->form->getState()));
Keep writes off the request cycle
createShipments() is not idempotent and is not retried on transport failures, so run it
from a queued job and report back with a notification. If you do call the API inline, lower
the timeouts for that path — the defaults (30s per attempt, three attempts on reads) are
sized for a worker, not for someone watching a spinner:
CORREOS_TIMEOUT=8 CORREOS_CONNECT_TIMEOUT=3
Serve the PDF from the action
decodedPdf() gives you the bytes directly:
Action::make('label') ->action(fn (Shipment $record) => response()->streamDownload( fn () => print $correos->labels()->printLabels($record->labelRequest())->decodedPdf(), "etiqueta-{$record->shipment_code}.pdf", ));
Catching the failure is one try, and errorCode / moreInformation make a better
notification body than the raw message, which falls back to the response body when Correos
answers without one:
} catch (CorreosApiException $e) { Notification::make() ->danger() ->title(__('The label could not be printed')) ->body($e->moreInformation ?? $e->errorCode) ->send(); }
Selects and DTO properties
Enum::options() feeds Select::make(...)->options(...) straight; note PHP turns numeric
string values into integer keys, so cast back when hydrating a string-backed enum from form
state (ShipmentType::from((string) $state)). And if you want to hold a DTO in a public
component property, turn on spatie's Livewire synthesizers — they ship disabled:
// config/data.php 'livewire' => [ 'enable_synths' => true, ],
Testing
composer test # Run tests (Pest 5) composer analyse # Static analysis of src, config and tests (PHPStan level 7) composer format # Code style (Laravel Pint) composer rector-dry # Preview automated refactors (Rector) composer rector # Apply automated refactors (Rector) composer test-coverage # Tests with coverage report
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Pull requests are welcome. Run composer test, composer analyse and composer format
before opening one.
Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
License
The MIT License (MIT). Please see License File for more information.