digitaldev-lx/laravel-moloni
最新稳定版本:2.0.0
Composer 安装命令:
composer require digitaldev-lx/laravel-moloni
包简介
A Laravel package for integrating with the Moloni invoicing API
README 文档
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 not scoped 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.
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::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() |
| Supplier Invoices | Moloni::supplierInvoices() |
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.
Credits
统计信息
- 总下载量: 16
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 2
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-04-01