beziworld / laravel-firmao
Laravel client for the Firmao CRM/ERP REST API: customers, products, invoices, offers, tasks, stock documents and more
Requires
- php: ^8.3
- ext-json: *
- guzzlehttp/guzzle: ^7.8
- illuminate/cache: ^12.0|^13.0
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/events: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- nesbot/carbon: ^3.8
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.7|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
- phpstan/phpstan-strict-rules: ^2.0
Suggests
- beziworld/laravel-polish-validation: Validates NIP, REGON and other Polish identifiers before they are sent to Firmao.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-04 20:48:31 UTC
README
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 (
startandlimit, including Firmao'stotalSize: -1), a filter builder for the documentedfield(operator)=valuesyntax 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:syncfor the scheduler andfirmao:check - generic
request()andresourceOf()for anything else
All requests go through the Laravel HTTP client, so everything works with Http::fake().
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.
- E-mail: biuro@beziworld.eu
- Issues: GitHub issues
Need an integration? Get in touch through beziworld.eu.
License
MIT. See LICENSE.md. This package is not affiliated with Firmao.