DEV Community

Md Murtuza Hussain
Md Murtuza Hussain

Posted on

Building an Enterprise-Grade Laravel 12 & React SPA: Architecture, Auth, and Full CRUD

Let’s bypass the endless debate over Monolith vs. SPA. If you've decided a decoupled architecture is genuinely what your project demands, you need to build it right.

A standard tutorial will show you how to connect Laravel to React. But in a production environment, dropping all your business logic into a Controller or relying on React's useEffect for data fetching is a recipe for unmaintainable spaghetti code.

When you build systems meant to scale, every read and write must pass through strict boundaries: Routing, Validation, Authorization, Services, and Repositories on the backend, paired with Feature-Sliced caching on the frontend.

Here is the definitive blueprint for a full CRUD application with authentication and authorization using Laravel 12, React, Sanctum, and TanStack Query.


Part 1: The Laravel Backend (Strict Boundaries)

First, scaffold the Laravel application and install the API and Sanctum requirements.

composer create-project laravel/laravel backend
Enter fullscreen mode Exit fullscreen mode

or

laravel new backend
Enter fullscreen mode Exit fullscreen mode
cd backend
php artisan install:api
php artisan migrate

Enter fullscreen mode Exit fullscreen mode

Ensure your .env is configured for Sanctum's stateful cookie authentication:

FRONTEND_URL=http://localhost:5173
SANCTUM_STATEFUL_DOMAINS=localhost:5173

Enter fullscreen mode Exit fullscreen mode

1. The Repository Contract (Data Access Layer)

Define the exact data operations allowed. By using an interface, we decouple our business logic from Eloquent.

app/Repositories/Contracts/PostRepositoryInterface.php:

namespace App\Repositories\Contracts;

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

interface PostRepositoryInterface
{
    public function paginate(int $perPage = 15): LengthAwarePaginator;
    public function create(array $data): Post;
    public function update(Post $post, array $data): bool;
    public function delete(Post $post): bool;
}

Enter fullscreen mode Exit fullscreen mode

app/Repositories/Eloquent/PostRepository.php:

namespace App\Repositories\Eloquent;

use App\Models\Post;
use App\Repositories\Contracts\PostRepositoryInterface;
use Illuminate\Pagination\LengthAwarePaginator;

class PostRepository implements PostRepositoryInterface
{
    public function paginate(int $perPage = 15): LengthAwarePaginator
    {
        return Post::with('user')->latest()->paginate($perPage);
    }

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

    public function update(Post $post, array $data): bool
    {
        return $post->update($data);
    }

    public function delete(Post $post): bool
    {
        return $post->delete();
    }
}

Enter fullscreen mode Exit fullscreen mode

(Note: Bind this interface to the implementation in your AppServiceProvider.)

2. The Service Layer (Business Logic)

This is where we orchestrate the CRUD operations.

app/Services/PostService.php:

namespace App\Services;

use App\Models\Post;
use App\Repositories\Contracts\PostRepositoryInterface;
use Illuminate\Support\Facades\DB;
use App\Events\PostCreated;

class PostService
{
    public function __construct(
        protected PostRepositoryInterface $repository
    ) {}

    public function getPosts(): LengthAwarePaginator
    {
        return $this->repository->paginate();
    }

    public function createPost(int $userId, array $data): Post
    {
        return DB::transaction(function () use ($userId, $data) {
            $data['user_id'] = $userId;
            $post = $this->repository->create($data);

            // Dispatch a background event
            PostCreated::dispatch($post);

            return $post;
        });
    }

    public function updatePost(Post $post, array $data): bool
    {
        return $this->repository->update($post, $data);
    }

    public function deletePost(Post $post): bool
    {
        return $this->repository->delete($post);
    }
}

Enter fullscreen mode Exit fullscreen mode

2.5. Events and Listeners (Asynchronous Side-Effects)

To handle heavy background tasks (like sending notifications to followers) without delaying the user's HTTP response, dispatch an event and queue a listener.

app/Events/PostCreated.php:

namespace App\Events;

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

class PostCreated 
{
    use Dispatchable;

    public function __construct(public Post $post) {}
}
Enter fullscreen mode Exit fullscreen mode

app/Listeners/NotifySubscribers.php:

namespace App\Listeners;

use App\Events\PostCreated;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;

class NotifySubscribers implements ShouldQueue 
{
    public function handle(PostCreated $event): void 
    {
        // Simulate sending emails via a background queue
        Log::info("Notifying subscribers about new post: {$event->post->id}");
    }
}
Enter fullscreen mode Exit fullscreen mode

3. Strict Authorization (Policies)

Never let a user modify something they don't own.

app/Policies/PostPolicy.php:

namespace App\Policies;

use App\Models\Post;
use App\Models\User;

class PostPolicy
{
    public function update(User $user, Post $post): bool
    {
        return $user->id === $post->user_id;
    }

    public function delete(User $user, Post $post): bool
    {
        return $user->id === $post->user_id;
    }
}

Enter fullscreen mode Exit fullscreen mode

4. The Anemic Controller

With validation handled by Form Requests (not shown for brevity), authorization by Policies, and logic by the Service, your Controller becomes beautifully anemic.

app/Http/Controllers/PostController.php:

namespace App\Http\Controllers;

