Skip to main content

Technology Stack Recommendations

Document Version: 1.0 Last Updated: 2025-10-28 Status: Recommended - Pending Final Decision


Executive Summary​

Recommended Stack:

  • Backend: Laravel 11
  • Frontend Bridge: Inertia.js
  • UI Framework: React 18+
  • Styling: Tailwind CSS v3+
  • Components: shadcn/ui + Radix UI
  • Forms: React Hook Form + Zod validation
  • Testing: Vitest + React Testing Library

Architecture: Modern monolith (no separate API)


1. Frontend Framework: React with Inertia.js​

Why React?​

Aligns with PBS Principles:

✅ Simplicity First

  • Component-based architecture promotes reusable code
  • Large ecosystem with proven solutions
  • Easy to hire developers

✅ UX-Driven Development

  • Excellent for interactive UIs (offer management, real-time updates)
  • Perfect for both desktop-optimized backend and mobile-first crew portal
  • Great developer experience = faster iteration

✅ Speed

  • Virtual DOM for efficient updates
  • Code splitting and lazy loading built-in
  • Fast page transitions with Inertia

✅ Quality Standards

  • Industry-standard testing tools (React Testing Library)
  • Excellent accessibility support
  • Large community for support and troubleshooting

Why Inertia.js Bridge?​

What is Inertia.js?

  • Connects Laravel backend with React frontend
  • No API needed - server-side data fetching
  • Single-page app experience without the complexity
  • "Modern monolith" approach

Benefits for PBS:

  1. No API Layer Required

    // Controller
    public function index()
    {
    return Inertia::render('Dashboard', [
    'projects' => Project::with('assignments')->get()
    ]);
    }
    // React Component
    export default function Dashboard({ projects }) {
    return <div>{/* Use projects directly */}</div>
    }
  2. Multi-tenant Subdomain Support

    // routes/web.php
    Route::domain('backend.pbs.com')->group(function () {
    Route::get('/dashboard', [BackendController::class, 'index']);
    });

    Route::domain('crew.pbs.com')->group(function () {
    Route::get('/dashboard', [CrewController::class, 'index']);
    });
  3. Laravel Features Work Normally

    • Authentication (Laravel Sanctum/Breeze)
    • Authorization (Gates, Policies)
    • Validation
    • Database queries (Eloquent)
    • Multi-tenancy packages
  4. Server-Side Rendering from PHP

    • Initial page load is server-rendered
    • Subsequent navigation is client-side
    • Best of both worlds

Alternative Considered: Next.js​

Why NOT Next.js for PBS:

  • ❌ Requires building a separate API
  • ❌ Two deployment pipelines
  • ❌ More complex authentication (CORS, tokens)
  • ❌ Doesn't leverage Laravel's strengths
  • ❌ Conflicts with "Simplicity First" principle

When Next.js makes sense:

  • Need native mobile apps (iOS/Android)
  • Multiple separate frontend clients
  • Want complete frontend/backend independence

2. CSS Framework: Tailwind CSS​

Why Tailwind?​

✅ Simplicity First

  • Utility-first = faster development
  • No naming conventions to learn (BEM, SMACSS, etc.)
  • Less custom CSS to maintain

✅ Mobile-First by Default

  • Perfect for crew portal requirements
  • Responsive design is built into the syntax:
    <div className="w-full md:w-1/2 lg:w-1/3">
    {/* Full width mobile, half on tablet, third on desktop */}
    </div>

✅ Multi-tenant Branding Support

  • Easy theme customization per company:

    // tailwind.config.js
    module.exports = {
    theme: {
    extend: {
    colors: {
    brand: {
    primary: 'var(--company-primary)',
    secondary: 'var(--company-secondary)'
    }
    }
    }
    }
    }
    // Dynamic CSS variables per company
    <div style={{
    '--company-primary': company.brand_color
    }}>
    <button className="bg-brand-primary">Company Button</button>
    </div>

✅ Speed

  • Small bundle size (typically <10KB after purge)
  • No runtime CSS-in-JS overhead
  • Build-time purging removes unused styles

✅ Maintainable

  • No CSS file organization needed
  • Styles are co-located with components
  • Easy to understand what styles apply

Tailwind in Practice​

You need components, not just utility classes.

Tailwind alone is just classes:

// Repetitive and hard to maintain
<input className="flex h-10 w-full rounded-md border border-input
bg-background px-3 py-2 text-sm ring-offset-background
focus-visible:outline-none focus-visible:ring-2" />

Solution: Component abstraction (see next section)

Alternatives Considered​

Material-UI / Ant Design:

  • ❌ Too opinionated (hard to match custom designs)
  • ❌ Larger bundles (conflicts with "speed" requirement)
  • ❌ Harder to customize for multi-tenant branding
  • ❌ Overkill for "simplicity first"

Styled Components / Emotion:

  • ❌ Runtime CSS-in-JS overhead (slower)
  • ❌ More code to write and maintain
  • ❌ Additional mental model to learn

3. Component Library: shadcn/ui + Radix UI​

What is shadcn/ui?​

Not an npm package - it's a collection of copy-paste components.

  1. You run CLI commands
  2. Component code is copied into your project
  3. You own and customize everything
  4. Built with Tailwind CSS + Radix UI primitives

Why shadcn/ui for PBS?​

✅ You Own the Code

  • Aligns with "clean, maintainable code" principle
  • No black-box dependencies
  • Easy to customize for exact needs

✅ Pre-built for Your Use Cases

  • Date pickers (assignment dates, availability calendar)
  • Data tables (projects list, staff directory)
  • Forms (offer creation, registration flow)
  • Modals, dialogs, dropdowns
  • Tabs, cards, badges

✅ Built on Tailwind

  • Consistent styling approach
  • Easy to extend and modify

✅ Accessible by Default

  • Uses Radix UI primitives (WCAG compliant)
  • Keyboard navigation
  • Screen reader support

✅ No Bundle Bloat

  • Only include components you actually use
  • Tree-shakeable

How It Works​

Installation:

npx shadcn-ui@latest init

Add components as needed:

npx shadcn-ui@latest add button
npx shadcn-ui@latest add input
npx shadcn-ui@latest add form
npx shadcn-ui@latest add table
npx shadcn-ui@latest add calendar
npx shadcn-ui@latest add dialog

Result: Files appear in your project:

resources/js/
├── Components/
│ └── ui/
│ ├── button.tsx
│ ├── input.tsx
│ ├── form.tsx
│ ├── table.tsx
│ └── calendar.tsx

Usage:

import { Button } from '@/Components/ui/button'
import { Input } from '@/Components/ui/input'

export default function CreateProject() {
return (
<form>
<Input type="text" placeholder="Project name" />
<Button type="submit">Create</Button>
</form>
)
}

Customization:

// Edit components/ui/button.tsx directly
// Change colors, sizes, animations
// It's YOUR code now

Example: Beautiful Form Components​

Install form dependencies:

npm install react-hook-form zod @hookform/resolvers
npx shadcn-ui@latest add form

Create form with validation:

import { useForm } from '@inertiajs/react'
import { z } from 'zod'
import { Input } from '@/Components/ui/input'
import { Button } from '@/Components/ui/button'
import { Label } from '@/Components/ui/label'

const schema = z.object({
name: z.string().min(1, 'Project name is required'),
location: z.string().min(1, 'Location is required'),
start_date: z.string().min(1, 'Start date is required')
})

export default function CreateProject() {
const { data, setData, post, errors } = useForm({
name: '',
location: '',
start_date: ''
})

const submit = (e) => {
e.preventDefault()
post('/projects')
}

return (
<form onSubmit={submit} className="space-y-6 max-w-2xl">
<div className="space-y-2">
<Label htmlFor="name">Project Name</Label>
<Input
id="name"
value={data.name}
onChange={e => setData('name', e.target.value)}
placeholder="Champions League Final 2025"
/>
{errors.name && (
<p className="text-sm text-red-500">{errors.name}</p>
)}
</div>

<div className="space-y-2">
<Label htmlFor="location">Location</Label>
<Input
id="location"
value={data.location}
onChange={e => setData('location', e.target.value)}
placeholder="Stockholm, Sweden"
/>
{errors.location && (
<p className="text-sm text-red-500">{errors.location}</p>
)}
</div>

<Button type="submit">Create Project</Button>
</form>
)
}

Laravel validation still works:

public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|max:255',
'location' => 'required',
'start_date' => 'required|date'
]);

$project = $request->user()->company->projects()->create($validated);

return redirect('/projects')->with('success', 'Project created!');
}

Alternative Components Considered​

Option B: Tailwind UI (Paid)

  • ❌ $299+ (though high quality)
  • ✅ Official Tailwind components
  • ❌ Still need to copy-paste (similar to shadcn)

Option C: DaisyUI / Flowbite (Free)

  • ✅ Free component libraries
  • ❌ Less flexibility than shadcn
  • ❌ Pre-defined look harder to customize

Option D: Build Everything from Scratch

  • ✅ Total control
  • ❌ Extremely time-consuming
  • ❌ Need to handle accessibility manually
  • ❌ Conflicts with MVP speed

4. Complete Stack Breakdown​

Backend​

  • Laravel 11
  • PostgreSQL (as per schema.dbml)
  • Multi-tenancy Package: stancl/tenancy or spatie/laravel-multitenancy

Frontend​

  • React 18+
  • Inertia.js 1.0+
  • TypeScript (recommended for type safety)

Styling​

  • Tailwind CSS v3+
  • shadcn/ui components
  • Radix UI primitives

Forms & Validation​

  • Client-side: React Hook Form + Zod
  • Server-side: Laravel validation rules

State Management​

  • Inertia form helpers (for form state)
  • React Context (for global UI state)
  • Zustand (optional, if complex state needed)

Testing​

  • Backend: PHPUnit (Laravel default)
  • Frontend: Vitest + React Testing Library
  • E2E: Laravel Dusk or Playwright

Development Tools​

  • Vite (bundler, included with Laravel)
  • ESLint + Prettier (code formatting)
  • Laravel Pint (PHP code formatting)

5. Project Structure​

pbs-app/
├── app/
│ ├── Http/
│ │ ├── Controllers/
│ │ │ ├── Backend/
│ │ │ │ ├── DashboardController.php
│ │ │ │ ├── ProjectController.php
│ │ │ │ └── StaffController.php
│ │ │ └── Crew/
│ │ │ ├── DashboardController.php
│ │ │ ├── OfferController.php
│ │ │ └── ProfileController.php
│ │ └── Middleware/
│ │ └── HandleInertiaRequests.php
│ └── Models/
│ ├── Company.php
│ ├── User.php
│ ├── Profile.php
│ ├── Project.php
│ └── Assignment.php
│
├── database/
│ └── migrations/
│ └── [migrations based on schema.dbml]
│
├── resources/
│ ├── js/
│ │ ├── Components/
│ │ │ ├── ui/ # shadcn components
│ │ │ │ ├── button.tsx
│ │ │ │ ├── input.tsx
│ │ │ │ ├── form.tsx
│ │ │ │ └── ...
│ │ │ ├── Backend/
│ │ │ │ ├── Navigation.tsx
│ │ │ │ └── ProjectCard.tsx
│ │ │ └── Crew/
│ │ │ ├── OfferCard.tsx
│ │ │ └── AssignmentTimeline.tsx
│ │ ├── Layouts/
│ │ │ ├── BackendLayout.tsx
│ │ │ └── CrewLayout.tsx
│ │ ├── Pages/
│ │ │ ├── Backend/
│ │ │ │ ├── Dashboard.tsx
│ │ │ │ ├── Projects/
│ │ │ │ │ ├── Index.tsx
│ │ │ │ │ ├── Show.tsx
│ │ │ │ │ └── Create.tsx
│ │ │ │ └── Staff/
│ │ │ │ ├── Index.tsx
│ │ │ │ └── Show.tsx
│ │ │ └── Crew/
│ │ │ ├── Dashboard.tsx
│ │ │ ├── Offers/
│ │ │ │ ├── Index.tsx
│ │ │ │ └── Show.tsx
│ │ │ └── Profile/
│ │ │ └── Edit.tsx
│ │ └── app.tsx # Inertia setup
│ └── css/
│ └── app.css # Tailwind imports
│
├── routes/
│ └── web.php # All routes (no api.php needed)
│
├── tailwind.config.js
├── vite.config.js
├── tsconfig.json
├── package.json
└── composer.json

6. Getting Started​

Initial Setup​

1. Create Laravel Project:

composer create-project laravel/laravel pbs-app
cd pbs-app

2. Install Laravel Breeze with Inertia + React:

composer require laravel/breeze --dev
php artisan breeze:install react

# This installs:
# - Inertia.js
# - React
# - Tailwind CSS
# - Basic authentication
# - Vite configuration

3. Install shadcn/ui:

npx shadcn-ui@latest init

# Answer prompts:
# - TypeScript: Yes
# - Style: Default
# - Base color: Slate
# - CSS variables: Yes

4. Add Initial Components:

npx shadcn-ui@latest add button
npx shadcn-ui@latest add input
npx shadcn-ui@latest add form
npx shadcn-ui@latest add table
npx shadcn-ui@latest add card
npx shadcn-ui@latest add badge
npx shadcn-ui@latest add calendar
npx shadcn-ui@latest add dialog
npx shadcn-ui@latest add dropdown-menu
npx shadcn-ui@latest add select
npx shadcn-ui@latest add tabs

5. Configure Multi-tenancy:

# Option A: Stancl Tenancy (subdomain-based)
composer require stancl/tenancy

# Option B: Spatie Multi-tenancy (simpler)
composer require spatie/laravel-multitenancy

6. Set up Database:

# Copy schema.dbml tables to Laravel migrations
php artisan make:migration create_companies_table
php artisan make:migration create_profiles_table
# ... etc

php artisan migrate

7. Run Development Server:

# Terminal 1: Laravel
php artisan serve

# Terminal 2: Vite (watches React/CSS changes)
npm run dev

Development Workflow​

Creating a New Page:

  1. Create React Component:

    // resources/js/Pages/Backend/Projects/Index.tsx
    import { Head } from '@inertiajs/react'
    import BackendLayout from '@/Layouts/BackendLayout'
    import { Button } from '@/Components/ui/button'

    export default function ProjectsIndex({ projects }) {
    return (
    <>
    <Head title="Projects" />
    <div className="p-6">
    <h1 className="text-2xl font-bold mb-4">Projects</h1>
    {/* Page content */}
    </div>
    </>
    )
    }

    ProjectsIndex.layout = page => <BackendLayout children={page} />
  2. Create Laravel Route & Controller:

    // routes/web.php
    Route::get('/projects', [ProjectController::class, 'index'])
    ->name('projects.index');

    // app/Http/Controllers/Backend/ProjectController.php
    public function index()
    {
    return Inertia::render('Backend/Projects/Index', [
    'projects' => Project::with('company')->paginate(20)
    ]);
    }
  3. That's it! Navigate to /projects and see your page.


7. Multi-tenant Configuration​

Subdomain Routing​

// routes/web.php

// Backend Portal (backend.pbs.com)
Route::domain('backend.' . config('app.domain'))
->middleware(['auth', 'admin'])
->group(function () {
Route::get('/dashboard', [BackendDashboardController::class, 'index'])
->name('backend.dashboard');

Route::resource('projects', ProjectController::class);
Route::resource('staff', StaffController::class);
Route::get('/offers', [OfferController::class, 'index']);
// ... etc
});

// Crew Portal (crew.pbs.com)
Route::domain('crew.' . config('app.domain'))
->middleware(['auth', 'crew'])
->group(function () {
Route::get('/dashboard', [CrewDashboardController::class, 'index'])
->name('crew.dashboard');

Route::get('/offers', [CrewOfferController::class, 'index']);
Route::get('/assignments', [CrewAssignmentController::class, 'index']);
Route::get('/profile', [CrewProfileController::class, 'edit']);
// ... etc
});

Dynamic Branding (Tailwind)​

// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
brand: {
primary: 'var(--brand-primary)',
secondary: 'var(--brand-secondary)',
}
}
}
}
}
// app/Http/Middleware/HandleInertiaRequests.php
public function share(Request $request): array
{
return array_merge(parent::share($request), [
'company' => $request->user()?->company ? [
'name' => $request->user()->company->name,
'logo' => $request->user()->company->logo_url,
'brand_color' => $request->user()->company->brand_color,
] : null,
]);
}
// resources/js/Layouts/BackendLayout.tsx
import { usePage } from '@inertiajs/react'

export default function BackendLayout({ children }) {
const { company } = usePage().props

return (
<div
style={{
'--brand-primary': company?.brand_color || '#3b82f6'
}}
>
{/* Navigation with company logo */}
<img src={company?.logo} alt={company?.name} />

{/* Buttons will use company color */}
<Button className="bg-brand-primary">Click Me</Button>

{children}
</div>
)
}

8. Mobile Considerations​

Crew Portal (Mobile-First)​

Tailwind Responsive Design:

<div className="
w-full /* Full width on mobile */
md:w-3/4 /* 75% width on tablet */
lg:w-1/2 /* 50% width on desktop */
mx-auto /* Center on larger screens */
p-4 /* Padding on all sizes */
md:p-6 /* More padding on tablet+ */
">
{/* Content */}
</div>

Touch-Friendly Components:

// Larger tap targets for mobile
<Button className="
h-12 px-6 /* Minimum 44x44px tap target */
text-base /* Readable text size */
">
Accept Offer
</Button>

Mobile Navigation:

// Show hamburger on mobile, sidebar on desktop
<div className="md:hidden">
{/* Hamburger menu */}
</div>

<div className="hidden md:block">
{/* Desktop sidebar */}
</div>

Progressive Web App (Optional)​

Consider adding PWA capabilities for crew portal:

  • Offline viewing of accepted assignments
  • Push notifications for new offers
  • Add to home screen

Packages:

  • vite-plugin-pwa

9. Testing Strategy​

