digitaldev-lx / laravel-moloni
A Laravel package for integrating with the Moloni invoicing API
Requires
- php: ^8.4
- laravel/framework: ^12.0 || ^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.0
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.1
- phpstan/phpstan: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A Laravel package for integrating with the Moloni invoicing API. Provides a clean, fluent interface to manage companies, customers, products, invoices, and all other Moloni resources with full type safety through DTOs and enums.
Official page: digitaldev.pt/packages/laravel-moloni
Requirements
- PHP 8.4+
- Laravel 12 or 13
Installation
Install the package via Composer:
composer require digitaldev-lx/laravel-moloni
Publish the configuration file:
php artisan vendor:publish --tag=moloni-config
Run the migrations (required for OAuth token storage):
php artisan migrate
Configuration
Add the following environment variables to your .env file:
MOLONI_CLIENT_ID=your-client-id MOLONI_CLIENT_SECRET=your-client-secret MOLONI_USERNAME=your-username MOLONI_PASSWORD=your-password MOLONI_COMPANY_ID=your-company-id # Optional. Defaults to production. Override to point at sandbox. # Production: https://api.moloni.pt/v1/ # Sandbox: https://api.moloni.pt/sandbox/ MOLONI_BASE_URL=https://api.moloni.pt/v1/
You can obtain your API credentials from the Moloni Developer Portal.
Sandbox
Moloni provides a sandbox environment for testing API integrations without affecting production data. The sandbox is available at https://api.moloni.pt/sandbox/ and exposes the same endpoints as production.
Getting sandbox credentials
- You need an active Moloni account. If your login does not yet have access to the demonstration company, request it from
apoio@moloni.ptindicating the email associated with your login. - The OAuth2
client_id/client_secretare the same as in production. Only thebase_urlchanges. - Optionally, explore the API interactively at the Moloni API Explorer.
Switching environments
Set MOLONI_BASE_URL in your .env:
# Sandbox MOLONI_BASE_URL=https://api.moloni.pt/sandbox/ # Production (or omit the variable entirely) MOLONI_BASE_URL=https://api.moloni.pt/v1/
Important: token persistence between environments
OAuth2 tokens are stored in the moloni_tokens table and are scoped per account (account_key) but not per environment. When you switch MOLONI_BASE_URL, the existing token is no longer valid for the new host and the package will silently re-authenticate on the next call. To force a clean state, truncate the table:
php artisan tinker --execute="\DigitaldevLx\LaravelMoloni\Models\MoloniToken::truncate();"
A common pattern is to use a separate database for local/testing (already the default with SQLite) so production tokens are never reused against the sandbox host.
Multiple accounts (multi-tenant)
By default the package behaves as a single-account client: one set of credentials from config/moloni.php. To talk to several Moloni accounts in the same application (for example one per tenant), use forAccount(). It returns a new manager and never modifies the container singleton, so accounts never share tokens or credentials:
use DigitaldevLx\LaravelMoloni\Facades\Moloni; $tenant = Moloni::forAccount("tenant-{$tenantId}", [ 'username' => $tenant->moloni_username, 'password' => $tenant->moloni_password, // optional overrides: client_id, client_secret, base_url ]); $customers = $tenant->customers()->getAll($tenant->moloni_company_id);
useAccount() is an alias of forAccount(). Credentials you do not pass are inherited from the default configuration (typically client_id/client_secret are shared and username/password are per account). The account key is what tokens are stored under, so use a stable identifier.
Token stores
Tokens are persisted through the DigitaldevLx\LaravelMoloni\Contracts\TokenStore contract (get, put, forget, all keyed by account). Choose the driver with MOLONI_TOKEN_STORE:
| Driver | Description |
|---|---|
database (default) |
moloni_tokens table, scoped by the account_key column. |
cache |
Any Laravel cache store (MOLONI_TOKEN_CACHE_STORE), values encrypted with the app key. Good for Redis. |
array |
In-memory, current process only (tests, queue workers that authenticate on start). |
Your\Class |
Any class implementing TokenStore. |
You can also inject a store for a single account:
use DigitaldevLx\LaravelMoloni\TokenStores\ArrayTokenStore; $moloni = Moloni::forAccount('tenant-1', ['username' => '...', 'password' => '...'], new ArrayTokenStore);
When a new token is stored for an account, its previous tokens are deleted. The default (keyless) account keeps its history as in earlier versions unless you set 'prune_default_account' => true.
Retry and rate limits
Read endpoints (getAll, getOne, getBySearch, count*, ...) are retried with exponential backoff on HTTP 429, 500, 502, 503 and 504, and the Retry-After header is honoured. If the server asks for longer than max_delay_ms, the request fails immediately instead of blocking the process. Write endpoints (insert, update, delete, close, ...) are never retried, so a timeout cannot create a duplicate document.
// config/moloni.php 'retry' => [ 'enabled' => true, // MOLONI_RETRY_ENABLED 'max_attempts' => 3, // total attempts, including the first 'base_delay_ms' => 500, // 500, 1000, 2000, ... 'max_delay_ms' => 10000, 'statuses' => [429, 500, 502, 503, 504], ],
When attempts run out on a 429, a RateLimitException is thrown with the suggested delay in $e->retryAfter (seconds, or null).
Security of error messages
Exception messages never contain client_secret, password, access_token or refresh_token. Moloni requires these in the query string, so both the request URL and the response body are scrubbed, and bodies are truncated to 500 characters. Connection errors are rethrown without the original exception (whose message would contain the credentialed URL).
Upgrade notes
2.1.x to 2.2.0
No breaking changes. Existing single-account installations need no code or config changes.
composer update digitaldev-lx/laravel-moloniphp artisan migrateto add the nullableaccount_keycolumn. Until it runs, the default account keeps working (the package detects the missing column); only keyed accounts require it.- Optional:
php artisan vendor:publish --tag=moloni-config --forceto get the newtoken_store,account_key,prune_default_accountandretrykeys (all have defaults in code, so this is not required). Review your config diff first.
Behaviour differences worth knowing: tokens are loaded on first request rather than in the constructor; read requests are now retried on 429/5xx (disable with MOLONI_RETRY_ENABLED=false); exception messages are scrubbed and truncated.
Usage
Using the Facade
All API interactions are available through the Moloni facade:
use DigitaldevLx\LaravelMoloni\Facades\Moloni;
Companies
// List all companies $companies = Moloni::companies()->getAll();
Customers
$companyId = config('moloni.company_id'); // List all customers $customers = Moloni::customers()->getAll($companyId); // Find a customer by VAT number $customer = Moloni::customers()->getByVat($companyId, '123456789'); // Create a customer using a DTO use DigitaldevLx\LaravelMoloni\DataTransferObjects\Customer as CustomerDto; $dto = new CustomerDto( vat: '123456789', number: 'C001', name: 'John Doe', email: 'john@example.com', address: '123 Main Street', city: 'Lisbon', zipCode: '1000-001', countryId: 1, ); $customer = Moloni::customers()->insert($companyId, $dto); // Create a customer using an array $customer = Moloni::customers()->insert($companyId, [ 'vat' => '123456789', 'number' => 'C001', 'name' => 'John Doe', 'email' => 'john@example.com', ]);
Products
// List all products $products = Moloni::products()->getAll($companyId); // Find a product by reference $product = Moloni::products()->getByReference($companyId, 'PROD-001'); // Create a product using a DTO use DigitaldevLx\LaravelMoloni\DataTransferObjects\Product as ProductDto; use DigitaldevLx\LaravelMoloni\Enums\ProductType; $dto = new ProductDto( name: 'Widget', reference: 'PROD-001', type: ProductType::Product, categoryId: 1, unitId: 1, price: 29.99, ); $product = Moloni::products()->insert($companyId, $dto);
Invoices
use DigitaldevLx\LaravelMoloni\DataTransferObjects\Document as DocumentDto; use DigitaldevLx\LaravelMoloni\DataTransferObjects\DocumentProduct; use DigitaldevLx\LaravelMoloni\DataTransferObjects\Payment; // Create an invoice $dto = new DocumentDto( documentSetId: 1, customerId: 1, date: '2026-03-31', expirationDate: '2026-04-30', products: [ new DocumentProduct( productId: 1, qty: 2, price: 29.99, ), ], payments: [ new Payment( paymentMethodId: 1, value: 59.98, date: '2026-03-31', ), ], ); $invoice = Moloni::invoices()->insert($companyId, $dto); // Get PDF link for a document $pdfLink = Moloni::invoices()->getPdfLink($companyId, $invoiceId);
Other Document Types
The package supports all Moloni document types through dedicated resources:
Moloni::receipts()- ReceiptsMoloni::creditNotes()- Credit NotesMoloni::debitNotes()- Debit NotesMoloni::simplifiedInvoices()- Simplified InvoicesMoloni::invoiceReceipts()- Invoice ReceiptsMoloni::deliveryNotes()- Delivery NotesMoloni::billsOfLading()- Bills of LadingMoloni::waybills()- WaybillsMoloni::estimates()- EstimatesMoloni::purchaseOrders()- Purchase OrdersMoloni::internalDocuments()- Internal DocumentsMoloni::paymentReturns()- Payment ReturnsMoloni::ownAssetsMovementGuides()- Own Asset Movement GuidesMoloni::customerReturnNotes()- Customer Return NotesMoloni::globalGuides()- Global GuidesMoloni::supplierInvoices()- Supplier InvoicesMoloni::supplierPurchaseOrders()- Supplier Purchase OrdersMoloni::supplierSimplifiedInvoices()- Supplier Simplified InvoicesMoloni::supplierCreditNotes()- Supplier Credit NotesMoloni::supplierDebitNotes()- Supplier Debit NotesMoloni::supplierReturnNotes()- Supplier Return NotesMoloni::supplierReceipts()- Supplier ReceiptsMoloni::supplierWarrantyRequests()- Supplier Warranty RequestsMoloni::documents()- Generic Documents
Settings Resources
Access configuration resources for taxes, payment methods, document sets, and more:
$taxes = Moloni::taxes()->getAll($companyId); $paymentMethods = Moloni::paymentMethods()->getAll($companyId); $documentSets = Moloni::documentSets()->getAll($companyId); $warehouses = Moloni::warehouses()->getAll($companyId); $units = Moloni::measurementUnits()->getAll($companyId); $maturityDates = Moloni::maturityDates()->getAll($companyId); $deliveryMethods = Moloni::deliveryMethods()->getAll($companyId); $bankAccounts = Moloni::bankAccounts()->getAll($companyId); // Global resources (no company_id needed) $countries = Moloni::countries()->getAll(); $currencies = Moloni::currencies()->getAll(); $languages = Moloni::languages()->getAll(); $exemptions = Moloni::taxExemptions()->getAll(); $fiscalZones = Moloni::fiscalZones()->getAll($countryId);
Using DTOs
The package provides readonly Data Transfer Objects for type-safe data handling:
Customer- Customer dataSupplier- Supplier dataSalesman- Salesman dataProduct- Product dataProductStock- Stock movement dataDocument- Document header dataDocumentProduct- Document line item dataPayment- Payment dataAddress- Address dataTax- Tax dataVehicle- Vehicle dataDeduction- Withholding tax dataPriceClass- Price class data
All DTOs are located in the DigitaldevLx\LaravelMoloni\DataTransferObjects namespace.
Using Events
The package dispatches events for all mutation operations:
| Event | Dispatched When |
|---|---|
DocumentCreated |
A document is successfully created |
DocumentUpdated |
A document is updated |
DocumentDeleted |
A document is deleted |
DocumentCancelled |
A document is cancelled |
DocumentClosed |
A document is closed/finalized |
CustomerCreated |
A new customer is created |
CustomerUpdated |
A customer is updated |
CustomerDeleted |
A customer is deleted |
SupplierCreated |
A new supplier is created |
SupplierUpdated |
A supplier is updated |
SupplierDeleted |
A supplier is deleted |
SalesmanCreated |
A new salesman is created |
SalesmanUpdated |
A salesman is updated |
SalesmanDeleted |
A salesman is deleted |
ProductCreated |
A new product is created |
ProductUpdated |
A product is updated |
ProductDeleted |
A product is deleted |
TokenRefreshed |
The OAuth token is refreshed |
DocumentCreated, DocumentUpdated and DocumentDeleted carry both the API response and a documentType string (e.g. 'invoices', 'creditNotes') so a single listener can route by document type.
All events are located in the DigitaldevLx\LaravelMoloni\Events namespace.
Auto-discovery (recommended)
Create a listener class in your app/Listeners directory. Laravel automatically discovers and registers listeners based on the type-hint in the handle method:
use DigitaldevLx\LaravelMoloni\Events\DocumentCreated; class SendInvoiceNotification { public function handle(DocumentCreated $event): void { // $event->data contains the API response // $event->documentType contains the document type } }
Manual registration
Register listeners manually in your AppServiceProvider:
use DigitaldevLx\LaravelMoloni\Events\DocumentCreated; use Illuminate\Support\Facades\Event; public function boot(): void { Event::listen( DocumentCreated::class, SendInvoiceNotification::class, ); }
Closure listeners
use DigitaldevLx\LaravelMoloni\Events\DocumentCreated; use Illuminate\Support\Facades\Event; Event::listen(function (DocumentCreated $event) { // ... });
Error Handling
The package provides granular exception handling that maps directly to Moloni's error system.
Exception Types
| Exception | When |
|---|---|
AuthenticationException |
OAuth2 errors (invalid credentials, expired tokens, invalid client) |
ValidationException |
Data validation errors (required fields, invalid NIF, invalid email, etc.) |
RateLimitException |
API rate limit exceeded (HTTP 429) |
MoloniException |
All other API errors |
All exceptions extend MoloniException, so you can catch them all with a single catch block or handle each type individually.
Authentication Errors
use DigitaldevLx\LaravelMoloni\Exceptions\AuthenticationException; use DigitaldevLx\LaravelMoloni\Enums\AuthError; try { $customers = Moloni::customers()->getAll($companyId); } catch (AuthenticationException $e) { $e->authError; // AuthError enum (e.g. AuthError::InvalidGrant) $e->errorDescription; // Moloni's error description string $e->getMessage(); // Full formatted message }
Available AuthError enum values: InvalidClient, InvalidUri, RedirectUriMismatch, InvalidRequest, UnsupportedGrantType, UnauthorizedClient, InvalidGrant, InvalidScope.
Validation Errors
use DigitaldevLx\LaravelMoloni\Exceptions\ValidationException; use DigitaldevLx\LaravelMoloni\Enums\ValidationErrorCode; try { $customer = Moloni::customers()->insert($companyId, $data); } catch (ValidationException $e) { $e->errors; // Array of ['code' => int, 'field' => string, 'description' => string] $e->getFieldErrors(); // ['field_name' => 'description', ...] $e->hasFieldError('vat'); // true/false }
Validation error codes (mapped via ValidationErrorCode enum):
| Code | Meaning |
|---|---|
| 1 | Campo obrigatorio |
| 2 | Campo numerico invalido |
| 3 | Endereco de email invalido |
| 4 | Valor deve ser unico |
| 5 | Valor invalido |
| 6 | URL invalido |
| 7 | Codigo postal invalido |
| 8 | NIF portugues invalido |
| 9 | Data deve estar no formato AAAA-MM-DD |
| 10 | Associacao de documento invalida |
| 11 | Documento nao pode ser enviado para a AT |
| 12 | Data invalida |
| 13 | Numero de telefone invalido |
| 14 | Artigo tem taxas conflituantes |
| 15 | Artigo tem multiplas entradas de IVA |
| 16 | Identificacao do cliente obrigatoria (Art. 36 CIVA) |
| 17 | Limite de caracteres excedido |
Catching All Errors
use DigitaldevLx\LaravelMoloni\Exceptions\MoloniException; use DigitaldevLx\LaravelMoloni\Exceptions\AuthenticationException; use DigitaldevLx\LaravelMoloni\Exceptions\ValidationException; use DigitaldevLx\LaravelMoloni\Exceptions\RateLimitException; try { $invoice = Moloni::invoices()->insert($companyId, $data); } catch (AuthenticationException $e) { // Handle auth errors (invalid credentials, expired tokens) } catch (ValidationException $e) { // Handle validation errors (invalid data) } catch (RateLimitException $e) { // Handle rate limiting (retry later) } catch (MoloniException $e) { // Handle all other API errors }
HasMoloniDocuments Trait
Add the HasMoloniDocuments trait to any Eloquent model to associate it with Moloni documents:
use DigitaldevLx\LaravelMoloni\Concerns\HasMoloniDocuments; class Order extends Model { use HasMoloniDocuments; } // Then use it $order->moloniDocuments;
Available Resources
Account & Profile
| Resource | Accessor Method |
|---|---|
| My Profile | Moloni::myProfile() |
Company
| Resource | Accessor Method |
|---|---|
| Companies | Moloni::companies() |
| Subscription | Moloni::subscription() |
| Users | Moloni::users() |
Entities
| Resource | Accessor Method |
|---|---|
| Customers | Moloni::customers() |
| Customer Alternate Addresses | Moloni::customerAlternateAddresses() |
| Suppliers | Moloni::suppliers() |
| Salesmen | Moloni::salesmen() |
Products
| Resource | Accessor Method |
|---|---|
| Products | Moloni::products() |
| Product Categories | Moloni::productCategories() |
| Product Stocks | Moloni::productStocks() |
| Product Properties | Moloni::productProperties() |
| Price Classes | Moloni::priceClasses() |
Documents
| Resource | Accessor Method |
|---|---|
| Documents | Moloni::documents() |
| Invoices | Moloni::invoices() |
| Receipts | Moloni::receipts() |
| Credit Notes | Moloni::creditNotes() |
| Debit Notes | Moloni::debitNotes() |
| Simplified Invoices | Moloni::simplifiedInvoices() |
| Invoice Receipts | Moloni::invoiceReceipts() |
| Delivery Notes | Moloni::deliveryNotes() |
| Bills of Lading | Moloni::billsOfLading() |
| Waybills | Moloni::waybills() |
| Estimates | Moloni::estimates() |
| Purchase Orders | Moloni::purchaseOrders() |
| Internal Documents | Moloni::internalDocuments() |
| Payment Returns | Moloni::paymentReturns() |
| Own Asset Movement Guides | Moloni::ownAssetsMovementGuides() |
| Customer Return Notes | Moloni::customerReturnNotes() |
| Global Guides | Moloni::globalGuides() |
| Supplier Invoices | Moloni::supplierInvoices() |
| Supplier Purchase Orders | Moloni::supplierPurchaseOrders() |
| Supplier Simplified Invoices | Moloni::supplierSimplifiedInvoices() |
| Supplier Credit Notes | Moloni::supplierCreditNotes() |
| Supplier Debit Notes | Moloni::supplierDebitNotes() |
| Supplier Return Notes | Moloni::supplierReturnNotes() |
| Supplier Receipts | Moloni::supplierReceipts() |
| Supplier Warranty Requests | Moloni::supplierWarrantyRequests() |
Settings
| Resource | Accessor Method |
|---|---|
| Taxes | Moloni::taxes() |
| Tax Exemptions | Moloni::taxExemptions() |
| Payment Methods | Moloni::paymentMethods() |
| Document Sets | Moloni::documentSets() |
| Warehouses | Moloni::warehouses() |
| Measurement Units | Moloni::measurementUnits() |
| Maturity Dates | Moloni::maturityDates() |
| Delivery Methods | Moloni::deliveryMethods() |
| Bank Accounts | Moloni::bankAccounts() |
| CAE Codes | Moloni::caeCodes() |
| Vehicles | Moloni::vehicles() |
| Deductions | Moloni::deductions() |
| Identification Templates | Moloni::identificationTemplates() |
Global Data
| Resource | Accessor Method |
|---|---|
| Countries | Moloni::countries() |
| Fiscal Zones | Moloni::fiscalZones() |
| Languages | Moloni::languages() |
| Currencies | Moloni::currencies() |
| Currency Exchange | Moloni::currencyExchange() |
| Document Models | Moloni::documentModels() |
| Multibanco Gateways | Moloni::multibancoGateways() |
Testing
Run the package test suite with Pest:
vendor/bin/pest
When testing your own application against the Moloni API, prefer Http::fake() so no real requests leave your machine. The host you fake should match MOLONI_BASE_URL exactly:
use Illuminate\Support\Facades\Http; Http::fake([ 'api.moloni.pt/sandbox/grant/*' => Http::response([ 'access_token' => 'fake-token', 'refresh_token' => 'fake-refresh', 'expires_in' => 3600, ]), 'api.moloni.pt/sandbox/customers/getAll/*' => Http::response([ ['customer_id' => 1, 'name' => 'Test Customer'], ]), ]); $customers = Moloni::customers()->getAll($companyId);
For an end-to-end example see tests/Unit/MoloniClientTest.php.
Troubleshooting
| Symptom | Cause / Fix |
|---|---|
Repeated 401 after correct credentials |
The token in moloni_tokens was issued for a different MOLONI_BASE_URL. Truncate the table and re-authenticate. |
AuthenticationException with AuthError::InvalidGrant |
Wrong MOLONI_USERNAME / MOLONI_PASSWORD. Verify against the Moloni client area. |
AuthenticationException with AuthError::InvalidClient |
Wrong MOLONI_CLIENT_ID / MOLONI_CLIENT_SECRET. Re-check at the Developer Portal. |
RateLimitException |
Moloni returned HTTP 429. Back off and retry; consider batching requests. |
| Calls hit production while you expected sandbox | MOLONI_BASE_URL is not set or is cached. Run php artisan config:clear. |
Static Analysis
vendor/bin/phpstan analyse
Code Style
vendor/bin/pint
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
License
The MIT License (MIT). Please see License File for more information.