Skip to content

Migrating from Filament v3 to Arqel ​

Migration guide with 12 side-by-side mappings.

Why migrate ​

Filament v3 is mature and productive, especially for apps heavily focused on forms. Arqel is a better choice when the team already invests in React and wants a single Laravel + Inertia + React stack (without the Livewire/Alpine cognitive jump). The table below summarizes the trade-offs.

CriteriaFilament v3Arqel
Render layerLivewire + AlpineInertia 3 + React 19.2+
Client languageBlade + Alpine.jsStrict TypeScript
UI customizationBlade componentsReact components (shadcn CLI v4 (new-york) over Radix UI)
Hot-reload in devLivewire reloadVite HMR
Admin bundle sizeMedium (Alpine + Tailwind)Larger (React + Inertia)
React learning curveNot requiredRequires TS + React
Community pluginsRich (4 years)Building (v0.8)
Multi-tenancyBuilt-inarqel-dev/tenant (5 resolvers + 2 adapters)
Code splittingLimitedVite + tsup native
Static analysisGoodPHPStan max + TS strict

When NOT to migrate: if 80% of the work is simple forms + CRUD and the team has no React fluency, Filament is still excellent. Arqel pays dividends when the panel grows in custom UI complexity.

Decision matrix ​

FeatureFilament v3ArqelNotes
Resource CRUDYes (Filament\Resources)Yes (Arqel\Core\Resources\Resource)Very similar API
Form schemaYes (Livewire-bound)Yes (declarative Form + Inertia)No magical two-way binding
Table builderYes (Filament\Tables)Yes (Arqel\Table\Table)Parity > 90% Phase 2
Inline editingYesYes (TABLE-V2-002)TextInputColumn/SelectColumn/ToggleColumn
Bulk actionsYesYes (TABLE-008)BulkAction::execute(Collection)
Header actionsYesYes (HeaderAction)
Row actionsYesYes (RowAction)
FiltersYesYes (SelectFilter/DateRangeFilter/etc.)6 types + Visual Query Builder
Lifecycle hooksYes (mutateFormDataBefore...)Yes (beforeSave/afterSave/etc.)Different names
PoliciesYes (Laravel Gates)Yes (Laravel Gates)Identical
Multi-tenancyYesYes (arqel-dev/tenant)Stancl + Spatie adapters
Widgets / DashboardsYesYes (arqel-dev/widgets)Stat/Chart/Table/Custom
NotificationsYesPartial (Phase 2)Use Laravel Notifications
Custom pagesYes (Livewire components)Yes (Inertia + React) — different paradigmRewrite required
PluginsRichUnder developmentEvaluate case by case
File uploadsYesYes (FileField / ImageField)
WizardsYesYes (WizardField in fields-advanced)
Rich textYes (Trix)Yes (Tiptap in RichTextField)

Side-by-side: 12 patterns ​

1. Resource declaration ​

Filament:

php
namespace App\Filament\Resources;

use Filament\Resources\Resource;
use App\Models\Post;

class PostResource extends Resource
{
    protected static ?string $model = Post::class;
    protected static ?string $navigationIcon = 'heroicon-o-document';
    protected static ?string $navigationGroup = 'Content';
    protected static ?int $navigationSort = 10;
}

Arqel:

php
namespace App\Arqel\Resources;

use Arqel\Core\Resources\Resource;
use App\Models\Post;

final class PostResource extends Resource
{
    public static string $model = Post::class;
    public static ?string $navigationIcon = 'document';
    public static ?string $navigationGroup = 'Content';
    public static ?int $navigationSort = 10;
}

Difference: public static instead of protected. Icons are React component name strings (not hardcoded Heroicons). final by convention.

2. Form schema ​

Filament:

php
public static function form(Form $form): Form
{
    return $form->schema([
        TextInput::make('title')->required()->maxLength(255),
        Select::make('status')->options(['draft' => 'Draft', 'published' => 'Published']),
        RichEditor::make('body'),
    ]);
}

Arqel:

php
use Arqel\Fields\FieldFactory as F;

public function fields(): array
{
    return [
        F::text('title')->required()->maxLength(255),
        F::select('status')->options(['draft' => 'Draft', 'published' => 'Published']),
        F::richText('body'), // requires arqel-dev/fields-advanced
    ];
}

