How I built FilaForms: architecture decisions behind a Filament plugin | FilaForms                                 [ ![Filaforms Logo](https://filaforms.app/logo.svg)FilaForms

 ](https://filaforms.app)  [ Features ](https://filaforms.app#features) [ Pricing ](https://filaforms.app#pricing) [ Blog ](https://filaforms.app/blog) [ Documentation ](https://docs.filaforms.app)  [ Try Demo ](https://filaforms.app/login) [ Get Started ](https://filaforms.app#pricing)

 [ Features ](https://filaforms.app#features) [ Pricing ](https://filaforms.app#pricing) [ Blog ](https://filaforms.app/blog) [ Documentation ](https://docs.filaforms.app) [ Try Demo ](https://filaforms.app/login) [ Get Started ](https://filaforms.app#pricing)

   ![FilaForms](https://filaforms.app/logo.svg) FilaForms

 InsightsHow I built FilaForms: architecture decisions behind a Filament plugin
======================================================================

 filaforms.app/blog

  [    Back to blog ](https://filaforms.app/blog) [ Insights ](https://filaforms.app/blog/category/insights)

How I built FilaForms: architecture decisions behind a Filament plugin
======================================================================

 Manuk Minasyan ·  April 6, 2025  · 8 min read

 FilaForms started because I kept building the same form infrastructure across projects. Contact form here, application form there — same migrations, same models, slightly different field names each time.

At some point I stopped and thought about what a proper solution would look like. Building a Filament plugin turned out to be the right approach, and this post covers the architecture decisions I made along the way — what I chose, why, and what I'd reconsider.

```
barev arev

```

Decision 1: Filament plugin vs standalone app
---------------------------------------------

The first fork in the road. Do you build a standalone Laravel application (like OpnForm) or a Filament plugin that installs into existing apps?

I went with a plugin. The reasoning:

Most Laravel developers I know already run Filament. They have an admin panel, they have users, they have authentication. Adding a form builder should feel like adding another section to the sidebar, not deploying a second application.

A standalone app means its own deployment, its own database, its own auth. That's fine for teams who want a dedicated form platform (we [compared all five Laravel form builder options](https://filaforms.app/blog/5-open-source-form-builders-for-laravel-compared-2026) separately). But for a developer who just wants public forms in their existing app, it's overhead.

The downside of the plugin approach is coupling. FilaForms depends on Filament, which depends on Livewire, which depends on Laravel. If any of these make breaking changes, I'm upgrading. When Filament went from v3 to v4, that was a real migration effort.

I'd make the same choice again. The integration benefits outweigh the coupling risk — your forms live in the same admin panel and the same database, with nothing extra to deploy.

Decision 2: JSON submission storage - 2
---------------------------------------

Form submissions are stored as JSON in a single column:

```
Schema::create('form_submissions', function (Blueprint $table) {
    $table->ulid('id')->primary();
    $table->foreignUlid('form_id')->constrained()->cascadeOnDelete();
    $table->json('data');
    $table->timestamps();
});

//dzec

```

The alternative was creating dynamic database columns — each form field gets its own column in the submissions table. Some form builders do this.

I chose JSON for a few reasons:

**Forms change.** Someone adds a field, removes a field, renames a field. With dynamic columns, each change requires a migration. With JSON, the schema is implicit in the data. Old submissions keep their old fields, new submissions have the new ones.

**Querying is fine.** Both MySQL and PostgreSQL support JSON querying. `WHERE data->>'email' = 'foo@bar.com'` works. It's not as fast as indexed columns, but form submissions aren't usually queried at the scale where that matters.

**Export is simpler.** When exporting to CSV, you read the JSON and map it to columns. The export logic doesn't need to know the table schema.

**The tradeoff:** You lose database-level validation and foreign key constraints on individual fields. If data integrity at the field level is critical, dynamic columns are better. For form submissions, where the data is user input with application-level validation, JSON is the pragmatic choice.

Decision 3: Building on Custom Fields
-------------------------------------

FilaForms uses Relaticle Custom Fields v2.0 as its field type foundation. This was partly practical — I built Custom Fields too, and it already solved the problem of defining, validating, and rendering dynamic field types.

But it was also an architectural decision. By building on Custom Fields, FilaForms gets:

- **20+ field types for free.** Text, email, phone, date, select, checkbox, file upload, rich editor — all already implemented with validation and rendering logic.
- **Custom field registration.** Developers can create their own field types and register them with FilaForms. The extension mechanism already exists in Custom Fields.
- **Type-safe validation.** Each field type defines its own validation rules. An email field validates email format. A number field validates numeric input. This is handled at the field type level, not the form level.

The risk here is dependency depth. FilaForms depends on Custom Fields, which has its own release cycle. A breaking change in Custom Fields could break FilaForms. I mitigate this by maintaining both packages, but if someone else owned Custom Fields, I'd think twice.

Decision 4: Event-based analytics
---------------------------------

Form analytics in FilaForms are built on a simple event log:

```
Schema::create('form_events', function (Blueprint $table) {
    $table->ulid('id')->primary();
    $table->foreignUlid('form_id')->constrained()->cascadeOnDelete();
    $table->string('event_type'); // VIEW, START, SUBMIT
    $table->string('session_hash', 64);
    $table->timestamps();
});

```

Three event types, one table, SHA-256 hashed session identifiers. That's the whole analytics system. (We go deeper on [form analytics and what the data tells you](https://filaforms.app/blog/form-submission-tracking-and-analytics-in-laravel-without-third-party-tools) in a separate post.)

**Why not use an existing analytics package?** I didn't want to track users. I wanted to track form performance. Plausible, Fathom, even PostHog — they track page views and user journeys. I needed something narrower: did someone view this form, did they start filling it out, did they finish?

**Why SHA-256?** The session hash is generated from the visitor's session ID plus the current date. SHA-256 is irreversible, so you can't recover the original session ID from the hash. And because the date is part of the input, the same person visiting tomorrow gets a different hash. This means you can deduplicate events within a single visit without building a tracking profile across days.

```
$hash = hash('sha256', $request->session()->getId() . now()->toDateString());

```

**Why not just count?** I could have used counters instead of an event log. Increment `form.views_count` instead of inserting a row. Counters are faster and use less storage.

But an event log lets you calculate metrics over time ranges. "What was the completion rate last week?" requires filtering events by date. Counters can only tell you the all-time total.

The storage cost is negligible — even a high-traffic form generating 1,000 events per day adds about 100KB to the database. Not worth optimizing.

Decision 5: ULIDs everywhere
----------------------------

FilaForms uses ULIDs (Universally Unique Lexicographically Sortable Identifiers) for primary keys instead of auto-incrementing integers.

```
$table->ulid('id')->primary();

```

Reasons:

- **No collision risk.** If you run multi-tenant or merge databases, integer IDs collide. ULIDs don't.
- **Sortable by creation time.** Unlike UUIDs (v4), ULIDs encode a timestamp. You can sort by ID and get chronological order. This means database indexes stay efficient — new records always go at the end.
- **Not guessable.** With integer IDs, someone can increment the URL to see if `/submissions/124` exists after viewing `/submissions/123`. ULIDs are opaque.

The downside is slightly larger storage (26 characters vs 4-8 bytes for integers) and slightly uglier URLs. Neither has mattered in practice.

Decision 6: Honeypot over reCAPTCHA
-----------------------------------

For spam protection, FilaForms uses a honeypot field by default instead of reCAPTCHA.

Honeypot: add a hidden form field that's invisible to humans but visible to bots. If the field has a value on submission, it's a bot. Reject the submission silently.

reCAPTCHA: Google's bot detection widget. Click "I'm not a robot" or solve image puzzles.

I chose honeypot because:

- **No third-party dependency.** reCAPTCHA requires loading Google's JavaScript and sending data to Google's servers. For a self-hosted form builder, this defeats part of the purpose.
- **No user friction.** Honeypot fields are invisible. Users don't have to click anything or solve puzzles. reCAPTCHA adds friction, and friction reduces completion rates.
- **It works for most forms.** Honeypot catches the majority of automated spam. Sophisticated bots can bypass it, but sophisticated bots can bypass reCAPTCHA too.

For high-value targets (login forms, payment forms), reCAPTCHA or similar is worth the friction. For a contact form or survey, honeypot handles it.

What I'd do differently
-----------------------

**Earlier multi-tenancy support.** Multi-tenancy was added after the initial release, and the migration path wasn't as smooth as I'd like. If you enable multi-tenancy after you already have forms and submissions, you need to backfill tenant IDs. Designing for multi-tenancy from day one would have avoided this.

**Better file upload architecture.** File uploads currently go to a configurable disk, which is fine. But the cleanup logic — deleting uploaded files when a submission is deleted — could be more robust. A dedicated media library approach (like Spatie's MediaLibrary) might have been cleaner.

**API-first form rendering.** The current rendering uses Livewire components. An API endpoint that returns the form schema as JSON would let people render forms in React, Vue, or any frontend framework. This is the most-requested feature I haven't built yet.

Building your own Filament plugin
---------------------------------

If you're considering building a Filament plugin, a few things I learned:

**Follow Filament's patterns.** Study how core Filament works — Resources, Pages, Widgets, Actions. The more your plugin feels like native Filament, the easier adoption is.

**Publish everything.** Migrations, config, views — let developers publish and override them. People will want to customize things you didn't anticipate.

**Test against multiple Filament versions.** Filament moves fast. What works on 4.0 might break on 4.2. CI against multiple versions saves you from surprise bug reports.

**Write docs like someone's reading them at midnight while debugging.** Because they are. Copy-pasteable code and a troubleshooting section go further than polished prose.

 Related posts
-------------

 [  Insights   Mar 11, 2025

 Why every Laravel app needs a form builder (not just code)
------------------------------------------------------------

You can code forms in Laravel. The question is whether you should keep doing it manually on every project when a form builder handles the plumbing.

 ](https://filaforms.app/blog/why-every-laravel-app-needs-a-form-builder-not-just-code)

    ![FilaForms Logo](/logo.svg) FilaForms

 Laravel form infrastructure for Filament. Stop rebuilding forms on every project.

 ### Product

 [ Features ](https://filaforms.app#features) [ Documentation ](https://docs.filaforms.app) [ Blog ](https://filaforms.app/blog) [ Pricing ](https://filaforms.app#pricing) [ Contact ](mailto:hello@filaforms.app)

 ### Legal

 [ Terms of Service ](https://filaforms.app/terms-of-service) [ Privacy Policy ](https://filaforms.app/privacy-policy)

  © 2025-2026 FilaForms. All rights reserved.

 [    ](mailto:hello@filaforms.app) [    ](https://x.com/MinasyanManuk)
