DEV Community

Jean-Marc Strauven
Jean-Marc Strauven

Posted on

How I built nis2you with Laravel 12 + Livewire 3 — a NIS2 compliance SaaS for European SMEs

The problem nobody wants to deal with

Late 2024, the EU's NIS2 directive started biting. Thousands of mid-sized European companies that never thought of themselves as "critical infrastructure" suddenly found themselves in scope: manufacturers, logistics providers, MSPs, mid-tier SaaS vendors. The directive itself is broad, the national transpositions vary, but the practical question on the ground is the same everywhere: where do we even start?

If you're a 60-person Belgian manufacturer, you don't have a CISO. You probably don't have a dedicated security analyst either. You have an IT manager already wearing three hats, and now they need to deliver a risk assessment, an incident response plan, and a board-level reporting framework — against a deadline that has already passed in some Member States.

The big consultancies will gladly sell you a six-figure engagement. The big GRC platforms (OneTrust, ServiceNow IRM and friends) are priced for enterprise procurement teams, not SMEs. Between "expensive consultant" and "ignore the problem and hope you don't get audited," there is a gap. That gap is where I've been building for the last few months.

Why Laravel 12 + Livewire 3 (and what I rejected)

I'm a long-time Laravel developer. I maintain a few open-source packages under the Grazulex namespace, so the framework choice was never really up for debate — but I want to walk through why Laravel 12 + Livewire 3 holds up specifically for a regulated B2B SaaS with a single founder-developer.

The stack:

  • Laravel 12 — backend. Eloquent, queues, policies, broadcasting, the whole thing.
  • Livewire 3 — server-rendered reactivity. No SPA, no separate API, no client-side state to keep in sync.
  • Filament 3 — internal admin panel. Tenants, audits, support tickets, billing.
  • Tailwind CSS — styling.
  • MySQL 8 — primary store. Postgres would have been fine; hosting defaults pushed MySQL.
  • Laravel Horizon — dashboard for the async work (PDF generation, scheduled assessments, email).

Two stacks I seriously considered and dropped:

  1. Laravel + Inertia + Vue. I like Inertia, but the moment you cross into Vue components you need build tooling, type definitions, and a parallel mental model. For a solo dev shipping fast, that's a tax with no matching benefit.
  2. Next.js + Supabase. Fashionable, fast to prototype, painful for compliance work. Row-level security in Postgres is great — until you need a deep audit trail with field-level diff history and predictable, framework-native authorization. I wanted Laravel's policy + Eloquent observers ecosystem.

Livewire 3 in 2026 is genuinely good. The wire:model improvements, morph-based DOM diffing, and Alpine.js integration mean I can build a multi-step risk assessment wizard with conditional fields, validation, and live progress indicators — without writing a single line of dedicated JavaScript.

Four technical decisions worth talking about

1. State machines, hardcoded — not configurable

Every compliance object in nis2you (assessments, incidents, audits, action plans) goes through a defined lifecycle: draft → in_review → validated → published → archived. Early on, I was tempted to make this configurable per tenant. "Some clients might want a different workflow."

I didn't, and I'm glad I didn't. Configurable workflows mean a workflow engine, which means YAML files, which means a UI to edit them, which means support tickets when someone breaks the YAML. For a single-developer SaaS, hardcoded states with transition guards in plain PHP are dramatically simpler:

final class AssessmentState
{
    public const DRAFT      = 'draft';
    public const IN_REVIEW  = 'in_review';
    public const VALIDATED  = 'validated';
    public const PUBLISHED  = 'published';
    public const ARCHIVED   = 'archived';

