Skip to content

Migrando de Laravel Nova a Arqel

Guía de migración con 12 mappings side-by-side.

Por qué migrar

Laravel Nova está pulido, es oficial y tiene licencia comercial — tres razones fuertes para seguir usándolo si el equipo está contento. Arqel empieza a tener sentido cuando el equipo quiere (a) control MIT + open-source, (b) React/TypeScript en lugar del stack Vue+Inertia históricamente sabor Nova, y (c) libertad para personalizar la UI sin pelearse con Cards/Resource Tools propietarios. La tabla de abajo resume.

CriterioLaravel NovaArqel
LicenciaComercial (de pago)MIT
Capa de renderInertia + VueInertia + React
Personalización UIComponentes Vue + ToolsReact + shadcn CLI v4 (new-york) sobre Radix UI
Tipado del clienteJS (TS opcional)TypeScript strict requerido
LensesBuilt-inMapean a vistas con scope
CardsBuilt-in (Vue)Widgets (arqel-dev/widgets)
Métricas (Value/Trend/Partition)Built-inStatWidget + ChartWidget
Multi-tenancyDIYarqel-dev/tenant
Tipos de Field built-in~30~25 (core + advanced)
Plugins de la comunidadMaduroEn construcción (v0.8)
Migración a Laravel CloudAmbos soportadosIdéntica

Cuándo NO migrar: si el equipo depende de Resource Tools/Cards propietarios sin equivalentes open-source, o si Vue es el stack canónico de la empresa.

Matriz de decisión

FeatureNovaArqelNotas
ResourceLaravel\Nova\ResourceArqel\Core\Resources\ResourceAPI similar
FieldsText::make/Select::make/etc.FieldFactory::text/select/etc.Pequeños renombres
FiltersLenses\Filters\FilterArqel\Table\Filters\*6 tipos listos
Lensesclase LensVistas con scope (Resource + ruta custom)Manual
ActionsActions\ActionRowAction/HeaderAction/BulkAction3 tipos explícitos
CardsCards\CardArqel\Widgets\Widget + subtiposStat/Chart/Table/Custom
Métrica: ValueMetrics\ValueStatWidgetDirecto
Métrica: TrendMetrics\TrendChartWidget (line/area)Directo
Métrica: PartitionMetrics\PartitionChartWidget (pie/donut)Directo
Resource policiesLaravel GatesLaravel GatesIdénticas
ToolsNova\Tool (Vue)Página Inertia personalizada (React)Reescritura
Field personalizadoVue SFCComponente React + clase Field PHPReescritura
File uploadsField File/ImageFileField/ImageFieldDirecto
Multi-tenancyDIY (con Stancl o similar)Adapters arqel-dev/tenantPaquete nativo
Action queueingShouldQueue (Laravel)ShouldQueue (Laravel)Idéntico
SearchScout o searchableColumna searchable() + Scout opcional
Inline createModal built-inPendiente Fase 3Usa la ruta create por ahora
Repeatable fieldsPlugins de la comunidadRepeaterField/BuilderFieldarqel-dev/fields-advanced

Side-by-side: 12 patrones

1. Declaración de Resource

Nova:

php
namespace App\Nova;

use Laravel\Nova\Resource;

class Post extends Resource
{
    public static $model = \App\Models\Post::class;
    public static $title = 'title';
    public static $search = ['id', 'title'];
}

Arqel:

php
namespace App\Arqel\Resources;

use Arqel\Core\Resources\Resource;

final class PostResource extends Resource
{
    public static string $model = \App\Models\Post::class;
    public static ?string $recordTitleAttribute = 'title';
    // search se declara vía column->searchable() en table()
}

Diferencias: el nombre de la clase incluye el sufijo Resource (convención Arqel). $title se vuelve $recordTitleAttribute. La búsqueda migra a nivel de columna.

2. Fields del Resource

Nova:

php
public function fields(NovaRequest $request): array
{
    return [
        ID::make()->sortable(),
        Text::make('Title')->rules('required', 'max:255'),
        Select::make('Status')->options(['draft' => 'Draft', 'published' => 'Published']),
        BelongsTo::make('Author', 'user', User::class),
        Date::make('Published At'),
    ];
}

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::belongsTo('user_id', 'user', 'name'),
        F::date('published_at'),
    ];
}

