DEV Community

Paulo Fox
Paulo Fox

Posted on

Building a Multi-Tenant NF-e API with Laravel + SEFAZ: 6 Hard Lessons

Brazil mandates that every business issuing goods or services emit an NF-e (Nota Fiscal Eletrônica) — a government-signed XML submitted to SEFAZ in real-time. The protocol is SOAP-based, from 2006, documented only in Portuguese, and the homologation environment goes offline on weekends.

I spent 6 months building FoxNFe — a multi-tenant SaaS that abstracts all of this into a simple REST API. Here is everything I learned the hard way.


The Architecture

Tenant App ──REST──► FoxNFe Laravel API ──SOAP/XML──► SEFAZ (27 states)
                            │
                     PostgreSQL RLS
                     Redis + Queues
                     Certificate Store
Enter fullscreen mode Exit fullscreen mode
  • Laravel 11 — API backend, queued jobs, service layer
  • PostgreSQL + RLS — row-level security per tenant
  • Redis + Laravel Queues — async processing (SEFAZ takes 2–30s per request)
  • A1 Digital Certificates — encrypted per-tenant, loaded at runtime
  • 27 SEFAZ endpoints — one per Brazilian state, routed by CNPJ UF

Lesson 1: PostgreSQL RLS finally Is Not Optional

Multi-tenant apps on PostgreSQL often use Row-Level Security with a session variable:

CREATE POLICY tenant_isolation ON tenant_plan_usages
  USING (
    tenant_id = current_setting('app.tenant_id')::int
    OR current_setting('app.bypass_rls', true) = 'on'
  );
Enter fullscreen mode Exit fullscreen mode

For admin queries that need to cross tenant boundaries, you bypass RLS:

// DANGEROUS without finally
DB::statement("SELECT set_config('app.bypass_rls', 'on', false)");
$usage = TenantPlanUsage::withoutGlobalScopes()->where('tenant_id', $id)->first();
DB::statement("SELECT set_config('app.bypass_rls', '', false)");
Enter fullscreen mode Exit fullscreen mode

The bug we shipped: an exception between the two set_config calls left bypass_rls = 'on' active for the entire database connection. Every subsequent query on that connection returned data from all tenants.

The fix:

public function getOrCreateUsage(Tenant $tenant): TenantPlanUsage
{
    return DB::transaction(function () use ($tenant): TenantPlanUsage {
        DB::statement("SELECT set_config('app.bypass_rls', 'on', false)");
        try {
            $usage = TenantPlanUsage::withoutGlobalScopes()
                ->where('tenant_id', $tenant->id)
                ->lockForUpdate()
                ->first();

            if ($usage === null) {
                $plan  = $this->getActivePlan($tenant);
                $quota = $plan->isEnterprise() ? null : $plan->monthly_note_quota;
                $usage = TenantPlanUsage::forceCreate([
                    'tenant_id'     => $tenant->id,
                    'plan_id'       => $plan->id,
                    'nfes_used'     => 0,
                    'period_start'  => now()->startOfMonth()->toDateString(),
                    'period_end'    => now()->endOfMonth()->toDateString(),
                    'monthly_quota' => $quota,
                ]);
            }

            return $usage;
        } finally {
            // Restore even if an exception fires
            DB::statement("SELECT set_config('app.bypass_rls', '', false)");
        }
    });
}
Enter fullscreen mode Exit fullscreen mode

Rule: Every bypass_rls on needs a finally. Code review should enforce this like a lint rule.


Lesson 2: A1 Certificates — Encrypt Everything, Write Nothing to Disk

Each Brazilian company has a digital A1 certificate (.pfx file, password-protected), issued by an ICP-Brasil authority. This certificate signs every NF-e XML. In multi-tenant, each tenant has their own.

What NOT to do: store certificates as files on disk named by CNPJ, or log the decrypted content anywhere.

What we do:

// Store — encrypt before saving to DB
public function storeCertificate(Tenant $tenant, UploadedFile $pfx, string $password): void
{
    $tenant->update([
        'certificate_content'  => encrypt($pfx->get()),
        'certificate_password' => encrypt($password),
        'certificate_expires'  => $this->extractExpiry($pfx->get(), $password),
    ]);
}