    public static function canTransition(string $from, string $to): bool
    {
        return match ($from) {
            self::DRAFT      => in_array($to, [self::IN_REVIEW, self::ARCHIVED]),
            self::IN_REVIEW  => in_array($to, [self::DRAFT, self::VALIDATED]),
            self::VALIDATED  => in_array($to, [self::PUBLISHED, self::IN_REVIEW]),
            self::PUBLISHED  => $to === self::ARCHIVED,
            self::ARCHIVED   => false,
            default          => false,
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

Combined with an Eloquent observer that throws on illegal transitions, this is ~80 lines of code replacing what would be a 2,000-line workflow engine. If a tenant ever genuinely needs a different workflow, that's a one-on-one conversation, not a config screen.

2. PDF generation via DomPDF, queued through Horizon

NIS2 deliverables are documents. Risk assessments, incident reports, supplier inventories — at some point, somebody needs a signed PDF. PDF generation in PHP is slow and memory-heavy. Doing it inline kills the request.

The setup: a Blade template renders the document, DomPDF turns it into a PDF, the whole thing runs as a queued job, and Livewire listens for completion:

final class GenerateAssessmentPdf implements ShouldQueue
{
    public function __construct(public Assessment $assessment) {}

    public function handle(): void
    {
        $html = view('pdf.assessment', [
            'assessment' => $this->assessment,
        ])->render();

        $pdf = Pdf::loadHTML($html)->setPaper('a4');

        Storage::disk('s3')->put(
            "tenants/{$this->assessment->tenant_id}/assessments/{$this->assessment->id}.pdf",
            $pdf->output()
        );

        $this->assessment->update(['pdf_generated_at' => now()]);

        AssessmentPdfReady::dispatch($this->assessment);
    }
}
Enter fullscreen mode Exit fullscreen mode

Horizon gives me retries, failure tracking, and a real-time dashboard. The Livewire component listens for AssessmentPdfReady and updates the UI without a refresh. Users see "generating...""ready, click to download" without ever holding a long-running HTTP request open.

3. Audit trail via Spatie ActivityLog — extended for field-level diffs

The default spatie/laravel-activitylog package logs creates / updates / deletes per model. For NIS2, that's not enough. Auditors want to know which field changed, who changed it, and what the previous value was. Two-year retention is the practical baseline.

I extended the default with a small Auditable trait, applied to every auditable model:

trait Auditable
{
    public function getActivitylogOptions(): LogOptions
    {
        return LogOptions::defaults()
            ->logFillable()
            ->logOnlyDirty()
            ->dontSubmitEmptyLogs()
            ->setDescriptionForEvent(
                fn (string $event) => "{$this->getMorphClass()}.{$event}"
            );
    }

    public function tapActivity(Activity $activity, string $event): void
    {
        $activity->properties = $activity->properties->merge([
            'tenant_id' => $this->tenant_id ?? null,
            'ip'        => request()?->ip(),
            'user_id'   => auth()->id(),
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

The activity log lives in a dedicated table partitioned monthly. Partitions older than twelve months get archived to S3. This was the single highest-ROI piece of plumbing in the whole app — auditors love it, and it has already answered "did the user actually approve this?" three times in beta.

4. Multi-tenancy: column-scoped now, schema-per-tenant on the V1.5 roadmap

Every tenant-bound model has a tenant_id. Global scopes apply automatically based on the authenticated session. It's the simplest possible model, and it works perfectly at current scale.

I deliberately did not ship schema-per-tenant in V1. The argument for it (hard data isolation, easier per-customer backups) is real but premature for a pre-revenue product. When a regulated customer eventually pushes for true schema isolation, V1.5 will introduce it via stancl/tenancy. Until then, column scoping plus exhaustive Pest tests on every policy is enough — and it keeps the operational story simple.

Three honest lessons

1. Filament saved me at least four weeks. I almost built a custom admin panel "because Filament might be limiting." It isn't. The internal panel for managing tenants, support tickets, and audit reviews took a weekend with Filament. A custom build would have eaten the entire month of February.

2. Livewire's gotchas are still real. Specifically: large arrays bound through wire:model cause noticeable hydration cost. I learned to flatten complex nested structures and to lift heavy state to the server through explicit method calls rather than blanket two-way binding. The "drop-in reactivity" promise has a ceiling — and you need to know where it is before a user complains the wizard feels sluggish.

3. Compliance UX is its own discipline. Devs love forms; compliance officers tolerate them. The single biggest UX investment was a "guided assessment" mode that walks the user question-by-question, with context, examples, and an explicit skip for now button. Completion rate tripled the week I shipped it. The lesson: in regulated B2B, the form is the product.

Closing

nis2you is live in private beta with a handful of Belgian SMEs and consultants. The stack — Laravel 12, Livewire 3, Filament, Horizon — is unglamorous, productive, and cheap to operate. If you're shipping a regulated B2B SaaS as a solo developer, I cannot recommend it strongly enough.

Genuinely curious what stack you'd reach for in this domain — drop a comment with the trade-offs you'd weigh differently. And if NIS2 happens to be on your customers' radar, nis2you.com is where the work lives.


Written by Jean-Marc Strauven — Belgian Laravel dev, maintainer of the Grazulex packages.

Top comments (0)