Diferencias: keys snake_case (atributos directos de Eloquent). BelongsTo en Arqel toma (foreign_key, relation, attribute_label). El ID se renderiza automáticamente — no lo declares.

3. Columnas de tabla (Nova: config de field index)

Nova (el mismo fields() controla index/detail/forms vía visibilidad):

php
Text::make('Title')->onlyOnIndex()->sortable(),
Text::make('Body')->onlyOnDetail()->asHtml(),

Arqel (separación clara: fields() para form, table() para index):

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'),
    ]);
}

Nova condensa todo en fields() con flags de visibilidad. Arqel divide en fields() (forms) + table() (index) — más explícito, menos magia.

4. Filtros

Nova:

php
class Status extends Filter
{
    public function apply(NovaRequest $request, $query, $value)
    { return $query->where('status', $value); }

    public function options(NovaRequest $request)
    { return ['Draft' => 'draft', 'Published' => 'published']; }
}

Arqel (declarativo, sin clase personalizada):

php
use Arqel\Table\Filters\SelectFilter;

SelectFilter::make('status')
    ->options(['draft' => 'Draft', 'published' => 'Published'])
    ->apply(fn ($query, $value) => $query->where('status', $value));

Para filtros complejos con múltiples constraints, usa QueryBuilderFilter con TextConstraint/NumberConstraint/DateConstraint (TABLE-V2-003).

5. Lenses → Vistas con scope

Las Lenses de Nova son "vistas alternativas del recurso" con query personalizado + columnas personalizadas. Arqel no tiene un concepto 1:1 — porta cada Lens como un Resource adicional o una ruta personalizada.

Nova Lens:

php
class MostValuableCustomers extends Lens
{
    public static function query(LensRequest $request, $query) { /* ... */ }
    public function fields(Request $request) { /* ... */ }
}

Arqel (opción A — Resource con scope):

php
final class MostValuableCustomersResource extends Resource
{
    public static string $model = Customer::class;

    public function indexQuery(): Builder
    { return Customer::query()->where('lifetime_value', '>', 10000); }

    public function table(): Table { /* columnas dedicadas */ }
}

Arqel (opción B — ruta personalizada + página Inertia) para casos con lógica compleja fuera del CRUD.

6. Actions

Nova:

php
class PublishPost extends Action
{
    public function handle(ActionFields $fields, Collection $models)
    { $models->each->update(['status' => 'published']); }
}

Arqel:

php
use Arqel\Actions\Types\{RowAction, BulkAction};

RowAction::make('publish')
    ->label('Publish')
    ->action(fn ($record) => $record->update(['status' => 'published']));

BulkAction::make('publish_bulk')
    ->label('Publish selected')
    ->execute(fn (Collection $records) => $records->each->update(['status' => 'published']))
    ->chunkSize(100);

Diferencia: Nova unifica row/bulk. Arqel divide explícitamente — bulk recibe un Collection chunked, row recibe un Model.

7. Cards → Widgets (overview)

Nova:

php
public function cards(NovaRequest $request): array
{
    return [new TotalCustomers, new RevenueChart];
}

class TotalCustomers extends Card { protected $width = '1/3'; /* componente Vue */ }

Arqel:

php
use Arqel\Widgets\Dashboard;

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

Diferencia: Nova vincula cards al Resource. Arqel los divide en un Dashboard dedicado, registrado en el Panel.

8. Métricas → Stat widgets

Nova Value metric:

php
class TotalRevenue extends Value
{
    public function calculate(NovaRequest $request)
    { return $this->result(Order::sum('total')); }
}

Arqel StatWidget:

php
use Arqel\Widgets\StatWidget;

final class TotalRevenue extends StatWidget
{
    protected string $type = 'total_revenue';
    protected string $component = 'StatWidget';

    public function data(): array
    {
        return ['label' => 'Total Revenue', 'value' => Order::sum('total')];
    }
}

Trend metric Nova → ChartWidget (line):

php
final class RevenueTrend extends ChartWidget
{
    protected string $type = 'revenue_trend';
    protected string $component = 'ChartWidget';

