Building a contact form in Laravel with Filament (step-by-step)
Building a contact form in Laravel with Filament (step-by-step)
Every Laravel web app needs a contact form. It's usually the first public-facing form you build, and it's deceptively annoying to get right.
This tutorial covers two approaches: building a Laravel contact form manually with Livewire and Filament's form package, and building one with FilaForms. (For a broader look at how the two approaches compare, see FilaForms vs building from scratch.) I'll walk through both so you can pick what fits.
What we're building
A contact form with:
- Name (required)
- Email (required, validated)
- Subject (optional dropdown)
- Message (required)
- Admin gets an email on submission
- Submitter gets a confirmation email
- Submissions viewable in the Filament admin panel
- Basic spam protection
Nothing exotic. The kind of form you've built ten times already.
Prerequisites
- Laravel 11+ application
- Filament 4.x or 5.x installed
- Mail configured (Mailgun, SES, SMTP — whatever you use)
- PHP 8.3+
Method 1: Manual build with Livewire
Step 1: Database
Create a migration:
php artisan make:migration create_contact_submissions_table
Schema::create('contact_submissions', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email');
$table->string('subject')->nullable();
$table->text('message');
$table->string('ip_address')->nullable();
$table->timestamps();
});
php artisan migrate
Create the model:
// app/Models/ContactSubmission.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ContactSubmission extends Model
{
protected $fillable = [
'name',
'email',
'subject',
'message',
'ip_address',
];
}
Step 2: Livewire component
php artisan make:livewire ContactForm
// app/Livewire/ContactForm.php
namespace App\Livewire;
use App\Mail\ContactAdminNotification;
use App\Mail\ContactConfirmation;
use App\Models\ContactSubmission;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Forms\Form;
use Illuminate\Support\Facades\Mail;
use Livewire\Component;
class ContactForm extends Component implements HasForms
{
use InteractsWithForms;
public ?array $data = [];
public bool $submitted = false;
public function mount(): void
{
$this->form->fill();
}
public function form(Form $form): Form
{
return $form
->schema([
TextInput::make('name')
->required()
->maxLength(255),
TextInput::make('email')
->email()
->required()
->maxLength(255),
Select::make('subject')
->options([
'general' => 'General inquiry',
'support' => 'Technical support',
'billing' => 'Billing question',
'partnership' => 'Partnership',
])
->placeholder('Select a subject'),
Textarea::make('message')
->required()
->rows(5)
->maxLength(5000),
])
->statePath('data');
}
public function submit(): void
{
$data = $this->form->getState();
// Honeypot check (see Blade template)
if (! empty($this->data['website'] ?? null)) {
return;
}
$submission = ContactSubmission::create([
...$data,
'ip_address' => request()->ip(),
]);
// Notify admin
Mail::to(config('mail.admin_address'))
->queue(new ContactAdminNotification($submission));
// Confirm to submitter
Mail::to($data['email'])
->queue(new ContactConfirmation($submission));
$this->submitted = true;
$this->form->fill();
}
public function render()
{
return view('livewire.contact-form');
}
}
Step 3: Blade template
{{-- resources/views/livewire/contact-form.blade.php --}}
<div class="max-w-xl mx-auto py-12">
@if ($submitted)
<div class="bg-green-50 border border-green-200 rounded-lg p-6">
<h3 class="text-green-800 font-semibold">Message sent</h3>
<p class="text-green-700 mt-1">We'll get back to you within 24 hours.</p>
</div>
@else
<form wire:submit="submit">
{{ $this->form }}
{{-- Honeypot: hidden from humans, bots fill it in --}}
<div class="hidden" aria-hidden="true">
<input type="text" name="website" wire:model="data.website" tabindex="-1" autocomplete="off">
</div>
<button type="submit" class="mt-4 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
Send message
</button>
</form>
@endif
</div>
Step 4: Mailables
php artisan make:mail ContactAdminNotification --markdown=mail.contact.admin
php artisan make:mail ContactConfirmation --markdown=mail.contact.confirmation
// app/Mail/ContactAdminNotification.php
namespace App\Mail;
use App\Models\ContactSubmission;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ContactAdminNotification extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public ContactSubmission $submission) {}
public function envelope(): Envelope
{
return new Envelope(
subject: "New contact: {$this->submission->subject ?? 'General'}",
);
}
public function content(): Content
{
return new Content(
markdown: 'mail.contact.admin',
);
}
}
{{-- resources/views/mail/contact/admin.blade.php --}}
<x-mail::message>
# New contact form submission
**From:** {{ $submission->name }} ({{ $submission->email }})
**Subject:** {{ $submission->subject ?? 'Not specified' }}
{{ $submission->message }}
<x-mail::button :url="url('/admin/contact-submissions/' . $submission->id)">
View in admin
</x-mail::button>
</x-mail::message>
I'll skip the confirmation mailable — same pattern, different copy.
Step 5: Filament resource
php artisan make:filament-resource ContactSubmission --view
Then customize the table columns and form schema. TextColumn for name, email, subject. TextColumn for message with a character limit. Created-at column. Maybe a filter for subject. An export action if you want CSV.
Another 50-80 lines of code I won't paste here because you've written it before.
Step 6: Route
// routes/web.php
Route::get('/contact', function () {
return view('contact');
});
With a simple page view that renders the Livewire component:
{{-- resources/views/contact.blade.php --}}
<x-layouts.app>
<livewire:contact-form />
</x-layouts.app>
Total file count
- 1 migration
- 1 model
- 1 Livewire component
- 1 Blade template (form)
- 1 page view
- 2 mailables
- 2 mail templates
- 1 Filament resource (with table + view page)
- 1 route
That's 10 files for a contact form. Roughly 3-4 hours if you're moving fast, including testing the email flow.
Method 2: FilaForms
Step 1: Install
composer require filaforms/core
php artisan filaforms:install
Step 2: Register the plugin
// app/Providers/Filament/AdminPanelProvider.php
use FilaForms\Core\FilaFormsPlugin;
public function panel(Panel $panel): Panel
{
return $panel
->plugins([
FilaFormsPlugin::make(),
]);
}
Step 3: Build the form
Open your Filament admin panel. Click "Fila Forms" in the sidebar.
- Create a new form, name it "Contact"
- Drag in a text field → label it "Name", mark required
- Drag in an email field → label it "Email", mark required
- Drag in a select field → label it "Subject", add your options
- Drag in a textarea field → label it "Message", mark required
- Turn on admin email notifications
- Turn on auto-responder
- Publish
The form is live at a public URL. Submissions appear in your admin panel with filtering, search, and CSV export. Analytics start tracking immediately. Honeypot spam protection is active by default.
Total file count
Zero new files. You edited one existing file (the panel provider) and added two lines.
Time
About 10 minutes, including installation.
Which method should you use?
Use Method 1 if:
- You want full control over every aspect of the form
- The submission triggers complex business logic (creates records in multiple tables, calls APIs, runs workflows)
- You're learning Laravel and want to understand the internals
- You have one form in one project and prefer fewer dependencies
Use Method 2 if:
- You need the form working today
- You'll have more forms in the future (feedback, support, applications, surveys)
- Non-developers on your team need to create or edit forms
- You want analytics on form performance
- You're tired of writing the same Livewire component for the fifteenth time
Both approaches produce a working contact form. The difference is time: do you want to spend it on form plumbing or on your actual product?
Resources: