Monorepo Layout: PNPM Workspaces Meets Composer
Putting PHP and JavaScript in one repository makes traditional frontend developers uneasy. JavaScript tools expect a single root node_modules or PNPM workspaces, while Laravel assumes it owns the top-level directory structure. If you dump Laravel into the root and stick Next.js inside a frontend/ subfolder, your root scripts get messy fast.
The cleanest layout separates runtimes cleanly under an apps/ directory while sharing TypeScript types inside a packages/ folder. Here's the layout we've been running in production with Laravel 12 (PHP 8.3) and Next.js 16 (React 19):
my-project/
├── apps/
│ ├── api/ # Laravel application
│ └── web/ # Next.js application
├── packages/
│ └── types/ # Shared generated TypeScript definitions
├── pnpm-workspace.yaml
└── package.jsonYour root package.json doesn't run the application logic; it manages workspace scripts using PNPM. Here is what pnpm-workspace.yaml looks like in the repository root:
packages:
- 'apps/*'
- 'packages/*'This separation lets Next.js 16 consume TS interfaces from @my-project/types directly without relative path hacks like ../../apps/api/....
Syncing PHP DTOs with TypeScript Types
Writing TypeScript interfaces manually to match Laravel API responses is a recipe for silent runtime bugs. When someone changes a column name or morphs a nullable string into an array on the backend, the frontend breaks silently until a user hits the page.
To fix this, we use Spatie's laravel-typescript-transformer package inside apps/api. It reads PHP 8.3 Data Transfer Objects (DTOs) and outputs valid .d.ts files straight into packages/types/src.
Here is a PHP DTO in apps/api/app/Data/UserData.php using PHP 8.3 readonly classes:
<?php
namespace App\Data;
use Spatie\LaravelData\Data;
readonly class UserData extends Data
{
public function __construct(
public int $id,
public string $name,
public string $email,
public ?string $avatarUrl,
public bool $isAdmin,
) {}
}Running php artisan typescript:transform parses this class and updates packages/types/src/generated.ts automatically:
export type UserData = {
id: number;
name: string;
email: string;
avatarUrl: string | null;
isAdmin: boolean;
};In apps/web/package.json, add the package reference: "@my-project/types": "workspace:*". Now, in your Next.js 16 Server Components or React 19 Client Components, you import the exact type directly:
import type { UserData } from '@my-project/types';
export default async function ProfilePage() {
const res = await fetch(`${process.env.INTERNAL_API_URL}/api/user`, {
headers: { Accept: 'application/json' },
});
const user: UserData = await res.json();
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}Don't commit stale types. Add the transformation command to your Composer post-autoload-dump hook or run it before running frontend build steps.
Environment Variables Across Two Runtimes
Managing environment files across Node and PHP creates friction if you try to share a single .env file. Laravel expects variables like DB_PASSWORD and APP_KEY, while Next.js 16 expects NEXT_PUBLIC_ prefixes for variables exposed to the browser.
Sharing one root .env file via symlinks sounds convenient, but it breaks security boundaries. You end up accidentally leaking backend secrets into the Next.js bundle when someone forgets a prefix rule. Keep two separate files: apps/api/.env and apps/web/.env.local.
To avoid host mismatch headaches during local development, explicitly set up predictable internal port bindings in your dev scripts:
Laravel API:
http://localhost:8000Next.js Web:
http://localhost:3000
In Next.js 16, distinguish between server-side fetch calls and client-side fetch calls. Server Components fetching from Laravel should call INTERNAL_API_URL (like http://127.0.0.1:8000), avoiding public DNS round-trips. Client components must call NEXT_PUBLIC_API_URL (like http://localhost:8000 or your public domain).
Validate Next.js environment variables at build time using Zod inside apps/web/src/env.ts. If NEXT_PUBLIC_API_URL is missing, fail the build immediately rather than throwing rendering errors in production.
CI Pipeline Strategy with GitHub Actions
Running CI on a polyglot monorepo can slow down quickly if you build everything sequentially. A single pull request shouldn't wait 10 minutes for PHPUnit tests before starting Next.js linting.
We split CI into parallel GitHub Actions jobs using paths filtering. PHP steps run when apps/api/** changes, Node steps run when apps/web/** or packages/** change. If both change, both run concurrently.
Here is a production-ready GitHub Actions workflow file (.github/workflows/ci.yml):
name: CI Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
backend-tests:
runs-on: ubuntu-24.04
defaults:
run:
working-directory: apps/api
steps:
- uses: actions/checkout@v4
- name: Setup PHP 8.3
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, pdo_sqlite, bcmath
coverage: none
- name: Cache Composer packages
uses: actions/cache@v4
with:
path: vendor
key: composer-${{ runner.os }}-${{ hashFiles('apps/api/composer.lock') }}
- name: Install Dependencies
run: composer install --prefer-dist --no-progress --no-interaction
- name: Run Tests
run: ./vendor/bin/pest
frontend-checks:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v3
with:
version: 9
- name: Setup Node.js 22
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Generate TypeScript Types from PHP DTOs
run: |
cd apps/api
php -r "copy('.env.example', '.env');"
composer install --prefer-dist
php artisan key:generate
php artisan typescript:transform
- name: Run ESLint and Build
run: pnpm --filter web buildProduction Gotchas: CORS and Cookies
The trickiest bug in this setup involves authentication state between React 19 client components and Laravel Sanctum or Session cookies.
If Next.js runs on app.example.com and Laravel runs on api.example.com, standard SameSite cookies won't work out of the box without specific browser configurations. You must configure SESSION_DOMAIN=.example.com in Laravel's .env and set SANCTUM_STATEFUL_DOMAINS=app.example.com.
Avoid cross-domain cookie headaches entirely by routing requests through a single domain using Nginx or Caddy. Route example.com/api/* to Laravel and example.com/* to Next.js. This eliminates CORS preflight overhead (saving about 40ms down from 120ms per initial request) and makes HttpOnly session cookies straightforward.










