How I Built FilaForms: Architecture Decisions Behind a Filament Plugin | FilaForms                                   [ ![Filaforms Logo](https://filaforms.app/logo.svg)FilaForms

 ](https://filaforms.app)   [ Documentation ](https://docs.filaforms.app) [ Blog ](https://filaforms.app/blog) [ Try Demo ](https://filaforms.app/login)

  [ Documentation ](https://docs.filaforms.app) [ Blog ](https://filaforms.app/blog) [ Try Demo ](https://filaforms.app/login)

   ![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, 2026  · 8 min read

 This post covers the real architecture decisions behind FilaForms — why it uses two separate packages, how form schemas are stored as JSON, why ULIDs were chosen over slugs, and what I'd do differently. It's for Laravel/Filament developers interested in plugin architecture. Filament has 29,000+ GitHub stars; this is how a production plugin fits into that ecosystem.

[\#](#the-problem-i-was-actually-solving "Permalink")The problem I was actually solving
---------------------------------------------------------------------------------------

The Filament ecosystem had a gap. Filament gives you a great admin panel with form components, but those forms live inside the panel. They're for authenticated users managing data. What about [public-facing forms](https://filaforms.app/blog/how-to-add-public-facing-forms-to-your-filament-app) that anyone can fill out? Contact pages, surveys, registration forms?

You could [build all of that from scratch](https://filaforms.app/blog/filaforms-vs-building-forms-from-scratch-in-laravel), and plenty of developers do. But once you need a form builder in the admin panel, submission storage, email notifications, analytics, conditional logic, integrations with external services, you're looking at weeks of work. And then you maintain it forever.

I wanted a plugin that handled the full lifecycle: build a form in Filament's admin, publish it with a public URL, collect submissions, get notified, track basic analytics. And I wanted it to be opinionated enough to work out of the box but flexible enough that developers could override anything.

[\#](#why-two-packages "Permalink")Why two packages
---------------------------------------------------

Early on I decided to split FilaForms into two packages: `filaforms/core` and `filaforms/integrations`.

Core handles the form builder, submissions, notifications, analytics, and public form rendering. Integrations is optional and adds Stripe Connect, Google Sheets, Webhooks, and Zapier.

I went back and forth on this. A single package would have been simpler to maintain and easier for users to install. But I kept running into the same issue: integrations require OAuth credentials, API keys, and external service dependencies. If you just want a contact form, you shouldn't need Stripe's SDK in your vendor folder.

The split also let me design integrations around a three-interface system: `IntegrationInterface` for the base, `ApiWriteIntegrationInterface` for services that push data out, and `ExternalIntegrationInterface` for OAuth-based connections. Each integration type extends a generic base class, so adding a new one follows a predictable pattern. There's a two-tier connection model too: Integration Connections are reusable OAuth accounts, and Form Integrations are per-form configurations that reference those connections.

Turns out the split was the right call. Most users install core only. The people who need integrations are glad they exist, and everyone else doesn't carry the extra weight.

[\#](#feature-flags-and-why-they-matter-for-plugins "Permalink")Feature flags and why they matter for plugins
-------------------------------------------------------------------------------------------------------------

Filament plugins run inside other people's apps. You can't assume everyone wants every feature enabled.

I built a feature flags system using a `FeatureConfigurator` class and a `FilaFormsFeature` enum. You can enable or disable features per deployment:

```
FilaForms::configureFeatures(function (FeatureConfigurator $features) {
    $features->enable(FilaFormsFeature::Analytics);
    $features->disable(FilaFormsFeature::Notifications);
});

```

More work than just shipping everything on by default, but it solved a real problem. Early users kept asking me to remove features they didn't need. One wanted forms and submissions but no analytics. Another wanted analytics but no email notifications. Without feature flags, I'd have ended up with a config file full of booleans and no structure.

[\#](#json-schema-storage-for-form-definitions "Permalink")JSON schema storage for form definitions
---------------------------------------------------------------------------------------------------

Form definitions are stored as JSON in the database. Each form has a schema that describes its fields, validation rules, layout, and conditional logic rules.

I considered normalizing everything: separate tables for fields, options, rules. The textbook relational approach. But that would have meant dozens of queries to load a single form, new migrations every time I added a field property, and painful versioning.

JSON schema let me iterate fast. Adding a new field property is just a key in the JSON. Loading a form is one query. The downside is that you can't easily query individual field properties with SQL, but in practice I've never needed to. Submissions are stored in their own table with the field values, and that's what people query.

Conditional logic is a good example of why JSON schema works well here. Rules are stored as part of the field definition, and the `AlpineVisibilityGenerator` translates them into Alpine.js expressions at render time using `visibleJs`. The same rules run server-side for validation. Keeping all of that in one JSON structure per form keeps the logic co-located and easy to reason about. If you want to understand what those conditional rules look like from a user's perspective, the [step-by-step contact form tutorial](https://filaforms.app/blog/building-a-contact-form-in-laravel-with-filament-step-by-step) shows a simple form being configured without touching the schema directly.

[\#](#ulids-for-public-form-urls "Permalink")ULIDs for public form URLs
-----------------------------------------------------------------------

Public forms use ULID-based URLs instead of sequential IDs or slugs. Small decision, but it saved me headaches later.

Sequential IDs are predictable. If your form is at `/forms/42`, someone can guess that `/forms/41` and `/forms/43` exist. ULIDs are unique, time-sortable, and opaque. They don't leak information about how many forms exist or when they were created relative to each other.

I also considered slugs, but forms don't always have meaningful titles. A form called "Q3 Customer Survey - Draft 2" doesn't make a good URL. ULIDs are generated automatically and never need to be changed.

[\#](#multi-tenancy-without-assumptions "Permalink")Multi-tenancy without assumptions
-------------------------------------------------------------------------------------

FilaForms supports multi-tenancy through a configurable foreign key that's auto-resolved from the Filament panel. I didn't want to bake in assumptions about how people structure their tenancy. Some apps use `team_id`, others use `organization_id`, others use something else entirely.

The approach is simple: you tell FilaForms which column to scope by, and it handles the rest. Forms, submissions, and events are all scoped to the tenant. This works because Filament already resolves the current tenant in its panel middleware, so FilaForms just reads that value.

[\#](#what-id-do-differently "Permalink")What I'd do differently
----------------------------------------------------------------

The database has three core tables: `forms`, `form_submissions`, and `form_events`. The events table handles [analytics tracking](https://filaforms.app/blog/form-submission-tracking-and-analytics-in-laravel-without-third-party-tools) through session fingerprinting with xxh64 hashing and deduplicated events. Looking back, I'd reconsider the events table structure. It works, but as form traffic grows, that table gets large. I should have planned for partitioning or archival from the start.

I'd also invest more in the custom model override system earlier. FilaForms lets you swap out the Form, Submission, and Event models via facades, but I added that after users asked for it. If I'd designed it from day one, the internal code would be cleaner.

And the honeypot spam protection on public forms works fine for basic bots, but I should have added rate limiting per IP from the beginning. I've had users report spam submissions that passed the honeypot because they were submitted by scripts that execute JavaScript.

[\#](#where-it-stands-now "Permalink")Where it stands now
---------------------------------------------------------

FilaForms is at $314 MRR. Not life-changing money, but enough to tell me the architecture mostly holds up. Laravel powers over 1.8 million websites worldwide, and the Filament ecosystem is a growing corner of that — the plugin market is real.

The thing I got most right is extensibility. Custom field types, model overrides, feature toggles, pluggable integrations. That flexibility came from building other packages first (Custom Fields, Flowforge) and learning what developers actually ask for versus what I assumed they'd want.

If you're building a Filament plugin, the main thing I'd pass along: make fewer assumptions about how people will use it. Give them extension points early. It's more architecture work upfront, but you stop getting feature requests that are really just "let me configure this one thing."

[\#](#frequently-asked-questions "Permalink")Frequently Asked Questions
-----------------------------------------------------------------------

### [\#](#why-does-filaforms-use-two-separate-packages "Permalink")Why does FilaForms use two separate packages?

FilaForms splits into `filaforms/core` and `filaforms/integrations` to keep dependencies lean. Core handles form building, submissions, notifications, and analytics. Integrations adds Stripe Connect, Google Sheets, webhooks, and Zapier — features that require API keys and OAuth credentials. Developers who only need a contact form don't have to pull in Stripe's SDK or Google's OAuth libraries.

### [\#](#what-database-design-does-filaforms-use-for-form-schemas "Permalink")What database design does FilaForms use for form schemas?

FilaForms stores form definitions as JSON in the database — one record per form containing all field definitions, validation rules, layout settings, and conditional logic. This avoids dozens of joins to load a single form and makes it easy to add new field properties without new migrations. Submissions are stored in a separate table with the actual field values, which is what gets queried for reporting.

### [\#](#why-did-filaforms-choose-ulids-over-uuids "Permalink")Why did FilaForms choose ULIDs over UUIDs?

ULIDs are time-sortable and lexicographically orderable, which makes database indexing more efficient than random UUIDs. They're also URL-safe and shorter to type. For public form URLs, ULIDs prevent sequential ID guessing (unlike integer IDs like `/forms/42`) while being more database-friendly than UUID v4. The time-ordered property also means newer forms sort naturally without an extra `created_at` index.

You can try FilaForms at [filaforms.app](https://filaforms.app).

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

 [  Insights   Mar 19, 2026

 Why Every Laravel App Needs a Form Builder (Not Just Code)
------------------------------------------------------------

Hand-coding forms works until it doesn't. Here's why a form builder saves time and sanity for most Laravel apps.

 ](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

 [ Documentation ](https://docs.filaforms.app) [ Blog ](https://filaforms.app/blog) [ Features ](https://filaforms.app#features) [ 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)