use App\Http\Requests\StorePostRequest;
use App\Http\Requests\UpdatePostRequest;
use App\Http\Resources\PostResource;
use App\Models\Post;
use App\Services\PostService;
use Illuminate\Support\Facades\Gate;
use Illuminate\Routing\Controllers\HasMiddleware;
use Illuminate\Routing\Controllers\Middleware;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response;

class PostController extends Controller implements HasMiddleware
{
    public static function middleware(): array
    {
        return [new Middleware('auth:sanctum')];
    }

    public function __construct(
        protected PostService $postService
    ) {}

    public function index(): AnonymousResourceCollection
    {
        return PostResource::collection($this->postService->getPosts());
    }

    public function store(StorePostRequest $request): PostResource
    {
        $post = $this->postService->createPost($request->user()->id, $request->validated());
        return new PostResource($post);
    }

    public function update(UpdatePostRequest $request, Post $post): PostResource
    {
        Gate::authorize('update', $post); 
        $this->postService->updatePost($post, $request->validated());
        return new PostResource($post->refresh());
    }

    public function destroy(Post $post): Response
    {
        Gate::authorize('delete', $post);
        $this->postService->deletePost($post);
        return response()->noContent();
    }
}

Enter fullscreen mode Exit fullscreen mode

Part 2: The React Frontend (Feature-Based Architecture)

A modern React application should be structured around Features, not file types. We will isolate the posts domain entirely and use TanStack Query for server-state management.

Setup your Vite React app:

npm create vite@latest frontend -- --template react
cd frontend
npm install axios react-router-dom @tanstack/react-query

Enter fullscreen mode Exit fullscreen mode

5. Global Axios & Auth Hooks

When using Sanctum with token-based authentication (Bearer tokens), your global Axios instance handles API requests and seamlessly redirects unauthenticated users.

src/lib/axios.js:

import Axios from 'axios';

const axios = Axios.create({
    baseURL: 'http://localhost:8000',
    headers: { 
        'X-Requested-With': 'XMLHttpRequest', 
        'Accept': 'application/json' 
    }
});

axios.interceptors.response.use(
    response => response,
    error => {
        if (error.response?.status === 401) {
            window.location.href = '/login';
        }
        return Promise.reject(error);
    }
);

export default axios;

Enter fullscreen mode Exit fullscreen mode

src/hooks/useAuth.js:

import { useQuery } from '@tanstack/react-query';
import axios from '../lib/axios';

export const useAuth = () => {
    return useQuery({
        queryKey: ['authUser'],
        queryFn: async () => {
            const { data } = await axios.get('/api/user');
            return data;
        },
        retry: false,
    });
};

Enter fullscreen mode Exit fullscreen mode

6. The Feature API & Hook Layers

Never call Axios directly inside a UI component.

src/features/posts/api/postApi.js:

import axios from '../../../lib/axios';

export const getPosts = async () => {
    const { data } = await axios.get('/api/posts');
    return data.data; 
};

export const createPost = async (postData) => {
    const { data } = await axios.post('/api/posts', postData);
    return data.data;
};

export const updatePost = async ({ id, ...postData }) => {
    const { data } = await axios.put(`/api/posts/${id}`, postData);
    return data.data;
};

export const deletePost = async (id) => {
    await axios.delete(`/api/posts/${id}`);
};

Enter fullscreen mode Exit fullscreen mode

src/features/posts/hooks/useMutatePost.js:

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createPost, updatePost, deletePost } from '../api/postApi';

export const useMutatePost = () => {
    const queryClient = useQueryClient();
    const onSuccess = () => queryClient.invalidateQueries(['posts']);

    const createMutation = useMutation({ mutationFn: createPost, onSuccess });
    const updateMutation = useMutation({ mutationFn: updatePost, onSuccess });
    const deleteMutation = useMutation({ mutationFn: deletePost, onSuccess });

    return {
        createPost: createMutation.mutate,
        updatePost: updateMutation.mutate,
        deletePost: deleteMutation.mutate,
        isPending: createMutation.isPending || updateMutation.isPending || deleteMutation.isPending
    };
};

Enter fullscreen mode Exit fullscreen mode

7. The UI Layer

Finally, piece together the components.

src/features/posts/components/PostItem.jsx:

import { useAuth } from '../../../hooks/useAuth';
import { useMutatePost } from '../hooks/useMutatePost';

export const PostItem = ({ post, onEdit }) => {
    const { data: authUser } = useAuth();
    const { deletePost } = useMutatePost();

    // UI Authorization check
    const isOwner = authUser?.id === post.user_id;

    return (
        <div className="p-4 border rounded shadow-sm">
            <h2 className="text-xl font-bold">{post.title}</h2>
            <p className="text-gray-700">{post.content}</p>

            {isOwner && (
                <div className="mt-4 space-x-2">
                    <button onClick={() => onEdit(post)} className="text-blue-500">Edit</button>
                    <button onClick={() => deletePost(post.id)} className="text-red-500">Delete</button>
                </div>
            )}
        </div>
    );
};

Enter fullscreen mode Exit fullscreen mode

The Takeaway

Architecture is about drawing strict boundaries. By binding Repository Interfaces, you decouple your app from the database. By utilizing Services, you isolate business logic. Combine this with TanStack Query and Feature-Sliced Design on the frontend, and you have a system capable of scaling without collapsing under its own weight.

Top comments (0)