Backend Testing (PHPUnit)​

// tests/Feature/ProjectTest.php
public function test_admin_can_create_project()
{
$admin = User::factory()->admin()->create();

$response = $this->actingAs($admin)
->post('/projects', [
'name' => 'Test Project',
'location' => 'Stockholm',
'start_date' => '2025-06-01'
]);

$response->assertRedirect('/projects');
$this->assertDatabaseHas('projects', [
'name' => 'Test Project'
]);
}

Frontend Testing (Vitest + React Testing Library)​

npm install -D vitest @testing-library/react @testing-library/jest-dom
// resources/js/Pages/Backend/Projects/__tests__/Index.test.tsx
import { render, screen } from '@testing-library/react'
import ProjectsIndex from '../Index'

test('renders project list', () => {
const projects = {
data: [
{ id: 1, name: 'Champions League', location: 'Stockholm' }
]
}

render(<ProjectsIndex projects={projects} />)

expect(screen.getByText('Champions League')).toBeInTheDocument()
expect(screen.getByText('Stockholm')).toBeInTheDocument()
})

10. Deployment Considerations​

Build Process​

# Production build
npm run build
php artisan optimize

Environment Variables​

APP_URL=https://pbs.com
BACKEND_URL=https://backend.pbs.com
CREW_URL=https://crew.pbs.com

# Database
DB_CONNECTION=pgsql
DB_HOST=your-postgres-host
DB_DATABASE=pbs_production

Hosting Options​

Recommended:

  • Laravel Forge (easiest) + DigitalOcean/AWS
  • Laravel Cloud (when available)
  • Ploi.io (alternative to Forge)

DIY Options:

  • AWS Elastic Beanstalk
  • Google Cloud Run
  • Railway.app
  • Render.com

11. Alternatives Recap​

Why NOT These Options?​

Livewire instead of React:

  • ❌ Limited mobile UX control
  • ❌ Harder to achieve "mobile-first" polish
  • ✅ Simpler for backend-heavy apps
  • Verdict: React + Inertia better for PBS's dual-portal needs

Separate Next.js API:

  • ❌ Requires building full REST/GraphQL API
  • ❌ More complex authentication
  • ❌ Two codebases to maintain
  • ❌ Against "Simplicity First" principle
  • Verdict: Use only if you need native mobile apps

Vue instead of React:

  • ✅ Works great with Inertia
  • ✅ Slightly easier learning curve
  • ❌ Smaller ecosystem than React
  • ❌ Fewer shadcn-like component libraries
  • Verdict: React preferred for broader talent pool

12. Cost Analysis​

Open Source (Free)​

  • Laravel ✅
  • React ✅
  • Inertia.js ✅
  • Tailwind CSS ✅
  • shadcn/ui ✅
  • Radix UI ✅

Optional Paid​

  • Tailwind UI: $299 (one-time)
  • Laravel Forge: $12-19/month (deployment)
  • Hosting: $12-50+/month (server costs)

Total MVP Cost: ~$0 (except hosting)


13. Timeline Estimate​

Assuming 1-2 developers:

  • Week 1-2: Setup, authentication, multi-tenancy
  • Week 3-4: Core models, database, migrations
  • Week 5-6: Backend portal (projects, staff directory)
  • Week 7-8: Offer management system
  • Week 9-10: Crew portal (dashboard, offers, profile)
  • Week 11-12: Assignment lifecycle, documents
  • Week 13-14: Reports, calendar, refinements
  • Week 15-16: Testing, bug fixes, deployment

Total: ~4 months to MVP


14. Next Steps​

  1. Review this document with the team
  2. Validate assumptions about multi-tenancy approach
  3. Set up development environment (see Section 6)
  4. Create initial migrations from schema.dbml
  5. Build first view (e.g., Backend Dashboard)
  6. Iterate based on feedback

15. Questions & Answers​

Can we use Vue instead of React?​

Yes! Inertia works equally well with Vue. The choice is mainly team preference and component ecosystem.

Do we need TypeScript?​

Recommended but not required. TypeScript adds type safety and better IDE support, helpful for larger teams.

Can we add mobile apps later?​

Yes. You can build a REST API later and keep Inertia for web. Or use Capacitor/Ionic to wrap the web app.

What about real-time features?​

Laravel Broadcasting + Pusher (or Laravel Echo Server) can be added for real-time notifications and updates.

How do we handle file uploads?​

Laravel's file handling works normally with Inertia. Use useForm with post(route, { forceFormData: true }).


References​


Document Status: Ready for team review Author: Technical Architecture Next Review: After team feedback and approval