Skip to main content

How I Structure Laravel + Vue Features So They Stay Maintainable (Not Just Shippable)

Author
Ignacio AmatIgnacio Amat
Published
Reading Time5 min
Clean Laravel and Vue code with modular architecture structure

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 practiceWhy
Generic repositoriesLaravel Eloquent is your data layer; don’t wrap it without reason
Global helpersBelong in app/Features/Shared/ or the relevant module
Events that fire without consequencesEvery event should have at least one listener or document why it’s fire-and-forget
Vuex/Pinia for everythingIf they’re Inertia props, you don’t need global state
any in TypeScriptEvery 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

Review my developer profile

If this article matches the kind of product work your team is facing, review my stack or professional availability.

Tell me what you need

You can reach out about a role, contract, technical collaboration, question, or general inquiry. A few lines of context are usually enough for me to reply within 24 business hours.

0/500
Availability