Laravel + React Starter Kit: Docker-verified fork
We took the official laravel/react-starter-kit (Inertia + React 19 + shadcn/ui + Fortify), ran it inside Docker Desktop to observe its actual behavior, and published a production-hardened fork with the complete commit-by-commit diff on Git…
Verification environment
- PHP 8.5.5
- Laravel 13.5.0
- Composer 2.9.7
- Node 22.22.2
- npm 10.9.7
- Database SQLite (tests)
- OS Docker Desktop (php:8.5-cli-bookworm)
Laravel + React Starter Kit: Docker-verified fork
We took the official laravel/react-starter-kit (Inertia + React 19 + shadcn/ui + Fortify), ran it inside Docker Desktop to observe its actual behavior, and published a production-hardened fork with the complete commit-by-commit diff on GitHub. Two documentation gaps and six code-level improvements — 8 commits on top of upstream, taking the test suite from the upstream 40 passed to 44 passed / 151 assertions. Below is the verification log, the rationale for each finding, and the diff itself.
Sister articles:
- Laravel + Vue Starter Kit: Docker-verified fork — same backend, same findings
- Laravel + Livewire Starter Kit: Docker-verified fork — different frontend architecture, partially overlapping findings
Target
| Field | Value |
|---|---|
| Name | Laravel + React Starter Kit |
| Official URL | https://github.com/laravel/react-starter-kit |
| Summary | Starter template built on Laravel 13 + Inertia + React 19 + shadcn/ui + Fortify auth |
| Upstream license | MIT |
| Improvement license | MIT |
Verification date
2026-04-19
Environment
All steps ran inside a Docker Desktop container. We deliberately avoid running verification on the Xserver production host or on the dev machine's native toolchain.
| Item | Value |
|---|---|
| Base image | php:8.5-cli-bookworm + Node 22 |
| PHP | 8.5.5 |
| Laravel Framework | 13.5.0 |
| Composer | 2.9.7 |
| Node / npm | 22.22.2 / 10.9.7 |
| Test DB | SQLite |
| Upstream commit | laravel/react-starter-kit@2e22614 |
The codelift/ directory on the improvement branch ships a Dockerfile, docker-compose.yml, and verify.sh. A single docker compose run --rm app walks through: composer install → .env generation → artisan migrate → npm install → npm run build → php artisan test. (Full commands in "Reproducing and adopting" at the bottom.)
Baseline
On upstream main without modification, php artisan test returns 40 passed (136 assertions), 20.51s. Vite build finishes cleanly. The template is well-formed.
From there, 8 items remain before a cautious production launch.
Improvements
A. npm run build fails opaquely when run before composer install
Symptom: on a clean clone, npm run build before composer install fails during Vite build with:
require(.../vendor/autoload.php): Failed to open stream: No such file or directory
in artisan on line 10
Root cause: @laravel/vite-plugin-wayfinder shells out to php artisan wayfinder:generate during the build. artisan can't boot without Composer dependencies. The error doesn't mention composer install, so contributors hunt for Vite / Wayfinder bugs instead.
Fix: add a Setup section to README that shows the correct order.
B. .env.example ships with dev defaults and no hint of production overrides
Symptom: .env.example has APP_ENV=local, APP_DEBUG=true, LOG_LEVEL=debug, SESSION_ENCRYPT=false, and no SESSION_SECURE_COOKIE. Copying it to production without auditing each line ships debug mode.
Fix: annotate the relevant keys with inline comments that call out the production-recommended values. Comment-only — no runtime change.
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
# Production overrides:
# APP_ENV=production
# APP_DEBUG=false
# APP_URL must be the https:// URL that TLS terminates at.
C. config/app.php hardcodes the timezone
Symptom: config/app.php:68 is 'timezone' => 'UTC'. Setting APP_TIMEZONE=Asia/Tokyo in .env has no effect. Laravel's own current skeleton uses env('APP_TIMEZONE', 'UTC') — the starter kit just hasn't caught up.
Fix: one-line change.
- 'timezone' => 'UTC',
+ 'timezone' => env('APP_TIMEZONE', 'UTC'),
.env.example now lists APP_TIMEZONE as an available key.
D. No security response headers in the middleware stack
Symptom: the web middleware stack in bootstrap/app.php appends HandleAppearance, HandleInertiaRequests, AddLinkHeadersForPreloadedAssets. None of CSP / HSTS / X-Content-Type-Options / X-Frame-Options / Referrer-Policy / Permissions-Policy is emitted.
Most production deployments terminate these at a reverse proxy, but a plain nginx + php-fpm topology leaves them off by default.
Fix: new app/Http/Middleware/SetSecurityHeaders.php appended to the web stack.
- Always on:
X-Content-Type-Options: nosniff,X-Frame-Options: SAMEORIGIN,Referrer-Policy: strict-origin-when-cross-origin,Permissions-Policy: camera=(), microphone=(), geolocation=() - Production only:
Strict-Transport-Security: max-age=31536000; includeSubDomainsand a Content-Security-Policy tuned for Inertia + Vite build output (same-origin scripts/styles,data:/blob:images,object-src 'none',frame-ancestors 'self')
CSP is gated to production because (a) Vite HMR collides with strict connect-src in local, and (b) HSTS pinned to localhost is annoying to unwind later.
'unsafe-inline' stays in script-src because Inertia serializes initial props into an inline script tag. Switching to a nonce-based CSP is a larger refactor touching HandleInertiaRequests; it's a future improvement, not this pass.
New test file tests/Feature/SecurityHeadersTest.php adds two assertions — always-on headers present, production-only headers absent in testing.
E. No HTTPS scheme enforcement helper
Symptom: grepping the repository for forceScheme, forceHttps, or TrustProxies returns nothing. When TLS terminates at a reverse proxy and the backend receives plain http://, Laravel's URL helpers return http:// URLs, which then produces mixed-scheme content or redirect loops.
Fix: three lines in AppServiceProvider::configureDefaults, gated on isProduction().
if (app()->isProduction()) {
URL::forceScheme('https');
}
Trusted proxy configuration (the TrustProxies middleware's CIDR list) depends on the specific infrastructure, so it's intentionally left to operators.
F. Password rules — previous log misread upstream; no change needed
An earlier version of this log claimed PasswordValidationRules::passwordRules() resolves to Laravel's loose default (8 chars, no complexity, no breach check). That was wrong.
app/Providers/AppServiceProvider.php already installs strong defaults at the framework level:
Password::defaults(fn (): ?Password => app()->isProduction()
? Password::min(12)->mixedCase()->letters()->numbers()->symbols()->uncompromised()
: null,
);
The subtlety is the name: Password::defaults() (plural, the setter) is where you register the closure; Password::default() (singular, the getter invoked inside PasswordValidationRules) then resolves to the registered rule. In production that means min 12, mixed case, letters + numbers + symbols, and an HIBP lookup.
No code change. Listed here because the earlier version of this verification log flagged it incorrectly, and we think "remove a finding when you realize you misread" is worth recording openly.
G. Login rate limiter keys on email + IP only
Symptom: FortifyServiceProvider::configureRateLimiting defines a single limit:
RateLimiter::for('login', function (Request $request) {
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
return Limit::perMinute(5)->by($throttleKey);
});
5 attempts / minute is fine against a naive single-source attack, but an attacker rotating source IPs (proxy pool, botnet) changes the key on every request and sustains thousands of attempts against one account.
Fix: RateLimiter::for accepts an array of limits; Laravel enforces all of them, tripping on whichever hits first. Two-layer approach:
return [
// Burst guard: 5 attempts / minute per email + IP (unchanged).
Limit::perMinute(5)->by($email.'|'.$ip),
// Account-level cumulative guard: 20 attempts / hour per email,
// independent of IP.
Limit::perHour(20)->by('login-account|'.$email),
];
The existing test_users_are_rate_limited test exercises the per-minute limit via its raw internal key; it continues to pass. Adding a test for the per-hour limit would couple against Laravel's internal key-hashing implementation, which is more brittle than the value it provides, so it's omitted.
H. Two-factor auth is available but not policy-enforced
Observation: config/fortify.php:148-152 enables Features::twoFactorAuthentication. The UI, the settings page, and SecurityTest are all in place. Users can opt in.
What's missing:
- A policy helper to require 2FA for admin-class accounts
- A reminder / nag banner for users who haven't enabled it
- Role-aware enforcement
This is a design decision, not a defect. Whether it belongs in a starter kit or in each product built on top is debatable, so we leave it out of the improvement commits and call it out here for operators to weigh.
I. All logs interleave into a single channel
Symptom: config/logging.php uses the stock stack → single layout. Login / failed login / password reset / 2FA enable & disable events interleave with query logs, app logs, and everything else in storage/logs/laravel.log. Post-incident auditing becomes a grep exercise.
Fix across two files:
- Add an
authchannel toconfig/logging.php(daily driver, 90-day retention, env-tunable viaLOG_AUTH_LEVEL/LOG_AUTH_DAYS). - Add
app/Listeners/AuthActivitySubscriber.phpand register it viaEvent::subscribeinAppServiceProvider::boot. It subscribes to the standard auth events (Registered,Login,Logout,Failed,PasswordReset) plus the Fortify 2FA events (TwoFactorAuthenticationEnabled/TwoFactorAuthenticationDisabled) and writes structuredinforecords to theauthchannel withuser_id,email,ip,user_agentcontext.
Records land in storage/logs/auth-YYYY-MM-DD.log, so audit tooling sees a clean stream without regex gymnastics.
New test file tests/Feature/Auth/AuthLoggingTest.php adds two assertions — successful login lands a login_succeeded line, failed login lands a login_failed line.
J. Only the password endpoint carries an explicit settings throttle
Symptom: routes/settings.php applies throttle:6,1 to settings/password and nothing else. profile.update (PATCH) and profile.destroy (DELETE) fall back to the framework default.
On a compromised session, profile edits or account deletion attempts have more headroom than they should.
Fix:
- Route::patch('settings/profile', [ProfileController::class, 'update'])->name('profile.update');
+ Route::patch('settings/profile', [ProfileController::class, 'update'])
+ ->middleware('throttle:10,1')
+ ->name('profile.update');
- Route::delete('settings/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
+ Route::delete('settings/profile', [ProfileController::class, 'destroy'])
+ ->middleware('throttle:3,1')
+ ->name('profile.destroy');
Account deletion is terminal, so 3 / minute is already more generous than any legitimate UI flow needs.
Before / after
| Dimension | Official | Improved |
|---|---|---|
php artisan test |
40 passed / 136 assertions | 44 passed / 151 assertions |
| Setup-order documentation | None | README Setup section |
Production hints in .env.example |
None | Inline comments on relevant keys |
APP_TIMEZONE takes effect |
No (hardcoded) | Yes |
| Baseline security headers | None | 4 always-on + HSTS / CSP in production |
| HTTPS scheme forcing | None | Production-only URL::forceScheme('https') |
| Login rate limit | Email + IP (one layer) | Email + IP and email-only (two layers) |
| Auth event logging | Mixed in default log | Dedicated auth daily channel (90-day) |
| Settings endpoint rate limit | Password only | + profile update / destroy |
When this improvement fits
- You're spinning up a new production product on top of
laravel/react-starter-kitand want the security / observability floor set before you start adding features. - You already scaffolded from the starter and want a concrete production-hardening checklist you can cherry-pick commit-by-commit.
- Your team has audit-log or CSP requirements and you'd rather close them at the starter level than push upstream or per-project.
It's probably overkill for a two-week throwaway PoC. Even then, A / B / C alone are cheap wins.
FAQ
Q. Why gate CSP and HSTS to production?
Strict connect-src collides with Vite's dev HMR WebSocket, and HSTS pinned to localhost outlives whatever you were testing. Production-only avoids both pitfalls without losing the protection where it matters.
Q. Is 'unsafe-inline' in script-src a compromise?
Yes — Inertia inlines initial props into a <script> tag, and removing 'unsafe-inline' breaks the app outright. A proper nonce-based CSP requires adjusting HandleInertiaRequests to emit a nonce and wiring it into the Inertia renderer. That's worth doing, but it's a larger change than this pass.
Q. Why keep a finding you removed?
CodeLift's editorial rule is "don't assert what you haven't verified." Removing the claim silently would be dishonest when the earlier log stood as documentation. Leaving a trace of the misread (and what the actual behavior is) is more useful to future readers than a clean list.
Q. Why not upstream these as a PR?
Some are reasonable PR candidates in isolation (A, C). Others — especially D's CSP and I's auth channel — involve scope decisions the upstream team may reasonably decline ("too opinionated for a starter template"). We keep the fork as the canonical home and decide per-commit whether to push upstream.
Reproducing and adopting
The improvements land on the improvements branch. To run it locally:
git clone https://github.com/codelift-dev/react-starter-kit.git
cd react-starter-kit
git checkout improvements
docker compose -f codelift/docker-compose.yml build
docker compose -f codelift/docker-compose.yml run --rm app
To see the application-level diff only (excluding CodeLift verification files under codelift/):
git diff main improvements -- . ':!codelift'
Each finding is a standalone commit, so cherry-pick what you want.
License
- Upstream: MIT (Laravel LLC and contributors)
- Improvement: MIT (CodeLift / JIT Inc.)
Fork-handling policy follows CodeLift's operational spec. Findings reflect the state on the verification date; upstream may change.
Featured in comparisons
Related articles
- Laravel + Vue Starter Kit: Docker-verified fork We took the official laravel/vue-starter-kit (Inertia + Vue 3 + shadcn-vue + Fortify), ran it inside Docker, and published a production-hardened fork with the complete diff on GitHub. The finding set matches the React Starter Kit's, by des…
- Laravel + Livewire Starter Kit: Docker-verified fork We took the official laravel/livewire-starter-kit (Livewire v4 + Flux + Alpine), ran it inside Docker, and published a production-hardened fork with the complete diff on GitHub. Upstream starts at 33 passed; the improved branch reaches 37 …