Saltar al contenido principal

Cómo estructuro features Laravel + Vue para que sean mantenibles (no solo entregables)

Autor
Ignacio AmatIgnacio Amat
Publicado
Lectura5 min
Código de Laravel y Vue con estructura modular y limpia

La mayoría de los proyectos Laravel empiezan ordenados. A los 6 meses, el controlador OrderController tiene 800 líneas, los componentes Vue reciben any como props, y nadie sabe qué pasa si tocas processPayment().

He cometido todos esos errores. Y he desarrollado una estructura que los evita: features modulares con fronteras claras entre backend y frontend.


El principio: un feature, un módulo

Olvida la estructura plana de Laravel por un momento. En lugar de:

app/Http/Controllers/OrderController.php
app/Http/Requests/OrderRequest.php
app/Models/Order.php
app/Services/OrderService.php
resources/js/Pages/Orders/Index.vue
resources/js/Pages/Orders/Show.vue

Organiza por feature:

app/Features/OrderManagement/
├── Http/
│   ├── Controllers/
│   │   └── OrderController.php
│   └── Requests/
│       ├── CreateOrderRequest.php
│       └── UpdateOrderRequest.php
├── Models/
│   └── Order.php
├── Services/
│   └── OrderService.php
├── Jobs/
│   └── ProcessOrderPayment.php
├── Tests/
│   ├── OrderControllerTest.php
│   └── OrderServiceTest.php
└── frontend/
    ├── Components/
    │   ├── OrderTable.vue
    │   └── OrderStatusBadge.vue
    ├── Pages/
    │   ├── OrderIndex.vue
    │   └── OrderShow.vue
    └── types.ts

Esto no es una moda. Es una decisión práctica: cuando tocas una feature, tocas un solo lugar del código. No tienes que navegar por 6 directorios diferentes para entender cómo funciona.


Contrato de datos: types compartidos

El mayor agujero en proyectos Laravel+Vue es la falta de un contrato entre backend y frontend. Con Inertia las props llegan del controlador, pero sin tipos explícitos.

// app/Features/OrderManagement/frontend/types.ts
export interface Order {
  id: number;
  customer_name: string;
  total_cents: number;
  total_formatted: string; // Calculado en backend
  status: 'pending' | 'processing' | 'completed' | 'cancelled';
  items: OrderItem[];
  created_at: string;
  can: {
    cancel: boolean;
    refund: boolean;
  };
}

export interface OrderItem {
  id: number;
  product_name: string;
  quantity: number;
  unit_price_cents: number;
  total_cents: number;
}

export interface OrderFilters {
  status?: string;
  date_from?: string;
  date_to?: string;
  search?: string;
}
// app/Features/OrderManagement/Services/OrderPresentationService.php
class OrderPresentationService
{
    public function forInertia(Order $order): array
    {
        return [
            'id' => $order->id,
            'customer_name' => $order->user->name,
            'total_cents' => $order->total_cents,
            'total_formatted' => number_format($order->total_cents / 100, 2) . ' €',
            'status' => $order->status,
            'items' => $order->items->map(fn ($item) => [
                'id' => $item->id,
                'product_name' => $item->product->name,
                'quantity' => $item->quantity,
                'unit_price_cents' => $item->unit_price_cents,
                'total_cents' => $item->total_cents,
            ]),
            'created_at' => $order->created_at->toIso8601String(),
            'can' => [
                'cancel' => auth()->user()->can('cancel', $order),
                'refund' => auth()->user()->can('refund', $order),
            ],
        ];
    }
}

El tipo TypeScript y el array PHP se mantienen sincronizados manualmente, pero al estar en el mismo módulo es fácil detectar discrepancias en el PR.


Validación que no se duplica

Usa FormRequest de Laravel como fuente de verdad y consume los errores desde Vue con useForm:

// app/Features/OrderManagement/Http/Requests/CreateOrderRequest.php
class CreateOrderRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'customer_id' => ['required', 'exists:users,id'],
            'items' => ['required', 'array', 'min:1'],
            'items.*.product_id' => ['required', 'exists:products,id'],
            'items.*.quantity' => ['required', 'integer', 'min:1', 'max:100'],
            'notes' => ['nullable', 'string', 'max:500'],
        ];
    }

    public function messages(): array
    {
        return [
            'items.*.product_id.required' => 'Each item must have a product selected.',
            'items.*.quantity.max' => 'Max 100 units per product.',
        ];
    }
}
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';

const form = useForm({
  customer_id: null,
  items: [{ product_id: null, quantity: 1 }],
  notes: '',
});

function submit() {
  form.post('/orders', {
    preserveScroll: true,
    onError: (errors) => {
      // Los errores de validación de Laravel llegan solos
      if (errors.items_0_product_id) {
        // Errores por item
      }
    },
  });
}
</script>

Sin duplicar reglas en JavaScript. Sin Zod en el frontend para lo mismo que ya validas en PHP.


Tests que protegen el feature

// app/Features/OrderManagement/Tests/OrderCreationTest.php
namespace Features\OrderManagement\Tests;

use App\Features\OrderManagement\Models\Order;
use App\Features\OrderManagement\Services\OrderService;

test('creates order with valid items', function () {
    $customer = User::factory()->create();
    $product = Product::factory()->create(['price_cents' => 2999]);

    $order = (new OrderService())->createOrder([
        'customer_id' => $customer->id,
        'items' => [['product_id' => $product->id, 'quantity' => 2]],
    ]);

    expect($order->items)->toHaveCount(1)
        ->and($order->total_cents)->toBe(5998);
});

test('rejects order with invalid product', function () {
    $customer = User::factory()->create();

    expect(fn () => (new OrderService())->createOrder([
        'customer_id' => $customer->id,
        'items' => [['product_id' => 9999, 'quantity' => 1]],
    ]))->toThrow(\Illuminate\Database\Eloquent\ModelNotFoundException::class);
});

test('renders order index page via inertia', function () {
    Order::factory()->count(5)->create();

    $response = $this->actingAs(User::factory()->create())
        ->get('/orders');

    $response->assertInertia(fn (AssertableInertia $page) => $page
        ->component('OrderManagement/OrderIndex')
        ->has('orders.data', 5)
    );
});

Lo que no hago

Práctica evitadaPor qué
Repositorios genéricosLaravel Eloquent ya es tu capa de datos; no la envuelvas sin motivo
Helpers globalesVan a app/Features/Shared/ o al módulo que corresponda
Eventos que “suenan” sin consecuenciasCada evento debe tener al menos un listener o documentar por qué es fire-and-forget
Vuex/Pinia para todoSi son props de Inertia, no necesitas estado global
any en TypeScriptCada prop de backend tiene su interfaz

Conclusión

La estructura de código no es un ejercicio estético. Es la diferencia entre poder incorporar a un desarrollador nuevo en un día versus una semana. Un feature, un módulo, un contrato, unos tests.

Si tu equipo necesita orden en un proyecto Laravel + Vue, revisa mi stack técnico y mi disponibilidad.


Artículos relacionados

Artículos relacionados

Revisa mi perfil como desarrollador

Si este artículo encaja con los retos técnicos de tu equipo, revisa mi stack o mi disponibilidad profesional.

Cuéntame qué necesitas

Puedes escribirme por un rol, contrato, colaboración técnica, una duda o una consulta general. Con 2-3 líneas de contexto suelo poder responder en menos de 24 horas hábiles.

0/500
Disponibilidad