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 evitada | Por qué |
|---|---|
| Repositorios genéricos | Laravel Eloquent ya es tu capa de datos; no la envuelvas sin motivo |
| Helpers globales | Van a app/Features/Shared/ o al módulo que corresponda |
| Eventos que “suenan” sin consecuencias | Cada evento debe tener al menos un listener o documentar por qué es fire-and-forget |
| Vuex/Pinia para todo | Si son props de Inertia, no necesitas estado global |
any en TypeScript | Cada 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
- Vue 3 + Inertia.js: la combinación ganadora para SaaS — SSR, testing, formularios
- Laravel + Vue para equipos distribuidos — Arquitectura Inertia, flujo async, onboarding
- Stack técnico para startups en 2026 — Laravel, Vue, Inertia, testing, despliegue
