Back to blog

How I Built FilaForms: Architecture Decisions Behind a Filament Plugin

Manuk Minasyan · · 6 min read

I started building FilaForms because I kept writing the same form code across projects. Contact forms, feedback forms, lead capture forms. Every Laravel app I worked on needed at least one, and every time I'd build the migration, the controller, the validation, the Blade view. By the third project I was copy-pasting from my own repos and wondering why I hadn't just made a package.

That's how it started. Not some grand plan. Just laziness dressed up as productivity.

#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 that anyone can fill out? Contact pages, surveys, registration forms?

You could build all of that from scratch, 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

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

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

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

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.

The conditional logic system 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.

#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

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 I'd do differently

The database has three core tables: forms, form_submissions, and form_events. The events table handles analytics 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

FilaForms is at $314 MRR. Not life-changing money, but enough to tell me the architecture mostly holds up.

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."

You can try FilaForms at filaforms.app.

Related posts