PHPackages                             bee-coded/laravel-efactura - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Payment Processing](/categories/payments)
4. /
5. bee-coded/laravel-efactura

ActiveLibrary[Payment Processing](/categories/payments)

bee-coded/laravel-efactura
==========================

Laravel wrapper for e-Factura SDK with token storage, job scheduling, and easy model integration

v3.1.1(1mo ago)297Apache-2.0PHPPHP ^8.4CI passing

Since Feb 5Pushed 1mo agoCompare

[ Source](https://github.com/BEE-CODED/laravel-efactura)[ Packagist](https://packagist.org/packages/bee-coded/laravel-efactura)[ RSS](/packages/bee-coded-laravel-efactura/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (10)Dependencies (36)Versions (23)Used By (0)

Laravel e-Factura
=================

[](#laravel-e-factura)

A Laravel package that wraps [bee-coded/laravel-efactura-sdk](https://packagist.org/packages/bee-coded/laravel-efactura-sdk) to provide token storage, job scheduling, and easy model integration for Romanian e-Factura (ANAF SPV) compliance.

Upgrading to v3.0 — read before deploying
-----------------------------------------

[](#upgrading-to-v30--read-before-deploying)

v3 encrypts the ANAF credentials already in your database and adds a unique index to `efactura_uploads`. **This upgrade requires a maintenance window with your queue workers stopped.**That is not a recommendation — a worker left running across the migration can permanently orphan a company's credentials, and no re-run of the migration will fix it.

Read this whole section before running anything. The steps are ordered deliberately: the prerequisites all come **before** the commands, because doing them afterwards is how the outages happen.

### Step 0 — Prerequisites, before you run any command

[](#step-0--prerequisites-before-you-run-any-command)

#### 0a. Back up `APP_KEY`, somewhere other than the database it protects

[](#0a-back-up-app_key-somewhere-other-than-the-database-it-protects)

From v3, `APP_KEY` decrypts your ANAF credentials. Lose it and **every connected company must redo the OAuth flow** (`php artisan efactura:auth {cui}`). It was never load-bearing for e-Factura before, so it may never have been treated as a secret worth backing up. It is now.

If you are rotating it, set `APP_PREVIOUS_KEYS` — see [`APP_KEY` obligations](#app_key-now-protects-your-anaf-credentials--this-is-new-in-v3).

#### 0b. Audit for writes that bypass the model

[](#0b-audit-for-writes-that-bypass-the-model)

`Builder::update()` and raw SQL skip casts, so `DB::table('efactura_tokens')->update([...])` — or `EfacturaToken::where(...)->update([...])` — silently writes a value nothing can decrypt. Load the model instance and update it, so the cast runs. Fix these **before** deploying; they fail silently.

#### 0c. Size the unique-index build

[](#0c-size-the-unique-index-build)

The index migration runs `ALTER TABLE efactura_uploads ADD UNIQUE (uploadable_type, uploadable_id)`. This is **not free on a large table**:

```
SELECT COUNT(*) FROM efactura_uploads;
```

- **Up to ~100k rows:** seconds. Run it inline.
- **Millions of rows:** expect **minutes**, during which the build takes a brief exclusive metadata lock. Under concurrent write load it can fail outright on `innodb_lock_wait_timeout`.

With workers stopped (Step 1) there is no concurrent write load, which is most of the risk gone. If the table is large enough that the window itself is the problem, build the index out of band with [`pt-online-schema-change`](https://docs.percona.com/percona-toolkit/pt-online-schema-change.html)or [`gh-ost`](https://github.com/github/gh-ost) instead, then mark the migration as run.

### Step 1 — Stop the queue workers (MANDATORY)

[](#step-1--stop-the-queue-workers-mandatory)

**Do this before `composer update`, and keep them down until after `php artisan migrate`.**

```
php artisan down
```

`php artisan down` is a necessary start but **is not sufficient on its own**:

- A worker already **mid-job** when you run it finishes that job — including a token refresh.
- Workers started with `queue:work --force` ignore maintenance mode entirely.
- Horizon needs `php artisan horizon:pause` (or `horizon:terminate`).

So: run `down`, then actually stop the worker processes (`supervisorctl stop laravel-worker:*`, or your orchestrator's equivalent) and **confirm nothing is in flight** before you migrate.

#### Why this is mandatory, and not a step-6 afterthought

[](#why-this-is-mandatory-and-not-a-step-6-afterthought)

A v2 worker that is still alive while the migration runs will:

1. **Refresh a token and write PLAINTEXT into an already-encrypted column.** `$token->update([...])`from v2 code has no cast. The encryption migration has by then already been **recorded as run**, so the usual remedy — "just re-run the migration" — **does nothing at all**. That company throws `DecryptException` on every operation until it re-authorises. This is the one failure in this upgrade with no clean recovery.
2. **Read an already-encrypted row and send the ciphertext to ANAF as a Bearer token.** ANAF answers `401`, and the invoices in that batch park as `Failed`.

Neither is a race you can win by being quick. Stop the workers.

### Step 2 — Check for duplicate uploads (inside the window, writes stopped)

[](#step-2--check-for-duplicate-uploads-inside-the-window-writes-stopped)

Run this **now**, with workers down — not the day before:

```
SELECT uploadable_type, uploadable_id, COUNT(*) AS copies
FROM efactura_uploads
GROUP BY uploadable_type, uploadable_id
HAVING COUNT(*) > 1;
```

v3 adds a unique `(uploadable_type, uploadable_id)` index, and its migration **aborts** if pre-3.0 duplicates exist. Duplicates can mean an invoice was filed at ANAF more than once, so the migration does not resolve them for you: reconcile each against ANAF, keep the row reflecting the real filing, remove the rest.

**Why inside the window:** v2's `queueUpload()` has no duplicate guard, so a check run while v2 is still accepting writes is only true for as long as it takes the next request to reintroduce a duplicate. With writes stopped the check is authoritative. (Since v3 now encrypts tokens **before**touching this index, an abort here no longer takes e-Factura down — this check is advisory rather than load-bearing. It still saves you a failed deploy.)

### Step 3 — Update and migrate

[](#step-3--update-and-migrate)

```
composer update bee-coded/laravel-efactura
php artisan migrate
```

Between those two commands, e-Factura operations for existing companies fail with `DecryptException: The payload is invalid.` **This is by design** (see below). Keep the gap short — this is exactly what the window is for.

Four migrations ship; three are new in v3 and run **in this order**:

OrderMigrationWhat it does1st`..._000004_encrypt_efactura_token_credentials`Encrypts the plaintext `access_token` / `refresh_token` already in `efactura_tokens`. **Runs first on purpose**: it is what restores service for the v3 code you just deployed, so nothing that can abort is allowed to precede it2nd`..._000005_add_failure_reason_to_efactura_uploads_table`Adds the indexed `failure_reason` column; backfills `rate_limited` for rows whose legacy `errors` text carries a `RATE_LIMIT_EXCEEDED:` marker. Anything unrecognised stays `NULL` rather than being guessed. **Re-entrant** — an interrupted backfill resumes on the next `migrate`3rd`..._000006_add_unique_uploadable_index_to_efactura_uploads_table`Adds the unique `(uploadable_type, uploadable_id)` index. **Aborts on pre-3.0 duplicates** (Step 2). Runs last because it is the only one expected to abort4th`..._000007_add_response_attempt_tracking_to_efactura_uploads_table`Adds `response_attempts` / `response_failed_at` so a poisoned `/descarcare` body (2xx that isn't a ZIP) is retried a bounded number of times rather than on every run forever. Additive columns only; cannot abort### Step 4 — Bring it back up

[](#step-4--bring-it-back-up)

```
php artisan queue:restart   # workers must come back on the NEW code
php artisan up
```

Start your worker processes again (`supervisorctl start laravel-worker:*`). `queue:restart` is the belt-and-braces for any worker that survived Step 1: a stale one holds pre-v3 code in memory and will keep firing `InvoiceFailed` on rate limits and writing plaintext credentials.

### If `migrate` aborts: how to get out of it

[](#if-migrate-aborts-how-to-get-out-of-it)

An abort is **not** a no-op at the database level. Migrations that already ran are committed and recorded; only the aborting one is not. What you have depends on where it stopped:

Aborted atStateDo this`..._000004` (encryption)Tokens partly/not encrypted. e-Factura is **down**The migration is idempotent — fix the cause and re-run `php artisan migrate`. It resumes safely`..._000005` (failure\_reason)Tokens **encrypted, service is up**. Column may exist without the backfill finishedRe-run `php artisan migrate`. It is re-entrant: it skips the DDL it already did and resumes the backfill`..._000006` (unique index)Tokens **encrypted, service is up**. Only the index is missingResolve the duplicates (Step 2), then re-run `php artisan migrate`. No rush — but the duplicate race stays open until you do**Emergency lever — restore service immediately.** If you are down and need tokens working *now*without waiting to resolve anything else, run the encryption migration on its own:

```
php artisan migrate --path=database/migrations/2024_01_01_000004_encrypt_efactura_token_credentials.php
```

That is safe to run at any point: it is idempotent, touches only `efactura_tokens`, and has no dependency on the other two. (If you published the migrations into your app, point `--path` at your copy instead.) Then sort out the rest at your own pace.

### OAuth tokens are now encrypted at rest

[](#oauth-tokens-are-now-encrypted-at-rest)

`efactura_tokens.access_token` and `refresh_token` are encrypted via Laravel's `encrypted` cast. Before v3 they were stored in plaintext, so anyone with a database read, a backup, or a query log held live ANAF credentials — enough to file and read legal tax documents for every connected company. (The model's `$hidden` never protected against this; it only omits the fields from `toArray()` / `toJson()` and has no bearing on what is written to disk.)

The cast has **no plaintext fallback**: a fallback would let a migration that never ran pass unnoticed while your credentials stayed readable in the database. Encryption at rest has to be a verifiable fact rather than a hope, so an unmigrated row fails loudly at the point of use instead of quietly working.

The migration is **idempotent** — it skips values that are already ciphertext, so re-running it cannot double-encrypt, and it is safe after a partial failure or on a table mixing migrated and freshly-authorised rows. It is also **reversible**: `php artisan migrate:rollback` restores plaintext, so downgrading to v2 (whose model has no cast) leaves no unreadable ciphertext behind.

A rollback attempted under the **wrong** `APP_KEY` (rotated key, or a production dump restored elsewhere) **aborts loudly** rather than reporting success — it will not leave ciphertext sitting in a column that a downgraded v2 would hand to ANAF as a Bearer token.

### `APP_KEY` now protects your ANAF credentials — this is new in v3

[](#app_key-now-protects-your-anaf-credentials--this-is-new-in-v3)

Your `APP_KEY` was never load-bearing for e-Factura before. It is now.

- **Losing or rotating `APP_KEY` orphans every token.** Without the old key present, nothing can be decrypted and **every connected company must complete the OAuth flow again**(`php artisan efactura:auth {cui}`). Back the key up somewhere other than the database it protects.
- **Rotate only with `APP_PREVIOUS_KEYS`:**

    ```
    APP_KEY=base64:
    APP_PREVIOUS_KEYS=base64:
    ```

    Laravel then decrypts existing tokens with the old key and re-encrypts them with the new one as they are written.
- **Restoring a production database to staging or local now requires the production `APP_KEY`.**A dump loaded into an environment with a different key leaves every token unreadable. This workflow silently worked before v3 because the values were plaintext — it will not anymore. Either carry the matching key across, or re-authorise in that environment. (Re-authorising is frequently the better answer: a staging box holding working production ANAF credentials is its own problem.)

> **Writes that bypass the model now store unreadable plaintext.** `Builder::update()` and raw SQL skip casts, so `DB::table('efactura_tokens')->update(['access_token' => $raw])` — or `EfacturaToken::where(...)->update([...])` — silently writes a value nothing can decrypt. Load the model instance and update it, so the cast runs. Audit for raw writes before deploying.

### Other v3 breaking changes

[](#other-v3-breaking-changes)

- **`InvoiceFailed` no longer fires on rate limits.** Transient rate-limit hits now fire `InvoiceRateLimited` and are retried automatically; `InvoiceFailed` is a terminal signal that is safe to alert on. **Silent** — a listener that reacted to rate limits simply stops running.
- **The `RATE_LIMIT_EXCEEDED:` error-text marker is gone.** Listeners that substring-matched it now match nothing. Use the indexed `failure_reason` column / `FailureReason` enum instead.
- **`efactura:upload` now queues** one `ProcessSingleUpload` per row instead of uploading inline, so it **requires a queue worker**. **Silent** — without a worker it prints `Queued N pending upload(s) for processing.` and nothing is filed. Use `--sync` to restore the old inline behaviour (which now stops at the first rate limit rather than failing the whole backlog).
- **`resetForRateLimit()` → `resetForRetry()`.** It now covers every retryable failure reason and refuses non-retryable ones.
- **`markUploadAsFailed()` gained a 4th `FailureReason $reason` parameter**, defaulting to `FailureReason::Validation` — existing calls still compile and are treated as terminal.
- **`processPendingUploads()` returns `int`** (was `void`) and stops at the first rate limit.
- **`syncMessages()` / `syncAllMessages()` return `int`** (was `void`) and now **throw** instead of swallowing errors. `syncAllMessages()` keeps per-token isolation but raises `MessageSyncFailedException` if any token failed, so a batch can no longer report false success.
- **`queueUpload()` is idempotent** — one row per model, recycled on re-upload. It throws `DuplicateUploadException` when the existing row's delivery is `indeterminate`.
- **Schedule the new `SweepStaleUploads` job.** Nothing else rescues an upload stranded in `uploading` by a SIGKILL'd worker.
- **New operational duty: `php artisan efactura:reconcile`.** Uploads whose delivery to ANAF cannot be proven are parked `Failed` / `indeterminate` and are never re-submitted automatically. See [Reconciliation](#reconciliation-indeterminate-uploads).
- **SDK v3 changes land in your `toEfacturaData()`**: `PartyData::$isVatPayer` is now **required and moved to the 4th constructor position** (use named arguments — a positional v2 call silently coerces your ONRC string to `isVatPayer: true`), and `InvoiceData::$taxAmountRon` is **required for non-RON invoices** and rejected for RON ones.
- **Queue workers must be stopped for the migration, not merely restarted after it** — see [Step 1](#step-1--stop-the-queue-workers-mandatory). A worker alive across the migration can permanently orphan a company's credentials, which no re-run of the migration will repair.

> **Full step-by-step upgrade guide:** ask the `efactura` MCP server for the `migration` topic (`get-wrapper-docs` → `topic: "migration"`). It covers each break, the symptom you will actually see, and the exact code change, in the order a real upgrade performs them.

Features
--------

[](#features)

- **Token Management** - OAuth token storage per CUI with automatic refresh
- **Background Jobs** - Ready-to-use jobs for invoice uploads, status checks, and message syncing
- **Model Integration** - Simple interface + trait pattern for your invoice models
- **Event-Driven** - Events for all key operations (uploads, failures, received invoices)
- **Minimal Setup** - Auto-discovery, publishable config and migrations

Requirements
------------

[](#requirements)

- PHP 8.4+
- Laravel 11.x, 12.x, or 13.x
- `bee-coded/laravel-efactura-sdk` ^3.0 (installed automatically)
- ANAF SPV OAuth credentials
- A stable, backed-up `APP_KEY` — it encrypts the stored ANAF credentials
- A shared, persistent cache store (redis, memcached, database — **not `array`**), for the OAuth state and token-refresh locks
- A running queue worker on `config('efactura.queue')` — uploads are dispatched, not inline

Installation
------------

[](#installation)

```
composer require bee-coded/laravel-efactura
```

Publish the configuration and migrations:

```
php artisan vendor:publish --tag=efactura-config
php artisan vendor:publish --tag=efactura-migrations
php artisan migrate
```

Configuration
-------------

[](#configuration)

### Environment Variables

[](#environment-variables)

```
# SDK Configuration (required)
EFACTURA_SANDBOX=true
EFACTURA_CLIENT_ID=your-anaf-client-id
EFACTURA_CLIENT_SECRET=your-anaf-client-secret
EFACTURA_REDIRECT_URI=https://your-app.com/efactura/callback

# Package Configuration
EFACTURA_ENABLED=true
EFACTURA_UPLOAD_ENABLED=true
EFACTURA_DOWNLOAD_RECEIVED=false
EFACTURA_SYNC_MESSAGES=true

# Storage
EFACTURA_STORAGE_DISK=local
EFACTURA_STORAGE_PATH=efactura

# Queue (null = default queue)
EFACTURA_QUEUE=null

# Rate Limit Handling
EFACTURA_RATE_LIMIT_RETRY_HOURS=24
EFACTURA_RATE_LIMIT_RETRY_BATCH=250
EFACTURA_RATE_LIMIT_RETRY_MAX_DAYS=7

# Routes
EFACTURA_ROUTES_ENABLED=true
EFACTURA_ROUTES_PREFIX=efactura
EFACTURA_SUCCESS_REDIRECT=/
EFACTURA_ERROR_REDIRECT=/
```

### Config File

[](#config-file)

The configuration file (`config/efactura.php`) allows you to:

- Enable/disable the entire package or specific features
- Choose the queue e-Factura jobs are dispatched to
- Configure file storage for XML and ZIP files
- Tune rate-limit retry behaviour and periodic batch-job hardening
- Customize OAuth callback routes

> **Job schedules are not configured here.** There is no schedule or cron key in `config/efactura.php` — this package does not schedule anything for you. You register the schedules in your own application; see [Job Scheduling](#job-scheduling-required).

Model Integration
-----------------

[](#model-integration)

### 1. Implement the Interface

[](#1-implement-the-interface)

Your invoice model must implement `EFacturaUploadableInterface`:

```
