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