@elyracode/stack-filament 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -0
- package/extensions/index.ts +13 -0
- package/package.json +27 -0
- package/skills/filament/SKILL.md +1841 -0
|
@@ -0,0 +1,1841 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: filament
|
|
3
|
+
description: Deep knowledge of Filament v5 for Laravel. Use when building admin panels, CRUD resources, data tables, forms, actions, widgets, dashboards, multi-tenancy, or any Filament feature in a Laravel application.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Filament v5 Reference
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
composer require filament/filament
|
|
12
|
+
php artisan filament:install --panels
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Create the first admin user:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
php artisan make:filament-user
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Artisan Commands
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
php artisan make:filament-resource Customer
|
|
25
|
+
php artisan make:filament-resource Customer --generate # Auto-generate form/table from DB columns
|
|
26
|
+
php artisan make:filament-resource Customer --simple # Modal CRUD (no separate create/edit pages)
|
|
27
|
+
php artisan make:filament-resource Customer --soft-deletes
|
|
28
|
+
php artisan make:filament-resource Customer --view # Include a view page
|
|
29
|
+
php artisan make:filament-page Settings
|
|
30
|
+
php artisan make:filament-widget SalesChart --chart
|
|
31
|
+
php artisan make:filament-widget LatestOrders --table
|
|
32
|
+
php artisan make:filament-relation-manager CategoryResource posts title
|
|
33
|
+
php artisan make:filament-cluster Settings
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Panel Configuration
|
|
37
|
+
|
|
38
|
+
```php
|
|
39
|
+
// app/Providers/Filament/AdminPanelProvider.php
|
|
40
|
+
use Filament\Panel;
|
|
41
|
+
use Filament\PanelProvider;
|
|
42
|
+
use Filament\Support\Colors\Color;
|
|
43
|
+
|
|
44
|
+
class AdminPanelProvider extends PanelProvider
|
|
45
|
+
{
|
|
46
|
+
public function panel(Panel $panel): Panel
|
|
47
|
+
{
|
|
48
|
+
return $panel
|
|
49
|
+
->default()
|
|
50
|
+
->id('admin')
|
|
51
|
+
->path('admin')
|
|
52
|
+
->login()
|
|
53
|
+
->registration()
|
|
54
|
+
->passwordReset()
|
|
55
|
+
->emailVerification()
|
|
56
|
+
->colors([
|
|
57
|
+
'primary' => Color::Indigo,
|
|
58
|
+
'danger' => Color::Rose,
|
|
59
|
+
'gray' => Color::Zinc,
|
|
60
|
+
'success' => Color::Emerald,
|
|
61
|
+
'warning' => Color::Amber,
|
|
62
|
+
])
|
|
63
|
+
->font('Inter')
|
|
64
|
+
->brandName('My App')
|
|
65
|
+
->brandLogo(asset('images/logo.svg'))
|
|
66
|
+
->favicon(asset('images/favicon.png'))
|
|
67
|
+
->sidebarCollapsibleOnDesktop()
|
|
68
|
+
->maxContentWidth('full')
|
|
69
|
+
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
|
|
70
|
+
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
|
|
71
|
+
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
|
|
72
|
+
->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\\Filament\\Clusters')
|
|
73
|
+
->middleware([...])
|
|
74
|
+
->authMiddleware([...])
|
|
75
|
+
->databaseNotifications()
|
|
76
|
+
->spa();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Resources (CRUD)
|
|
82
|
+
|
|
83
|
+
### v5 File Structure
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
app/Filament/Resources/Customers/
|
|
87
|
+
CustomerResource.php # Main resource class
|
|
88
|
+
Pages/
|
|
89
|
+
CreateCustomer.php
|
|
90
|
+
EditCustomer.php
|
|
91
|
+
ListCustomers.php
|
|
92
|
+
ViewCustomer.php # Optional
|
|
93
|
+
Schemas/
|
|
94
|
+
CustomerForm.php # Form schema (split from resource)
|
|
95
|
+
Tables/
|
|
96
|
+
CustomersTable.php # Table definition (split from resource)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Resource Class
|
|
100
|
+
|
|
101
|
+
```php
|
|
102
|
+
<?php
|
|
103
|
+
|
|
104
|
+
namespace App\Filament\Resources\Customers;
|
|
105
|
+
|
|
106
|
+
use App\Models\Customer;
|
|
107
|
+
use Filament\Resources\Resource;
|
|
108
|
+
use Filament\Schemas\Schema;
|
|
109
|
+
use Filament\Tables\Table;
|
|
110
|
+
|
|
111
|
+
class CustomerResource extends Resource
|
|
112
|
+
{
|
|
113
|
+
protected static ?string $model = Customer::class;
|
|
114
|
+
protected static ?string $navigationIcon = 'heroicon-o-user-group';
|
|
115
|
+
protected static ?string $navigationGroup = 'Shop';
|
|
116
|
+
protected static ?int $navigationSort = 2;
|
|
117
|
+
protected static ?string $recordTitleAttribute = 'name';
|
|
118
|
+
protected static ?string $slug = 'customers';
|
|
119
|
+
|
|
120
|
+
public static function form(Schema $schema): Schema
|
|
121
|
+
{
|
|
122
|
+
return CustomerForm::configure($schema);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
public static function table(Table $table): Table
|
|
126
|
+
{
|
|
127
|
+
return CustomersTable::configure($table);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
public static function getRelations(): array
|
|
131
|
+
{
|
|
132
|
+
return [
|
|
133
|
+
OrdersRelationManager::class,
|
|
134
|
+
];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
public static function getPages(): array
|
|
138
|
+
{
|
|
139
|
+
return [
|
|
140
|
+
'index' => ListCustomers::route('/'),
|
|
141
|
+
'create' => CreateCustomer::route('/create'),
|
|
142
|
+
'edit' => EditCustomer::route('/{record}/edit'),
|
|
143
|
+
];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
public static function getGloballySearchableAttributes(): array
|
|
147
|
+
{
|
|
148
|
+
return ['name', 'email', 'phone'];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
public static function getGlobalSearchResultDetails(\Illuminate\Database\Eloquent\Model $record): array
|
|
152
|
+
{
|
|
153
|
+
return [
|
|
154
|
+
'Email' => $record->email,
|
|
155
|
+
];
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Form Schema Class (v5 split pattern)
|
|
161
|
+
|
|
162
|
+
```php
|
|
163
|
+
<?php
|
|
164
|
+
|
|
165
|
+
namespace App\Filament\Resources\Customers\Schemas;
|
|
166
|
+
|
|
167
|
+
use Filament\Forms\Components\TextInput;
|
|
168
|
+
use Filament\Forms\Components\Select;
|
|
169
|
+
use Filament\Forms\Components\DatePicker;
|
|
170
|
+
use Filament\Forms\Components\Toggle;
|
|
171
|
+
use Filament\Schemas\Components\Section;
|
|
172
|
+
use Filament\Schemas\Components\Grid;
|
|
173
|
+
use Filament\Schemas\Schema;
|
|
174
|
+
|
|
175
|
+
class CustomerForm
|
|
176
|
+
{
|
|
177
|
+
public static function configure(Schema $schema): Schema
|
|
178
|
+
{
|
|
179
|
+
return $schema->components([
|
|
180
|
+
Section::make('Personal Information')
|
|
181
|
+
->schema([
|
|
182
|
+
Grid::make(2)->schema([
|
|
183
|
+
TextInput::make('name')
|
|
184
|
+
->required()
|
|
185
|
+
->maxLength(255),
|
|
186
|
+
TextInput::make('email')
|
|
187
|
+
->email()
|
|
188
|
+
->required()
|
|
189
|
+
->unique(ignoreRecord: true),
|
|
190
|
+
]),
|
|
191
|
+
TextInput::make('phone')
|
|
192
|
+
->tel(),
|
|
193
|
+
DatePicker::make('date_of_birth')
|
|
194
|
+
->native(false)
|
|
195
|
+
->maxDate(now()),
|
|
196
|
+
]),
|
|
197
|
+
Section::make('Settings')
|
|
198
|
+
->schema([
|
|
199
|
+
Select::make('status')
|
|
200
|
+
->options([
|
|
201
|
+
'active' => 'Active',
|
|
202
|
+
'inactive' => 'Inactive',
|
|
203
|
+
'blocked' => 'Blocked',
|
|
204
|
+
])
|
|
205
|
+
->default('active')
|
|
206
|
+
->required(),
|
|
207
|
+
Toggle::make('is_vip')
|
|
208
|
+
->label('VIP Customer'),
|
|
209
|
+
])
|
|
210
|
+
->collapsible(),
|
|
211
|
+
]);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Table Class (v5 split pattern)
|
|
217
|
+
|
|
218
|
+
```php
|
|
219
|
+
<?php
|
|
220
|
+
|
|
221
|
+
namespace App\Filament\Resources\Customers\Tables;
|
|
222
|
+
|
|
223
|
+
use Filament\Tables\Table;
|
|
224
|
+
use Filament\Tables\Columns\TextColumn;
|
|
225
|
+
use Filament\Tables\Columns\IconColumn;
|
|
226
|
+
use Filament\Tables\Filters\SelectFilter;
|
|
227
|
+
use Filament\Actions\EditAction;
|
|
228
|
+
use Filament\Actions\DeleteBulkAction;
|
|
229
|
+
use Filament\Actions\BulkActionGroup;
|
|
230
|
+
|
|
231
|
+
class CustomersTable
|
|
232
|
+
{
|
|
233
|
+
public static function configure(Table $table): Table
|
|
234
|
+
{
|
|
235
|
+
return $table
|
|
236
|
+
->columns([
|
|
237
|
+
TextColumn::make('name')
|
|
238
|
+
->searchable()
|
|
239
|
+
->sortable(),
|
|
240
|
+
TextColumn::make('email')
|
|
241
|
+
->searchable()
|
|
242
|
+
->copyable(),
|
|
243
|
+
TextColumn::make('status')
|
|
244
|
+
->badge()
|
|
245
|
+
->color(fn (string $state): string => match ($state) {
|
|
246
|
+
'active' => 'success',
|
|
247
|
+
'inactive' => 'gray',
|
|
248
|
+
'blocked' => 'danger',
|
|
249
|
+
}),
|
|
250
|
+
IconColumn::make('is_vip')
|
|
251
|
+
->boolean(),
|
|
252
|
+
TextColumn::make('created_at')
|
|
253
|
+
->dateTime()
|
|
254
|
+
->sortable()
|
|
255
|
+
->toggleable(isToggledHiddenByDefault: true),
|
|
256
|
+
])
|
|
257
|
+
->filters([
|
|
258
|
+
SelectFilter::make('status')
|
|
259
|
+
->options([
|
|
260
|
+
'active' => 'Active',
|
|
261
|
+
'inactive' => 'Inactive',
|
|
262
|
+
'blocked' => 'Blocked',
|
|
263
|
+
]),
|
|
264
|
+
])
|
|
265
|
+
->recordActions([
|
|
266
|
+
EditAction::make(),
|
|
267
|
+
])
|
|
268
|
+
->toolbarActions([
|
|
269
|
+
BulkActionGroup::make([
|
|
270
|
+
DeleteBulkAction::make(),
|
|
271
|
+
]),
|
|
272
|
+
]);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### Page Classes
|
|
278
|
+
|
|
279
|
+
```php
|
|
280
|
+
// Pages/ListCustomers.php
|
|
281
|
+
<?php
|
|
282
|
+
|
|
283
|
+
namespace App\Filament\Resources\Customers\Pages;
|
|
284
|
+
|
|
285
|
+
use App\Filament\Resources\Customers\CustomerResource;
|
|
286
|
+
use Filament\Resources\Pages\ListRecords;
|
|
287
|
+
use Filament\Actions\CreateAction;
|
|
288
|
+
|
|
289
|
+
class ListCustomers extends ListRecords
|
|
290
|
+
{
|
|
291
|
+
protected static string $resource = CustomerResource::class;
|
|
292
|
+
|
|
293
|
+
protected function getHeaderActions(): array
|
|
294
|
+
{
|
|
295
|
+
return [CreateAction::make()];
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Pages/CreateCustomer.php
|
|
300
|
+
<?php
|
|
301
|
+
|
|
302
|
+
namespace App\Filament\Resources\Customers\Pages;
|
|
303
|
+
|
|
304
|
+
use App\Filament\Resources\Customers\CustomerResource;
|
|
305
|
+
use Filament\Resources\Pages\CreateRecord;
|
|
306
|
+
|
|
307
|
+
class CreateCustomer extends CreateRecord
|
|
308
|
+
{
|
|
309
|
+
protected static string $resource = CustomerResource::class;
|
|
310
|
+
|
|
311
|
+
protected function getRedirectUrl(): string
|
|
312
|
+
{
|
|
313
|
+
return $this->getResource()::getUrl('index');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
protected function mutateFormDataBeforeCreate(array $data): array
|
|
317
|
+
{
|
|
318
|
+
$data['created_by'] = auth()->id();
|
|
319
|
+
return $data;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Pages/EditCustomer.php
|
|
324
|
+
<?php
|
|
325
|
+
|
|
326
|
+
namespace App\Filament\Resources\Customers\Pages;
|
|
327
|
+
|
|
328
|
+
use App\Filament\Resources\Customers\CustomerResource;
|
|
329
|
+
use Filament\Resources\Pages\EditRecord;
|
|
330
|
+
use Filament\Actions\DeleteAction;
|
|
331
|
+
|
|
332
|
+
class EditCustomer extends EditRecord
|
|
333
|
+
{
|
|
334
|
+
protected static string $resource = CustomerResource::class;
|
|
335
|
+
|
|
336
|
+
protected function getHeaderActions(): array
|
|
337
|
+
{
|
|
338
|
+
return [DeleteAction::make()];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
protected function afterSave(): void
|
|
342
|
+
{
|
|
343
|
+
// Hook after record is saved
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
## Tables
|
|
349
|
+
|
|
350
|
+
### Column Types
|
|
351
|
+
|
|
352
|
+
```php
|
|
353
|
+
use Filament\Tables\Columns\TextColumn;
|
|
354
|
+
use Filament\Tables\Columns\IconColumn;
|
|
355
|
+
use Filament\Tables\Columns\ImageColumn;
|
|
356
|
+
use Filament\Tables\Columns\SelectColumn;
|
|
357
|
+
use Filament\Tables\Columns\ToggleColumn;
|
|
358
|
+
use Filament\Tables\Columns\CheckboxColumn;
|
|
359
|
+
use Filament\Tables\Columns\ColorColumn;
|
|
360
|
+
use Filament\Tables\Columns\TextInputColumn;
|
|
361
|
+
|
|
362
|
+
// Text column variants
|
|
363
|
+
TextColumn::make('name')->searchable()->sortable()->copyable()
|
|
364
|
+
TextColumn::make('email')->icon('heroicon-m-envelope')
|
|
365
|
+
TextColumn::make('created_at')->dateTime('M j, Y')->sortable()
|
|
366
|
+
TextColumn::make('amount')->money('USD')->sortable()
|
|
367
|
+
TextColumn::make('size')->formatStateUsing(fn (int $state): string => number_format($state / 1024, 2) . ' KB')
|
|
368
|
+
TextColumn::make('tags')->badge()->separator(',')
|
|
369
|
+
TextColumn::make('description')->limit(50)->tooltip(fn ($record) => $record->description)
|
|
370
|
+
TextColumn::make('status')->badge()->color(fn (string $state): string => match ($state) {
|
|
371
|
+
'draft' => 'gray',
|
|
372
|
+
'reviewing' => 'warning',
|
|
373
|
+
'published' => 'success',
|
|
374
|
+
'rejected' => 'danger',
|
|
375
|
+
})
|
|
376
|
+
TextColumn::make('author.name') // Dot-notation auto-eager-loads relationships
|
|
377
|
+
TextColumn::make('tags.name')->badge() // HasMany / BelongsToMany
|
|
378
|
+
|
|
379
|
+
// Other column types
|
|
380
|
+
IconColumn::make('is_featured')->boolean()
|
|
381
|
+
ImageColumn::make('avatar')->circular()->size(40)
|
|
382
|
+
ToggleColumn::make('is_active') // Inline toggle, updates DB immediately
|
|
383
|
+
SelectColumn::make('status')->options([...]) // Inline select
|
|
384
|
+
CheckboxColumn::make('is_verified')
|
|
385
|
+
ColorColumn::make('hex_color')
|
|
386
|
+
TextInputColumn::make('sort_order')->rules(['required', 'integer']) // Inline editable
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
### Column Formatting
|
|
390
|
+
|
|
391
|
+
```php
|
|
392
|
+
TextColumn::make('price')->money('USD') // $1,234.56
|
|
393
|
+
TextColumn::make('views')->numeric(decimalPlaces: 0) // 1,234
|
|
394
|
+
TextColumn::make('created_at')->since() // 3 hours ago
|
|
395
|
+
TextColumn::make('birthday')->date('F j, Y') // January 1, 2000
|
|
396
|
+
TextColumn::make('duration')->time() // 12:30:00
|
|
397
|
+
TextColumn::make('total')
|
|
398
|
+
->summarize([
|
|
399
|
+
Sum::make()->label('Total'),
|
|
400
|
+
Average::make()->label('Average'),
|
|
401
|
+
])
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
### Column Toggleability
|
|
405
|
+
|
|
406
|
+
```php
|
|
407
|
+
TextColumn::make('created_at')
|
|
408
|
+
->toggleable() // User can show/hide
|
|
409
|
+
TextColumn::make('updated_at')
|
|
410
|
+
->toggleable(isToggledHiddenByDefault: true) // Hidden by default
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
### Filters
|
|
414
|
+
|
|
415
|
+
```php
|
|
416
|
+
use Filament\Tables\Filters\Filter;
|
|
417
|
+
use Filament\Tables\Filters\SelectFilter;
|
|
418
|
+
use Filament\Tables\Filters\TernaryFilter;
|
|
419
|
+
use Filament\Tables\Filters\QueryBuilder;
|
|
420
|
+
use Filament\Tables\Filters\QueryBuilder\Constraints\TextConstraint;
|
|
421
|
+
use Filament\Tables\Filters\QueryBuilder\Constraints\DateConstraint;
|
|
422
|
+
use Filament\Tables\Filters\QueryBuilder\Constraints\NumberConstraint;
|
|
423
|
+
use Illuminate\Database\Eloquent\Builder;
|
|
424
|
+
|
|
425
|
+
// Simple query filter
|
|
426
|
+
Filter::make('is_featured')
|
|
427
|
+
->query(fn (Builder $query) => $query->where('is_featured', true))
|
|
428
|
+
->toggle() // Shows as a toggle instead of checkbox
|
|
429
|
+
|
|
430
|
+
// Select filter
|
|
431
|
+
SelectFilter::make('status')
|
|
432
|
+
->options([
|
|
433
|
+
'draft' => 'Draft',
|
|
434
|
+
'published' => 'Published',
|
|
435
|
+
'archived' => 'Archived',
|
|
436
|
+
])
|
|
437
|
+
->multiple() // Allow selecting multiple values
|
|
438
|
+
|
|
439
|
+
// Relationship filter
|
|
440
|
+
SelectFilter::make('category')
|
|
441
|
+
->relationship('category', 'name')
|
|
442
|
+
->searchable()
|
|
443
|
+
->preload()
|
|
444
|
+
|
|
445
|
+
// Ternary filter (yes / no / any)
|
|
446
|
+
TernaryFilter::make('email_verified')
|
|
447
|
+
->nullable() // Uses whereNull / whereNotNull
|
|
448
|
+
->label('Email verified')
|
|
449
|
+
|
|
450
|
+
// Custom form filter
|
|
451
|
+
Filter::make('created_at')
|
|
452
|
+
->schema([
|
|
453
|
+
DatePicker::make('created_from'),
|
|
454
|
+
DatePicker::make('created_until'),
|
|
455
|
+
])
|
|
456
|
+
->query(function (Builder $query, array $data): Builder {
|
|
457
|
+
return $query
|
|
458
|
+
->when($data['created_from'], fn (Builder $q, $date) => $q->whereDate('created_at', '>=', $date))
|
|
459
|
+
->when($data['created_until'], fn (Builder $q, $date) => $q->whereDate('created_at', '<=', $date));
|
|
460
|
+
})
|
|
461
|
+
->indicateUsing(function (array $data): array {
|
|
462
|
+
$indicators = [];
|
|
463
|
+
if ($data['created_from'] ?? null) {
|
|
464
|
+
$indicators[] = 'Created from ' . Carbon::parse($data['created_from'])->toFormattedDateString();
|
|
465
|
+
}
|
|
466
|
+
if ($data['created_until'] ?? null) {
|
|
467
|
+
$indicators[] = 'Created until ' . Carbon::parse($data['created_until'])->toFormattedDateString();
|
|
468
|
+
}
|
|
469
|
+
return $indicators;
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
// Advanced query builder (user-facing filter UI)
|
|
473
|
+
QueryBuilder::make()
|
|
474
|
+
->constraints([
|
|
475
|
+
TextConstraint::make('name'),
|
|
476
|
+
DateConstraint::make('created_at'),
|
|
477
|
+
NumberConstraint::make('amount'),
|
|
478
|
+
])
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
### Table Actions
|
|
482
|
+
|
|
483
|
+
```php
|
|
484
|
+
use Filament\Actions\Action;
|
|
485
|
+
use Filament\Actions\EditAction;
|
|
486
|
+
use Filament\Actions\ViewAction;
|
|
487
|
+
use Filament\Actions\DeleteAction;
|
|
488
|
+
use Filament\Actions\ForceDeleteAction;
|
|
489
|
+
use Filament\Actions\RestoreAction;
|
|
490
|
+
use Filament\Actions\ReplicateAction;
|
|
491
|
+
use Filament\Actions\BulkActionGroup;
|
|
492
|
+
use Filament\Actions\DeleteBulkAction;
|
|
493
|
+
use Filament\Actions\ForceDeleteBulkAction;
|
|
494
|
+
use Filament\Actions\RestoreBulkAction;
|
|
495
|
+
use Filament\Actions\ExportAction;
|
|
496
|
+
use Filament\Actions\ImportAction;
|
|
497
|
+
|
|
498
|
+
$table
|
|
499
|
+
->recordActions([
|
|
500
|
+
ViewAction::make(),
|
|
501
|
+
EditAction::make(),
|
|
502
|
+
DeleteAction::make(),
|
|
503
|
+
ReplicateAction::make()
|
|
504
|
+
->excludeAttributes(['slug']),
|
|
505
|
+
Action::make('feature')
|
|
506
|
+
->icon('heroicon-o-star')
|
|
507
|
+
->action(fn (Post $record) => $record->update(['is_featured' => true]))
|
|
508
|
+
->requiresConfirmation()
|
|
509
|
+
->hidden(fn (Post $record): bool => $record->is_featured)
|
|
510
|
+
->color('warning'),
|
|
511
|
+
ForceDeleteAction::make(),
|
|
512
|
+
RestoreAction::make(),
|
|
513
|
+
])
|
|
514
|
+
->toolbarActions([
|
|
515
|
+
BulkActionGroup::make([
|
|
516
|
+
DeleteBulkAction::make(),
|
|
517
|
+
ForceDeleteBulkAction::make(),
|
|
518
|
+
RestoreBulkAction::make(),
|
|
519
|
+
ExportAction::make()->exporter(CustomerExporter::class),
|
|
520
|
+
]),
|
|
521
|
+
ImportAction::make()->importer(CustomerImporter::class),
|
|
522
|
+
])
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
### Table Configuration
|
|
526
|
+
|
|
527
|
+
```php
|
|
528
|
+
$table
|
|
529
|
+
->paginated([10, 25, 50, 100])
|
|
530
|
+
->defaultPaginationPageOption(25)
|
|
531
|
+
->defaultSort('created_at', 'desc')
|
|
532
|
+
->striped()
|
|
533
|
+
->deferLoading()
|
|
534
|
+
->poll('10s')
|
|
535
|
+
->reorderable('sort')
|
|
536
|
+
->heading('Clients')
|
|
537
|
+
->description('Manage your clients')
|
|
538
|
+
->emptyStateHeading('No clients yet')
|
|
539
|
+
->emptyStateDescription('Create your first client to get started.')
|
|
540
|
+
->emptyStateIcon('heroicon-o-users')
|
|
541
|
+
->groups([
|
|
542
|
+
Group::make('status')->collapsible(),
|
|
543
|
+
Group::make('category.name')->label('Category'),
|
|
544
|
+
])
|
|
545
|
+
->modifyQueryUsing(fn (Builder $query) => $query->withoutTrashed())
|
|
546
|
+
->recordUrl(fn (Model $record): string => route('customers.show', $record))
|
|
547
|
+
->recordClasses(fn (Model $record) => match ($record->status) {
|
|
548
|
+
'blocked' => 'opacity-50',
|
|
549
|
+
default => '',
|
|
550
|
+
})
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
## Forms
|
|
554
|
+
|
|
555
|
+
### Field Types
|
|
556
|
+
|
|
557
|
+
```php
|
|
558
|
+
use Filament\Forms\Components\TextInput;
|
|
559
|
+
use Filament\Forms\Components\Textarea;
|
|
560
|
+
use Filament\Forms\Components\Select;
|
|
561
|
+
use Filament\Forms\Components\Checkbox;
|
|
562
|
+
use Filament\Forms\Components\Toggle;
|
|
563
|
+
use Filament\Forms\Components\CheckboxList;
|
|
564
|
+
use Filament\Forms\Components\Radio;
|
|
565
|
+
use Filament\Forms\Components\DatePicker;
|
|
566
|
+
use Filament\Forms\Components\DateTimePicker;
|
|
567
|
+
use Filament\Forms\Components\TimePicker;
|
|
568
|
+
use Filament\Forms\Components\FileUpload;
|
|
569
|
+
use Filament\Forms\Components\RichEditor;
|
|
570
|
+
use Filament\Forms\Components\MarkdownEditor;
|
|
571
|
+
use Filament\Forms\Components\Repeater;
|
|
572
|
+
use Filament\Forms\Components\Builder;
|
|
573
|
+
use Filament\Forms\Components\TagsInput;
|
|
574
|
+
use Filament\Forms\Components\KeyValue;
|
|
575
|
+
use Filament\Forms\Components\ColorPicker;
|
|
576
|
+
use Filament\Forms\Components\ToggleButtons;
|
|
577
|
+
use Filament\Forms\Components\Hidden;
|
|
578
|
+
use Filament\Forms\Components\Slider;
|
|
579
|
+
use Filament\Forms\Components\CodeEditor;
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
### TextInput Patterns
|
|
583
|
+
|
|
584
|
+
```php
|
|
585
|
+
TextInput::make('name')
|
|
586
|
+
->required()
|
|
587
|
+
->maxLength(255)
|
|
588
|
+
->autofocus()
|
|
589
|
+
->placeholder('Enter customer name')
|
|
590
|
+
|
|
591
|
+
TextInput::make('slug')
|
|
592
|
+
->required()
|
|
593
|
+
->unique(ignoreRecord: true)
|
|
594
|
+
->alphaDash()
|
|
595
|
+
|
|
596
|
+
TextInput::make('price')
|
|
597
|
+
->numeric()
|
|
598
|
+
->prefix('$')
|
|
599
|
+
->step(0.01)
|
|
600
|
+
->minValue(0)
|
|
601
|
+
->maxValue(999999.99)
|
|
602
|
+
|
|
603
|
+
TextInput::make('email')
|
|
604
|
+
->email()
|
|
605
|
+
->required()
|
|
606
|
+
->unique(ignoreRecord: true)
|
|
607
|
+
|
|
608
|
+
TextInput::make('phone')
|
|
609
|
+
->tel()
|
|
610
|
+
->mask('(999) 999-9999')
|
|
611
|
+
|
|
612
|
+
TextInput::make('password')
|
|
613
|
+
->password()
|
|
614
|
+
->revealable()
|
|
615
|
+
->confirmed() // Requires a password_confirmation field
|
|
616
|
+
->dehydrateStateUsing(fn (string $state): string => Hash::make($state))
|
|
617
|
+
->dehydrated(fn (?string $state): bool => filled($state)) // Skip if empty on edit
|
|
618
|
+
->required(fn (string $operation): bool => $operation === 'create')
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
### Select Patterns
|
|
622
|
+
|
|
623
|
+
```php
|
|
624
|
+
// Static options
|
|
625
|
+
Select::make('status')
|
|
626
|
+
->options([
|
|
627
|
+
'draft' => 'Draft',
|
|
628
|
+
'reviewing' => 'Reviewing',
|
|
629
|
+
'published' => 'Published',
|
|
630
|
+
])
|
|
631
|
+
->default('draft')
|
|
632
|
+
->required()
|
|
633
|
+
|
|
634
|
+
// From relationship
|
|
635
|
+
Select::make('category_id')
|
|
636
|
+
->relationship('category', 'name')
|
|
637
|
+
->searchable()
|
|
638
|
+
->preload()
|
|
639
|
+
->createOptionForm([
|
|
640
|
+
TextInput::make('name')->required(),
|
|
641
|
+
TextInput::make('slug')->required(),
|
|
642
|
+
])
|
|
643
|
+
->editOptionForm([
|
|
644
|
+
TextInput::make('name')->required(),
|
|
645
|
+
TextInput::make('slug')->required(),
|
|
646
|
+
])
|
|
647
|
+
|
|
648
|
+
// Multiple select
|
|
649
|
+
Select::make('tags')
|
|
650
|
+
->multiple()
|
|
651
|
+
->relationship('tags', 'name')
|
|
652
|
+
->preload()
|
|
653
|
+
|
|
654
|
+
// Dependent selects
|
|
655
|
+
Select::make('country_id')
|
|
656
|
+
->options(Country::pluck('name', 'id'))
|
|
657
|
+
->live()
|
|
658
|
+
->afterStateUpdated(fn (Set $set) => $set('city_id', null))
|
|
659
|
+
|
|
660
|
+
Select::make('city_id')
|
|
661
|
+
->options(fn (Get $get): array =>
|
|
662
|
+
City::where('country_id', $get('country_id'))->pluck('name', 'id')->toArray())
|
|
663
|
+
->searchable()
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
### Date & Time
|
|
667
|
+
|
|
668
|
+
```php
|
|
669
|
+
DatePicker::make('published_at')
|
|
670
|
+
->native(false) // Use Filament date picker instead of browser native
|
|
671
|
+
->default(now())
|
|
672
|
+
->minDate(now())
|
|
673
|
+
->maxDate(now()->addYear())
|
|
674
|
+
->displayFormat('d/m/Y')
|
|
675
|
+
->closeOnDateSelection()
|
|
676
|
+
|
|
677
|
+
DateTimePicker::make('starts_at')
|
|
678
|
+
->native(false)
|
|
679
|
+
->seconds(false) // Hide seconds
|
|
680
|
+
->minutesStep(15) // 15-minute intervals
|
|
681
|
+
->timezone('America/New_York')
|
|
682
|
+
|
|
683
|
+
TimePicker::make('opening_time')
|
|
684
|
+
->native(false)
|
|
685
|
+
->seconds(false)
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
### File Uploads
|
|
689
|
+
|
|
690
|
+
```php
|
|
691
|
+
FileUpload::make('avatar')
|
|
692
|
+
->image()
|
|
693
|
+
->directory('avatars')
|
|
694
|
+
->disk('public')
|
|
695
|
+
->imageEditor()
|
|
696
|
+
->imageResizeMode('cover')
|
|
697
|
+
->imageCropAspectRatio('1:1')
|
|
698
|
+
->imageResizeTargetWidth('300')
|
|
699
|
+
->imageResizeTargetHeight('300')
|
|
700
|
+
->maxSize(2048) // 2MB in KB
|
|
701
|
+
|
|
702
|
+
FileUpload::make('attachments')
|
|
703
|
+
->multiple()
|
|
704
|
+
->directory('attachments')
|
|
705
|
+
->acceptedFileTypes(['application/pdf', 'image/*'])
|
|
706
|
+
->maxFiles(5)
|
|
707
|
+
->reorderable()
|
|
708
|
+
->downloadable()
|
|
709
|
+
->openable()
|
|
710
|
+
->previewable()
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
### Rich Text & Markdown
|
|
714
|
+
|
|
715
|
+
```php
|
|
716
|
+
RichEditor::make('body')
|
|
717
|
+
->required()
|
|
718
|
+
->columnSpanFull()
|
|
719
|
+
->toolbarButtons([
|
|
720
|
+
'bold', 'italic', 'underline', 'strike',
|
|
721
|
+
'h2', 'h3',
|
|
722
|
+
'bulletList', 'orderedList',
|
|
723
|
+
'link', 'blockquote', 'codeBlock',
|
|
724
|
+
'attachFiles',
|
|
725
|
+
])
|
|
726
|
+
->fileAttachmentsDisk('public')
|
|
727
|
+
->fileAttachmentsDirectory('attachments')
|
|
728
|
+
|
|
729
|
+
MarkdownEditor::make('content')
|
|
730
|
+
->columnSpanFull()
|
|
731
|
+
->toolbarButtons([
|
|
732
|
+
'bold', 'italic', 'strike',
|
|
733
|
+
'heading', 'bulletList', 'orderedList',
|
|
734
|
+
'link', 'codeBlock', 'attachFiles',
|
|
735
|
+
])
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
### Repeater & Builder
|
|
739
|
+
|
|
740
|
+
```php
|
|
741
|
+
// Repeater -- ordered list of identical items
|
|
742
|
+
Repeater::make('line_items')
|
|
743
|
+
->schema([
|
|
744
|
+
Select::make('product_id')
|
|
745
|
+
->options(Product::pluck('name', 'id'))
|
|
746
|
+
->required()
|
|
747
|
+
->columnSpan(2),
|
|
748
|
+
TextInput::make('quantity')
|
|
749
|
+
->numeric()
|
|
750
|
+
->default(1)
|
|
751
|
+
->minValue(1)
|
|
752
|
+
->required(),
|
|
753
|
+
TextInput::make('unit_price')
|
|
754
|
+
->numeric()
|
|
755
|
+
->prefix('$')
|
|
756
|
+
->required(),
|
|
757
|
+
])
|
|
758
|
+
->columns(4)
|
|
759
|
+
->defaultItems(1)
|
|
760
|
+
->addActionLabel('Add line item')
|
|
761
|
+
->reorderable()
|
|
762
|
+
->collapsible()
|
|
763
|
+
->cloneable()
|
|
764
|
+
->minItems(1)
|
|
765
|
+
->maxItems(20)
|
|
766
|
+
->itemLabel(fn (array $state): ?string => $state['product_id'] ?? null)
|
|
767
|
+
|
|
768
|
+
// Builder -- flexible block-based content
|
|
769
|
+
Builder::make('content')
|
|
770
|
+
->blocks([
|
|
771
|
+
Builder\Block::make('heading')
|
|
772
|
+
->schema([
|
|
773
|
+
TextInput::make('content')->required(),
|
|
774
|
+
Select::make('level')
|
|
775
|
+
->options(['h2' => 'H2', 'h3' => 'H3', 'h4' => 'H4'])
|
|
776
|
+
->default('h2'),
|
|
777
|
+
]),
|
|
778
|
+
Builder\Block::make('paragraph')
|
|
779
|
+
->schema([
|
|
780
|
+
RichEditor::make('content')->required(),
|
|
781
|
+
]),
|
|
782
|
+
Builder\Block::make('image')
|
|
783
|
+
->schema([
|
|
784
|
+
FileUpload::make('url')->image()->required(),
|
|
785
|
+
TextInput::make('alt')->required(),
|
|
786
|
+
]),
|
|
787
|
+
Builder\Block::make('call_to_action')
|
|
788
|
+
->schema([
|
|
789
|
+
TextInput::make('label')->required(),
|
|
790
|
+
TextInput::make('url')->url()->required(),
|
|
791
|
+
]),
|
|
792
|
+
])
|
|
793
|
+
->collapsible()
|
|
794
|
+
->blockNumbers(false)
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
### Other Field Types
|
|
798
|
+
|
|
799
|
+
```php
|
|
800
|
+
Toggle::make('is_active')
|
|
801
|
+
->label('Active')
|
|
802
|
+
->default(true)
|
|
803
|
+
->onColor('success')
|
|
804
|
+
->offColor('danger')
|
|
805
|
+
|
|
806
|
+
ToggleButtons::make('status')
|
|
807
|
+
->inline()
|
|
808
|
+
->options([
|
|
809
|
+
'draft' => 'Draft',
|
|
810
|
+
'published' => 'Published',
|
|
811
|
+
'archived' => 'Archived',
|
|
812
|
+
])
|
|
813
|
+
->colors([
|
|
814
|
+
'draft' => 'info',
|
|
815
|
+
'published' => 'success',
|
|
816
|
+
'archived' => 'warning',
|
|
817
|
+
])
|
|
818
|
+
->icons([
|
|
819
|
+
'draft' => 'heroicon-o-pencil',
|
|
820
|
+
'published' => 'heroicon-o-check-circle',
|
|
821
|
+
'archived' => 'heroicon-o-archive-box',
|
|
822
|
+
])
|
|
823
|
+
|
|
824
|
+
CheckboxList::make('roles')
|
|
825
|
+
->relationship('roles', 'name')
|
|
826
|
+
->columns(2)
|
|
827
|
+
|
|
828
|
+
Radio::make('plan')
|
|
829
|
+
->options([
|
|
830
|
+
'free' => 'Free',
|
|
831
|
+
'pro' => 'Pro',
|
|
832
|
+
'enterprise' => 'Enterprise',
|
|
833
|
+
])
|
|
834
|
+
->descriptions([
|
|
835
|
+
'free' => 'Up to 5 projects',
|
|
836
|
+
'pro' => 'Unlimited projects',
|
|
837
|
+
'enterprise' => 'Custom limits',
|
|
838
|
+
])
|
|
839
|
+
|
|
840
|
+
TagsInput::make('tags')
|
|
841
|
+
->separator(',')
|
|
842
|
+
->suggestions(['Laravel', 'PHP', 'Filament'])
|
|
843
|
+
|
|
844
|
+
KeyValue::make('metadata')
|
|
845
|
+
->keyLabel('Key')
|
|
846
|
+
->valueLabel('Value')
|
|
847
|
+
->reorderable()
|
|
848
|
+
|
|
849
|
+
ColorPicker::make('color')
|
|
850
|
+
->hex()
|
|
851
|
+
|
|
852
|
+
Hidden::make('user_id')
|
|
853
|
+
->default(auth()->id())
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
### Reactive Fields (live)
|
|
857
|
+
|
|
858
|
+
```php
|
|
859
|
+
use Filament\Forms\Get;
|
|
860
|
+
use Filament\Forms\Set;
|
|
861
|
+
|
|
862
|
+
TextInput::make('title')
|
|
863
|
+
->live(debounce: 500) // Debounce server round-trips by 500ms
|
|
864
|
+
->afterStateUpdated(fn (Set $set, ?string $state) =>
|
|
865
|
+
$set('slug', Str::slug($state)))
|
|
866
|
+
|
|
867
|
+
TextInput::make('slug')
|
|
868
|
+
->required()
|
|
869
|
+
->unique(ignoreRecord: true)
|
|
870
|
+
|
|
871
|
+
// Conditional visibility (server-side)
|
|
872
|
+
TextInput::make('other_reason')
|
|
873
|
+
->visible(fn (Get $get): bool => $get('reason') === 'other')
|
|
874
|
+
|
|
875
|
+
// JS-side reactivity (no server round-trip)
|
|
876
|
+
Toggle::make('is_admin')
|
|
877
|
+
TextInput::make('admin_notes')
|
|
878
|
+
->hiddenJs(<<<'JS' $get('is_admin') !== true JS)
|
|
879
|
+
|
|
880
|
+
// Calculated fields
|
|
881
|
+
TextInput::make('quantity')->numeric()->live()
|
|
882
|
+
TextInput::make('unit_price')->numeric()->live()
|
|
883
|
+
TextInput::make('total')
|
|
884
|
+
->numeric()
|
|
885
|
+
->readOnly()
|
|
886
|
+
->dehydrated(false)
|
|
887
|
+
->afterStateHydrated(function (Get $get, Set $set) {
|
|
888
|
+
$set('total', ($get('quantity') ?? 0) * ($get('unit_price') ?? 0));
|
|
889
|
+
})
|
|
890
|
+
```
|
|
891
|
+
|
|
892
|
+
### Utility Injection Parameters
|
|
893
|
+
|
|
894
|
+
Closures used in field configuration receive named parameters via dependency injection:
|
|
895
|
+
|
|
896
|
+
| Parameter | Type | Description |
|
|
897
|
+
|-----------|------|-------------|
|
|
898
|
+
| `$state` | `mixed` | Current field value |
|
|
899
|
+
| `$get` | `Get` | Get sibling field values: `$get('field_name')` |
|
|
900
|
+
| `$set` | `Set` | Set sibling field values: `$set('field_name', $value)` |
|
|
901
|
+
| `$record` | `?Model` | Current Eloquent record (null on create) |
|
|
902
|
+
| `$operation` | `string` | `'create'`, `'edit'`, or `'view'` |
|
|
903
|
+
| `$livewire` | `Component` | Parent Livewire component instance |
|
|
904
|
+
| `$component` | `Component` | The field component instance itself |
|
|
905
|
+
| `$model` | `string` | Model class name |
|
|
906
|
+
|
|
907
|
+
### Validation
|
|
908
|
+
|
|
909
|
+
```php
|
|
910
|
+
TextInput::make('name')
|
|
911
|
+
->required()
|
|
912
|
+
->minLength(2)
|
|
913
|
+
->maxLength(255)
|
|
914
|
+
->rules(['alpha_dash'])
|
|
915
|
+
->unique(table: 'users', column: 'name', ignoreRecord: true)
|
|
916
|
+
->exists(table: 'categories', column: 'slug')
|
|
917
|
+
->requiredWith('other_field')
|
|
918
|
+
->prohibitedUnless('status', 'active')
|
|
919
|
+
|
|
920
|
+
// Custom validation
|
|
921
|
+
TextInput::make('code')
|
|
922
|
+
->rules([
|
|
923
|
+
fn (): \Closure => function (string $attribute, $value, \Closure $fail) {
|
|
924
|
+
if (! preg_match('/^[A-Z]{3}-\d{4}$/', $value)) {
|
|
925
|
+
$fail('The code must match the format XXX-0000.');
|
|
926
|
+
}
|
|
927
|
+
},
|
|
928
|
+
])
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
## Schemas (Layouts)
|
|
932
|
+
|
|
933
|
+
```php
|
|
934
|
+
use Filament\Schemas\Components\Grid;
|
|
935
|
+
use Filament\Schemas\Components\Flex;
|
|
936
|
+
use Filament\Schemas\Components\Section;
|
|
937
|
+
use Filament\Schemas\Components\Tabs;
|
|
938
|
+
use Filament\Schemas\Components\Wizard;
|
|
939
|
+
use Filament\Schemas\Components\Fieldset;
|
|
940
|
+
use Filament\Schemas\Components\Callout;
|
|
941
|
+
use Filament\Schemas\Components\EmptyState;
|
|
942
|
+
|
|
943
|
+
// Grid layout
|
|
944
|
+
Grid::make(2)->schema([
|
|
945
|
+
TextInput::make('first_name'),
|
|
946
|
+
TextInput::make('last_name'),
|
|
947
|
+
])
|
|
948
|
+
|
|
949
|
+
// Columns on individual fields
|
|
950
|
+
TextInput::make('description')->columnSpan(2)
|
|
951
|
+
TextInput::make('notes')->columnSpanFull()
|
|
952
|
+
|
|
953
|
+
// Section with header
|
|
954
|
+
Section::make('Contact Information')
|
|
955
|
+
->description('How can we reach this customer?')
|
|
956
|
+
->schema([...])
|
|
957
|
+
->collapsible()
|
|
958
|
+
->collapsed() // Start collapsed
|
|
959
|
+
->icon('heroicon-o-phone')
|
|
960
|
+
->columns(2)
|
|
961
|
+
->aside() // Show section content beside the header
|
|
962
|
+
|
|
963
|
+
// Tabs
|
|
964
|
+
Tabs::make('Settings')
|
|
965
|
+
->tabs([
|
|
966
|
+
Tabs\Tab::make('General')
|
|
967
|
+
->icon('heroicon-o-cog-6-tooth')
|
|
968
|
+
->schema([...]),
|
|
969
|
+
Tabs\Tab::make('Security')
|
|
970
|
+
->icon('heroicon-o-lock-closed')
|
|
971
|
+
->schema([...])
|
|
972
|
+
->badge(5), // Show badge count
|
|
973
|
+
Tabs\Tab::make('Notifications')
|
|
974
|
+
->schema([...]),
|
|
975
|
+
])
|
|
976
|
+
->persistTabInQueryString() // Remember selected tab in URL
|
|
977
|
+
|
|
978
|
+
// Wizard (multi-step form)
|
|
979
|
+
Wizard::make([
|
|
980
|
+
Wizard\Step::make('Account')
|
|
981
|
+
->description('Set up your account')
|
|
982
|
+
->schema([
|
|
983
|
+
TextInput::make('email')->email()->required(),
|
|
984
|
+
TextInput::make('password')->password()->required(),
|
|
985
|
+
])
|
|
986
|
+
->icon('heroicon-o-user'),
|
|
987
|
+
Wizard\Step::make('Profile')
|
|
988
|
+
->description('Tell us about yourself')
|
|
989
|
+
->schema([
|
|
990
|
+
TextInput::make('name')->required(),
|
|
991
|
+
FileUpload::make('avatar')->image(),
|
|
992
|
+
]),
|
|
993
|
+
Wizard\Step::make('Billing')
|
|
994
|
+
->schema([...]),
|
|
995
|
+
])
|
|
996
|
+
->skippable() // Allow skipping steps
|
|
997
|
+
->submitAction(view('filament.wizard-submit'))
|
|
998
|
+
|
|
999
|
+
// Fieldset
|
|
1000
|
+
Fieldset::make('Address')
|
|
1001
|
+
->schema([
|
|
1002
|
+
TextInput::make('street'),
|
|
1003
|
+
TextInput::make('city'),
|
|
1004
|
+
TextInput::make('state'),
|
|
1005
|
+
TextInput::make('zip'),
|
|
1006
|
+
])
|
|
1007
|
+
->columns(2)
|
|
1008
|
+
|
|
1009
|
+
// Callout
|
|
1010
|
+
Callout::make()
|
|
1011
|
+
->info()
|
|
1012
|
+
->content('This resource is read-only because the record is archived.')
|
|
1013
|
+
```
|
|
1014
|
+
|
|
1015
|
+
## Actions & Modals
|
|
1016
|
+
|
|
1017
|
+
```php
|
|
1018
|
+
use Filament\Actions\Action;
|
|
1019
|
+
|
|
1020
|
+
// Simple confirmation
|
|
1021
|
+
Action::make('delete')
|
|
1022
|
+
->requiresConfirmation()
|
|
1023
|
+
->modalHeading('Delete record')
|
|
1024
|
+
->modalDescription('Are you sure you want to delete this record? This cannot be undone.')
|
|
1025
|
+
->modalSubmitActionLabel('Yes, delete')
|
|
1026
|
+
->action(fn () => $this->record->delete())
|
|
1027
|
+
->color('danger')
|
|
1028
|
+
->icon('heroicon-o-trash')
|
|
1029
|
+
|
|
1030
|
+
// With modal form
|
|
1031
|
+
Action::make('sendEmail')
|
|
1032
|
+
->icon('heroicon-o-envelope')
|
|
1033
|
+
->schema([
|
|
1034
|
+
TextInput::make('subject')->required(),
|
|
1035
|
+
RichEditor::make('body')->required(),
|
|
1036
|
+
])
|
|
1037
|
+
->action(function (array $data) {
|
|
1038
|
+
Mail::to($this->record->email)->send(
|
|
1039
|
+
new GenericEmail($data['subject'], $data['body'])
|
|
1040
|
+
);
|
|
1041
|
+
Notification::make()->title('Email sent')->success()->send();
|
|
1042
|
+
})
|
|
1043
|
+
|
|
1044
|
+
// Button styles
|
|
1045
|
+
Action::make('save')->button()
|
|
1046
|
+
Action::make('edit')->link()
|
|
1047
|
+
Action::make('delete')->iconButton()->icon('heroicon-m-trash')
|
|
1048
|
+
Action::make('info')->badge()
|
|
1049
|
+
Action::make('export')->outlined()
|
|
1050
|
+
|
|
1051
|
+
// Grouped actions
|
|
1052
|
+
\Filament\Actions\ActionGroup::make([
|
|
1053
|
+
Action::make('view'),
|
|
1054
|
+
Action::make('edit'),
|
|
1055
|
+
Action::make('delete'),
|
|
1056
|
+
])->icon('heroicon-m-ellipsis-vertical')
|
|
1057
|
+
|
|
1058
|
+
// Action with custom URL
|
|
1059
|
+
Action::make('visit')
|
|
1060
|
+
->url(fn (): string => route('posts.show', $this->record))
|
|
1061
|
+
->openUrlInNewTab()
|
|
1062
|
+
|
|
1063
|
+
// Header actions on pages
|
|
1064
|
+
protected function getHeaderActions(): array
|
|
1065
|
+
{
|
|
1066
|
+
return [
|
|
1067
|
+
Action::make('export')
|
|
1068
|
+
->action(fn () => $this->export())
|
|
1069
|
+
->color('gray'),
|
|
1070
|
+
\Filament\Actions\CreateAction::make(),
|
|
1071
|
+
];
|
|
1072
|
+
}
|
|
1073
|
+
```
|
|
1074
|
+
|
|
1075
|
+
## Widgets & Dashboard
|
|
1076
|
+
|
|
1077
|
+
### Stats Overview
|
|
1078
|
+
|
|
1079
|
+
```php
|
|
1080
|
+
use Filament\Widgets\StatsOverviewWidget;
|
|
1081
|
+
use Filament\Widgets\StatsOverviewWidget\Stat;
|
|
1082
|
+
|
|
1083
|
+
class StatsOverview extends StatsOverviewWidget
|
|
1084
|
+
{
|
|
1085
|
+
protected static ?int $sort = 1;
|
|
1086
|
+
|
|
1087
|
+
protected function getStats(): array
|
|
1088
|
+
{
|
|
1089
|
+
return [
|
|
1090
|
+
Stat::make('Total Revenue', '$' . number_format(Order::sum('total'), 2))
|
|
1091
|
+
->description('32% increase')
|
|
1092
|
+
->descriptionIcon('heroicon-m-arrow-trending-up')
|
|
1093
|
+
->color('success')
|
|
1094
|
+
->chart([7, 2, 10, 3, 15, 4, 17]),
|
|
1095
|
+
Stat::make('New Customers', Customer::whereMonth('created_at', now()->month)->count())
|
|
1096
|
+
->description('3% decrease')
|
|
1097
|
+
->descriptionIcon('heroicon-m-arrow-trending-down')
|
|
1098
|
+
->color('danger'),
|
|
1099
|
+
Stat::make('Active Orders', Order::where('status', 'processing')->count())
|
|
1100
|
+
->description('21 shipped today')
|
|
1101
|
+
->color('info'),
|
|
1102
|
+
];
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
```
|
|
1106
|
+
|
|
1107
|
+
### Chart Widget
|
|
1108
|
+
|
|
1109
|
+
```php
|
|
1110
|
+
use Filament\Widgets\ChartWidget;
|
|
1111
|
+
|
|
1112
|
+
class RevenueChart extends ChartWidget
|
|
1113
|
+
{
|
|
1114
|
+
protected static ?string $heading = 'Monthly Revenue';
|
|
1115
|
+
protected static ?int $sort = 2;
|
|
1116
|
+
protected static ?string $maxHeight = '300px';
|
|
1117
|
+
protected int|string|array $columnSpan = 'full';
|
|
1118
|
+
|
|
1119
|
+
protected function getData(): array
|
|
1120
|
+
{
|
|
1121
|
+
$data = Order::selectRaw('MONTH(created_at) as month, SUM(total) as revenue')
|
|
1122
|
+
->whereYear('created_at', now()->year)
|
|
1123
|
+
->groupBy('month')
|
|
1124
|
+
->pluck('revenue', 'month')
|
|
1125
|
+
->toArray();
|
|
1126
|
+
|
|
1127
|
+
return [
|
|
1128
|
+
'datasets' => [
|
|
1129
|
+
[
|
|
1130
|
+
'label' => 'Revenue',
|
|
1131
|
+
'data' => array_values($data),
|
|
1132
|
+
'backgroundColor' => '#36A2EB',
|
|
1133
|
+
'borderColor' => '#36A2EB',
|
|
1134
|
+
],
|
|
1135
|
+
],
|
|
1136
|
+
'labels' => ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
|
1137
|
+
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
|
|
1138
|
+
];
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
protected function getType(): string
|
|
1142
|
+
{
|
|
1143
|
+
return 'line'; // bar, pie, doughnut, radar, polarArea, bubble, scatter
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
protected function getOptions(): array
|
|
1147
|
+
{
|
|
1148
|
+
return [
|
|
1149
|
+
'scales' => [
|
|
1150
|
+
'y' => ['beginAtZero' => true],
|
|
1151
|
+
],
|
|
1152
|
+
];
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
```
|
|
1156
|
+
|
|
1157
|
+
### Table Widget
|
|
1158
|
+
|
|
1159
|
+
```php
|
|
1160
|
+
use Filament\Widgets\TableWidget;
|
|
1161
|
+
|
|
1162
|
+
class LatestOrders extends TableWidget
|
|
1163
|
+
{
|
|
1164
|
+
protected static ?int $sort = 3;
|
|
1165
|
+
protected int|string|array $columnSpan = 'full';
|
|
1166
|
+
|
|
1167
|
+
public function table(Table $table): Table
|
|
1168
|
+
{
|
|
1169
|
+
return $table
|
|
1170
|
+
->query(Order::query()->latest()->limit(5))
|
|
1171
|
+
->columns([
|
|
1172
|
+
TextColumn::make('number')->label('Order #'),
|
|
1173
|
+
TextColumn::make('customer.name'),
|
|
1174
|
+
TextColumn::make('total')->money('USD'),
|
|
1175
|
+
TextColumn::make('status')->badge(),
|
|
1176
|
+
TextColumn::make('created_at')->since(),
|
|
1177
|
+
])
|
|
1178
|
+
->paginated(false);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
```
|
|
1182
|
+
|
|
1183
|
+
### Dashboard Filters
|
|
1184
|
+
|
|
1185
|
+
```php
|
|
1186
|
+
use Filament\Pages\Dashboard as BaseDashboard;
|
|
1187
|
+
use Filament\Pages\Dashboard\Actions\FilterAction;
|
|
1188
|
+
use Filament\Pages\Dashboard\Concerns\HasFiltersAction;
|
|
1189
|
+
use Filament\Forms\Components\DatePicker;
|
|
1190
|
+
use Filament\Forms\Components\Select;
|
|
1191
|
+
|
|
1192
|
+
class Dashboard extends BaseDashboard
|
|
1193
|
+
{
|
|
1194
|
+
use HasFiltersAction;
|
|
1195
|
+
|
|
1196
|
+
protected function getHeaderActions(): array
|
|
1197
|
+
{
|
|
1198
|
+
return [
|
|
1199
|
+
FilterAction::make()->schema([
|
|
1200
|
+
DatePicker::make('startDate')->default(now()->subMonth()),
|
|
1201
|
+
DatePicker::make('endDate')->default(now()),
|
|
1202
|
+
Select::make('status')
|
|
1203
|
+
->options(['all' => 'All', 'active' => 'Active', 'inactive' => 'Inactive'])
|
|
1204
|
+
->default('all'),
|
|
1205
|
+
]),
|
|
1206
|
+
];
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// Consuming filters in a widget
|
|
1211
|
+
use Filament\Widgets\Concerns\InteractsWithPageFilters;
|
|
1212
|
+
|
|
1213
|
+
class RevenueWidget extends StatsOverviewWidget
|
|
1214
|
+
{
|
|
1215
|
+
use InteractsWithPageFilters;
|
|
1216
|
+
|
|
1217
|
+
protected function getStats(): array
|
|
1218
|
+
{
|
|
1219
|
+
$start = $this->pageFilters['startDate'] ?? now()->subMonth();
|
|
1220
|
+
$end = $this->pageFilters['endDate'] ?? now();
|
|
1221
|
+
|
|
1222
|
+
$revenue = Order::whereBetween('created_at', [$start, $end])->sum('total');
|
|
1223
|
+
|
|
1224
|
+
return [
|
|
1225
|
+
Stat::make('Revenue', '$' . number_format($revenue, 2)),
|
|
1226
|
+
];
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
```
|
|
1230
|
+
|
|
1231
|
+
### Widget Placement
|
|
1232
|
+
|
|
1233
|
+
```php
|
|
1234
|
+
// In resource class -- attach widgets to resource pages
|
|
1235
|
+
public static function getWidgets(): array
|
|
1236
|
+
{
|
|
1237
|
+
return [CustomerStatsWidget::class];
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// In ListRecords page
|
|
1241
|
+
protected function getHeaderWidgets(): array
|
|
1242
|
+
{
|
|
1243
|
+
return [CustomerStatsWidget::class];
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// Widget column span
|
|
1247
|
+
protected int|string|array $columnSpan = 'full'; // or 1, 2, etc.
|
|
1248
|
+
```
|
|
1249
|
+
|
|
1250
|
+
## Multi-Tenancy
|
|
1251
|
+
|
|
1252
|
+
```php
|
|
1253
|
+
// Panel configuration
|
|
1254
|
+
$panel->tenant(Team::class, slugAttribute: 'slug')
|
|
1255
|
+
->tenantRegistration(RegisterTeam::class)
|
|
1256
|
+
->tenantProfile(EditTeamProfile::class)
|
|
1257
|
+
->requiresTenantSubscription()
|
|
1258
|
+
->tenantMenu(true)
|
|
1259
|
+
->tenantMiddleware([
|
|
1260
|
+
ApplyTenantScopes::class,
|
|
1261
|
+
]);
|
|
1262
|
+
|
|
1263
|
+
// User model
|
|
1264
|
+
use Filament\Models\Contracts\HasTenants;
|
|
1265
|
+
use Filament\Panel;
|
|
1266
|
+
use Illuminate\Database\Eloquent\Model;
|
|
1267
|
+
use Illuminate\Support\Collection;
|
|
1268
|
+
|
|
1269
|
+
class User extends Authenticatable implements HasTenants
|
|
1270
|
+
{
|
|
1271
|
+
public function teams(): BelongsToMany
|
|
1272
|
+
{
|
|
1273
|
+
return $this->belongsToMany(Team::class);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
public function getTenants(Panel $panel): Collection
|
|
1277
|
+
{
|
|
1278
|
+
return $this->teams;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
public function canAccessTenant(Model $tenant): bool
|
|
1282
|
+
{
|
|
1283
|
+
return $this->teams()->whereKey($tenant)->exists();
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// Accessing current tenant anywhere
|
|
1288
|
+
use Filament\Facades\Filament;
|
|
1289
|
+
|
|
1290
|
+
$tenant = Filament::getTenant();
|
|
1291
|
+
|
|
1292
|
+
// Scoping resource queries to tenant
|
|
1293
|
+
// In resource:
|
|
1294
|
+
public static function getEloquentQuery(): Builder
|
|
1295
|
+
{
|
|
1296
|
+
return parent::getEloquentQuery()->whereBelongsTo(Filament::getTenant());
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// Or use the global scope approach via TenantScope
|
|
1300
|
+
```
|
|
1301
|
+
|
|
1302
|
+
## Infolists (Read-Only Display)
|
|
1303
|
+
|
|
1304
|
+
```php
|
|
1305
|
+
use Filament\Infolists\Components\TextEntry;
|
|
1306
|
+
use Filament\Infolists\Components\IconEntry;
|
|
1307
|
+
use Filament\Infolists\Components\ImageEntry;
|
|
1308
|
+
use Filament\Infolists\Components\ColorEntry;
|
|
1309
|
+
use Filament\Infolists\Components\RepeatableEntry;
|
|
1310
|
+
use Filament\Infolists\Components\KeyValueEntry;
|
|
1311
|
+
|
|
1312
|
+
// In resource or ViewRecord page
|
|
1313
|
+
public static function infolist(Infolist $infolist): Infolist
|
|
1314
|
+
{
|
|
1315
|
+
return $infolist->schema([
|
|
1316
|
+
Section::make('Customer Details')->schema([
|
|
1317
|
+
TextEntry::make('name'),
|
|
1318
|
+
TextEntry::make('email')
|
|
1319
|
+
->copyable()
|
|
1320
|
+
->icon('heroicon-m-envelope'),
|
|
1321
|
+
TextEntry::make('status')
|
|
1322
|
+
->badge()
|
|
1323
|
+
->color(fn (string $state) => match ($state) {
|
|
1324
|
+
'active' => 'success',
|
|
1325
|
+
'inactive' => 'gray',
|
|
1326
|
+
'blocked' => 'danger',
|
|
1327
|
+
}),
|
|
1328
|
+
IconEntry::make('is_vip')->boolean(),
|
|
1329
|
+
ImageEntry::make('avatar')->circular(),
|
|
1330
|
+
TextEntry::make('created_at')->dateTime(),
|
|
1331
|
+
])->columns(2),
|
|
1332
|
+
|
|
1333
|
+
Section::make('Orders')->schema([
|
|
1334
|
+
RepeatableEntry::make('orders')->schema([
|
|
1335
|
+
TextEntry::make('number'),
|
|
1336
|
+
TextEntry::make('total')->money('USD'),
|
|
1337
|
+
TextEntry::make('status')->badge(),
|
|
1338
|
+
])->columns(3),
|
|
1339
|
+
]),
|
|
1340
|
+
|
|
1341
|
+
KeyValueEntry::make('metadata'),
|
|
1342
|
+
]);
|
|
1343
|
+
}
|
|
1344
|
+
```
|
|
1345
|
+
|
|
1346
|
+
## Notifications
|
|
1347
|
+
|
|
1348
|
+
```php
|
|
1349
|
+
use Filament\Notifications\Notification;
|
|
1350
|
+
|
|
1351
|
+
// Success notification
|
|
1352
|
+
Notification::make()
|
|
1353
|
+
->title('Saved successfully')
|
|
1354
|
+
->success()
|
|
1355
|
+
->send();
|
|
1356
|
+
|
|
1357
|
+
// Error notification
|
|
1358
|
+
Notification::make()
|
|
1359
|
+
->title('Error occurred')
|
|
1360
|
+
->body('Could not save the record. Please try again.')
|
|
1361
|
+
->danger()
|
|
1362
|
+
->persistent() // Does not auto-dismiss
|
|
1363
|
+
->send();
|
|
1364
|
+
|
|
1365
|
+
// Warning with action
|
|
1366
|
+
Notification::make()
|
|
1367
|
+
->title('Rate limit approaching')
|
|
1368
|
+
->body('You have used 90% of your API quota.')
|
|
1369
|
+
->warning()
|
|
1370
|
+
->actions([
|
|
1371
|
+
\Filament\Notifications\Actions\Action::make('upgrade')
|
|
1372
|
+
->button()
|
|
1373
|
+
->url(route('billing')),
|
|
1374
|
+
\Filament\Notifications\Actions\Action::make('dismiss')
|
|
1375
|
+
->close(),
|
|
1376
|
+
])
|
|
1377
|
+
->send();
|
|
1378
|
+
|
|
1379
|
+
// Database notifications (persisted, shown in bell icon)
|
|
1380
|
+
Notification::make()
|
|
1381
|
+
->title('New order received')
|
|
1382
|
+
->body("Order #{$order->number} was placed by {$order->customer->name}.")
|
|
1383
|
+
->icon('heroicon-o-shopping-cart')
|
|
1384
|
+
->sendToDatabase($user);
|
|
1385
|
+
|
|
1386
|
+
// Broadcast (real-time via websockets)
|
|
1387
|
+
Notification::make()
|
|
1388
|
+
->title('Deployment complete')
|
|
1389
|
+
->success()
|
|
1390
|
+
->broadcast($user);
|
|
1391
|
+
```
|
|
1392
|
+
|
|
1393
|
+
## Navigation
|
|
1394
|
+
|
|
1395
|
+
```php
|
|
1396
|
+
// Resource / Page navigation properties
|
|
1397
|
+
protected static ?string $navigationIcon = 'heroicon-o-document-text';
|
|
1398
|
+
protected static ?string $navigationGroup = 'Content';
|
|
1399
|
+
protected static ?int $navigationSort = 3;
|
|
1400
|
+
protected static ?string $navigationLabel = 'Articles';
|
|
1401
|
+
protected static ?string $navigationParentItem = 'Content';
|
|
1402
|
+
|
|
1403
|
+
// Dynamic navigation badge
|
|
1404
|
+
public static function getNavigationBadge(): ?string
|
|
1405
|
+
{
|
|
1406
|
+
return static::getModel()::count();
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
public static function getNavigationBadgeColor(): ?string
|
|
1410
|
+
{
|
|
1411
|
+
return static::getModel()::count() > 10 ? 'warning' : 'primary';
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// Clusters (group of related pages)
|
|
1415
|
+
use Filament\Clusters\Cluster;
|
|
1416
|
+
|
|
1417
|
+
class Settings extends Cluster
|
|
1418
|
+
{
|
|
1419
|
+
protected static ?string $navigationIcon = 'heroicon-o-cog-6-tooth';
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// Page inside a cluster
|
|
1423
|
+
class GeneralSettings extends Page
|
|
1424
|
+
{
|
|
1425
|
+
protected static ?string $cluster = Settings::class;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// Custom navigation items in panel provider
|
|
1429
|
+
$panel->navigationItems([
|
|
1430
|
+
NavigationItem::make('Documentation')
|
|
1431
|
+
->url('https://docs.example.com', shouldOpenInNewTab: true)
|
|
1432
|
+
->icon('heroicon-o-book-open')
|
|
1433
|
+
->group('Support')
|
|
1434
|
+
->sort(10),
|
|
1435
|
+
])
|
|
1436
|
+
|
|
1437
|
+
// User menu items
|
|
1438
|
+
$panel->userMenuItems([
|
|
1439
|
+
MenuItem::make()
|
|
1440
|
+
->label('Profile')
|
|
1441
|
+
->url(fn () => route('profile'))
|
|
1442
|
+
->icon('heroicon-o-user'),
|
|
1443
|
+
])
|
|
1444
|
+
```
|
|
1445
|
+
|
|
1446
|
+
## Custom Pages
|
|
1447
|
+
|
|
1448
|
+
```php
|
|
1449
|
+
use Filament\Pages\Page;
|
|
1450
|
+
|
|
1451
|
+
class Settings extends Page
|
|
1452
|
+
{
|
|
1453
|
+
protected static ?string $navigationIcon = 'heroicon-o-cog-6-tooth';
|
|
1454
|
+
protected static string $view = 'filament.pages.settings';
|
|
1455
|
+
protected static ?string $title = 'Application Settings';
|
|
1456
|
+
|
|
1457
|
+
public ?array $data = [];
|
|
1458
|
+
|
|
1459
|
+
public function mount(): void
|
|
1460
|
+
{
|
|
1461
|
+
$this->form->fill([
|
|
1462
|
+
'site_name' => config('app.name'),
|
|
1463
|
+
'support_email' => config('mail.from.address'),
|
|
1464
|
+
]);
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
public function form(Schema $schema): Schema
|
|
1468
|
+
{
|
|
1469
|
+
return $schema->components([
|
|
1470
|
+
Section::make('General')->schema([
|
|
1471
|
+
TextInput::make('site_name')->required(),
|
|
1472
|
+
TextInput::make('support_email')->email()->required(),
|
|
1473
|
+
]),
|
|
1474
|
+
])->statePath('data');
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
public function save(): void
|
|
1478
|
+
{
|
|
1479
|
+
$data = $this->form->getState();
|
|
1480
|
+
// Persist settings...
|
|
1481
|
+
Notification::make()->title('Settings saved')->success()->send();
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
protected function getHeaderActions(): array
|
|
1485
|
+
{
|
|
1486
|
+
return [
|
|
1487
|
+
Action::make('save')
|
|
1488
|
+
->action('save')
|
|
1489
|
+
->button(),
|
|
1490
|
+
];
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
```
|
|
1494
|
+
|
|
1495
|
+
## Relation Managers
|
|
1496
|
+
|
|
1497
|
+
```php
|
|
1498
|
+
use Filament\Resources\RelationManagers\RelationManager;
|
|
1499
|
+
|
|
1500
|
+
class OrdersRelationManager extends RelationManager
|
|
1501
|
+
{
|
|
1502
|
+
protected static string $relationship = 'orders';
|
|
1503
|
+
protected static ?string $recordTitleAttribute = 'number';
|
|
1504
|
+
|
|
1505
|
+
public function table(Table $table): Table
|
|
1506
|
+
{
|
|
1507
|
+
return $table
|
|
1508
|
+
->columns([
|
|
1509
|
+
TextColumn::make('number'),
|
|
1510
|
+
TextColumn::make('total')->money('USD'),
|
|
1511
|
+
TextColumn::make('status')->badge(),
|
|
1512
|
+
TextColumn::make('created_at')->dateTime(),
|
|
1513
|
+
])
|
|
1514
|
+
->recordActions([
|
|
1515
|
+
EditAction::make(),
|
|
1516
|
+
DeleteAction::make(),
|
|
1517
|
+
])
|
|
1518
|
+
->toolbarActions([
|
|
1519
|
+
\Filament\Actions\CreateAction::make(),
|
|
1520
|
+
BulkActionGroup::make([
|
|
1521
|
+
DeleteBulkAction::make(),
|
|
1522
|
+
]),
|
|
1523
|
+
]);
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
public function form(Schema $schema): Schema
|
|
1527
|
+
{
|
|
1528
|
+
return $schema->components([
|
|
1529
|
+
TextInput::make('number')->required(),
|
|
1530
|
+
TextInput::make('total')->numeric()->prefix('$')->required(),
|
|
1531
|
+
Select::make('status')
|
|
1532
|
+
->options(['pending' => 'Pending', 'processing' => 'Processing', 'shipped' => 'Shipped'])
|
|
1533
|
+
->required(),
|
|
1534
|
+
]);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
```
|
|
1538
|
+
|
|
1539
|
+
## Authorization
|
|
1540
|
+
|
|
1541
|
+
Filament maps to Laravel model policies automatically:
|
|
1542
|
+
|
|
1543
|
+
| Policy Method | Controls |
|
|
1544
|
+
|---------------|----------|
|
|
1545
|
+
| `viewAny` | Can see resource in navigation and list page |
|
|
1546
|
+
| `view` | Can see view page / view action |
|
|
1547
|
+
| `create` | Can see create button and create page |
|
|
1548
|
+
| `update` | Can see edit button and edit page |
|
|
1549
|
+
| `delete` | Can delete individual records |
|
|
1550
|
+
| `deleteAny` | Can bulk-delete records |
|
|
1551
|
+
| `forceDelete` | Can force-delete soft-deleted records |
|
|
1552
|
+
| `forceDeleteAny` | Can bulk force-delete |
|
|
1553
|
+
| `restore` | Can restore soft-deleted records |
|
|
1554
|
+
| `restoreAny` | Can bulk-restore |
|
|
1555
|
+
| `reorder` | Can reorder records |
|
|
1556
|
+
|
|
1557
|
+
```php
|
|
1558
|
+
// app/Policies/PostPolicy.php
|
|
1559
|
+
class PostPolicy
|
|
1560
|
+
{
|
|
1561
|
+
public function viewAny(User $user): bool
|
|
1562
|
+
{
|
|
1563
|
+
return $user->hasPermissionTo('view posts');
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
public function create(User $user): bool
|
|
1567
|
+
{
|
|
1568
|
+
return $user->hasPermissionTo('create posts');
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
public function update(User $user, Post $post): bool
|
|
1572
|
+
{
|
|
1573
|
+
return $user->hasPermissionTo('edit posts') || $post->user_id === $user->id;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
public function delete(User $user, Post $post): bool
|
|
1577
|
+
{
|
|
1578
|
+
return $user->hasPermissionTo('delete posts');
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
```
|
|
1582
|
+
|
|
1583
|
+
## Exports & Imports
|
|
1584
|
+
|
|
1585
|
+
```php
|
|
1586
|
+
// Generate exporter
|
|
1587
|
+
// php artisan make:filament-exporter Customer
|
|
1588
|
+
|
|
1589
|
+
use App\Filament\Exports\CustomerExporter;
|
|
1590
|
+
use Filament\Actions\ExportAction;
|
|
1591
|
+
|
|
1592
|
+
class CustomerExporter extends Exporter
|
|
1593
|
+
{
|
|
1594
|
+
protected static ?string $model = Customer::class;
|
|
1595
|
+
|
|
1596
|
+
public static function getColumns(): array
|
|
1597
|
+
{
|
|
1598
|
+
return [
|
|
1599
|
+
ExportColumn::make('name'),
|
|
1600
|
+
ExportColumn::make('email'),
|
|
1601
|
+
ExportColumn::make('created_at'),
|
|
1602
|
+
];
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// Use in table toolbar
|
|
1607
|
+
ExportAction::make()->exporter(CustomerExporter::class)
|
|
1608
|
+
|
|
1609
|
+
// Generate importer
|
|
1610
|
+
// php artisan make:filament-importer Customer
|
|
1611
|
+
|
|
1612
|
+
use App\Filament\Imports\CustomerImporter;
|
|
1613
|
+
use Filament\Actions\ImportAction;
|
|
1614
|
+
|
|
1615
|
+
class CustomerImporter extends Importer
|
|
1616
|
+
{
|
|
1617
|
+
protected static ?string $model = Customer::class;
|
|
1618
|
+
|
|
1619
|
+
public static function getColumns(): array
|
|
1620
|
+
{
|
|
1621
|
+
return [
|
|
1622
|
+
ImportColumn::make('name')->requiredMapping()->rules(['required', 'max:255']),
|
|
1623
|
+
ImportColumn::make('email')->requiredMapping()->rules(['required', 'email']),
|
|
1624
|
+
];
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
ImportAction::make()->importer(CustomerImporter::class)
|
|
1629
|
+
```
|
|
1630
|
+
|
|
1631
|
+
## Testing
|
|
1632
|
+
|
|
1633
|
+
```php
|
|
1634
|
+
use function Pest\Livewire\livewire;
|
|
1635
|
+
|
|
1636
|
+
// Test list page renders
|
|
1637
|
+
it('can render the list page', function () {
|
|
1638
|
+
livewire(ListCustomers::class)->assertSuccessful();
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1641
|
+
// Test table shows records
|
|
1642
|
+
it('can list customers', function () {
|
|
1643
|
+
$customers = Customer::factory()->count(5)->create();
|
|
1644
|
+
|
|
1645
|
+
livewire(ListCustomers::class)
|
|
1646
|
+
->assertCanSeeTableRecords($customers);
|
|
1647
|
+
});
|
|
1648
|
+
|
|
1649
|
+
// Test table columns exist
|
|
1650
|
+
it('has the expected columns', function () {
|
|
1651
|
+
livewire(ListCustomers::class)
|
|
1652
|
+
->assertTableColumnExists('name')
|
|
1653
|
+
->assertTableColumnExists('email')
|
|
1654
|
+
->assertTableColumnVisible('status');
|
|
1655
|
+
});
|
|
1656
|
+
|
|
1657
|
+
// Test table search
|
|
1658
|
+
it('can search customers by name', function () {
|
|
1659
|
+
$customer = Customer::factory()->create(['name' => 'John Doe']);
|
|
1660
|
+
Customer::factory()->count(5)->create();
|
|
1661
|
+
|
|
1662
|
+
livewire(ListCustomers::class)
|
|
1663
|
+
->searchTable('John')
|
|
1664
|
+
->assertCanSeeTableRecords([$customer])
|
|
1665
|
+
->assertCountTableRecords(1);
|
|
1666
|
+
});
|
|
1667
|
+
|
|
1668
|
+
// Test table filters
|
|
1669
|
+
it('can filter by status', function () {
|
|
1670
|
+
$active = Customer::factory()->create(['status' => 'active']);
|
|
1671
|
+
$inactive = Customer::factory()->create(['status' => 'inactive']);
|
|
1672
|
+
|
|
1673
|
+
livewire(ListCustomers::class)
|
|
1674
|
+
->filterTable('status', 'active')
|
|
1675
|
+
->assertCanSeeTableRecords([$active])
|
|
1676
|
+
->assertCanNotSeeTableRecords([$inactive]);
|
|
1677
|
+
});
|
|
1678
|
+
|
|
1679
|
+
// Test create form
|
|
1680
|
+
it('can create a customer', function () {
|
|
1681
|
+
livewire(CreateCustomer::class)
|
|
1682
|
+
->fillForm([
|
|
1683
|
+
'name' => 'Jane Doe',
|
|
1684
|
+
'email' => 'jane@example.com',
|
|
1685
|
+
'status' => 'active',
|
|
1686
|
+
])
|
|
1687
|
+
->call('create')
|
|
1688
|
+
->assertHasNoFormErrors();
|
|
1689
|
+
|
|
1690
|
+
assertDatabaseHas('customers', ['email' => 'jane@example.com']);
|
|
1691
|
+
});
|
|
1692
|
+
|
|
1693
|
+
// Test form validation
|
|
1694
|
+
it('validates required fields', function () {
|
|
1695
|
+
livewire(CreateCustomer::class)
|
|
1696
|
+
->fillForm(['name' => ''])
|
|
1697
|
+
->call('create')
|
|
1698
|
+
->assertHasFormErrors(['name' => 'required']);
|
|
1699
|
+
});
|
|
1700
|
+
|
|
1701
|
+
// Test edit form
|
|
1702
|
+
it('can update a customer', function () {
|
|
1703
|
+
$customer = Customer::factory()->create();
|
|
1704
|
+
|
|
1705
|
+
livewire(EditCustomer::class, ['record' => $customer->getRouteKey()])
|
|
1706
|
+
->fillForm(['name' => 'Updated Name'])
|
|
1707
|
+
->call('save')
|
|
1708
|
+
->assertHasNoFormErrors();
|
|
1709
|
+
|
|
1710
|
+
expect($customer->fresh()->name)->toBe('Updated Name');
|
|
1711
|
+
});
|
|
1712
|
+
|
|
1713
|
+
// Test table actions
|
|
1714
|
+
it('can delete a customer', function () {
|
|
1715
|
+
$customer = Customer::factory()->create();
|
|
1716
|
+
|
|
1717
|
+
livewire(ListCustomers::class)
|
|
1718
|
+
->callTableAction('delete', $customer);
|
|
1719
|
+
|
|
1720
|
+
assertDatabaseMissing('customers', ['id' => $customer->id]);
|
|
1721
|
+
});
|
|
1722
|
+
|
|
1723
|
+
// Test bulk actions
|
|
1724
|
+
it('can bulk delete customers', function () {
|
|
1725
|
+
$customers = Customer::factory()->count(3)->create();
|
|
1726
|
+
|
|
1727
|
+
livewire(ListCustomers::class)
|
|
1728
|
+
->callTableBulkAction('delete', $customers);
|
|
1729
|
+
|
|
1730
|
+
assertDatabaseCount('customers', 0);
|
|
1731
|
+
});
|
|
1732
|
+
```
|
|
1733
|
+
|
|
1734
|
+
## Common Patterns
|
|
1735
|
+
|
|
1736
|
+
### Global Configuration (Service Provider)
|
|
1737
|
+
|
|
1738
|
+
```php
|
|
1739
|
+
use Illuminate\Support\ServiceProvider;
|
|
1740
|
+
use Filament\Tables\Table;
|
|
1741
|
+
use Filament\Tables\Columns\TextColumn;
|
|
1742
|
+
use Filament\Schemas\Components\Section;
|
|
1743
|
+
|
|
1744
|
+
class AppServiceProvider extends ServiceProvider
|
|
1745
|
+
{
|
|
1746
|
+
public function boot(): void
|
|
1747
|
+
{
|
|
1748
|
+
// Apply columns to every table
|
|
1749
|
+
Table::configureUsing(function (Table $table) {
|
|
1750
|
+
$table->pushColumns([
|
|
1751
|
+
TextColumn::make('created_at')
|
|
1752
|
+
->dateTime()
|
|
1753
|
+
->sortable()
|
|
1754
|
+
->toggleable(isToggledHiddenByDefault: true),
|
|
1755
|
+
TextColumn::make('updated_at')
|
|
1756
|
+
->dateTime()
|
|
1757
|
+
->sortable()
|
|
1758
|
+
->toggleable(isToggledHiddenByDefault: true),
|
|
1759
|
+
]);
|
|
1760
|
+
});
|
|
1761
|
+
|
|
1762
|
+
// Apply to all sections
|
|
1763
|
+
Section::configureUsing(fn (Section $section) => $section->columns(2));
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
```
|
|
1767
|
+
|
|
1768
|
+
### Soft Deletes in Resource
|
|
1769
|
+
|
|
1770
|
+
```php
|
|
1771
|
+
// In table class
|
|
1772
|
+
use Filament\Tables\Filters\TrashedFilter;
|
|
1773
|
+
|
|
1774
|
+
$table->filters([TrashedFilter::make()])
|
|
1775
|
+
|
|
1776
|
+
// In resource
|
|
1777
|
+
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
|
1778
|
+
|
|
1779
|
+
public static function getEloquentQuery(): Builder
|
|
1780
|
+
{
|
|
1781
|
+
return parent::getEloquentQuery()
|
|
1782
|
+
->withoutGlobalScopes([SoftDeletingScope::class]);
|
|
1783
|
+
}
|
|
1784
|
+
```
|
|
1785
|
+
|
|
1786
|
+
### Simple (Modal) Resources
|
|
1787
|
+
|
|
1788
|
+
```php
|
|
1789
|
+
// Generated with --simple flag
|
|
1790
|
+
// Uses modals instead of separate create/edit pages
|
|
1791
|
+
class CategoryResource extends Resource
|
|
1792
|
+
{
|
|
1793
|
+
protected static ?string $model = Category::class;
|
|
1794
|
+
|
|
1795
|
+
public static function form(Schema $schema): Schema { /* ... */ }
|
|
1796
|
+
public static function table(Table $table): Table { /* ... */ }
|
|
1797
|
+
|
|
1798
|
+
public static function getPages(): array
|
|
1799
|
+
{
|
|
1800
|
+
return [
|
|
1801
|
+
'index' => ListCategories::route('/'),
|
|
1802
|
+
];
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
```
|
|
1806
|
+
|
|
1807
|
+
### Multiple Panels
|
|
1808
|
+
|
|
1809
|
+
```php
|
|
1810
|
+
// Second panel provider (e.g., for customers)
|
|
1811
|
+
class CustomerPanelProvider extends PanelProvider
|
|
1812
|
+
{
|
|
1813
|
+
public function panel(Panel $panel): Panel
|
|
1814
|
+
{
|
|
1815
|
+
return $panel
|
|
1816
|
+
->id('customer')
|
|
1817
|
+
->path('customer')
|
|
1818
|
+
->login()
|
|
1819
|
+
->colors(['primary' => Color::Blue])
|
|
1820
|
+
->discoverResources(
|
|
1821
|
+
in: app_path('Filament/Customer/Resources'),
|
|
1822
|
+
for: 'App\\Filament\\Customer\\Resources'
|
|
1823
|
+
);
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
```
|
|
1827
|
+
|
|
1828
|
+
## Gotchas
|
|
1829
|
+
|
|
1830
|
+
1. **v5 file structure**: Resources split forms/tables into separate classes under `Schemas/` and `Tables/` directories.
|
|
1831
|
+
2. **`live()` is required** for reactive fields -- without it, `afterStateUpdated` won't fire.
|
|
1832
|
+
3. **Dot notation** auto-eager-loads relationships: `TextColumn::make('author.name')`.
|
|
1833
|
+
4. **Authorization** uses model policies by default -- if pages 404 or actions are hidden, check your policy methods.
|
|
1834
|
+
5. **`configureUsing()`** in service providers applies globally to all instances of a component type.
|
|
1835
|
+
6. **Utility injection** uses named parameters: `$state`, `$get`, `$set`, `$record`, `$operation`. Don't use positional args.
|
|
1836
|
+
7. **JS reactivity** (`hiddenJs`, `actionJs`) runs entirely client-side -- use for simple toggle/show/hide logic without server calls.
|
|
1837
|
+
8. **`ignoreRecord: true`** on unique validation is required for edit forms, otherwise the current record fails its own uniqueness check.
|
|
1838
|
+
9. **`dehydrated(false)`** prevents a field's value from being saved to the database -- use for display-only calculated fields.
|
|
1839
|
+
10. **Relation managers** vs **Select with relationship**: Use relation managers for HasMany/BelongsToMany management with full CRUD. Use `Select::make()->relationship()` for BelongsTo assignments.
|
|
1840
|
+
11. **Navigation badges** returning `null` hides the badge; return `'0'` (string) to show zero.
|
|
1841
|
+
12. **`->searchable()` on columns** adds per-column search. `->searchable(isGlobal: true)` adds to global search. Resource-level `$recordTitleAttribute` also feeds global search.
|