DEV Community

Cover image for How to Build an Enterprise-Grade App with Laravel 12 & React (Clean Architecture)
Md Murtuza Hussain
Md Murtuza Hussain

Posted on

How to Build an Enterprise-Grade App with Laravel 12 & React (Clean Architecture)

Building an application that scales efficiently from a few hundred users to hundreds of thousands is a complex challenge. Many developers start by placing all their business logic directly into controllers. While this allows for rapid prototyping, it quickly transforms the application into a ticking time bomb of technical debt.

Clean Architecture is the solution.

Clean architecture is a software engineering philosophy that separates your code into distinct, isolated layers. This separation of concerns ensures that your application remains highly testable, independent of underlying frameworks (like your database or UI), and incredibly easy to maintain as it grows.

In this comprehensive tutorial, we are going to build a high-performance, enterprise-grade application using Laravel 12 for the backend and React (with TypeScript) for the frontend. We will walk through building full CRUD (Create, Read, Update, Delete) operations, implementing Database Transactions to prevent data corruption, configuring Policies and Gates for authorization, and exploring every essential layer of Laravel's architecture.


The Architecture Diagram & Request Lifecycle

Before we write any code, it is critical to understand the architecture we are building. In a traditional MVC app, a Controller handles the request, talks to the Model, and returns the View. In Clean Architecture, we introduce intermediate layers to isolate logic.

Here is the exact lifecycle of an HTTP Request in our enterprise backend:

[HTTP Request]
      │
      ▼
1. [Route Definition] (routes/api.php)
      │
      ▼
2. [Middleware] (Sanctum Authentication)
      │
      ▼
3. [Gate / Policy] (Authorization - Can the user do this?)
      │
      ▼
4. [Form Request] (Validation - Is the data correct?)
      │
      ▼
5. [Controller] (The Traffic Cop - Routes data, contains NO logic)
      │
      ▼
6. [Service Layer] (Business Logic & Database Transactions)
      │      └─▶ [Event Dispatcher] (Asynchronous side-effects like emails)
      ▼
7. [Repository Layer] (Abstracts the Database/ORM queries)
      │
      ▼
8. [Database] (MySQL / Postgres)
      │
      ▼
   (Data flows back up through the layers)
      │
      ▼
9. [API Resource] (Transforms internal Models into strictly typed JSON)
      │
      ▼
[HTTP Response]
Enter fullscreen mode Exit fullscreen mode

The Directory Structure

By the end of this tutorial, our backend folder structure will look like this, separating every concern into its own domain:

app/
├── Events/
│   └── OrderCreated.php           # Holds simple event data
├── Http/
│   ├── Controllers/
│   │   └── OrderController.php    # Handles HTTP transport only
│   ├── Requests/
│   │   ├── StoreOrderRequest.php  # Validates incoming POST data
│   │   └── UpdateOrderRequest.php # Validates incoming PUT data
│   └── Resources/
│       └── OrderResource.php      # Formats the outgoing JSON
├── Listeners/
│   └── SendOrderNotification.php  # Handles background tasks (e.g. Emails)
├── Models/
│   ├── Order.php                  # Eloquent Database Model
│   └── User.php
├── Policies/
│   └── OrderPolicy.php            # Model-specific security rules
├── Providers/
│   ├── AppServiceProvider.php     # Global security Gates
│   └── RepositoryServiceProvider.php # Binds Repository Interfaces
├── Repositories/
│   ├── Contracts/
│   │   └── OrderRepositoryInterface.php # The strict contract
│   └── Eloquent/
│       └── OrderRepository.php    # The actual database queries
└── Services/
    └── OrderService.php           # Complex business logic & DB Transactions
Enter fullscreen mode Exit fullscreen mode

The Backend: Laravel 12 Clean Architecture

We are building a scalable Order management backend. Let's build it layer by layer, scaffolding the files using Artisan as we go.

1. Model & Database Setup

Before writing logic, we need our database schema. Laravel makes this effortless. We use Artisan to generate our Model, Migration, Factory, and a Controller wired up for an API.

laravel new enterprise-app --api
cd enterprise-app

# Generate the Model, Migration, Factory, and Controller (API mode)
php artisan make:model Order -mfc --api
Enter fullscreen mode Exit fullscreen mode

2. Routes (routes/api.php)

We define our endpoints using apiResource. This instantly wires up all standard CRUD routes (index, show, store, update, destroy) without cluttering the routing file.

use App\Http\Controllers\OrderController;
use Illuminate\Support\Facades\Route;

Route::apiResource('orders', OrderController::class);
Enter fullscreen mode Exit fullscreen mode

