sylpi10 / symfony-inertia-react-starter
Symfony starter with React + TypeScript via Inertia.js, with SSR
Package info
github.com/sylpi10/symfony-inertia-react-starter
Type:project
pkg:composer/sylpi10/symfony-inertia-react-starter
Requires
- php: >=8.4
- ext-ctype: *
- ext-iconv: *
- ext-pdo_sqlite: *
- doctrine/doctrine-bundle: ^3.3
- doctrine/doctrine-migrations-bundle: ^4.0
- doctrine/orm: ^3.7
- nytodev/inertia-bundle: ^3.1
- pentatrion/vite-bundle: ^8.2
- phpdocumentor/reflection-docblock: ^6.0
- phpstan/phpdoc-parser: ^2.3
- symfony/console: 8.1.*
- symfony/dotenv: 8.1.*
- symfony/flex: ^2
- symfony/framework-bundle: 8.1.*
- symfony/http-client: 8.1.*
- symfony/mailer: 8.1.*
- symfony/property-access: 8.1.*
- symfony/property-info: 8.1.*
- symfony/rate-limiter: 8.1.*
- symfony/runtime: 8.1.*
- symfony/security-bundle: 8.1.*
- symfony/serializer: 8.1.*
- symfony/twig-bundle: 8.1.*
- symfony/validator: 8.1.*
- symfony/yaml: 8.1.*
- symfonycasts/reset-password-bundle: ^1.25
- symfonycasts/verify-email-bundle: ^1.18
- twig/extra-bundle: ^2.12|^3.0
- twig/twig: ^2.12|^3.30
Requires (Dev)
- dama/doctrine-test-bundle: ^8.7
- doctrine/doctrine-fixtures-bundle: ^4.3
- phpunit/phpunit: ^13.3.6
- spatie/typescript-transformer: ^3.3
- symfony/browser-kit: 8.1.*
- symfony/css-selector: 8.1.*
- symfony/debug-bundle: 8.1.*
- symfony/maker-bundle: ^1.68
- symfony/monolog-bundle: ^3.0|^4.0
- symfony/stopwatch: 8.1.*
- symfony/web-profiler-bundle: 8.1.*
Suggests
None
Provides
None
Conflicts
README
A Symfony 8.1 starter with a React 19 + TypeScript front end, glued together by Inertia.js v3: you build a single-page app without writing an API. Controllers stay regular Symfony controllers, but instead of rendering Twig templates they return a React page + its props.
Included: Vite, Sass, server-side rendering, authentication (login, registration, password reset, email verification), stateless CSRF protection (no session for anonymous visitors), SEO (head tags per page, sitemap, robots.txt), an example admin CRUD, TypeScript types generated from PHP DTOs, functional tests, a Docker dev stack and a GitHub Actions CI.
Quick start
composer create-project sylpi10/symfony-inertia-react-starter my-app
cd my-app
npm install
make dev
Open http://localhost:8000 and sign in with admin@example.com / admin1234.
Needs PHP ≥ 8.4, Composer, Node 24 and the Symfony CLI. No PHP or Node on your machine? Use Docker instead. Details in Getting started.
Requirements
- PHP ≥ 8.4 with
pdo_sqlite - Composer, Node.js (v24) + npm
- Symfony CLI (recommended, required by
make dev) make(optional)
Or only Docker: see Running with Docker.
Getting started
composer create-project sylpi10/symfony-inertia-react-starter my-app
cd my-app
npm install
create-project installs the PHP dependencies, then runs composer setup, which:
- generates a fresh
APP_SECRETin.env.dev(so that no two projects share the same one); - runs the migrations, creating
var/data_dev.db(SQLite); - loads the fixtures: demo data + demo accounts;
- prepares the test database (
var/data_test.db) the same way, so thatphp bin/phpunitworks right away.
Then make the project yours: replace name and description in composer.json (they still read sylpi10/symfony-inertia-react-starter), and run composer update --lock to keep composer.lock in sync.
Prefer GitHub's Use this template button? Clone your new repository, then run the same steps by hand:
composer install composer setup npm install
⚠️
composer setupis meant to run once: it resets both databases (dev and test) and replacesAPP_SECRET.
Running in development
make dev
This starts the Symfony server in the background, along with the Vite dev server (hot reload), declared as a worker in .symfony.local.yaml.
| Command | What it does |
|---|---|
make dev |
Starts the Symfony server + Vite in the background |
make stop |
Stops the server and its workers |
make logs |
Tails the server and Vite logs |
make build |
Builds the client and SSR bundles (public/build, bootstrap/ssr) |
make test |
Prepares the test database, then runs PHPUnit |
make test-ssr |
Builds the assets, then checks the home page is server-rendered |
Without make: symfony serve -d does the same as make dev. Without the Symfony CLI, run your PHP server and npm run dev in two terminals.
Open http://localhost:8000 and sign in with one of the demo accounts:
| Account | Password | Role |
|---|---|---|
test@example.com |
test1234 |
Regular user |
admin@example.com |
admin1234 |
ROLE_ADMIN: gets the Admin nav link |
⚠️
localhost:5173is Vite's asset server, not the app. Always browse through Symfony (port 8000).
Useful commands:
| Command | What it does |
|---|---|
composer types |
Regenerates assets/types/generated.ts from PHP DTOs |
npm run typecheck |
Type-checks the front end (tsc --noEmit) |
npm run lint |
Checks the formatting of assets/ with Prettier |
symfony console doctrine:fixtures:load -n |
Resets the database with demo data (deletes all users!) |
/_profiler |
Symfony profiler: requests, SQL, emails sent in dev |
SSR is off in development (no hot reload on the Node side). To check it: see SSR and production.
Running with Docker
A development stack that needs neither PHP nor Node on your machine: FrankenPHP for Symfony, and a Node container for Vite (hot reload included).
make docker-up # first start takes a minute or two (composer install, npm ci) make docker-fixtures # demo data + demo accounts
Then open http://localhost:8000, as without Docker.
| Command | What it does |
|---|---|
make docker-up |
Builds and starts the containers |
make docker-down |
Stops them (volumes are kept) |
make docker-logs |
Follows the logs of both containers |
make docker-fixtures |
Loads the fixtures in the Docker database |
make docker-test |
Runs PHPUnit in the php container |
Good to know:
- The
phpcontainer runscomposer installand the migrations each time it starts. - The SQLite database lives in a Docker volume, separate from
var/data_dev.db.docker compose down -vdeletes it, along with the container'snode_modules. - Containers run as your user, so
vendor/andvar/stay yours. - Ports 8000 and 5173 are the same as
make dev: stop one before starting the other, or change them withAPP_PORT=8080 VITE_PORT=5174 make docker-up. - This stack is for development only. For production, see SSR and production.
Adding a page
1. A props DTO, marked #[TypeScript]:
// src/Dto/Page/ContactPageProps.php #[TypeScript] final readonly class ContactPageProps { public function __construct( public string $email, ) { } }
2. The controller returns a page name + the DTO:
// src/Controller/ContactController.php #[Route('/contact', name: 'app_contact')] public function index(Inertia $inertia): Response { return $inertia->render('Contact', get_object_vars(new ContactPageProps( email: 'hello@example.com', ))); }
3. Generate the types: composer types
4. The page component, in assets/Pages/ (the name passed to render() is the file path):
// assets/Pages/Contact.tsx import type { ContactPageProps } from "../types/generated"; export default function Contact({ email }: ContactPageProps) { return <h1>Write to {email}</h1>; }
No front-end route, no API call. Links between pages use <Link href="/contact"> from @inertiajs/react.
Example admin CRUD
/admin/technos manages the technos listed on the home page. It is a reference for the patterns you will reuse in your own CRUDs:
| Pattern | Where to look |
|---|---|
| Access control | #[IsGranted("ROLE_ADMIN")] on Admin/TechnoController: anonymous → /login, non-admin → 403 error page |
| One input DTO, two uses | TechnoInput with #[MapRequestPayload], shared by create (POST) and update (PUT) |
| One form page, two uses | Pages/Admin/Technos/Form.tsx: form.post() or form.put() depending on the techno prop |
| Delete | router.delete() after a confirm(), answered by a 303 redirect |
| Pagination | Doctrine Paginator in TechnoRepository, ?page=N bound with #[MapQueryParameter] |
| Optimistic update | Reordering with router.optimistic(...).patch(...): the row moves at once, Inertia rolls back on failure |
| Enum shared with TS | MoveDirection (up / down), exported to generated.ts |
Conventions
- Never pass a Doctrine entity to
render(): always a DTO (an entity would leak every getter, including password hashes). - Forms: an input DTO with
#[MapRequestPayload]+ Validator constraints on the PHP side,useFormon the React side. Validation errors come back automatically inform.errors. - After a successful POST, PUT, PATCH or DELETE: flash message + redirect with a 303 (with a 302, the browser would replay the redirect with the same method).
- Static UI text goes in the component; props are for data only the server knows (database, user, computed values).
- Twig is only used for the root view (
templates/base.html.twig) and emails.
Project layout (short)
assets/
├── Pages/ # One file = one page rendered by $inertia->render()
├── Layouts/ # AppLayout (default), GuestLayout (Auth/* pages)
├── Components/ # Shared components
├── images/ # UI images, imported by components
├── types/ # generated.ts (never edit) + inertia.d.ts
└── styles/ # Sass
src/
├── Controller/ # Controllers → $inertia->render(), Admin/ for the CRUD
├── Dto/ # Input DTOs, output DTOs, Page/ props
├── Enum/ # Enums, exported to TypeScript
├── Entity/, Repository/, DataFixtures/
└── EventListener/ # Shared props, CSRF, error pages
tests/ # Functional tests (InertiaWebTestCase + Controller/)
Testing
make test # migrations + fixtures on var/data_test.db, then PHPUnit php bin/phpunit --filter testCreate # a single test, once the test database exists
The test database is created by composer setup (or make test). If PHPUnit fails with no such table, it is missing: run make test. Run it again after adding a migration or changing the fixtures.
Functional tests live in tests/Controller/ and extend tests/InertiaWebTestCase.php, which talks to the app the way the Inertia client does:
visit()sends theX-Inertiaheader andSec-Fetch-Site: same-origin, as a browser does (without it, every POST is rejected by the CSRF protection). Responses are JSON, so tests need neither Vite nor a build.page()returns anAssertableInertiaPageto assert the component and its props;flash()reads flash messages.- Fixtures are loaded once; dama/doctrine-test-bundle rolls back the database after each test.
Covered: pages and 404, SEO props and head tags, sitemap and robots.txt, CSRF, registration, email verification, login (case-insensitive email, throttling), password reset, and the admin CRUD including access control.
Continuous integration
.github/workflows/ci.yaml runs on every push to master and on pull requests, without Docker:
- PHP:
composer validate,lint:container, migrations +doctrine:schema:validate, PHPUnit, and a check thatcomposer typesleavesassets/typesunchanged (a DTO changed without regenerating the types fails the build). - Front:
npm run typecheck,npm run lint, and the client + SSR builds.
Documentation
| Guide | Contents |
|---|---|
| Architecture | How Inertia works, page resolution, layouts, styles, images, full project structure |
| TypeScript types | DTOs, #[TypeScript], composer types, shared props typing |
| Authentication, forms and security | Login, registration, password reset, email verification, shared props, flash messages, CSRF, error pages, emails |
| SSR and production | Enabling SSR, Node server, production build, asset versioning, deploy order, known limitations |
| SEO | Head tags per page (SeoDto), indexing rules, share image, sitemap and robots.txt |
| Troubleshooting | Common errors and their fixes |