// Load at runtime — decrypt in memory only, never touch disk
public function loadCertificate(Tenant $tenant): array
{
    $certData = [];
    $success  = openssl_pkcs12_read(
        decrypt($tenant->certificate_content),
        $certData,
        decrypt($tenant->certificate_password)
    );

    if (!$success) {
        throw new InvalidCertificateException(
            "Certificate invalid or password wrong for tenant {$tenant->id}"
        );
    }

    return $certData; // ['cert' => '...PEM...', 'pkey' => '...PEM...']
}
Enter fullscreen mode Exit fullscreen mode

Additional safeguards:

  • encrypt() uses Laravel's APP_KEY (AES-256-CBC) — rotate keys carefully, plan for re-encryption
  • Index certificate_expires — alert tenants 30 days before expiry, or their NF-e emissions stop cold
  • Never write the decrypted .pfx to disk, not even /tmp

Lesson 3: SEFAZ cStat Codes — Every One Matters

SEFAZ returns XML with a cStat field. Most docs only mention 100. Here is the full map of what you will actually see in production:

cStat Meaning Correct action
100 Authorized ✅ Save XML, increment quota
150 Authorized (out-of-time window) Same as 100
204 Already authorized (duplicate) Treat as 100
110 Denied — data error Fix XML, re-emit
301 Unauthorized usage (wrong UF endpoint) Check state routing
539 Schema validation error Your XML is malformed
999 SEFAZ internal error Retry with exponential backoff

cStat 204 is the trap. If your job times out and retries, SEFAZ may have already processed the NF-e. Treating 204 as an error causes double-counting in your quota and confuses the tenant. Always treat 204 as success.

private function processResponse(NFeResponse $response): void
{
    match (true) {
        in_array($response->cStat, [100, 150, 204]) => $this->handleAuthorized($response),
        $response->cStat === 110                    => $this->handleDenied($response),
        $response->cStat === 999                    => $this->handleRetry($response),
        default                                     => $this->handleUnknown($response),
    };
}
Enter fullscreen mode Exit fullscreen mode

Lesson 4: Quota Increment Must Happen AFTER Authorization

The billing bug we shipped: processResponse() saved the authorized NF-e but never called incrementNfeUsage(). Result: every tenant had infinite quota forever.

The fix — increment after, with a catch that logs but does not throw:

