Most Laravel projects start organized. Six months later, OrderController is 800 lines, Vue components receive any as props, and nobody knows what breaks if you touch processPayment().
I’ve made all those mistakes. And I’ve developed a structure that prevents them: feature-first modules with clear backend-to-frontend boundaries.
The principle: one feature, one module
Forget the flat Laravel structure for a moment. Instead of:
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
Organize by 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
This is not a trend. It’s a practical decision: when you touch a feature, you touch one place in the code. No need to navigate 6 different directories to understand how something works.
Data contracts: shared types
The biggest gap in Laravel+Vue projects is the missing contract between backend and frontend. With Inertia the props arrive from the controller, but without explicit types.
// app/Features/OrderManagement/frontend/types.ts
export interface Order {
id: number;
customer_name: string;
total_cents: number;
total_formatted: string; // Computed in 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),
],
];
}
}
The TypeScript type and the PHP array stay in sync manually, but being in the same module makes discrepancies easy to catch in PR review.
Validation that doesn’t duplicate
Use Laravel FormRequest as the source of truth and consume errors in Vue with 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) => {
// Laravel validation errors arrive automatically
if (errors.items_0_product_id) {
// Per-item errors
}
},
});
}
</script>
No duplicated rules in JavaScript. No Zod on the frontend for what you already validate in PHP.
Tests that protect the 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)
);
});
What I avoid
| Avoided practice | Why |
|---|---|
| Generic repositories | Laravel Eloquent is your data layer; don’t wrap it without reason |
| Global helpers | Belong in app/Features/Shared/ or the relevant module |
| Events that fire without consequences | Every event should have at least one listener or document why it’s fire-and-forget |
| Vuex/Pinia for everything | If they’re Inertia props, you don’t need global state |
any in TypeScript | Every backend prop must have its interface |
Conclusion
Code structure is not aesthetic. It’s the difference between onboarding a new developer in one day versus one week. One feature, one module, one contract, one set of tests.
If your team needs order in a Laravel + Vue project, check my technical stack and availability.
Related Articles
- Vue 3 + Inertia.js: The Winning Combo for Building Fast SaaS — SSR, testing, forms
- Laravel + Vue for Distributed Teams — Inertia architecture, async workflow, onboarding
- Tech Stack for Startups in 2026 — Laravel, Vue, Inertia, testing, deployment
