with API Platform
Symfony is the most robust PHP framework for complex backend logic. React is the most powerful UI framework for dynamic frontends. API Platform connects the two through automatically generated REST and GraphQL APIs, complete with validation, serialization and OpenAPI documentation out of the box. This article shows how to bring the two worlds together in a production ready way.
Table of Contents
- 1. Why React and Symfony together
- 2. API Platform: a REST API in minutes
- 3. Configuring CORS correctly
- 4. JWT authentication with LexikJWTBundle
- 5. Generating a type-safe API client from OpenAPI
- 6. React Query for data fetching and caching
- 7. Forms with React Hook Form and validation
- 8. Error handling and API error mapping
- 9. Architecture approaches compared
- 10. Summary
- 11. FAQ
1. Why React and Symfony together
Symfony and React each bring different strengths to fullstack development. Symfony shines with complex domain logic, dependency injection, command bus patterns and a mature ecosystem for database operations, queue processing and security. React takes care of the interactive UI layer with reactive state, smooth transitions and a huge ecosystem of UI components. The connection between the two is an HTTP API, and that is exactly where API Platform comes in.
The decisive advantage of this combination over a Next.js fullstack approach: Symfony offers first class PHP tooling for everything that happens outside the HTTP request, cron jobs, console commands, message queue handlers, complex database migrations. React with a separate backend fully decouples frontend and backend deployment, which brings significant organizational benefits for teams with different specializations. API Platform closes the gap by turning Symfony entities automatically into documented, type-safe APIs.
2. API Platform: a REST API in minutes
API Platform is a Symfony bundle that turns PHP classes annotated with the #[ApiResource] attribute into full REST APIs. It automatically generates CRUD endpoints, validation via Symfony constraints, serialization via the Symfony serializer and an interactive OpenAPI documentation (Swagger UI). The API follows the JSON:API or JSON-LD standard, but can be configured for plain JSON as well. What used to require hundreds of lines of controller code is now just a few lines of PHP attribute configuration.
The most important configuration option for React frontend development is the normalization group. With normalizationContext: ['groups' => ['product:read']] and denormalizationContext: ['groups' => ['product:write']] you control exactly which fields the API returns and which it accepts, without having to create separate DTO classes. This prevents both over-fetching and the accidental exposure of internal fields. Every property additionally gets a #[Groups] attribute that defines in which contexts it is visible.
<?php
// src/Entity/Product.php: API Platform resource with serialization groups
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use ApiPlatform\Metadata\Delete;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new Get(normalizationContext: ['groups' => ['product:read']]),
new GetCollection(normalizationContext: ['groups' => ['product:read']]),
new Post(
normalizationContext: ['groups' => ['product:read']],
denormalizationContext: ['groups' => ['product:write']],
security: "is_granted('ROLE_ADMIN')"
),
new Put(denormalizationContext: ['groups' => ['product:write']]),
new Delete(security: "is_granted('ROLE_ADMIN')"),
]
)]
#[ORM\Entity]
class Product
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
#[Groups(['product:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['product:read', 'product:write'])]
#[Assert\NotBlank]
#[Assert\Length(max: 255)]
private string $name = '';
#[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
#[Groups(['product:read', 'product:write'])]
#[Assert\Positive]
private string $price = '0.00';
}
3. Configuring CORS correctly
The most common first mistake when connecting React (localhost:3000) to a Symfony backend (localhost:8000) is a CORS error in the browser. CORS (Cross-Origin Resource Sharing) is a browser security mechanism that blocks HTTP requests between different origins unless the server explicitly agrees. Symfony solves this via the nelmio/cors-bundle. After installation you configure in config/packages/nelmio_cors.yaml which origins, methods and headers are allowed.
A common mistake is configuring CORS only for GET requests. POST, PUT, DELETE and PATCH trigger a preflight request (an OPTIONS method) that the server must also answer. In development a generous CORS configuration (allow_origin: ['*']) is fine, while in production a strict list of all allowed frontend origins is recommended. JWT tokens are sent as an Authorization: Bearer header, and this header must be explicitly listed in allow_headers, otherwise the browser blocks authenticated requests too.
4. JWT authentication with LexikJWTBundle
JSON Web Tokens are the standard for stateless authentication in React-Symfony fullstack applications. The lexik/jwt-authentication-bundle generates and validates JWTs and integrates seamlessly with Symfony's security firewall. The flow works like this: the React frontend sends email and password to /api/login_check, receives a JWT back and stores it securely. All subsequent API requests send the token in the Authorization: Bearer header. Symfony automatically validates the token, extracts the user and makes it available in the security infrastructure.
For storing the token in the React frontend there are two options: localStorage (simple, but vulnerable to XSS) and httpOnly cookies (more secure against XSS, but requires CSRF protection). For React applications with careful XSS prevention, sessionStorage is often a good compromise, the token is deleted when the tab is closed, which limits the session length. A refresh token mechanism using gesdinet/jwt-refresh-token-bundle extends sessions without forcing the user to log in again.
// hooks/useAuth.tsx: JWT auth with React Query and secure storage
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface LoginCredentials { email: string; password: string; }
interface AuthResponse { token: string; }
// Auth client: send credentials, receive JWT
const loginRequest = async (credentials: LoginCredentials): Promise<AuthResponse> => {
const res = await fetch('/api/login_check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
});
if (!res.ok) throw new Error('Login failed');
return res.json();
};
export const useAuth = () => {
const queryClient = useQueryClient();
const loginMutation = useMutation({
mutationFn: loginRequest,
onSuccess: ({ token }) => {
// Store token in memory (most secure) or sessionStorage
sessionStorage.setItem('jwt_token', token);
// Invalidate all cached queries, user context changed
queryClient.invalidateQueries();
},
});
const logout = () => {
sessionStorage.removeItem('jwt_token');
queryClient.clear();
};
return { login: loginMutation.mutate, logout, isLoading: loginMutation.isPending };
};
// Axios interceptor: attach JWT to every request automatically
import axios from 'axios';
axios.interceptors.request.use(config => {
const token = sessionStorage.getItem('jwt_token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
5. Generating a type-safe API client from OpenAPI
API Platform automatically generates an OpenAPI 3.0 specification for all #[ApiResource] classes. This specification is available at /api/docs.json and contains all endpoints, request bodies, response schemas and possible error codes. The next step is to automatically generate a TypeScript client from this specification. The tool openapi-typescript converts the OpenAPI spec into TypeScript types, and openapi-fetch generates a fully typed fetch client.
The workflow then becomes: change a Symfony entity, run npm run generate-api-types, and every place in the React code that is now incompatible with the new types immediately surfaces as a TypeScript error, before a single test even runs. This is the biggest advantage of this combination over manually written API clients: the types are always in sync with the actual backend, and breaking changes in the API are caught at compile time, not at runtime.
6. React Query for data fetching and caching
React Query (TanStack Query) is the recommended data fetching library for React-Symfony fullstack applications. It manages the entire lifecycle of API requests: loading state, success state, error state, caching, automatic refetching after focus events and optimistic updates. Compared to manual fetching with useEffect and useState, React Query eliminates thousands of lines of boilerplate and provides caching mechanisms that outperform many hand rolled solutions.
For working together with API Platform, the queryKey strategy is crucial. Every API endpoint gets a consistent query key, for example ['products', { page, filter }] for a paginated product list. When a mutation completes successfully, you invalidate all queries with the prefix ['products']. React Query then automatically refetches all affected queries, the cache stays consistent without having to synchronize state manually. Integration with API Platform's pagination, filtering and sorting via query parameters is straightforward and needs no additional configuration.
7. Forms with React Hook Form and validation
React Hook Form is the performance-first library for form validation in React. It registers inputs via refs instead of via state, which means no re-render on every keystroke. For complex forms with many fields, the performance difference compared to state based approaches (Formik, controlled inputs) is dramatically measurable. Integration with Symfony's validation errors is direct: API Platform returns validation errors in JSON:API format, and you map these onto the corresponding form fields with setError(fieldName, { message }).
Combining this with Zod for client side validation lets you define the same rules on the frontend that Symfony validates on the backend, as an additional safety layer and for instant feedback without an API round trip. With @hookform/resolvers/zod, Zod integrates directly into React Hook Form. The combination of client validation (Zod), API validation (Symfony constraints) and type-safe form data (TypeScript) closes the validation loop completely.
// components/ProductForm.tsx: React Hook Form + Zod + API Platform error mapping
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../lib/apiClient'; // generated OpenAPI client
// Zod schema mirrors Symfony constraints (NotBlank, Length, Positive)
const productSchema = z.object({
name: z.string().min(1, 'Name is required').max(255),
price: z.number().positive('Price must be positive'),
});
type ProductFormData = z.infer<typeof productSchema>;
export const ProductForm = () => {
const queryClient = useQueryClient();
const { register, handleSubmit, setError, formState: { errors } } = useForm<ProductFormData>({
resolver: zodResolver(productSchema),
});
const mutation = useMutation({
mutationFn: (data: ProductFormData) => apiClient.POST('/api/products', { body: data }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] });
},
onError: async (error: Response) => {
// Map API Platform validation errors to form fields
const body = await error.json();
body.violations?.forEach(({ propertyPath, message }: any) => {
setError(propertyPath as keyof ProductFormData, { message });
});
},
});
return (
<form onSubmit={handleSubmit(data => mutation.mutate(data))}>
<input {...register('name')} placeholder="Product name" />
{errors.name && <span>{errors.name.message}</span>}
<input {...register('price', { valueAsNumber: true })} type="number" step="0.01" />
{errors.price && <span>{errors.price.message}</span>}
<button type="submit" disabled={mutation.isPending}>Save</button>
</form>
);
};
8. Error handling and API error mapping
API Platform returns errors in a standardized format: an HTTP status code plus a JSON body with type, title, detail and, for validation errors, a violations array. This is a huge advantage over hand rolled APIs, every frontend can use the same parsing schema. A central error handling function in the React frontend parses this format and converts it into usable error objects: HTTP 404 becomes "not found", HTTP 422 with validation errors becomes a mapping of field to error message.
For non-trivial errors, server timeouts, network outages, unexpected 500s, you additionally need a global error boundary in React that shows the user a meaningful message instead of letting the whole app crash. React Query offers a global onError callback on the QueryClient that can handle all query errors centrally, for example to automatically log the user out on a 401 error.
9. Architecture approaches compared
There are several architecture patterns for React-Symfony fullstack applications. The choice significantly affects deployment complexity, development speed and long term maintainability.
| Architecture | SEO | Complexity | Recommendation |
|---|---|---|---|
| React SPA + Symfony API | Poor without SSR | Low | Internal dashboard, admin tools |
| Next.js + Symfony API | Good (SSR/SSG) | Medium | Public facing apps with SEO requirements |
| Symfony + Twig + React islands | Very good | Medium | Content sites with interactive areas |
| API Platform + Admin (React Admin) | Not relevant | Very low | Building CRUD-heavy admin panels quickly |
| Symfony Mercure + React (Live) | Medium | High | Realtime features (chat, live updates) |
For most use cases, the combination of React SPA + Symfony API with API Platform is the best starting point. It is easy to understand, easy to deploy (two separate services) and gives teams full control over frontend and backend development. If SEO becomes important later, you can migrate the React frontend to Next.js, the backend stays unchanged. This evolutionary strategy is better than choosing a level of complexity from the start that you do not yet need.
Mironsoft
React frontend and Symfony backend as a production ready fullstack app
Building a fullstack app with React and Symfony?
We plan and implement your fullstack architecture with React, Symfony and API Platform, from domain modeling through JWT auth to a production ready deployment with CI/CD.
API design
Configuring API Platform, defining serialization groups, generating the OpenAPI spec
Auth & security
JWT, refresh tokens, role based access control and CORS configuration
Frontend integration
Type-safe API clients, React Query and form validation with Zod
10. Summary
React + Symfony with API Platform is a production ready fullstack combination that brings together the strengths of both worlds: Symfony's robust backend infrastructure and React's reactive UI layer. API Platform generates REST APIs, OpenAPI documentation and validation directly from PHP attributes. The TypeScript client generated from OpenAPI ensures that the frontend code is always in sync with the backend contract. JWT with LexikJWTBundle implements stateless authentication, React Query manages caching and loading states, and React Hook Form with Zod closes the validation loop between frontend and backend.
Building this architecture iteratively starts with a single #[ApiResource] class, a generated client and a simple React Query hook. From there the application grows organically: more entities, more queries, more mutations. Type safety through OpenAPI generated clients ensures that growth does not accumulate technical debt, breaking changes in the API become compile errors, not runtime surprises.
React + Symfony + API Platform: the key points at a glance
API Platform setup
#[ApiResource] on entity classes generates CRUD, validation, OpenAPI and JSON-LD out of the box.
Type-safe clients
openapi-typescript + openapi-fetch generate TypeScript clients from the API spec, breaking changes become compile errors.
JWT + React Query
LexikJWTBundle for token generation, an Axios interceptor for automatic attachment, React Query for caching and invalidation.
Configuring CORS
nelmio/cors-bundle with allow_headers: [Authorization, Content-Type] and allow_methods: [GET, POST, PUT, PATCH, DELETE, OPTIONS].