Search by

beziworld / laravel-firmao

kamcw

Laravel client for the Firmao CRM/ERP REST API: customers, products, invoices, offers, tasks, stock documents and more

Package info

github.com/Beziworld/laravel-firmao

Homepage

Issues

pkg:composer/beziworld/laravel-firmao

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

v1.0.0 2026-11-04 20:48 UTC

This package is auto-updated.

Last update: 2026-11-04 20:48:31 UTC


README

tests Latest Version License

Laravel client for the Firmao CRM/ERP REST API (https://system.firmao.pl/{organization}/svc/v1):

  • every resource of the official API documentation: customers, contact persons, customer groups, products and services, sales and purchase documents (invoices, receipts, pro formas, corrections), document lines, offers, orders, agreements, stock documents (PZ, WZ, PW, RW, MM), tasks, projects, sales notes, sales opportunities, working time, files, e-mails and numbering series
  • typed, immutable data objects: customers with NIP, REGON, addresses and contacts, products with purchase price, sale price groups and stock per warehouse, invoices and offers with lines and a VAT breakdown; money as decimal strings, dates in Europe/Warsaw
  • lazy iteration over every page (start and limit, including Firmao's totalSize: -1), a filter builder for the documented field(operator)=value syntax and fluent queries
  • document creation from lines with exact net, VAT and gross totals, automatic numbering and the next free number
  • invoice, offer, order and agreement PDFs from print templates
  • attachments, soft deletion (deleted: true) and permanent deletion where the API requires it
  • several Firmao organizations (named connections and runtime credentials per tenant)
  • Firmao error bodies turned into typed exceptions (duplicates with the existing id, suggested document numbers, rejected credentials, unknown organization); reads retried on 5xx, connection errors and HTTP 429, writes never
  • client-side throttling to the documented limit of 60 requests per minute, shared through the cache
  • change polling by modification date with Laravel events, firmao:sync for the scheduler and firmao:check
  • generic request() and resourceOf() for anything else

All requests go through the Laravel HTTP client, so everything works with Http::fake().

Polska wersja dokumentacji

Why this package

At the time of writing Packagist lists a single Firmao package, b44x/firmao-php, whose repository contains only a README and a composer.json (no code, no releases, no downloads). Firmao publishes its API as a PDF and a GitHub README with cURL examples. This package is a complete, tested Laravel integration built on that documentation and on experience from a production Firmao integration.

Installation

composer require beziworld/laravel-firmao
php artisan vendor:publish --tag=firmao-config   # optional

Enable the API in Firmao under Firma > Ustawienia > API, copy the API login and generate a password:

FIRMAO_ORGANIZATION=acme          # https://system.firmao.pl/acme/... (a full URL works too)
FIRMAO_LOGIN=api-login@acme.pl
FIRMAO_PASSWORD=generated-password

Requests use HTTP Basic authentication. Other settings: FIRMAO_TIMEOUT (30 s), FIRMAO_CONNECT_TIMEOUT (10 s), FIRMAO_RETRIES (2, reads only), FIRMAO_RETRY_DELAY (1000 ms, doubled per attempt), FIRMAO_MAX_RETRY_WAIT (60 s), FIRMAO_THROTTLE (60 requests per minute; 0 disables), FIRMAO_PAGE_SIZE (500), FIRMAO_DATA_FORMAT (MEDIUM) and FIRMAO_TIMEZONE (Europe/Warsaw).

Check the configuration:

php artisan firmao:check          # or --all for every connection

Reading data

use Beziworld\Firmao\Facades\Firmao;

$customer = Firmao::customers()->find(51);                 // Customer|null
$customer = Firmao::customers()->findByNip('729-107-78-43');
$customer->nip;                                            // "7291077843"
$customer->officeAddress?->city;                           // "Pruszków"
$customer->customerType?->key;                             // "PARTNER"
$customer->customFields;                                   // ['custom5' => 'VIP']

// One page
$page = Firmao::products()->page(['start' => 0, 'limit' => 100, 'sort' => 'name', 'dir' => 'ASC']);
$page->items; $page->total(); $page->hasMore();

// Every record, page by page while iterating (LazyCollection)
foreach (Firmao::products()->lazy() as $product) {
    $product->code;                         // productCode
    $product->salePrice('A')?->grossPrice;  // "61.50"
    $product->purchasePrice?->netPrice;     // "21.12"
    $product->stock;                        // "32.000" (all warehouses)
    $product->availableStock();             // stock minus reservations
    $product->stockIn(2);                   // one warehouse
}

// Filters: field(operator)=value
$tasks = Firmao::tasks()
    ->where('name', 'contains', 'wdrożenie')
    ->where('priority', '>=', 200)
    ->whereIn('id', [15, 16, 17])
    ->orderByDesc('lastUpdateDate')
    ->get();

Firmao::customers()->query()->whereCustomField(5, 'eq', 'VIP')->lazy();
Firmao::transactions()->query()->modifiedSince(now()->subDay())->lazy();

Operators: contains, notContains, eq, ne, in, notin, gt, ngt, ge, nge, lt, nlt, le, nle (also =, !=, >, >=, <, <=). Lists are joined with semicolons. Firmao notes that not every field supports every operator. Date values are sent as UTC ISO 8601 date-times (2026-03-05T10:10:16Z), the format in which Firmao returns them. Beziworld\Firmao\Support\Query builds the same parameters outside a resource.

Products can be matched to a shop catalogue with findByCode(), findByEan() or findBySku() (code first, then EAN). Negative stock is reported by Firmao as an overrun ($product->overrun).

Customers and contacts

$log = Firmao::customers()->create([
    'name' => 'KOMP-SERVICE Jan Kowalski',
    'label' => 'KOMP-SERVICE',
    'nipNumber' => '7291077843',
    'customerType' => 'PARTNER',
    'emails' => ['biuro@example.com'],
    'phones' => ['223457426'],
    'officeAddress' => ['street' => 'Stefana Batorego 3/4', 'postCode' => '05-800', 'city' => 'Pruszków', 'country' => 'Polska'],
    'customFields' => ['custom5' => 'VIP'],
]);

$id = $log->createdId();     // Firmao answers with a change log, not with the object
$customer = Firmao::customers()->withContacts(Firmao::customers()->findOrFail($id));

Firmao::contacts()->create(['firstName' => 'Marzena', 'lastName' => 'Wojciechowska', 'customer' => $id]);
Firmao::customers()->update($id, ['name' => 'KOMP-SERVICE Sp. z o.o.']);
Firmao::customers()->markDeleted($id);   // "deleted": true, can be restored with restore()

Nested arrays are sent as the dotted keys Firmao expects (officeAddress.city, customFields.custom5); dates are formatted for you (calendar-date fields such as invoiceDate as Y-m-d, other date-times with the Warsaw offset). Firmao recommends changing one field per update request.

A second customer with the same NIP is rejected with HTTP 409; ApiException::duplicateIds() returns the existing id. To validate a NIP before sending it, use beziworld/laravel-polish-validation (optional, not required by this package).

Invoices and other documents

use Beziworld\Firmao\Data\DocumentLine;

$log = Firmao::invoices()->createInvoice([
    'customer' => 50,
    'invoiceDate' => '2026-11-04',
    'transactionDate' => '2026-11-04',
    'paymentDate' => '2026-11-18',
    'paymentType' => 'TRANSFER',
], [
    DocumentLine::product(52, 2, '1000.00', 23, 'szt.', 'ST0003'),
    DocumentLine::text('Transport', 1, '50.00', 8),   // free-text line, not linked to a product
]);
// totals: net 2050.00, VAT 464.00, gross 2514.00; numbering by Firmao unless transactionNumber is given

$invoice = Firmao::invoices()->findWithEntries($log->createdId('transaction'));
$invoice->grossTotal;      // "2514.00"
$invoice->vatSummary();    // list of VatRateTotal (rate, net, vat, gross)

$pdf = Firmao::invoices()->pdfFor($invoice);          // last used print template, original copy
$pdf->store(Storage::disk('local'), 'invoices');
Firmao::invoices()->pdf($invoice->id, 'Faktura', 'FV-1-2026');

Firmao::invoiceNumbers()->next('transaction', 'proforma', 'SALE', seriesId: 2);
Firmao::invoices()->forCustomer(50);                   // LazyCollection, newest first

createDocument() creates receipts, pro formas and other types; corrections follow the documented shape (two lines per corrected line) through create(). A number that already exists is rejected; ApiException::suggestedNumber() holds the next free one.

Lines can be added to an existing document with Firmao::transactionEntries()->addTo('transaction', $id, $line, ['entryDate' => '2026-11-04']) and removed with destroy($entryId).

Offers, orders and agreements

Firmao::offers()->createOffer(['number' => 'OF/1/2026', 'customer' => 50, 'offerDate' => '2026-11-04'], $lines);
Firmao::offers()->createOrder(['number' => 'ZAM/1/2026', 'customer' => 50], $lines);
Firmao::agreements()->createAgreement(['number' => 'U/1/2026', 'customer' => 50], $lines);

Firmao::offers()->setStatus($id, 'ACCEPTED');   // NEW, SENT, DURING_NEGOTIATIONS, ACCEPTED, REJECTED, EXECUTED
Firmao::offers()->pdf($id, 'Oferta');
Firmao::offers()->orders()->each(fn ($order) => ...);

Stock documents

use Beziworld\Firmao\Api\StorageDocsApi;

Firmao::storageDocs()->createDocument(StorageDocsApi::OUTSIDE_RELEASE, [
    'storageDocNumber' => 'WZ/2026/001',
    'storagedocDate' => '2026-11-04',
    'warehouse' => 1,
    'customer' => 50,
], [DocumentLine::product(52, 10, '100.00', 23)]);

Firmao::storageDocs()->transfer(fromWarehouse: 1, toWarehouse: 2, attributes: ['storageDocNumber' => 'MM/2026/001'], lines: $lines);
Firmao::products()->setStock(50001, 50);   // requires "Bezpośrednia modyfikacja stanów magazynowych"

Tasks, projects, CRM and working time

Firmao::tasks()->create(['name' => 'Wdrożenie', 'taskType' => 'PROJECT', 'priority' => 300, 'responsibleUsers' => [['id' => 2]], 'status' => 'WAITING']);
Firmao::tasks()->forProject(15);
Firmao::projects()->create(['name' => 'Projekt', 'managers' => [['id' => 10]], 'startDate' => now()]);
Firmao::salesNotes()->create(['description' => 'Spotkanie', 'type' => 'MEETING', 'customer' => 51, 'date' => now()]);
Firmao::salesOpportunities()->create(['label' => 'Nowy klient', 'currency' => 'PLN', 'customer' => 1, 'salesOpportunityValue' => 11111]);
Firmao::salesOpportunities()->moveToStage($id, '3');

$timer = Firmao::timeEntries()->start(userId: 1, from: now());
Firmao::timeEntries()->stop($timer->createdId(), now());

Firmao::mails()->query()->where('box', 'eq', 'INBOX')->orderByDesc('sentDate')->get();
Firmao::documents()->forObject('customer', 51);
Firmao::customers()->uploadAttachment(51, $contents, 'umowa.pdf', 'Signed agreement');

Several organizations

// config/firmao.php
'connections' => [
    'default' => ['organization' => env('FIRMAO_ORGANIZATION'), 'login' => env('FIRMAO_LOGIN'), 'password' => env('FIRMAO_PASSWORD')],
    'second'  => ['organization' => 'beta', 'login' => '...', 'password' => '...'],
],

Firmao::connection('second')->customers()->lazy();

// Credentials stored per tenant
Firmao::using(['organization' => $tenant->firmao_org, 'login' => $tenant->firmao_login, 'password' => $tenant->firmao_password])
    ->invoices()->find(123);

Anything else

Firmao::request('GET', 'customers', ['limit' => 1]);       // decoded JSON
Firmao::request('DELETE', 'transactionentries/50081');
Firmao::resourceOf('someresource')->lazy();                // rows as arrays

Change synchronisation

Firmao has no webhooks. ChangeSynchronizer polls each resource for records whose lastUpdateDate is not older than the stored cursor (minus a 5-minute overlap) and dispatches RecordChanged for each (typed object in $event->record, raw row in $event->raw), then SyncCompleted; a failure dispatches SyncFailed and keeps the cursor. Delivery is at least once, so upsert by id.

// routes/console.php
Schedule::command('firmao:sync')->everyFiveMinutes()->withoutOverlapping();
Schedule::command('firmao:sync products --connection=second')->hourly();

// listener
Event::listen(function (\Beziworld\Firmao\Events\RecordChanged $event) {
    if ($event->resource === 'products') {
        Product::updateOrCreate(['firmao_id' => $event->id], ['stock' => $event->record->stock]);
    }
});

Resources, overlap, initial look-back and the cache store are set in firmao.sync; bind your own Beziworld\Firmao\Sync\SyncStateStore to keep cursors in the database. php artisan firmao:sync customers --reset --since=2026-01-01 replays changes.

Limits

Firmao accepts 60 requests per minute and 5 concurrent requests per organization; above that it answers HTTP 429. The maximum limit of a page depends on the plan (SIMPLE 1 000, STANDARD 2 000, ENTERPRISE 10 000, SUPREME 20 000, PROFESSIONAL 50 000 between 7:00 and 17:00, four times more at night). Keep FIRMAO_PAGE_SIZE within your plan.

Errors

Exception When
Exceptions\ApiException Error response: httpStatus(), errorCode(), field(), rejectedValue(), additionalInfo(), body(); helpers isAuthenticationError() (401, userNotFound), isNotFound() (404, entityNotFoundForId), isDuplicate() (duplicateValueFoundException, transactionNumberAlreadyExists), isConflict() (409), isRetryable(), duplicateIds(), suggestedNumber(). Also connection failures (isTransportError()) and unreadable responses
Exceptions\RateLimitExceeded HTTP 429 after the read retries, or on the first write; retryAfter()
Exceptions\RecordNotFound findOrFail() found nothing
Exceptions\PdfNotAvailable A report request did not return a PDF (wrong id or template name)
Exceptions\InvalidConfiguration Missing organization, login or password, unknown connection

All extend Exceptions\FirmaoException. An unknown organization identifier makes Firmao return its HTML 404 page; the exception message says so.

Development

composer install
composer ci   # pint --test, phpstan (level 8), pest

Author

Created and maintained by Kamil Ćwiertnia, beziworld.eu.

Need an integration? Get in touch through beziworld.eu.

License

MIT. See LICENSE.md. This package is not affiliated with Firmao.