There is no shortage of full stack opinions on the internet. Everyone has a take on which framework you should be using, which ORM is most performant, which frontend library will make your code sing. After 20 years of building web applications professionally — across PHP, Laravel, React, Node, and more stacks than I care to count — I've converged on one combination that I reach for by default on almost every new project: Laravel on the backend, React on the frontend.
This isn't a hot take and it isn't tribal loyalty. It's the output of shipping real products to real users and learning what breaks down under pressure and what doesn't. The React + Laravel stack is mature, well-documented, has a massive hiring pool, and has precisely the right level of convention — enough to move fast, not so much that it gets in your way.
This article is a precise, opinionated description of the setup I actually use. Not a survey of every option; a concrete recommendation with the reasoning behind it.
"Picking a stack is like picking a city to build your home in. Don't optimise for the newest or the trendiest. Optimise for the one that has infrastructure, community, and a future — and that your team can actually move fast in."
Why React + Laravel in 2025
The argument for Laravel as a full stack developer's backend has only gotten stronger over the years. Its ecosystem is exceptionally rich: queues, websockets via Reverb, scheduled tasks, file storage, email, testing — it's all there and it all follows consistent conventions. When I pick up a Laravel project someone else started, I can orient myself in minutes. That's priceless in a team environment.
React's case is equally strong on the frontend. The component model maps naturally to how modern UIs are actually structured. The hook system is genuinely ergonomic once you've internalized it. And the ecosystem — TanStack Query for server state, Zustand for client state, React Router or TanStack Router for navigation — is stable and battle-tested.
Together they form a clean separation: Laravel is a headless API engine; React is the rendering layer. Each does what it's best at with a well-defined boundary between them. That boundary is where most of the interesting architectural decisions live, and it's what we'll spend most of this article on.
SPA vs Inertia.js: Making the Right Choice
Before you wire React and Laravel together, you have a fundamental architectural question to answer: do you want a fully decoupled SPA communicating with a JSON API, or do you want to use Inertia.js to keep the backend driving your routes while React handles the rendering?
| Factor | Decoupled SPA | Inertia.js |
|---|---|---|
| Auth complexity | Higher (CORS, tokens, cookie config) | Low (Laravel session auth) |
| SEO | Needs SSR or prerendering | Good out of the box |
| Deployment | Two separate apps/hosts | Single Laravel app |
| Team flexibility | Frontend team can work fully independently | Closer coupling |
| API reusability | Same API serves mobile apps, third parties | Purpose-built for one frontend |
| Best for | SaaS with mobile app, public API | Internal tools, admin panels, content apps |
My decision tree is simple: if the product will eventually have a mobile app or needs to expose a public API, I build a decoupled SPA from day one. If it's an internal tool, admin panel, or a product where SEO is important and we don't anticipate a mobile app, I reach for Inertia.js with React — it removes so much ceremony and lets the team ship faster.
Authentication with Laravel Sanctum
If you're going the decoupled SPA route, Laravel Sanctum is the right authentication layer. Not Passport — Sanctum. Passport is OAuth2 and it's genuinely complex; you don't need OAuth2 for your own first-party SPA. Sanctum gives you cookie-based session authentication for browser clients and token-based authentication for mobile or third-party API clients, through the same middleware.
The most common mistake I see developers make with Sanctum is not configuring the stateful domains correctly. Here's exactly what that looks like:
// config/sanctum.php
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS',
sprintf('%s%s', 'localhost,localhost:3000,127.0.0.1',
env('APP_URL') ? ','.parse_url(env('APP_URL'), PHP_URL_HOST) : ''
))),
// config/cors.php — critical pieces
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:3000')],
'supports_credentials' => true, // Never forget this
On the React side, you must configure your HTTP client to send credentials with every request. If you're using Axios:
// lib/axios.ts
import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
withCredentials: true, // Send cookies cross-origin
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
});
export default api;
The authentication flow for cookie-based Sanctum is: hit /sanctum/csrf-cookie first to get the XSRF token, then POST credentials to /login. After that, every subsequent request automatically includes the session cookie and XSRF header, and Laravel treats you as authenticated. Clean, stateless on the client, stateful on the server.
Always set SESSION_DOMAIN to .yourdomain.com (note the leading dot) in production so the session cookie is accessible to your frontend subdomain. And always run both your Laravel API and React frontend on the same top-level domain in production — Sanctum's cookie auth breaks across different domains by design.
TypeScript API Contracts
This is the part of the React + Laravel full stack setup that pays the biggest dividends over time. Most teams treat the API contract as implicit — the backend returns whatever it returns, and the frontend figures it out. This works until someone renames a field, and then you have a runtime bug in production that a type system would have caught at compile time.
My approach is to define TypeScript interfaces that mirror your Laravel API Resource output, and treat these as the single source of truth for the frontend:
// types/api.ts — mirrors Laravel API Resources
export interface User {
id: number;
name: string;
email: string;
avatar_url: string | null;
tenant_id: number;
created_at: string;
}
export interface Tenant {
id: number;
name: string;
subdomain: string;
plan: 'starter' | 'pro' | 'enterprise';
}
export interface PaginatedResponse<T> {
data: T[];
meta: {
current_page: number;
last_page: number;
per_page: number;
total: number;
};
}
// API error shape from Laravel's validation
export interface ValidationError {
message: string;
errors: Record<string, string[]>;
}
On the Laravel side, keep your API Resources aligned with these interfaces. When you change a Resource, update the TypeScript interface. This discipline sounds tedious but it's far less tedious than debugging a production data mismatch at midnight.
For teams that want to automate this, tools like Scramble (a Laravel package) can generate OpenAPI specs from your Laravel routes and controllers, which you can then run through openapi-typescript to generate TypeScript types automatically. I've used this on larger teams and it's a genuine quality-of-life improvement.
Vite, TanStack Query and the Frontend Layer
Laravel ships with Vite configured out of the box since Laravel 9, and it's genuinely fast. For a decoupled SPA, I scaffold the React project separately with npm create vite@latest and keep it in a sibling directory or a separate repository depending on team size.
For server state management — fetching, caching, invalidating, and synchronising API data — I use TanStack Query (formerly React Query). It handles loading states, error states, cache invalidation, background refetching, and optimistic updates out of the box. The difference in DX versus managing this manually with useEffect and useState is staggering.
// hooks/useUsers.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../lib/axios';
import type { PaginatedResponse, User } from '../types/api';
export function useUsers(page = 1) {
return useQuery({
queryKey: ['users', page],
queryFn: () =>
api.get<PaginatedResponse<User>>('/api/users', { params: { page } })
.then(r => r.data),
staleTime: 30_000, // 30 seconds before re-fetch
});
}
export function useDeleteUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => api.delete(`/api/users/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
});
}
For global client state — things like UI preferences, sidebar open/closed, current theme — I use Zustand. It's tiny, it doesn't require a provider, and its API is the simplest of any state library I've used. Don't reach for Redux in 2025 unless you have a genuinely complex, deeply interactive UI that needs time-travel debugging.
API Design Principles That Age Well
The Laravel API layer is where discipline pays the most dividends over a long project. A few principles I follow without exception:
- Always version your API. Even if it's just
/api/v1/. When you need to introduce a breaking change six months in, you'll be very glad that option exists. - Use API Resources, always. Never return raw Eloquent models from your controllers. API Resources are the contract between your backend and your frontend. They let you control exactly what's exposed, add computed fields, and nest related resources without exposing database schema details.
- Return consistent error shapes. Your React error handlers should be able to assume a predictable error structure. Laravel's validation errors in the
422response have a consistent shape; make sure your custom exceptions follow the same pattern. - Keep controllers thin. Business logic belongs in service classes or action objects, not controllers. A controller method should do three things: validate input, delegate to a service, return a response.
- Use form requests for validation. Every endpoint that accepts input should have a dedicated
FormRequestclass. This keeps validation out of controllers and makes it easy to test in isolation.
Deployment: Keeping the Stack Lean
For most Laravel + React SaaS applications, my deployment setup is deliberately simple. The Laravel app runs on a VPS or a managed platform like Render or Railway, fronted by Nginx. The React SPA is built with npm run build and deployed to a CDN — Cloudflare Pages or Vercel work perfectly.
Environment-specific configuration lives in .env files on the server and in the platform's environment variable settings for the frontend. The key environment variable on the React side is VITE_API_URL — point it at your Laravel API domain and everything else flows from there.
For the Laravel side, a standard production checklist looks like this:
- Run
php artisan config:cache,route:cache,view:cacheon every deploy - Run migrations with
php artisan migrate --force - Restart the queue workers after each deploy
- Confirm
APP_DEBUG=falseandAPP_ENV=productionare set - Set
SANCTUM_STATEFUL_DOMAINSto your production frontend domain
Final Thoughts
The React + Laravel stack is not exciting in the way that new JavaScript frameworks are exciting. It doesn't generate conference talks or Twitter threads. What it does do is let you ship reliable, maintainable software to real users, iterate quickly when requirements change, and hand the codebase off to a new team member without a week of onboarding.
I've built on a lot of stacks over two decades. This is the one I keep coming back to. If you're starting a new full stack web application in 2025 and you want something that will be productive today and still make sense two years from now, React + Laravel is a genuinely excellent choice.
The setup I've described above isn't the only way to wire these two tools together — but it's the result of many iterations, many production bugs, and many projects. It's what I'd build with if I were starting from scratch tomorrow.