Difference: instance method (fields()) instead of static. Layouts (Section/Tabs/Grid) sit in a separate form() when needed.

3. Table columns ​

Filament:

php
public static function table(Table $table): Table
{
    return $table->columns([
        TextColumn::make('title')->sortable()->searchable()->limit(60),
        BadgeColumn::make('status')->colors(['gray' => 'draft', 'success' => 'published']),
        TextColumn::make('published_at')->date('d/m/Y'),
    ]);
}

Arqel:

php
use Arqel\Table\Table;
use Arqel\Table\Columns\{TextColumn, BadgeColumn, DateColumn};

public function table(): Table
{
    return Table::make()->columns([
        TextColumn::make('title')->sortable()->searchable()->limit(60),
        BadgeColumn::make('status')->colors(['draft' => 'gray', 'published' => 'green']),
        DateColumn::make('published_at')->date('d/m/Y'),
    ]);
}

Difference: separate DateColumn (not TextColumn::date()). Colors in Arqel map value → color (more natural).

4. Filters ​

Filament:

php
->filters([
    SelectFilter::make('status')->options(['draft' => 'Draft', 'published' => 'Published']),
    Filter::make('is_featured')->query(fn ($query) => $query->where('is_featured', true)),
])

Arqel:

php
use Arqel\Table\Filters\{SelectFilter, TernaryFilter};

->filters([
    SelectFilter::make('status')->options(['draft' => 'Draft', 'published' => 'Published']),
    TernaryFilter::make('is_featured'), // Yes/No/All automatically
])

5. Actions: row, bulk, and header ​

Filament:

php
->actions([
    Tables\Actions\EditAction::make(),
    Tables\Actions\DeleteAction::make(),
])
->bulkActions([
    Tables\Actions\BulkActionGroup::make([
        Tables\Actions\DeleteBulkAction::make(),
    ]),
])
->headerActions([
    Tables\Actions\CreateAction::make(),
])

Arqel:

php
use Arqel\Actions\Actions;

->actions([
    Actions::edit(),
    Actions::delete(),
])
->bulkActions([
    Actions::deleteBulk(),
])
->toolbarActions([
    Actions::create(),
])

Difference: toolbarActions() instead of headerActions(). No BulkActionGroup wrapper — the array is the group.

6. Policies / Authorization ​

Filament:

php
// app/Policies/PostPolicy.php — Laravel standard
public function update(User $user, Post $post): bool { return $user->id === $post->user_id; }

Arqel (identical — uses native Laravel Gates):

php
public function update(User $user, Post $post): bool { return $user->id === $post->user_id; }

Arqel respects Gate::authorize automatically in controllers and filters per-row actions via Action::canBeExecutedBy (TABLE-007). Policy migration = zero code.

7. Lifecycle hooks ​

Filament:

php
protected function mutateFormDataBeforeCreate(array $data): array
{
    $data['user_id'] = auth()->id();
    return $data;
}

protected function afterCreate(): void { /* ... */ }

Arqel:

php
// inside the Resource:
protected function beforeCreate(array $data): array
{
    $data['user_id'] = auth()->id();
    return $data;
}

protected function afterCreate(Model $record): void { /* ... */ }

Difference: afterCreate receives the Model (does not use $this->record). There's also beforeSave/afterSave that run on create+update.

8. Multi-tenancy ​

Filament:

php
// AdminPanelProvider
$panel->tenant(Team::class)->tenantRoutePrefix('team');

Arqel (with arqel-dev/tenant):

php
// config/arqel.php
'tenancy' => [
    'resolver' => Arqel\Tenant\Resolvers\AuthUserResolver::class,
    'model'    => App\Models\Team::class,
    'foreign_key' => 'team_id',
],

// Tenanted models:
class Post extends Model
{
    use \Arqel\Tenant\Concerns\BelongsToTenant;
}

// Routes:
Route::middleware(['web', 'auth', 'arqel.tenant'])->group(function () { /* ... */ });

Arqel offers 5 resolvers (Subdomain, Path, Header, Session, AuthUser) + 2 adapters (stancl/tenancy, spatie/laravel-multitenancy).