    public function data(): array
    {
        return [
            'kind'   => 'line',
            'labels' => $this->last30Days(),
            'series' => [['name' => 'Revenue', 'data' => $this->revenueByDay()]],
        ];
    }
}

9. Policies / Autorización

Idéntico en ambos — Laravel Gates. Migración = cero código.

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

Arqel filtra acciones per-row automáticamente vía Action::canBeExecutedBy (TABLE-007); Nova lo hace vía authorizedToRun/authorizedToView. Con una Policy limpia, ambos funcionan sin wiring extra.

10. Multi-tenancy

Nova no tiene multi-tenancy nativo — integras stancl/tenancy o similar manualmente. Arqel incluye arqel-dev/tenant con adapters listos:

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

// Modelos tenanted:
class Post extends Model { use \Arqel\Tenant\Concerns\BelongsToTenant; }

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

Validación tenant-aware: cambia Rule::unique por new ScopedUnique('posts', 'slug').

11. File uploads

Nova:

php
File::make('Attachment')->disk('s3')->path('attachments');
Image::make('Cover')->disk('s3')->path('covers');

Arqel:

php
use Arqel\Fields\FieldFactory as F;

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

Diferencia mínima: path() se vuelve directory().

12. Tools / Páginas personalizadas

Las Tools de Nova son componentes Vue con integración server-side de menú. Arqel no tiene un concepto 1:1 — implémentalas como controller + página Inertia + componente React, luego añade al panel vía Panel widgets() o una ruta personalizada.

Nova Tool:

bash
php artisan nova:tool acme/analytics-tool
# genera SFC Vue + ToolServiceProvider

Arqel (manual):

php
// app/Http/Controllers/AnalyticsController.php
final class AnalyticsController
{
    public function __invoke(): \Inertia\Response
    { return inertia('Analytics', ['stats' => /* ... */]); }
}

// resources/js/Pages/Analytics.tsx — componente React personalizado
// routes/admin.php
Route::get('/admin/analytics', AnalyticsController::class)->name('admin.analytics');

Checklist paso a paso

  1. [ ] Audita Models, Migrations, Policies — no cambian.
  2. [ ] Instala Arqel: composer require arqel-dev/framework seguido de php artisan arqel:install (instala core + table + actions + fields, publica configs y el tema shadcn (new-york) sobre Radix UI).
  3. [ ] Configura un panel paralelo en /admin-v2 (coexistiendo con Nova).
  4. [ ] Migra primero los Resources simples; renombra clases con el sufijo Resource.
  5. [ ] Divide fields() de Nova en fields() (forms) + table() (index) en Arqel.
  6. [ ] Porta los Filtros: clases custom de Nova → Arqel\Table\Filters\* declarativos.
  7. [ ] Mapea Lenses → Resources con scope o rutas personalizadas.
  8. [ ] Migra Cards/Métricas → arqel-dev/widgets (StatWidget/ChartWidget).
  9. [ ] Reescribe las Tools Vue como páginas Inertia React.
  10. [ ] Configura arqel-dev/tenant si aplica; abandona Nova; promueve /admin-v2 a /admin.

Pitfalls comunes

  1. fields() no controla index/detail/forms al mismo tiempo. En Arqel, fields() es solo para forms. Usa table() para columnas del index. Esta división suele sorprender a quienes vienen de Nova.
  2. ID::make() no existe. La columna primaria se renderiza automáticamente; declárala solo si quieres ordering personalizado u ocultarla.
  3. Las Lenses no tienen concepto 1:1. Evalúa cada Lens: si es una "vista alternativa del mismo Resource", crea otro Resource con un indexQuery() personalizado. Si es un dashboard analítico, se vuelve un Widget.
  4. Vue Tools → reescritura React. No existe traductor — los SFC Vue con <script setup> necesitan volverse .tsx. Esto es típicamente el trabajo más demandante.
  5. El queueing de Action es igual, y el modal de formulario de la action ya viene built-in. Arqel soporta el "modal de confirmación con fields" de Nova de forma nativa: declara los inputs con Action::form([...fields]) (y modalSize()), y el ActionFormModal de @arqel-dev/ui renderiza el form de fields dentro del modal de la action — sin necesidad de un form React personalizado.

Licencia MIT — construido con Inertia + React + Laravel.