3. Policies & Gates (Authorization)

In an enterprise application, you must never assume an authenticated user is allowed to perform an action. We use Gates for global, application-wide rules (e.g., "Is this an admin?") and Policies for model-specific rules (e.g., "Does this user own this specific order?").

# Generate a Policy for our Order model
php artisan make:policy OrderPolicy --model=Order
Enter fullscreen mode Exit fullscreen mode

The Global Gate (AppServiceProvider):

// app/Providers/AppServiceProvider.php
namespace App\Providers;

use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
use App\Models\User;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // Global Gate: Only admins can delete anything across the system
        Gate::define('delete-records', function (User $user): bool {
            return $user->role === 'admin';
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

The Model Policy:

// app/Policies/OrderPolicy.php
namespace App\Policies;

use App\Models\Order;
use App\Models\User;

class OrderPolicy
{
    // Model-specific Policy: Users can only view their own orders
    public function view(User $user, Order $order): bool
    {
        return $user->id === $order->user_id;
    }
}
Enter fullscreen mode Exit fullscreen mode

4. Form Requests (Validation)

Never put validation logic directly in your controller. We intercept the incoming HTTP request using Form Requests. This ensures that the controller and business logic layers only ever receive sanitized, correct data.

# Generate Form Requests for validation
php artisan make:request StoreOrderRequest
php artisan make:request UpdateOrderRequest
Enter fullscreen mode Exit fullscreen mode

The Store Request:

// App\Http\Requests\StoreOrderRequest.php
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreOrderRequest extends FormRequest
{
    public function authorize(): bool 
    { 
        return true; // We delegate authorization to Policies and Controllers
    }

    public function rules(): array
    {
        return [
            'product_id' => 'required|exists:products,id',
            'quantity' => 'required|integer|min:1',
            'customer_email' => 'required|email',
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

5. The Repository Pattern (Data Access Layer)

The Repository Pattern abstracts the database layer entirely. By writing queries in a repository rather than a controller, we decouple our business logic from Eloquent. If you need to switch databases, optimize a complex select query, or mock the database for testing, you alter one repository file instead of hunting through dozens of services.

# Repositories aren't native Artisan stubs, so we make the folders manually:
mkdir app/Repositories app/Repositories/Contracts app/Repositories/Eloquent

# Generate a Service Provider to bind our Repositories to Laravel's Container
php artisan make:provider RepositoryServiceProvider
Enter fullscreen mode Exit fullscreen mode

Note: Make sure to register your RepositoryServiceProvider in bootstrap/providers.php in Laravel 12.

The Interface (The Contract):

// App\Repositories\Contracts\OrderRepositoryInterface.php
namespace App\Repositories\Contracts;

use Illuminate\Pagination\LengthAwarePaginator;
use App\Models\Order;

interface OrderRepositoryInterface
{
    public function getPaginated(int $perPage = 15): LengthAwarePaginator;
    public function findById(int $id): ?Order;
    public function create(array $data): Order;
    public function update(int $id, array $data): bool;
    public function delete(int $id): bool;
}
Enter fullscreen mode Exit fullscreen mode

The Concrete Implementation (The Eloquent Queries):

// App\Repositories\Eloquent\OrderRepository.php
namespace App\Repositories\Eloquent;

use App\Models\Order;
use App\Repositories\Contracts\OrderRepositoryInterface;
use Illuminate\Pagination\LengthAwarePaginator;

class OrderRepository implements OrderRepositoryInterface
{
    public function getPaginated(int $perPage = 15): LengthAwarePaginator 
    { 
        return Order::paginate($perPage); 
    }

    public function findById(int $id): ?Order 
    { 
        return Order::findOrFail($id); 
    }

    public function create(array $data): Order 
    { 
        return Order::create($data); 
    }

    public function update(int $id, array $data): bool 
    { 
        return (bool) Order::whereId($id)->update($data); 
    }

    public function delete(int $id): bool 
    { 
        return (bool) Order::destroy($id); 
    }
}
Enter fullscreen mode Exit fullscreen mode

The Service Provider binding:
We must tell Laravel: "Whenever a class demands the OrderRepositoryInterface, inject the concrete OrderRepository".

// App\Providers\RepositoryServiceProvider.php
namespace App\Providers;

use App\Repositories\Contracts\OrderRepositoryInterface;
use App\Repositories\Eloquent\OrderRepository;
use Illuminate\Support\ServiceProvider;

class RepositoryServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(OrderRepositoryInterface::class, OrderRepository::class);
    }
}
Enter fullscreen mode Exit fullscreen mode

6. The Service Class (Business Logic & Transactions)

This is the heart of the application. The Service Class is responsible for all complex business rules. Furthermore, we use Database Transactions here to ensure data integrity.

When creating an order, if the database insert succeeds but a subsequent step (like generating an invoice) fails and throws an exception, you do not want an orphaned order in your database. A DB::transaction() ensures that if anything fails within the closure, the entire database transaction rolls back instantly.

# Services aren't native Artisan stubs either, so we create the folder
mkdir app/Services
Enter fullscreen mode Exit fullscreen mode

The Service Class:

// App\Services\OrderService.php
namespace App\Services;

use App\Repositories\Contracts\OrderRepositoryInterface;
use App\Events\OrderCreated;
use Illuminate\Support\Facades\DB;
use Illuminate\Pagination\LengthAwarePaginator;
use App\Models\Order;
use Exception;

class OrderService
{
    // We inject the interface, not the concrete class, for maximum testability.
    public function __construct(protected OrderRepositoryInterface $orderRepository) {}

    public function getOrders(): LengthAwarePaginator 
    { 
        return $this->orderRepository->getPaginated(); 
    }

    public function getOrder(int $id): ?Order 
    { 
        return $this->orderRepository->findById($id); 
    }

    public function processNewOrder(array $validatedData): Order
    {
        return DB::transaction(function () use ($validatedData): Order {
            // 1. Calculate prices, apply discounts, run complex logic...
            $validatedData['total'] = $validatedData['quantity'] * 100; // Mock calculation

            // 2. Persist to DB using the Repository
            $order = $this->orderRepository->create($validatedData);

            // 3. Fire domain events 
            OrderCreated::dispatch($order);

            return $order;
        }); // If an exception is thrown inside this closure, the DB rolls back automatically.
    }

    public function updateOrder(int $id, array $validatedData): ?Order
    {
        return DB::transaction(function () use ($id, $validatedData): ?Order {
            $this->orderRepository->update($id, $validatedData);
            return $this->orderRepository->findById($id);
        });
    }

    public function deleteOrder(int $id): bool
    {
        return DB::transaction(function () use ($id): bool {
            return $this->orderRepository->delete($id);
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

7. Events & Listeners (Asynchronous Side-Effects)

To keep the main HTTP thread as fast as possible, side effects such as sending emails or generating PDFs should be decoupled from the core business logic. We dispatch an Event, and a Listener handles the logic in a background queue.

# Generate Events and Listeners in a single shot
php artisan make:event OrderCreated
php artisan make:listener SendOrderNotification --event=OrderCreated
Enter fullscreen mode Exit fullscreen mode

The Event (A simple data transfer object):

// App\Events\OrderCreated.php
namespace App\Events;

use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;

class OrderCreated 
{
    use Dispatchable;

    public function __construct(public Order $order) {}
}
Enter fullscreen mode Exit fullscreen mode

The Listener (The background worker):

// App\Listeners\SendOrderNotification.php
namespace App\Listeners;

use App\Events\OrderCreated;
use Illuminate\Contracts\Queue\ShouldQueue; // Implementing this moves the listener to a queue
use Illuminate\Support\Facades\Log;

class SendOrderNotification implements ShouldQueue 
{
    public function handle(OrderCreated $event): void 
    {
        // Simulating sending an email asynchronously
        Log::info("Email queued for order: {$event->order->id}");
    }
}
Enter fullscreen mode Exit fullscreen mode

8. API Resources (The Presentation Layer)

Returning Eloquent models directly to the frontend is dangerous; you risk exposing hidden database columns or internal IDs. API Resources allow you to strictly define the JSON contract your frontend will receive, and allows you to format dates or combine names safely.

# Generate API Resources for presentation
php artisan make:resource OrderResource
Enter fullscreen mode Exit fullscreen mode

The Resource Class:

// App\Http\Resources\OrderResource.php
namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class OrderResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'total_amount' => $this->total,
            'status' => $this->status ?? 'pending',
            'ordered_at' => $this->created_at->format('Y-m-d H:i:s'),
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

9. The Controller (The Traffic Cop)

Our controller handles the entire CRUD lifecycle and Authorization, yet it remains completely devoid of logic. It only exists to receive the request, defer to permissions via Gates, hand off valid data to the Service class, and return the formatted Resource.

// App\Http\Controllers\OrderController.php
namespace App\Http\Controllers;

use App\Http\Requests\StoreOrderRequest;
use App\Http\Requests\UpdateOrderRequest;
use App\Services\OrderService;
use App\Http\Resources\OrderResource;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Gate;
use Illuminate\Routing\Controllers\HasMiddleware;
use Illuminate\Routing\Controllers\Middleware;

class OrderController extends Controller implements HasMiddleware
{
    public function __construct(protected OrderService $orderService) {}

    // Apply Sanctum auth middleware to the entire controller using Laravel 11/12 syntax
    public static function middleware(): array
    {
        return [new Middleware('auth:sanctum')];
    }

    public function index(): AnonymousResourceCollection
    {
        return OrderResource::collection($this->orderService->getOrders());
    }

    public function store(StoreOrderRequest $request): OrderResource
    {
        $order = $this->orderService->processNewOrder($request->validated());
        return new OrderResource($order);
    }

    public function show(int $id): OrderResource
    {
        $order = $this->orderService->getOrder($id);

        // Authorization via Policy! Will throw a 403 Forbidden if false.
        Gate::authorize('view', $order);

        return new OrderResource($order);
    }

    public function update(UpdateOrderRequest $request, int $id): OrderResource
    {
        $order = $this->orderService->updateOrder($id, $request->validated());
        return new OrderResource($order);
    }

    public function destroy(int $id): JsonResponse
    {
        // Global Gate Authorization Check (Admin only)
        Gate::authorize('delete-records');

        $this->orderService->deleteOrder($id);
        return response()->json(['message' => 'Order deleted successfully'], 204);
    }
}
Enter fullscreen mode Exit fullscreen mode

10. Authentication (Bearer Tokens)

For an SPA, we use Laravel Sanctum to issue Bearer Tokens, which will be sent in the Authorization header of our Axios requests. This avoids the CORS wildcard issues often encountered with cookie-based sessions.

app/Http/Controllers/AuthController.php:

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Http\JsonResponse;

class AuthController extends Controller
{
    public function login(Request $request): JsonResponse
    {
        if (!Auth::attempt($request->validate(['email' => 'required|email', 'password' => 'required']))) {
            return response()->json(['message' => 'Invalid credentials'], 401);
        }

        $token = Auth::user()->createToken('auth_token')->plainTextToken;

        return response()->json([
            'access_token' => $token,
            'token_type' => 'Bearer',
            'user' => Auth::user()
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

The Frontend: React & TypeScript Clean Architecture

The Frontend also benefits tremendously from Clean Architecture. Rather than combining UI state, URL endpoints, and HTTP request logic in one bloated React Component file, we will abstract our entire data layer using TypeScript.

The React Architecture Diagram

Our frontend request lifecycle isolates the UI from the network, preventing components from knowing how data is fetched:

[User Interaction] (e.g., Clicks "Delete Order")
      │
      ▼
1. [UI Component] (src/components/OrderList.tsx)
      │      (Fires a mutation function provided by the Hook. It knows NOTHING about Axios.)
      ▼
2. [Custom Hook / Application Layer] (src/hooks/useOrders.ts)
      │      (React Query manages the loading state, caching, error catching, and success toasts.)
      ▼
3. [API Service / Data Layer] (src/api/services/orderService.ts)
      │      (Constructs the Axios request and defines the exact URL and Payload.)
      ▼
4. [Backend API] (Laravel 12)
Enter fullscreen mode Exit fullscreen mode

The React Directory Structure

By adhering to Clean Architecture, our React src/ folder organically separates concerns:

src/
├── api/
│   ├── client.ts                  # The Axios instance (Base URLs, Interceptors)
│   ├── services/
│   │   └── orderService.ts        # Pure functions returning Promises
│   └── types/
│       └── order.ts               # Strict TypeScript interfaces matching the Backend Resources
├── components/
│   └── OrderList.tsx              # Pure UI elements rendering data 
├── hooks/
│   └── useOrders.ts               # React Query wrappers tying the UI to the Services
└── pages/                         # (If using Next.js/React Router) Top level route containers
Enter fullscreen mode Exit fullscreen mode

1. The API Client & Interceptors

To avoid CORS wildcard issues, we configure our Axios client strictly for Bearer Tokens and do not use withCredentials. We'll receive the token from the login payload and inject it globally into Axios.

src/api/client.ts:

import axios from 'axios';

export const apiClient = axios.create({
    baseURL: 'http://localhost:8001',
    headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json',
    },
});
Enter fullscreen mode Exit fullscreen mode

2. Types & Service Layer

We define strict TypeScript Interfaces to map exactly to the data provided by our backend's API Resource. We then create a central API service to abstract the actual Axios/Fetch calls.

TypeScript Interfaces:

// src/api/types/order.ts
export interface Order { 
    id: number; 
    total_amount: number; 
    status: string; 
    ordered_at: string;
}

export interface OrderPayload { 
    product_id: number; 
    quantity: number; 
    customer_email: string; 
}
Enter fullscreen mode Exit fullscreen mode

The Data Service:
This service file centralizes our endpoints. If the backend URL structure changes, we only need to update this single file.

// src/api/services/orderService.ts
import apiClient from '../client';
import { Order, OrderPayload } from '../types/order';

export const orderApi = {
    getAll: async (page = 1): Promise<{ data: Order[], meta: any }> => {
        const res = await apiClient.get(`/orders?page=${page}`);
        return res.data;
    },

    getOne: async (id: number): Promise<Order> => {
        const res = await apiClient.get<{ data: Order }>(`/orders/${id}`);
        return res.data.data;
    },

    create: async (payload: OrderPayload): Promise<Order> => {
        const res = await apiClient.post<{ data: Order }>('/orders', payload);
        return res.data.data;
    },

    update: async (id: number, payload: Partial<OrderPayload>): Promise<Order> => {
        const res = await apiClient.put<{ data: Order }>(`/orders/${id}`, payload);
        return res.data.data;
    },

    delete: async (id: number): Promise<void> => {
        await apiClient.delete(`/orders/${id}`);
    }
};
Enter fullscreen mode Exit fullscreen mode

3. Custom Hooks (React Query)

Managing loading states, caching, and error handling manually is tedious. We wrap our data service in a Custom Hook using React Query.

The Application Layer Hook:

// src/hooks/useOrders.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { orderApi } from '../api/services/orderService';
import { Order, OrderPayload } from '../api/types/order';
import { toast } from 'react-hot-toast';

export const useOrders = (page = 1) => {
    const queryClient = useQueryClient();

    const query = useQuery<{ data: Order[], meta: any }, Error>({ 
        queryKey: ['orders', page], 
        queryFn: () => orderApi.getAll(page) 
    });

    const createMutation = useMutation<Order, Error, OrderPayload>({
        mutationFn: orderApi.create,
        onSuccess: (): void => {
            queryClient.invalidateQueries({ queryKey: ['orders'] });
            toast.success('Order created!');
        }
    });

    const deleteMutation = useMutation<void, Error, number>({
        mutationFn: orderApi.delete,
        onSuccess: (): void => {
            queryClient.invalidateQueries({ queryKey: ['orders'] });
            toast.success('Order deleted!');
        }
    });

    return { 
        orders: query.data?.data, 
        meta: query.data?.meta,
        isLoading: query.isLoading, 
        createOrder: createMutation.mutate, 
        deleteOrder: deleteMutation.mutate 
    };
};
Enter fullscreen mode Exit fullscreen mode

4. The Pure UI Component

Because we abstracted data fetching, types, and state management, our presentation component is remarkably clean. It knows nothing about axios, endpoints, or complex try-catch blocks.

The Presentation Component:

// src/components/OrderList.tsx
import React from 'react';
import { useOrders } from '../hooks/useOrders';

export const OrderList: React.FC = () => {
    // Destructuring state directly from our application logic hook
    const { orders, isLoading, deleteOrder } = useOrders();

    if (isLoading) return <div>Loading records...</div>;

    return (
        <div className="p-6 bg-white shadow rounded-lg">
            <h2 className="text-2xl font-bold mb-4">Orders (Full CRUD)</h2>
            <ul>
                {orders?.map(order => (
                    <li key={order.id} className="flex justify-between items-center bg-gray-50 border p-4 mb-2">
                        <span>Order #{order.id} - ${order.total_amount} ({order.status})</span>
                        <div className="space-x-2">
                           <button className="text-blue-500 hover:underline">Edit</button>
                           <button onClick={() => deleteOrder(order.id)} className="text-red-500 hover:underline">Delete</button>
                        </div>
                    </li>
                ))}
            </ul>
        </div>
    );
};
Enter fullscreen mode Exit fullscreen mode

Conclusion

We have successfully established a highly scalable, decoupled architecture on both our Laravel backend and React frontend.

By implementing Clean Architecture, we secured our endpoints with Gates and Policies, abstracted queries via Repositories, guaranteed data integrity with Database Transactions, optimized response times using asynchronous Events, standardized our output via API Resources, and maintained robust type safety across the entire application using TypeScript.

This is a professional, modular approach configured to sustain massive enterprise applications seamlessly.

Did you find this helpful? Save this article as a reference for your next big project, and feel free to discuss your thoughts on Repositories and clean code patterns in the comments! Happy Coding!

Top comments (0)