A Laravel package for generating and submitting TicketBAI (Ticket BAI) invoices for the Basque Country (Euskadi), Spain. This package provides a flexible and configurable solution for integrating TicketBAI compliance into your Laravel application.
- Features
- Requirements
- Installation
- Configuration
- Usage
- Database Configuration
- API Reference
- Examples
- Testing
- Contributing
- License
- ✅ Generate TicketBAI-compliant invoices
- ✅ Automatic invoice signing with X.509 certificates
- ✅ Queue-based invoice submission to TicketBAI API
- ✅ Flexible database table and column configuration
- ✅ Support for custom table structures
- ✅ Optional columns (signature, data, territory) for maximum flexibility
- ✅ Encadenamiento: firma y territorio siempre en columna JSON
databajo clave configurable (por defectoticketbai) - ✅ QR code generation for invoices
- ✅ Support for multiple territories (Araba, Bizkaia, Gipuzkoa)
- ✅ Automatic fingerprint calculation from previous invoices
- ✅ Artisan command to resend failed/pending invoices (
ticketbai:resend) - ✅ Graceful error handling: API errors and exceptions logged, invoices marked as failed
- ✅ Error persistence: API errors and exception details stored in invoice data for debugging
- ✅ Invoice status tracking: Optional status column to track invoice state (draft, failed, sent)
- ✅ Domain-specific exceptions:
CertificateNotFoundException,InvalidTerritoryException,InvalidConfigurationException - ✅ Comprehensive test suite: 43+ tests (Pest 2.x) covering all features and edge cases
- ✅ ULID invoice numbering: Automatically truncated to 20 characters for XSD compliance
- PHP >= 8.2
- Laravel >= 8.0 (tested with Laravel 10.x and 11.x)
- barnetik/ticketbai package
- X.509 certificate (.p12 file) for signing invoices
- TicketBAI license and credentials
Install the package via Composer:
composer require ebethus/laravel-ticketbaiAfter installation, add your TicketBAI credentials and certificate path to your .env file (see Configuration section).
If you want to use the default invoice table structure:
php artisan migrateNote: You can also use your own table structure by configuring column mappings via environment variables.
The simplest approach is to use environment variables only — no need to publish the config file:
# .env file - TicketBAI Credentials
TICKETBAI_LICENSE=TB12345678
TICKETBAI_NIF=B1111111A
TICKETBAI_APP_NAME=My Application
TICKETBAI_APP_VERSION=1.0.0
TICKETBAI_CERT_PASSWORD=your_p12_password
TICKETBAI_CERT_PATH=certificado.p12
# TicketBAI Data Storage
TICKETBAI_TABLE_NAME=invoices
TICKETBAI_DISK=local
TICKETBAI_DATA_KEY=ticketbai
# Optional: Customize column mappings (defaults work for standard Laravel tables)
# TICKETBAI_COLUMN_ISSUER=issuer
# TICKETBAI_COLUMN_NUMBER=provider_reference
# TICKETBAI_COLUMN_PATH=path
# TICKETBAI_COLUMN_DATA=dataIn config/services.php:
'ticketbai' => [
'license' => env('TICKETBAI_LICENSE'),
'nif' => env('TICKETBAI_NIF'),
'appName' => env('TICKETBAI_APP_NAME'),
'appVersion' => env('TICKETBAI_APP_VERSION'),
'certPassword' => env('TICKETBAI_CERT_PASSWORD'),
'disk' => env('TICKETBAI_DISK', 'local'),
],If your invoices table has different column names, use environment variables to map them:
Example: Vivetix custom table structure
# In your .env file
TICKETBAI_TABLE_NAME=invoices
TICKETBAI_COLUMN_ISSUER=user_id
TICKETBAI_COLUMN_NUMBER=provider_reference
TICKETBAI_COLUMN_PATH=path
TICKETBAI_COLUMN_DATA=data
TICKETBAI_COLUMN_TERRITORY=
TICKETBAI_COLUMN_SIGNATURE=
TICKETBAI_COLUMN_SENT=This maps:
- Internal
issuer→ Table columnuser_id - Internal
number→ Table columnprovider_reference territoryandsignature→ Only stored in JSONdatacolumn (not as separate DB columns)
If you need fine-grained control or want to document your custom setup, publish the config file:
php artisan vendor:publish --tag=ticketbai-configThis creates config/ticketbai.php which you can customize directly:
// config/ticketbai.php
return [
'cert_path' => env('TICKETBAI_CERT_PATH', 'certificado.p12'),
'data_key' => env('TICKETBAI_DATA_KEY', 'ticketbai'),
'table' => [
'name' => env('TICKETBAI_TABLE_NAME', 'invoices'),
'columns' => [
'issuer' => env('TICKETBAI_COLUMN_ISSUER', 'issuer'),
'number' => env('TICKETBAI_COLUMN_NUMBER', 'provider_reference'),
'territory' => env('TICKETBAI_COLUMN_TERRITORY', null),
'signature' => env('TICKETBAI_COLUMN_SIGNATURE', null),
'path' => env('TICKETBAI_COLUMN_PATH', 'path'),
'data' => env('TICKETBAI_COLUMN_DATA', 'data'),
'sent' => env('TICKETBAI_COLUMN_SENT', null),
'created_at' => env('TICKETBAI_COLUMN_CREATED_AT', 'created_at'),
'updated_at' => env('TICKETBAI_COLUMN_UPDATED_AT', 'updated_at'),
],
],
];The certificate path can be:
- Relative:
certificado.p12→ Resolved tostorage/certificado.p12 - Absolute Linux:
/etc/certs/ticketbai.p12 - Absolute Windows:
C:\certs\ticketbai.p12
Set via: TICKETBAI_CERT_PATH environment variable
Validation: The package automatically:
- Strips leading/trailing whitespace from the path
- Checks that the file exists
- Verifies it's readable
- Throws
CertificateNotFoundExceptionif invalid
This package automatically applies a security patch to barnetik/ticketbai that validates certificates before signing. The patch:
- ✅ Validates P12 file existence and content before parsing
- ✅ Checks
openssl_pkcs12_read()return value and throws descriptive exceptions - ✅ Detects incorrect passwords and corrupt P12 files
- ✅ Includes OpenSSL error messages for debugging
- ✅ Prevents "Trying to access array offset on null" errors in PHP 8+
No additional configuration needed — the patch applies automatically during composer install.
use EBethus\LaravelTicketBAI\TicketBAI;
// Get TicketBAI instance (configured via service provider)
$ticketbai = app('ticketbai');
// Or use the facade
use TicketBAI;
// Set issuer information
$ticketbai->issuer(
nif: 'B12345678',
name: 'Company Name',
idIssuer: 1,
serie: '' // Optional
);
// Set VAT percentage
$ticketbai->setVat(21); // 21% VAT
// Add invoice items
$ticketbai->add(
desc: 'Product description',
unitPrice: 100.00,
q: 2,
discount: 0 // Optional
);
// Generate and sign invoice
$qrUrl = $ticketbai->invoice(
territory: 'BIZKAIA', // or 'ARABA', 'GIPUZKOA'
description: 'Invoice description'
);
// The invoice is automatically saved and queued for submission
// $qrUrl contains the QR code URL for the invoiceuse EBethus\LaravelTicketBAI\TicketBAI;
class InvoiceController extends Controller
{
public function __construct(
protected TicketBAI $ticketbai
) {}
public function create(Request $request)
{
$this->ticketbai->issuer(
nif: $request->nif,
name: $request->company_name,
idIssuer: $request->issuer_id
);
$this->ticketbai->setVat(21);
foreach ($request->items as $item) {
$this->ticketbai->add(
desc: $item['description'],
unitPrice: $item['price'],
q: $item['quantity'],
discount: $item['discount'] ?? 0
);
}
// IMPORTANT: Store buyer data BEFORE generating invoice
$this->ticketbai->data([
'buyer_name' => $request->buyer_name,
'buyer_cif' => $request->buyer_cif,
'buyer_address' => $request->buyer_address,
'buyer_email' => $request->buyer_email,
'transaction_id' => $request->transaction_id,
]);
$qrUrl = $this->ticketbai->invoice(
territory: 'BIZKAIA',
description: $request->description
);
return response()->json(['qr_url' => $qrUrl]);
}
}You can attach additional JSON data to invoices. Important: Call data() BEFORE calling invoice():
// Always attach buyer data BEFORE generating the invoice
$ticketbai->data([
'taxname' => 'Alberto Berroteran',
'cif' => 'A12345678',
'address' => 'Calle Principal 123',
'emailInvoice' => 'customer@example.com',
'order_id' => 12345,
'customer_id' => 67890,
'custom_field' => 'value'
]);
// NOW generate and sign the invoice
$qrUrl = $ticketbai->invoice('BIZKAIA', 'Invoice description');This data will be stored in the data column as JSON if configured (see Database Configuration). The data column is the only place to store buyer information for later retrieval.
The library supports flexible table and column configuration, allowing you to use your existing database structure.
The default migration creates an invoices table with columns issuer, provider_reference, path, data, sent, and timestamps. TicketBAI stores signature and territory in the data JSON column under the key ticketbai.
If you use your own table with different column names (e.g. transaction_id instead of issuer), override the mappings via environment variables. Example: TICKETBAI_COLUMN_ISSUER=transaction_id, TICKETBAI_COLUMN_NUMBER=invoice_number, TICKETBAI_COLUMN_SENT=attempted_at.
Configure column mappings in config/ticketbai.php:
'table' => [
'name' => env('TICKETBAI_TABLE_NAME', 'invoices'),
'columns' => [
// Defaults match the default migration. For your own table use env, e.g.:
// TICKETBAI_COLUMN_ISSUER=transaction_id, TICKETBAI_COLUMN_NUMBER=invoice_number
'issuer' => env('TICKETBAI_COLUMN_ISSUER', 'issuer'),
'number' => env('TICKETBAI_COLUMN_NUMBER', 'provider_reference'),
'territory' => env('TICKETBAI_COLUMN_TERRITORY', 'territory'),
'signature' => env('TICKETBAI_COLUMN_SIGNATURE', 'signature'),
'path' => env('TICKETBAI_COLUMN_PATH', 'path'),
'data' => env('TICKETBAI_COLUMN_DATA', 'data'),
'sent' => env('TICKETBAI_COLUMN_SENT', 'sent'),
'created_at' => env('TICKETBAI_COLUMN_CREATED_AT', 'created_at'),
'updated_at' => env('TICKETBAI_COLUMN_UPDATED_AT', 'updated_at'),
],
],Use these only when you have a custom table with different column names:
TICKETBAI_TABLE_NAME=invoices
TICKETBAI_COLUMN_ISSUER=transaction_id
TICKETBAI_COLUMN_NUMBER=provider_reference
TICKETBAI_COLUMN_TERRITORY=territory
TICKETBAI_COLUMN_SIGNATURE=signature
TICKETBAI_COLUMN_PATH=path
TICKETBAI_COLUMN_DATA=data
TICKETBAI_COLUMN_SENT=attempted_at
TICKETBAI_COLUMN_CREATED_AT=created_at
TICKETBAI_COLUMN_UPDATED_AT=updated_atSignature and territory are always stored in the JSON data column under a configurable key so that encadenamiento (signature chaining) works. Default key: ticketbai. Set in .env or config:
TICKETBAI_DATA_KEY=ticketbaiThe package stores and reads signature (first 100 chars) and territory under data->ticketbai. Path stays in the path column. Example:
{
"ticketbai": {
"signature": "first 100 chars of chain signature",
"territory": "02"
},
"order_id": 12345
}Your table needs: id, issuer, number, path (file path), data (JSON), sent, created_at, updated_at. Other providers can use other keys in data (e.g. data->other_provider).
data: Set tonullor empty to use the default column name'data'.
path: Required - stores the signed XML file path (filesystem).data: Required - JSON column where TicketBAI stores signature and territory underTICKETBAI_DATA_KEY.
Set the issuer information for the invoice.
$nif: Tax identification number (NIF/CIF)$name: Company name$idIssuer: Internal issuer ID (used for database storage)$serie: Optional invoice series
Set the VAT percentage for invoice items.
$vatPerc: VAT percentage (e.g., 21 for 21%)
Add an item to the invoice.
$desc: Item description$unitPrice: Unit price (including VAT)$q: Quantity$discount: Optional discount amount
Attach additional JSON data to the invoice.
$data: Array or object to be stored as JSON
Generate, sign, and save the invoice. Returns the QR code URL.
$territory: Territory code:'ARABA','BIZKAIA', or'GIPUZKOA'(or numeric codes'01','02','03')$description: Invoice description
Returns: string - QR code URL
Get the Eloquent model instance for the saved invoice.
Returns: Invoice
Get the underlying TicketBAI object from the barnetik/ticketbai package.
Returns: \Barnetik\Tbai\TicketBai
use EBethus\LaravelTicketBAI\TicketBAI;
$ticketbai = app('ticketbai');
// Configure issuer
$ticketbai->issuer(
nif: 'B12345678',
name: 'My Company S.L.',
idIssuer: 1
);
// Set VAT
$ticketbai->setVat(21);
// Add items
$ticketbai->add('Product A', 50.00, 2, 0);
$ticketbai->add('Product B', 30.00, 1, 5.00);
// Add extra data
$ticketbai->data([
'order_id' => 12345,
'customer_email' => 'customer@example.com'
]);
// Generate invoice
$qrUrl = $ticketbai->invoice(
territory: 'BIZKAIA',
description: 'Order #12345'
);
echo "QR Code: $qrUrl";// In config/ticketbai.php or .env
// TICKETBAI_TABLE_NAME=my_invoices
// TICKETBAI_COLUMN_ISSUER=user_id
// TICKETBAI_COLUMN_NUMBER=invoice_ref
// TICKETBAI_COLUMN_SIGNATURE= (empty, disabled)
// TICKETBAI_COLUMN_DATA= (empty, disabled)
$ticketbai = app('ticketbai');
$ticketbai->issuer('B12345678', 'Company', 1);
$ticketbai->setVat(21);
$ticketbai->add('Item', 100, 1);
$qrUrl = $ticketbai->invoice('BIZKAIA', 'Invoice');$ticketbai = app('ticketbai');
// ... configure and generate invoice ...
$model = $ticketbai->getModel();
echo $model->provider_reference; // Invoice number (default column name)
echo $model->path; // XML file pathThis package uses Pest 2.x for testing with 43+ comprehensive tests covering:
- Invoice generation and storage
- TicketBAI signature and QR code generation
- Error handling and graceful job failure
- Invoice resend workflows
- Configuration and column mapping flexibility
- ULID invoice number generation and truncation (20 chars)
Run the test suite:
composer testOr with Pest directly:
./vendor/bin/pest- Feature tests (
tests/Feature/): End-to-end scenarios (invoice creation, job dispatch, resend) - Unit tests (
tests/Unit/): Component-level tests (TicketBAI, Invoice model, configuration)
Optional: run static analysis and code style checks:
composer analyse # PHPStan (level 5)
composer format # Laravel Pint (fixes style)✅ 43 tests passing (100% of non-risky tests)
✅ Job error handling: API errors and exceptions gracefully handled (status='failed', error stored)
✅ ULID truncation: Invoice numbers automatically truncated to 20 characters
✅ Resend workflow: Re-queuing of previously signed invoices
✅ Configuration: Flexible column mapping via environment variables
✅ Territory validation: Only ARABA, BIZKAIA, GIPUZKOA allowed
Invoice submission is asynchronous: after generating and signing an invoice, the package dispatches an InvoiceSend job to the Laravel queue. You must have at least one queue worker running for invoices to be sent to the TicketBAI API:
php artisan queue:work- Production: Use a process manager (e.g. Supervisor) to keep
queue:workrunning. - Testing / sync: If you use
QUEUE_CONNECTION=sync, jobs run immediately in the same process (no worker needed, but slower and no retries).
The InvoiceSend job submits the invoice to the TicketBAI API and updates the sent timestamp on success.
Invoices that were not sent (e.g. API error or worker down) have sent = null. To re-queue them for sending:
# List and resend all pending invoices
php artisan ticketbai:resend --all
# Resend a single invoice by ID
php artisan ticketbai:resend --id=123
# Dry run: only list what would be resent
php artisan ticketbai:resend --all --dry-runResend requires territory and path: territory is read from data[data_key] (default data->ticketbai), path from the path column.
- Ensure
storage/certificado.p12exists and is readable - Verify the certificate password is correct
- Check file permissions
- Verify column mappings in
config/ticketbai.php - Ensure required columns exist in your table
- Set optional columns to
nullif not needed
- Ensure queue worker is running
- Check queue connection configuration
- Review failed jobs:
php artisan queue:failed
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
This package is open-sourced software licensed under the MIT license.
- Built on top of barnetik/ticketbai
- Developed by EBethus
For issues and feature requests, please use the GitHub issue tracker.