Laravel integration
Laravel is optional. The builders, UblValidator, ViesService and CompanyRegistrationService are plain PHP. The Laravel layer is four classes:
| Class | What it does |
|---|---|
Darvis\UblPeppol\UblPeppolServiceProvider | Registers the config, the bindings, the publish tags and the command. Laravel discovers it by itself |
Darvis\UblPeppol\PeppolService | Posts the XML to your access point provider. See Sending invoices |
Darvis\UblPeppol\Models\PeppolLog | The Eloquent model for the optional peppol_logs table |
Darvis\UblPeppol\Console\CleanupPeppolLogsCommand | The peppol:cleanup command |
The package has no routes, views, middleware, events or translations.
Installation has the setup steps. This page explains what you get.
Build an invoice inside Laravel
Create a new builder for every document:
// app/Actions/BuildInvoiceXml.php
namespace App\Actions;
use App\Models\Invoice;
use Darvis\UblPeppol\UblNlBis3Service;
class BuildInvoiceXml
{
public function handle(Invoice $invoice): string
{
$ubl = new UblNlBis3Service();
$ubl->createDocument();
$ubl->addInvoiceHeader(
$invoice->number,
$invoice->issued_at->format('Y-m-d'),
$invoice->due_at->format('Y-m-d'),
);
// ... parties, payment, tax total, monetary total and lines,
// as in "Your first invoice"
return $ubl->generateXml(validateFirst: true);
}
}
App\Models\Invoice and its columns are your own; the package has no invoice model. The dates are formatted because the builder accepts a YYYY-MM-DD string or a \DateTime, and refuses the CarbonImmutable that a model returns when you use immutable dates. Your first invoice has the complete list of calls.
Do not take the Dutch builder from the container twice
The service provider binds UblNlBis3Service as a singleton (one shared instance per application), with the alias ubl-peppol. app(UblNlBis3Service::class) and app('ubl-peppol') return that same instance every time, and a builder holds one document. The second invoice in the same request, queue worker or test therefore throws:
Document is already initialized. Avoid initializing the document multiple times.
Use new UblNlBis3Service() instead, as above. UblBeBis3Service has no binding, so app(UblBeBis3Service::class) does give a new instance each time.
The config file
The config key is ubl-peppol. Publishing the file is optional; without it the defaults and your .env values apply.
php artisan vendor:publish --tag=ubl-peppol-config
| Key | Env variable | Default | What it does |
|---|---|---|---|
log_retention_days | PEPPOL_LOG_RETENTION_DAYS | 60 | How many days peppol:cleanup keeps a log row |
password | PEPPOL_PASSWORD | none | Password for your access point provider |
url | PEPPOL_URL | none | The address PeppolService posts the XML to |
username | PEPPOL_USERNAME | none | Username for your access point provider |
Only PeppolService and peppol:cleanup read these values. The builders read no configuration.
PeppolService is a singleton as well, and it reads the three credentials when it is created. When you change the config at runtime, for example per tenant, create the service with new PeppolService() after the change.
After you change .env on a server that caches its config, run php artisan config:clear.
The log table is optional
peppol_logs keeps one row per attempt to send. It is not created by itself:
php artisan vendor:publish --tag=ubl-peppol-migrations
php artisan migrate
The first command copies a migration named ..._create_peppol_logs_table.php to database/migrations.
Without the table, sending works and the result has 'log_id' => null. With the table, PeppolService writes a row with status pending before the request and updates it to success or error afterwards.
| Column | Type | Holds |
|---|---|---|
id | big integer | |
invoice_id | unsigned big integer, nullable | The id of the object you passed to sendInvoice() |
invoice_nr | string, nullable | The invoice number |
status | pending, success or error | |
http_status_code | integer, nullable | The HTTP status of the provider, 0 when the request itself failed |
message | text, nullable | Invoice successfully sent to Peppol network or Error sending to Peppol network |
error | text, nullable | The response body of a refusal, or the exception message |
response | JSON, nullable | The JSON answer of the provider on success |
sent_at | timestamp, nullable | When the attempt started |
created_at, updated_at | timestamps |
Read the log
use Darvis\UblPeppol\Models\PeppolLog;
PeppolLog::success()->get(); // status success
PeppolLog::error()->get(); // status error
PeppolLog::pending()->get(); // started and never finished
PeppolLog::recent(7)->get(); // created in the last 7 days (default 60)
PeppolLog::olderThan(90)->get(); // created more than 90 days ago
PeppolLog::where('invoice_id', $invoice->id)->latest()->first();
PeppolLog::tableExists(); // false until you ran the migration
A scope is a named query filter on an Eloquent model (Laravel docs). Querying the model without the table throws a database error, so ask PeppolLog::tableExists() first in code that must work in both situations.
Clean up old log rows
php artisan peppol:cleanup # deletes rows older than log_retention_days (60)
php artisan peppol:cleanup --days=30 # deletes rows older than 30 days, for this run
The output is Deleting Peppol logs older than 60 days... and then ✓ 3 log(s) deleted. Without the table the command says there is nothing to clean up and ends successfully.
Schedule it when you send often:
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('peppol:cleanup')->weekly();
In your own code, PeppolLog::cleanupOldLogs(int $days = 60) deletes the same rows and returns how many.
Next steps
- Sending invoices
- Testing your own code with
Http::fake()