Sending invoices
Darvis\UblPeppol\PeppolService posts a finished XML document to your access point provider. It is part of the Laravel layer: it uses Laravel’s HTTP client, log and config.
What it sends
One HTTP POST to PEPPOL_URL, with:
- HTTP Basic authentication from
PEPPOL_USERNAMEandPEPPOL_PASSWORD - the headers
Content-Type: application/xmlandAccept: application/json - the XML as the request body
Ask your provider whether its API accepts exactly that. PeppolService has no other mode: no API key header, no JSON envelope, no OAuth. With such a provider, build the XML with this package and send it with your own HTTP code.
Set the three values first; see Installation.
Test the connection
// php artisan tinker
app(\Darvis\UblPeppol\PeppolService::class)->testConnection();
This sends one GET to PEPPOL_URL with your credentials. It sends no document.
| Answer of the provider | success | message |
|---|---|---|
| 2xx | true | Connection successful |
| 401 | false | Authentication failed - check credentials |
| 403 | false | Access denied - the credentials are not allowed to use this URL |
| 404 | false | Peppol URL not found - check PEPPOL_URL |
| 500 and higher | false | The Peppol provider answered with a server error (HTTP 503) |
| Anything else, such as 405 or 400 | true | Peppol provider reached (HTTP 405); the credentials were not refused |
| No answer at all | false | Cannot connect to Peppol provider, with the reason in error |
A send address often accepts only a POST, so a 405 on this GET is normal and counts as success. The result also has status_code; it is 0 when there was no answer.
Send a document
// app/Http/Controllers/InvoiceSendController.php
namespace App\Http\Controllers;
use App\Actions\BuildInvoiceXml;
use App\Models\Invoice;
use Darvis\UblPeppol\PeppolService;
use Illuminate\Http\RedirectResponse;
class InvoiceSendController extends Controller
{
public function __invoke(Invoice $invoice, BuildInvoiceXml $build, PeppolService $peppol): RedirectResponse
{
$xml = $build->handle($invoice);
$result = $peppol->sendUblXml($xml, $invoice->number);
if (! $result['success']) {
return back()->withErrors(['peppol' => $result['message'].': '.$result['error']]);
}
$invoice->update(['sent_to_peppol_at' => now()]);
return back()->with('status', 'Invoice sent.');
}
}
Laravel injects PeppolService because it is type-hinted in the method. BuildInvoiceXml is the class from Laravel integration; Invoice, its number and its sent_to_peppol_at column are your own.
sendUblXml(string $ublXml, ?string $invoiceNumber = null) posts the XML. The invoice number is only used for the log.
The result
sendUblXml() and sendInvoice() return an array and do not throw for an HTTP problem.
| Key | On success | On failure |
|---|---|---|
success | true: the provider answered with a 2xx status | false |
status_code | The HTTP status | The HTTP status, or 0 when the request itself failed |
message | Invoice successfully sent to Peppol network | Error sending to Peppol network |
response | The JSON answer as an array, or the raw body when it is not JSON | Not present |
error | Not present | The response body, or the exception message |
log_id | The id of the peppol_logs row, or null without the table | The same |
success means your provider accepted the request. Delivery to the receiver happens later, inside the PEPPOL network. Whether it arrived is something you read from your provider, not from this package.
They do throw a RuntimeException before any request when a credential is missing:
Peppol URL is not configured (PEPPOL_URL)
Peppol username is not configured (PEPPOL_USERNAME)
Peppol password is not configured (PEPPOL_PASSWORD)
sendInvoice() updates your model
sendInvoice(object $invoice, string $ublXml) takes your own invoice object. It reads $invoice->id and, when present, $invoice->invoice_nr, and stores both on the log row.
After a successful send it also does this, when the object has an update method, which every Eloquent model has:
$invoice->update(['peppol_sent_at' => now()]);
So an Eloquent model needs:
- a nullable
peppol_sent_attimestamp column, and peppol_sent_atin$fillable(or an unguarded model).
// database/migrations/2026_01_15_000000_add_peppol_sent_at_to_invoices_table.php
Schema::table('invoices', function (Blueprint $table) {
$table->timestamp('peppol_sent_at')->nullable();
});
Without the column, a send that worked is reported as a failure. The document is already with your provider when the update throws. The exception is caught, the result becomes 'success' => false with status_code 0 and the database error in error, and the log row is changed from success to error. Code that retries on failure then sends the invoice twice.
If you do not want that column, use sendUblXml() as in the example above and record the send yourself. sendUblXml() never touches your model.
What is written to your application log
Every send writes to Laravel’s default log channel:
| Level | Message | Context |
|---|---|---|
| info | Peppol: Sending invoice or Peppol: Sending UBL XML | invoice_id, invoice_nr |
| info | Peppol: Response received | status_code and the full response body |
| error | Peppol: Error sending | The exception message |
The response body of a provider can contain invoice data. Keep that in mind when your logs go to an external service. The password is never logged.
Read the configuration back
app(\Darvis\UblPeppol\PeppolService::class)->getConfig();
// ['url' => 'https://...', 'username' => '...', 'password_configured' => true]
The service is a singleton and reads the config when it is created. See The config file.
Send from a queue
Sending waits for your provider, so a queued job (a task Laravel runs in the background, see the Laravel docs) keeps the request fast:
// app/Jobs/SendInvoiceToPeppol.php
namespace App\Jobs;
use App\Actions\BuildInvoiceXml;
use App\Models\Invoice;
use Darvis\UblPeppol\PeppolService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use RuntimeException;
class SendInvoiceToPeppol implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $backoff = 60;
public function __construct(public Invoice $invoice) {}
public function handle(BuildInvoiceXml $build, PeppolService $peppol): void
{
$result = $peppol->sendUblXml($build->handle($this->invoice), $this->invoice->number);
// Retry only when the provider was not reached or had a server error.
if (! $result['success'] && ($result['status_code'] === 0 || $result['status_code'] >= 500)) {
throw new RuntimeException($result['error']);
}
if (! $result['success']) {
$this->fail($result['error']);
return;
}
$this->invoice->update(['sent_to_peppol_at' => now()]);
}
}
A 4xx answer means the provider refused the document. Sending the same XML again gives the same answer, so the job fails at once instead of retrying.
Build the XML inside the job with new UblNlBis3Service(). A queue worker is one long-running application, and the container’s Dutch builder can hold only one document; see Laravel integration.
Next steps
- Testing shows how to test this without calling your provider
- Troubleshooting lists the errors