Imagine you need a system for booking appointments: doctor, hair salon, or cart racing track. In this tutorial, we will create exactly that, with a 2-in-1 demo: how to build a dynamic form in Filament and how to reuse it outside the adminpanel for non-logged-in users to be able to book an appointment.
Specifically, we will make bookings for cart racing tracks. But the same logic could be applied to a different company. Just use room numbers or doctor names instead of track names.
As usual, the link to the complete repository will be at the end of the tutorial.
Here's the Table of Contents for this detailed step-by-step tutorial.
Section 1. Filament Panel: Reservation Table/Form
Section 2. Public Form for Reservation
Before getting to Filament, we need to create the DB structure in Laravel.
Here are our migrations and Models:
Tracks table:
Schema::create('tracks', function (Blueprint $table) { $table->id(); $table->string('title'); $table->timestamps();});
app/Models/Track.php
use Illuminate\Database\Eloquent\Relations\HasMany; // ... protected $fillable = ['title']; public function reservations(): HasMany{ return $this->hasMany(Reservation::class);}
Reservations table:
use App\Models\Track;use App\Models\User; // ... Schema::create('reservations', function (Blueprint $table) { $table->id(); $table->foreignIdFor(User::class)->constrained()->cascadeOnDelete(); $table->foreignIdFor(Track::class)->constrained()->cascadeOnDelete(); $table->datetime('start_time'); $table->datetime('end_time'); $table->timestamps();});
app/Models/Reservation.php
use Illuminate\Database\Eloquent\Relations\BelongsTo; // ... protected $fillable = [ 'user_id', 'track_id', 'start_time', 'end_time']; public function track(): BelongsTo{ return $this->belongsTo(Track::class);} public function user(): BelongsTo{ return $this->belongsTo(User::class);}
app/Models/User.php
use Illuminate\Database\Eloquent\Relations\HasMany; public function reservations(): HasMany{ return $this->hasMany(Reservation::class);}
We also seed the admin user and five racing tracks.
database/seeders/DatabaseSeeder.php
use App\Models\Track;use App\Models\User; public function run(): void{ User::create([ 'name' => 'Admin', 'email' => 'admin@admin.com', 'password' => bcrypt('password'), ]); $tracks = [ 'Track 1', 'Track 2', 'Track 3', 'Track 4', 'Track 5', ]; foreach ($tracks as $track) { Track::create(['title' => $track]); }}
And finally, migrate and seed the database.
php artisan migrate:fresh --seed
Ok, we have the database ready. Now it's time to build the admin panel.
First, we will build a Filament project so that the administrator can place a booking. In the following Section 2, we will re-use that form to accept reservations from the outside.
Our form will be dynamic: the admin chooses the date, sees the available timeslots, and then picks the radio button selected.
Since Livewire v3 is still in beta, set the minimum-stability
in your composer.json
to dev
.
composer.json
"minimum-stability": "dev"
Install the Filament Panel Builder by running the following commands in your Laravel project directory.
composer require filament/filament:"^3.0-stable" -W php artisan filament:install --panels
--with-all-dependencies (-W): Update also dependencies of packages in the argument list, including those which are root requirements.
To set up your User
Model to access Filament in non-local environments, you must implement the FilamentUser
contract and define the canAccessPanel()
method.
use Filament\Models\Contracts\FilamentUser;use Filament\Panel; class User extends Authenticatable implements FilamentUser{ // ... public function canAccessPanel(Panel $panel): bool { return true; }}
The canAccessPanel()
method returns true
or false
depending on whether the user can access the $panel
. In this example, we let all users access the panel.
When using Filament, you do not need to define any routes manually. Now you can log in by visiting the /admin
path.
After logging in, you should see the following dashboard.
Our dashboard is empty, so let's make a section for managing tracks.
Let's allow admins to manage Tracks.
In Filament, resources are static classes used to build CRUD interfaces for your Eloquent models. They describe how administrators can interact with data from your panel using tables and forms.
Use the following artisan command to create a new Filament resource for the Track
Model.
php artisan make:filament-resource Track
Then in the table()
method columns()
call, define TextColumn
to display title
.
app/Filament/Resources/TrackResource.php
use Filament\Tables\Columns\TextColumn; public static function table(Table $table): Table{ return $table ->columns([ TextColumn::make('title'), ]) // ...}
That's it, and when you refresh the dashboard, you should see a new Tracks entry in the menu.
You can explore different column types on Filament Table Columns Documentation.
Now let's define input fields for creating and editing Track in the form()
method.
app/Filament/Resources/TrackResource.php
use Filament\Forms\Components\TextInput; public static function form(Form $form): Form{ return $form ->schema([ TextInput::make('title')->required()->maxLength(255), ]);}
Validation rules can be applied to the TextInput
by calling required()
and maxLength()
methods.
Now we can edit and create new tracks in just a minute.
Filament will try to save model attributes under the same name you defined by default.
Possible form components and their options are on Filament Fields Documentation.
Let's create a Reservation resource.
php artisan make:filament-resource Reservation
And define the create form's date
and track
fields. We use the getAvailableReservations()
static method to get available reservations. Note that in the resource, all methods are static.
app/Filament/Resources/ReservationResource.php
use App\Models\Track;use Carbon\Carbon;use Carbon\CarbonPeriod;use Filament\Forms\Components\DatePicker;use Filament\Forms\Components\Radio;use Filament\Forms\Get; // ... public static function form(Form $form): Form{ $dateFormat = 'Y-m-d'; return $form ->schema([ DatePicker::make('date') ->native(false) ->minDate(now()->format($dateFormat)) ->maxDate(now()->addWeeks(2)->format($dateFormat)) ->format($dateFormat) ->required() ->live(), Radio::make('track') ->options(fn (Get $get) => self::getAvailableReservations($get)) ->hidden(fn (Get $get) => ! $get('date')) ->required() ->columnSpan(2), ]);} public static function getAvailableReservations(Get $get): array{ $date = Carbon::parse($get('date')); $startPeriod = $date->copy()->hour(14); $endPeriod = $date->copy()->hour(16); $times = CarbonPeriod::create($startPeriod, '1 hour', $endPeriod); $availableReservations = []; $tracks = Track::with([ 'reservations' => function ($q) use ($startPeriod, $endPeriod) { $q->whereBetween('start_time', [$startPeriod, $endPeriod]); }, ]) ->get(); foreach ($tracks as $track) { $reservations = $track->reservations->pluck('start_time')->toArray(); $availableTimes = $times->copy()->filter(function ($time) use ($reservations) { return ! in_array($time, $reservations) && ! $time->isPast(); })->toArray(); foreach ($availableTimes as $time) { $key = $track->id . '-' . $time->format('H'); $availableReservations[$key] = $track->title . ' ' . $time->format('H:i'); } } return $availableReservations;}
Now let's get through all the logic.
DatePicker::make('date') ->native(false) ->minDate(now()->format($dateFormat)) ->maxDate(now()->addWeeks(2)->format($dateFormat)) ->format($dateFormat) ->required() ->live(),
By default DatePicker
component uses the browser's default date picker. We want the date picker consistent, so we use the native(false)
method.
Reservations can be made from the current date to two weeks upfront. We can limit that using minDate()
and maxDate()
methods. Then we define the date format using the format()
method.
Initially, we want to display only the date field and will show available reservations only when the date field value is present. First, we need to ensure that the state is sent to the server immediately when the user interacts with the field by using the live()
method.
We use the hidden()
method on a radio field, which accepts a boolean or closure to show or hide this field.
use Filament\Forms\Get; // ... Radio::make('track') ->options(fn (Get $get) => self::getAvailableReservations($get)) ->hidden(fn (Get $get) => ! $get('date')) ->required() ->columnSpan(2),
We can retrieve the value of another field from within a callback using a $get
parameter by injecting the Get
class.
In the same way, we define closure for options()
to get available reservations for a selected date.
Now let's look into the getAvailableReservations()
method. First, we make a Carbon instance of the date field value.
$date = Carbon::parse($get('date'));
Then specify the starting and ending hours we want to reserve by making a copy of the $date
.
$startPeriod = $date->copy()->hour(14);$endPeriod = $date->copy()->hour(16);
Now we need to make a list between those hours with 1-hour intervals.
$times = CarbonPeriod::create($startPeriod, '1 hour', $endPeriod);
To simplify, this will result in Carbon objects with dates like this:
[ '2023-08-11 14:00:00', '2023-08-11 15:00:00', '2023-08-11 16:00:00',]
The next objective is to filter those times for a selected date already reserved. Let's get all the Tracks with reservations for the selected date. We can use the whereBetween()
Eloquent method.
$tracks = Track::with([ 'reservations' => function ($q) use ($startPeriod, $endPeriod) { $q->whereBetween('start_time', [$startPeriod, $endPeriod]); }, ])->get();
Then we iterate through each Track and get all the Reservation starting times.
$reservations = $track->reservations->pluck('start_time')->toArray();
The next step is to exclude all the $reservations
values from all possible dates from the $times
variable, and also, we don't include period values if the date is in the past. If now is 14:05, you shouldn't be able to book a track for 14:00.
$availableTimes = $times->copy() ->filter(function ($time) use ($reservations) { return ! in_array($time, $reservations) && ! $time->isPast(); })->toArray();
Now we have $track
and $availableTimes
for Reservations. The problem is that we want to store both values in a single radio option. We can combine track id and hour value in an array key.
[ '<track_id>-<hour>' => 'Radio button text...' // ...]
For that, we can do another loop.
foreach ($availableTimes as $time) { $key = $track->id . '-' . $time->format('H'); $availableReservations[$key] = $track->title . ' ' . $time->format('H:i');}
And finally, return $availableReservations
. It may look similar to this:
[ '1-15' => 'Track 1 15:00' '1-16' => 'Track 1 16:00' '2-14' => 'Track 2 14:00' '2-15' => 'Track 2 15:00' '3-14' => 'Track 3 14:00' '3-15' => 'Track 3 15:00' '4-15' => 'Track 4 15:00' '4-16' => 'Track 4 16:00' '5-14' => 'Track 5 14:00' '5-15' => 'Track 5 15:00' '5-16' => 'Track 5 16:00']
If you try to save this form, it won't work. The Reservation
Model has the following fields.
['user_id', 'track_id', 'start_time', 'end_time']
The problem is that Filament tries to map fields to attributes with the same name by default, but we only have date
and track
(track id with hour) fields. We need to mutate submitted data before saving it.
Add the mutateFormDataBeforeCreate()
method CreateReservation
class.
app/Filament/Resources/ReservationResource/Pages/CreateReservation.php
use Carbon\Carbon; // ... protected function mutateFormDataBeforeCreate(array $data): array{ $date = Carbon::parse($data['date']); [$trackId, $hour] = explode('-', $data['track']); $startTime = $date->copy()->hour($hour); $endTime = $startTime->copy()->addHour(); $dateTimeFormat = 'Y-m-d H:i:s'; return [ 'user_id' => auth()->id(), 'track_id' => $trackId, 'start_time' => $startTime->format($dateTimeFormat), 'end_time' => $endTime->format($dateTimeFormat), ];}
We use array destructuring on the track
field to separate track id and hour into variables.
[$trackId, $hour] = explode('-', $data['track']);
We can make the Carbon instance again to set $startTime easily
from the submitted data. The $endTime
is set automatically by adding one hour to $startTime
.
$date = Carbon::parse($data['date']);$startTime = $date->copy()->hour($hour);$endTime = $startTime->copy()->addHour();
The user_id
is also set automatically by the currently logged-in user.
Finally, we return an array of attributes we want to save on a Model.
return [ 'user_id' => auth()->id(), 'track_id' => $trackId, 'start_time' => $startTime->format($dateTimeFormat), 'end_time' => $endTime->format($dateTimeFormat),];
Let's display the reservations table in the same ReservationResource.
Update the table()
method in ReservationResource
.
app/Filament/Resources/ReservationResource.php
use Filament\Tables\Columns\TextColumn; // ... public static function table(Table $table): Table{ return $table ->columns([ TextColumn::make('user.name'), TextColumn::make('track.title'), TextColumn::make('start_time')->dateTime('Y-m-d H:i'), TextColumn::make('end_time')->dateTime('Y-m-d H:i'), ]) ->filters([ // ]) ->actions([ Tables\Actions\EditAction::make(), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ Tables\Actions\DeleteBulkAction::make(), ]), ]) ->emptyStateActions([ Tables\Actions\CreateAction::make(), ]) ->defaultSort('start_time', 'desc'); }
We can display which user made the Reservation using the dot notation relationshipName.columnName
.
TextColumn::make('user.name')
We can format dates using the dateTime
method that accepts format as an argument.
TextColumn::make('start_time')->dateTime('Y-m-d H:i')
We can also alter the default sort using the defaultSort()
method.
->defaultSort('start_time', 'desc')
This is the final thing in our admin panel.
After creating a new reservation user gets redirected to the edit page. According to our requirements, the user should not be able to edit Reservations. Let's disable the edit page.
Update the table()
and getPages()
methods in ReservationResource
.
app/Filament/Resources/ReservationResource.php
public static function table(Table $table): Table{ return $table ->columns([ TextColumn::make('user.name'), TextColumn::make('track.title'), TextColumn::make('start_time')->dateTime('Y-m-d H:i'), TextColumn::make('end_time')->dateTime('Y-m-d H:i'), ]) ->filters([ // ]) ->actions([ Tables\Actions\EditAction::make(), ]) ->actions([]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ Tables\Actions\DeleteBulkAction::make(), ]), ]) ->emptyStateActions([ Tables\Actions\CreateAction::make(), ]) ->defaultSort('start_time', 'desc');} public static function getPages(): array{ return [ 'index' => Pages\ListReservations::route('/'), 'create' => Pages\CreateReservation::route('/create'), 'edit' => Pages\EditReservation::route('/{record}/edit'), ];}
Ok great, so we have a fully working admin panel. Now, let's try to re-use the same form outside of it, as a public reservation page.
What if we want to make a reservation form not as a part of the Filament admin panel but as a public page? We will learn how to create Livewire Component to use Filament Forms.
name
and email
fields.Before we go any further, let's first install TailwindCSS.
npm install tailwindcss @tailwindcss/forms @tailwindcss/typography postcss autoprefixer --save-dev
Create a new TailwindCSS config.
tailwind.config.js
import preset from './vendor/filament/support/tailwind.config.preset' export default { presets: [preset], content: [ './app/Filament/**/*.php', './resources/views/**/*.blade.php', './vendor/filament/**/*.blade.php', ],}
Create a new CSS file. The x-cloak
attribute should hide DOM elements. Filament uses this Alpine feature to show/hide form elements.
resources/css/app.css
@tailwind base;@tailwind components;@tailwind utilities; @layer components { [x-cloak] { display: none !important; }}
Create a new PostCSS config.
postcss.config.js
export default { plugins: { tailwindcss: {}, autoprefixer: {}, },}
And update the Vite config to enable auto-refresh when we update Livewire components.
vite.config.js
import { defineConfig } from 'vite';import laravel, { refreshPaths } from 'laravel-vite-plugin'; export default defineConfig({ plugins: [ laravel({ input: ['resources/css/app.css', 'resources/js/app.js'], refresh: [ ...refreshPaths, 'app/Livewire/**', ], }), ],});
We have no application layout since we didn't install any starter kits. Create the AppLayout
Blade component.
app/View/Components/AppLayout.php
namespace App\View\Components; use Illuminate\View\Component;use Illuminate\View\View; class AppLayout extends Component{ public function render(): View { return view('layouts.app'); }}
Create Blade file for the AppLayout
component.
resources/views/layouts/app.blade.php
<!DOCTYPE html><html lang="{{ str_replace('_', '-', app()->getLocale()) }}"> <head> <meta charset="utf-8"> <meta name="application-name" content="{{ config('app.name') }}"> <meta name="csrf-token" content="{{ csrf_token() }}"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>{{ config('app.name') }}</title> @filamentStyles @vite('resources/css/app.css') </head> <body class="antialiased bg-gray-100"> <div class="max-w-7xl mx-auto"> {{ $slot }} </div> @filamentScripts @vite('resources/js/app.js') @livewire('notifications') </body></html>
The {{ $slot }}
part is where your content will appear when using this layout.
If we want to use Filament Notification pop-ups, it is essential to add @livewire('notifications')
component.
Then create a homepage.blade.php
file with dummy content.
resources/views/homepage.blade.php
<x-app-layout> App Layout Works!</x-app-layout>
And update the routes file to display this page when we navigate to the site's root /
path.
routes/web.php
Route::get('/', function () { return view('welcome'); return view('homepage'); });
Finally, let's compile CSS using npm
.
npm run dev
You should see the "App Layout Works!" title.
Moving the getAvailableReservations()
method from ReservationResource
would be better because we want to also use that logic in the custom Livewire component we are about to create.
Create the ReservationService
class.
app/Services/ReservationService.php
namespace App\Services; use App\Models\Track;use Carbon\Carbon;use Carbon\CarbonPeriod; class ReservationService{ public function getAvailableReservations(string $date): array { $date = Carbon::parse($date); $startPeriod = $date->copy()->hour(14); $endPeriod = $date->copy()->hour(16); $times = CarbonPeriod::create($startPeriod, '1 hour', $endPeriod); $availableReservations = []; $tracks = Track::with([ 'reservations' => function ($q) use ($startPeriod, $endPeriod) { $q->whereBetween('start_time', [$startPeriod, $endPeriod]); }, ]) ->get(); foreach ($tracks as $track) { $reservations = $track->reservations->pluck('start_time')->toArray(); $availableTimes = $times->copy()->filter(function ($time) use ($reservations) { return ! in_array($time, $reservations); })->toArray(); foreach ($availableTimes as $time) { $key = $track->id . '-' . $time->format('H'); $availableReservations[$key] = $track->title . ' ' . $time->format('H:i'); } } return $availableReservations; }}
Update the form()
method and delete the getAvailableReservations()
method in ReservationResource
.
app/Filament/Resources/ReservationResource.php
use App\Models\Track; use Carbon\Carbon;use Carbon\CarbonPeriod;use App\Services\ReservationService; // ... public static function form(Form $form): Form{ // ... Radio::make('track') ->options(fn (Get $get) => self::getAvailableReservations($get)) ->options(fn (Get $get) => (new ReservationService())->getAvailableReservations($get('date'))) ->hidden(fn (Get $get) => ! $get('date')) ->required() ->columnSpan(2), // ... public static function getAvailableReservations(Get $get): array { $date = Carbon::parse($get('date')); $startPeriod = $date->copy()->hour(14); $endPeriod = $date->copy()->hour(16); $times = CarbonPeriod::create($startPeriod, '1 hour', $endPeriod); $availableReservations = []; $tracks = Track::with([ 'reservations' => function ($q) use ($startPeriod, $endPeriod) { $q->whereBetween('start_time', [$startPeriod, $endPeriod]); }, ]) ->get(); foreach ($tracks as $track) { $reservations = $track->reservations->pluck('start_time')->toArray(); $availableTimes = $times->copy()->filter(function ($time) use ($reservations) { return ! in_array($time, $reservations) && ! $time->isPast(); })->toArray(); foreach ($availableTimes as $time) { $key = $track->id . '-' . $time->format('H'); $availableReservations[$key] = $track->title . ' ' . $time->format('H:i'); } } return $availableReservations;}
It is time to create a reservation form for the homepage. Run the make:livewire-form
Artisan command to create a new Livewire component.
php artisan make:livewire-form ReservationForm
┌ What is the model name? ─────────────────────────────────────┐│ Reservation │└──────────────────────────────────────────────────────────────┘ ┌ Which namespace would you like to create this in? ───────────┐│ Create │└──────────────────────────────────────────────────────────────┘ INFO Successfully created ReservationForm!
Update the ReservationForm
.
app/Livewire/ReservationForm.php
namespace App\Livewire; use App\Models\Reservation;use App\Models\User;use App\Services\ReservationService;use Carbon\Carbon;use Filament\Forms;use Filament\Forms\Components\DatePicker;use Filament\Forms\Components\Radio;use Filament\Forms\Components\TextInput;use Filament\Forms\Concerns\InteractsWithForms;use Filament\Forms\Contracts\HasForms;use Filament\Forms\Form;use Filament\Forms\Get;use Filament\Notifications\Notification;use Illuminate\Contracts\View\View;use Livewire\Component; class ReservationForm extends Component implements HasForms{ use InteractsWithForms; public ?array $data = []; protected ReservationService $service; public function __construct() { $this->service = new ReservationService(); } public function mount(): void { $this->form->fill(); } public function form(Form $form): Form { $dateFormat = 'Y-m-d'; return $form ->schema([ TextInput::make('name') ->required(), TextInput::make('email') ->email() ->unique('users', 'email') ->required(), DatePicker::make('date') ->native(false) ->minDate(now()->format($dateFormat)) ->maxDate(now()->addWeeks(2)->format($dateFormat)) ->format($dateFormat) ->required() ->live(), Radio::make('track') ->options(fn (Get $get) => $this->service->getAvailableReservations($get('date'))) ->hidden(fn (Get $get) => ! $get('date')) ->required(), ]) ->statePath('data') ->model(Reservation::class); } public function create(): void { $data = $this->form->getState(); $date = Carbon::parse($data['date']); [$trackId, $hour] = explode('-', $data['track']); $startTime = $date->copy()->hour($hour); $endTime = $startTime->copy()->addHour(); $dateTimeFormat = 'Y-m-d H:i:s'; User::create([ 'name' => $data['name'], 'email' => $data['email'], 'password' => '', ])->reservations()->create([ 'track_id' => $trackId, 'start_time' => $startTime->format($dateTimeFormat), 'end_time' => $endTime->format($dateTimeFormat), ]); Notification::make() ->success() ->title('Reservation has been created') ->seconds(5) ->send(); $this->form->fill(); } public function render(): View { return view('livewire.reservation-form'); }}
Now let's go through the code. To use Filament Forms in a Livewire Component, it must implement the HasForms
interface and use the InteractsWithForms
trait.
use Filament\Forms\Contracts\HasForms;use Filament\Forms\Concerns\InteractsWithForms; // ... class ReservationForm extends Component implements HasForms{ use InteractsWithForms; // ...
We define a public Livewire property to store the form's data. In our example, we'll call this $data
.
public ?array $data = [];
In the form()
method, we add form schema as in ReservationResource
and tell Filament to store the form data in the $data
property using the statePath()
method.
public function form(Form $form): Form{ $dateFormat = 'Y-m-d'; return $form ->schema([ TextInput::make('name') ->required(), TextInput::make('email') ->email() ->unique('users', 'email') ->required(), DatePicker::make('date') ->native(false) ->minDate(now()->format($dateFormat)) ->maxDate(now()->addWeeks(2)->format($dateFormat)) ->format($dateFormat) ->required() ->live(), Radio::make('track') ->options(fn (Get $get) => $this->service->getAvailableReservations($get('date'))) ->hidden(fn (Get $get) => ! $get('date')) ->required(), ]) ->statePath('data') ->model(Reservation::class);}
The form is initialized with $this->form->fill()
in the mount()
method. This is imperative for every form that you build, even if it doesn't have any initial data.
public function mount(): void{ $this->form->fill();}
The create()
method will handle form processing. The $this->form->getState()
statement retrieves the form data.
$data = $this->form->getState();
Notifications can also be handled from the back end using the Filament\Notifications\Notification
class. In this example, we display a success message for five seconds. It is essential to call send()
at the end; otherwise, it won't appear.
use Filament\Notifications\Notification; // ... Notification::make() ->success() ->title('Reservation has been created') ->seconds(5) ->send();
After processing, we call $this->form->fill()
again to re-initialize the form with empty values to clear the form.
$this->form->fill();
Now update the Livewire component's Blade file.
resources/views/livewire/reservation-form.blade.php
<div class="mt-16"> <h2 class="text-2xl font-bold tracking-tight mb-4">Create Reservation</h2> <div class="bg-white p-6 rounded-md shadow-sm"> <form wire:submit="create"> {{ $this->form }} <button type="submit" class="bg-primary-600 text-white font-bold rounded-md px-3 py-2 tracking-tight mt-8"> Create </button> </form> </div> <x-filament-actions::modals /></div>
And include it on the homepage.
resources/views/homepage.blade.php
<x-app-layout> App Layout Works! @livewire('reservation-form') </x-app-layout>
The following form should appear when you visit the homepage.
When you submit the form, a notification will be shown.
In the list, we should see new reservations made by other users. In this example, John Doe
.
The full source code is available in this GitHub repository.
If you want more Filament examples, you can find more real-life projects on our FilamentExamples.com.