private function handleAuthorized(NFeResponse $response): void
{
    // 1. Persist authorization first
    $this->nfe->update([
        'status'        => NFeStatus::AUTHORIZED,
        'protocol'      => $response->nProt,
        'authorized_at' => now(),
        'xml_authorized'=> $response->xml,
    ]);

    // 2. Increment quota — if this fails, the NF-e is already authorized
    // in SEFAZ. We cannot reverse it. Log the failure and continue.
    try {
        $this->planEnforcer->incrementNfeUsage($this->tenant);
    } catch (\Throwable $e) {
        Log::warning('Quota increment failed after NF-e authorization', [
            'nfe_id'    => $this->nfe->id,
            'tenant_id' => $this->tenant->id,
            'error'     => $e->getMessage(),
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Why catch instead of letting it propagate? The NF-e is already authorized in SEFAZ — it exists in the government's system. If we throw here, the job retries, SEFAZ returns 204 ("already authorized"), and we end up in an infinite loop. The tenant gets their authorized NF-e, and ops gets an alert to fix the quota manually.


Lesson 5: IDOR Hidden in Tenant Resolution

Our controllers originally resolved the current tenant from the X-Tenant-ID header:

// VULNERABLE
private function resolveTenant(Request $request): Tenant
{
    return Tenant::findOrFail($request->header('X-Tenant-ID'));
}

// upgrade() used this — any authenticated user could upgrade ANY tenant's plan
public function upgrade(Request $request): JsonResponse
{
    $tenant = $this->resolveTenant($request);
    // ... proceed to upgrade billing
}
Enter fullscreen mode Exit fullscreen mode

An attacker with a valid JWT for tenant A could send X-Tenant-ID: B and upgrade B's plan, downgrade it, or access its usage data.

The fix — one line, always verify ownership:

private function resolveTenant(Request $request): Tenant
{
    $tenant = Tenant::findOrFail($request->header('X-Tenant-ID'));

    if ($request->user()->tenant_id !== $tenant->id) {
        abort(403, 'Unauthorized: tenant mismatch');
    }

    return $tenant;
}
Enter fullscreen mode Exit fullscreen mode

Simple. But in multi-tenant systems where the tenant ID comes from the client, this check is easy to forget when you assume the JWT is sufficient authorization. It is not.


Lesson 6: Testing Without RefreshDatabase

Our staging PostgreSQL does not allow DROP TABLE — security policy. RefreshDatabase trait is out entirely. We use explicit setUp/tearDown with direct cleanup:

class PlanEnforcerTest extends TestCase
{
    private ?Tenant $tenant = null;

    protected function setUp(): void
    {
        parent::setUp();

        if (!$this->isPgsqlAvailable()) {
            $this->markTestSkipped('PostgreSQL not available');
            return; // guard required — see tearDown note below
        }

        $this->tenant = Tenant::factory()->create();
    }

    protected function tearDown(): void
    {
        // CRITICAL: markTestSkipped() in setUp() does NOT prevent tearDown()
        // from running. If $this->tenant is null (test was skipped),
        // forceDelete() will throw. Always null-check.
        if ($this->tenant !== null) {
            TenantPlanUsage::where('tenant_id', $this->tenant->id)->forceDelete();
            $this->tenant->forceDelete();
        }

        parent::tearDown();
    }

    public function test_rls_restored_after_exception(): void
    {
        // Simulate exception inside getOrCreateUsage
        $this->mock(TenantPlanUsage::class)
             ->shouldReceive('forceCreate')
             ->andThrow(new \RuntimeException('Simulated DB failure'));

        try {
            app(PlanEnforcer::class)->getOrCreateUsage($this->tenant);
        } catch (\Throwable) {
            // Expected
        }

        $setting = DB::selectOne("SELECT current_setting('app.bypass_rls', true) AS val");
        $this->assertNotEquals('on', $setting->val, 'bypass_rls must be restored after exception');
    }
}
Enter fullscreen mode Exit fullscreen mode

This pattern — factory in setUp, forceDelete in tearDown, null guard everywhere — works reliably with 11+ months of CI runs.


Bonus: Local XSD Validation Before Submission

SEFAZ cStat 539 (schema error) does not tell you which field is wrong. Debug cycle: submit → wait 5s → get 539 → guess what's wrong → repeat. Brutal.

Solution: validate against the official XSD locally before signing:

public function validateSchema(string $xml): void
{
    $dom = new \DOMDocument();
    $dom->loadXML($xml);

    $xsdPath = storage_path('sefaz/nfe_v4.00.xsd');

    if (!$dom->schemaValidate($xsdPath)) {
        $errors = libxml_get_errors();
        throw new SchemaValidationException(
            'NF-e XML schema invalid: ' . $errors[0]->message . ' on line ' . $errors[0]->line
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Run this before AND after signing. SEFAZ XSDs are available on the portal.nfe.fazenda.gov.br portal (buried 4 clicks deep).


What's Next

FoxNFe enters SEFAZ official homologation this week. After certification goes live:

  • Multi-state routing for all 27 Brazilian states (UF auto-detection from CNPJ)
  • NFS-e (service invoices) via municipal APIs — each city has its own protocol
  • Webhook notifications on authorization, denial, and cancellation
  • Dashboard with quota usage, certificate expiry countdown, and NF-e status timeline

If you are building fiscal integrations in Brazil, or have questions about SEFAZ, SOAP, or PHP certificate handling — drop a comment. Happy to go deeper on any of these.

25 years of development. Built with: Laravel 11 · PostgreSQL 16 · Redis · Docker · Claude Code (Anthropic)

🔗 foxnfe.centralfox.online | Reddit u/foxdigitaldev

Top comments (0)