9. Dashboards and Widgets ​

Filament:

php
class StatsOverview extends BaseWidget
{
    protected function getStats(): array
    {
        return [Stat::make('Users', User::count())];
    }
}

Arqel (arqel-dev/widgets):

php
use Arqel\Widgets\StatWidget;

final class UsersStat extends StatWidget
{
    protected string $type = 'users_count';
    protected string $component = 'StatWidget';

    public function data(): array
    {
        return ['label' => 'Users', 'value' => User::count()];
    }
}

// Dashboard:
Dashboard::make('main', 'Overview')
    ->widgets([UsersStat::class, RevenueChart::class])
    ->columns(3);

10. File uploads ​

Filament:

php
FileUpload::make('attachment')->disk('s3')->directory('attachments')->image();

Arqel:

php
use Arqel\Fields\FieldFactory as F;

F::file('attachment')->disk('s3')->directory('attachments');
F::image('cover')->disk('s3')->directory('covers');

11. Custom Inertia pages (the biggest difference) ​

Filament (Livewire):

php
// app/Filament/Pages/AnalyticsPage.php
class AnalyticsPage extends Page
{
    protected static string $view = 'filament.pages.analytics';
    public int $rangeDays = 30;
    public function loadStats(): array { /* ... */ }
}

Arqel (Controller + Inertia + React — different paradigm):

php
// app/Http/Controllers/AnalyticsController.php
final class AnalyticsController
{
    public function __invoke(Request $request): \Inertia\Response
    {
        return inertia('Analytics', [
            'rangeDays' => (int) $request->query('range', 30),
            'stats'     => app(AnalyticsService::class)->stats(),
        ]);
    }
}
tsx
// resources/js/Pages/Analytics.tsx
export default function Analytics({ rangeDays, stats }: Props) {
    return <DashboardLayout>{/* JSX */}</DashboardLayout>;
}

This is the biggest rewrite. Livewire pages with reactive state need to be ported to React components. There's no shortcut.

12. Notifications ​

Filament:

php
Notification::make()->title('Saved').success()->send();

Arqel (Phase 2 — temporary):

php
return back()->with('flash', ['message' => 'Saved', 'type' => 'success']);
// React side: useFlash() hook reads props.flash

A unified notification system is planned for Phase 2 (arqel-dev/notifications). Today, use Laravel Notifications + Inertia shared props.

Step-by-step checklist ​

  1. [ ] Audit Models, Migrations, Policies — they don't change.
  2. [ ] Install Arqel: composer require arqel-dev/framework followed by php artisan arqel:install (installs core + table + actions + fields, publishes configs and the shadcn (new-york) theme over Radix UI).
  3. [ ] Configure a parallel panel at /admin-v2 (coexisting with Filament).
  4. [ ] Migrate the simplest Resources first (shallow CRUD, no relations).
  5. [ ] Port form() → fields() + adjust hook names (mutateFormDataBeforeCreate → beforeCreate).
  6. [ ] Port table() columns/filters — close to 1:1 parity.
  7. [ ] Migrate Actions (header → toolbar, BulkActionGroup → direct array).
  8. [ ] Migrate Widgets/Dashboards to arqel-dev/widgets.
  9. [ ] Rewrite custom Livewire pages in React + Inertia (last — biggest effort).
  10. [ ] Configure arqel-dev/tenant if there's multi-tenancy; remove the Filament panel; switch the /admin-v2 prefix to /admin.

Common pitfalls ​

  1. $this->record does not exist in Arqel hooks. Use the Model $record passed as an argument. Hooks receive the record explicitly.
  2. No magical two-way binding. Arqel uses Inertia + React state — updates require useForm from @inertiajs/react, not Livewire polling.
  3. Badge colors are inverted. Filament: colors(['color' => 'value']). Arqel: colors(['value' => 'color']) — more natural.
  4. BulkActionGroup does not exist. In Arqel, bulkActions() accepts a direct array.
  5. Custom Livewire pages = rewrite. There's no automatic translator. Components with reactive state become React components; server-side state becomes Inertia props.

MIT License — built with Inertia + React + Laravel.