Search by

digitaldev-lx / laravel-moloni

digitaldevlx

A Laravel package for integrating with the Moloni invoicing API

Package info

github.com/digitaldev-lx/laravel-moloni

pkg:composer/digitaldev-lx/laravel-moloni

Statistics

Installs: 192

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

2.2.0 2026-09-26 13:22 UTC

This package is auto-updated.

Last update: 2026-09-26 13:22:20 UTC


README

Packagist Version Tests PHPStan License

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

  1. You need an active Moloni account. If your login does not yet have access to the demonstration company, request it from apoio@moloni.pt indicating the email associated with your login.
  2. The OAuth2 client_id / client_secret are the same as in production. Only the base_url changes.
  3. 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.

  1. composer update digitaldev-lx/laravel-moloni
  2. php artisan migrate to add the nullable account_key column. Until it runs, the default account keeps working (the package detects the missing column); only keyed accounts require it.
  3. Optional: php artisan vendor:publish --tag=moloni-config --force to get the new token_store, account_key, prune_default_account and retry keys (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() - Receipts
  • Moloni::creditNotes() - Credit Notes
  • Moloni::debitNotes() - Debit Notes
  • Moloni::simplifiedInvoices() - Simplified Invoices
  • Moloni::invoiceReceipts() - Invoice Receipts
  • Moloni::deliveryNotes() - Delivery Notes
  • Moloni::billsOfLading() - Bills of Lading
  • Moloni::waybills() - Waybills
  • Moloni::estimates() - Estimates
  • Moloni::purchaseOrders() - Purchase Orders
  • Moloni::internalDocuments() - Internal Documents
  • Moloni::paymentReturns() - Payment Returns
  • Moloni::ownAssetsMovementGuides() - Own Asset Movement Guides
  • Moloni::customerReturnNotes() - Customer Return Notes
  • Moloni::globalGuides() - Global Guides
  • Moloni::supplierInvoices() - Supplier Invoices
  • Moloni::supplierPurchaseOrders() - Supplier Purchase Orders
  • Moloni::supplierSimplifiedInvoices() - Supplier Simplified Invoices
  • Moloni::supplierCreditNotes() - Supplier Credit Notes
  • Moloni::supplierDebitNotes() - Supplier Debit Notes
  • Moloni::supplierReturnNotes() - Supplier Return Notes
  • Moloni::supplierReceipts() - Supplier Receipts
  • Moloni::supplierWarrantyRequests() - Supplier Warranty Requests
  • Moloni::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 data
  • Supplier - Supplier data
  • Salesman - Salesman data
  • Product - Product data
  • ProductStock - Stock movement data
  • Document - Document header data
  • DocumentProduct - Document line item data
  • Payment - Payment data
  • Address - Address data
  • Tax - Tax data
  • Vehicle - Vehicle data
  • Deduction - Withholding tax data
  • PriceClass - 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.

Credits