This comprehensive guide will walk you through building a modern web application using Laravel as the backend, Inertia.js as the bridge, and React for the frontend. We'll create a complete product management system from scratch.
Laravel Installation and Setup#
Let's start by setting up a new Laravel project. There are several ways to create a Laravel application:
# Install global laravel installer
composer global require laravel/installer
# Create a new Laravel project
laravel new introduction
# Alternative: using composer
composer create-project laravel/laravel introduction
# Run the development server
php artisan serve
npm run dev
# Run both with single command (using concurrently, open composer.json)
composer dev
The Laravel installer provides various options including Docker Sail and starter kits like Breeze with Blade or Livewire templates.
Docker Setup with PostgreSQL#
For a consistent development environment, let's set up Docker with PostgreSQL. Create a docker-compose.yml file in your project root:
services:
db:
image: postgres:17
container_name: advantryx_introduction_db
restart: unless-stopped
environment:
POSTGRES_DB: advantryx_introduction
POSTGRES_USER: laravel
POSTGRES_PASSWORD: password
POSTGRES_ROOT_PASSWORD: admin
ports:
- '5432:5432'
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- advantryx_introduction
networks:
advantryx_introduction:
driver: bridge
volumes:
postgres_data:
driver: local
Database Configuration#
Update your .env file with the PostgreSQL database configuration:
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=advantryx_introduction
DB_USERNAME=laravel
DB_PASSWORD=password
Start your PostgreSQL container:
# Start the PostgreSQL container
docker-compose up -d
# Check if the container is running
docker-compose ps
# View container logs
docker-compose logs db
# Stop the container when done
docker-compose down
Laravel Basics and Creating Products with Routes and Controllers#
Understanding Laravel Core Concepts#
Before diving into code, let's understand the key Laravel components:
Models provide a powerful and enjoyable interface for you to interact with the tables in your database.
Migrations allow you to easily create and modify the tables in your database. They ensure that the same database structure exists everywhere that your application runs.
Controllers are responsible for processing requests made to your application and returning a response.
Creating Products with Routes and Controllers#
Let's create a Product model with migration, controller, and resource routes:
php artisan make:model Product -mcr
This command creates:
- Model: app/Models/Product.php
- Migration: database/migrations/xxxx_create_products_table.php
- Controller: app/Http/Controllers/ProductController.php
- Resource routes (we'll add manually)
Now let's set up the routes:
Route::resource('products', ProductController::class)
->only(['index', 'store']);
// Alternative individual routes:
// Route::get('/products', [ProductController::class, 'index'])->name('products.index');
// Route::post('/products', [ProductController::class, 'store'])->name('products.store');
You can verify your routes using these commands:
php artisan route:list
php artisan route:list --path=products
Let's update the ProductController to return a basic response:
<?php
...
namespace App\Http\Controllers;
use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class ProductController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(): Response
{
return response('Hello, World!');
}
...
}
Now let's integrate Inertia.js to render React components:
<?php
namespace App\Http\Controllers;
use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Inertia\Inertia;
class ProductController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(): Response
{
return Inertia::render('Products/Index');
}
}
Frontend Setup with Inertia.js and React#
Now that we have our backend set up, let's create the React frontend component that will handle product creation.
Creating the Products Index Component#
Create the React component that will be rendered by our Inertia controller:
import AppLayout from '@/layouts/app-layout';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useForm, Head } from '@inertiajs/react';
import { FormEventHandler } from 'react';
export default function Index() {
const { data, setData, post, processing, reset, errors } = useForm({
name: '',
});
const submit: FormEventHandler = (e) => {
e.preventDefault();
post(route('products.store'), { onSuccess: () => reset() });
};
return (
<AppLayout>
<Head title="Products" />
<div className="mx-auto max-w-2xl p-4 sm:p-6 lg:p-8">
<form onSubmit={submit}>
<Input
value={data.name}
placeholder="Name of the product"
onChange={(e) => setData('name', e.target.value)}
/>
<InputError message={errors.name} className="mt-2" />
<Button className="mt-4" disabled={processing}>
Create
</Button>
</form>
</div>
</AppLayout>
);
}
This component demonstrates several key Inertia.js and React concepts:
- useForm hook: Manages form state, submission, and validation errors
- Head component: Sets the page title
- route() helper: Generates URLs for named routes
- Form handling: Prevents default submission and uses Inertia's post method
Adding Navigation#
To make the products page accessible, let's add it to the sidebar navigation:
// resources/js/components/app-sidebar.tsx
const mainNavItems: NavItem[] = [
{
title: 'Products',
href: '/products',
icon: Package,
},
];
This adds a "Products" link to your application's sidebar with a Package icon.
Database Relationships and Eloquent#
Now let's set up proper database relationships and implement the store functionality for our products.
Setting Up the Database Migration#
First, let's update our products migration to include the necessary fields and foreign key relationships:
<?php
...
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete(); // Shorthand
// $table->unsignedBigInteger('user_id');
// $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
$table->string('name');
$table->timestamps();
});
}
...
};
Implementing the Store Method#
Let's update our ProductController to handle product creation with proper validation:
<?php
namespace App\Http\Controllers;
use App\Models\Product;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ProductController extends Controller
{
...
/**
* Store a newly created resource in storage.
*/
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
]);
$request->user()->products()->create($validated);
return redirect(route('products.index'));
}
...
}
Setting Up Eloquent Relationships#
Now let's define the relationships between User and Product models. Check out the Eloquent Relationships documentation for more details.
User Model Relationship#
<?php
...
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
...
public function products(): HasMany
{
return $this->hasMany(Product::class);
}
}
Product Model Configuration#
<?php
class Product extends Model
{
protected $fillable = ['name'];
// protected $guarded = []; // if you want to allow all fields (not recommended)
}
Running Migrations#
php artisan migrate
php artisan migrate:rollback
During development, you can add new database fields without creating a new migration file by modifying the existing migration before running it.
You can test your models using Laravel Tinker:
php artisan tinker
App\Models\Product::all();
Displaying Products#
Now that we have our database relationships set up, let's implement the functionality to display products with their associated user information.
Updating the Controller to Fetch Products#
Let's modify our ProductController to fetch products with their related user data:
<?php
...
class ProductController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(): Response
{
return Inertia::render('Products/Index', [
'products' => Product::with('user:id,name')->latest()->get(),
]);
}
...
}
Adding Product Model Relationship#
We need to add the belongsTo relationship in our Product model:
<?php
use Illuminate\Database\Eloquent\Relations\BelongsTo;
...
class Product extends Model
{
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
Creating the Product Component#
Let's create a reusable Product component to display individual products:
// resources/js/components/Product.tsx
import { User } from 'lucide-react';
export interface ProductProps {
id: number;
name: string;
user: {
name: string;
};
created_at: string;
}
export default function Product(product: ProductProps) {
return (
<div className="flex space-x-2 p-6">
<User className="h-6 w-6 -scale-x-100 text-gray-600" />
<div className="flex-1">
<div className="flex items-center justify-between">
<div>
<span className="text-gray-800">{product.user.name}</span>
<small className="ml-2 text-sm text-gray-600">{new Date(product.created_at).toLocaleString()}</small>
</div>
</div>
<p className="mt-4 text-lg text-gray-900">{product.name}</p>
</div>
</div>
);
}
Updating the Index Page#
Now let's update our Products Index page to display the list of products:
// resources/js/pages/Products/Index.tsx
import InputError from '@/components/input-error';
import Product, { ProductProps } from '@/components/Product';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import AppLayout from '@/layouts/app-layout';
import { Head, useForm } from '@inertiajs/react';
import { FormEventHandler } from 'react';
export default function Index({ products }: { products: ProductProps[] }) {
const { data, setData, post, processing, reset, errors } = useForm({
name: '',
});
const submit: FormEventHandler = (e) => {
e.preventDefault();
post(route('products.store'), { onSuccess: () => reset() });
};
return (
<AppLayout>
<Head title="Products" />
<div className="mx-auto max-w-2xl p-4 sm:p-6 lg:p-8">
<form onSubmit={submit}>
<Input value={data.name} placeholder="Name of the product" onChange={(e) => setData('name', e.target.value)} />
<InputError message={errors.name} className="mt-2" />
<Button className="mt-4" disabled={processing}>
Create
</Button>
</form>
</div>
<div className="mt-6 divide-y rounded-lg bg-white shadow-sm">
{products.map((product) => (
<Product key={product.id} {...product} />
))}
</div>
</AppLayout>
);
}
N+1 Query Problems and Solutions#
One of the most common performance issues in Laravel applications is the N+1 query problem. Let's understand what it is and how to solve it.
Understanding the N+1 Problem#
class ProductController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
$products = Product::latest()->get();
// This will trigger N+1 queries when we access user relationship
// For each product, Laravel will make a separate query to fetch the user
// If you have 100 products, it will make 1 query for products + 100 queries for users = 101 queries
$products->each(function ($product) {
$product->user;
});
return Inertia::render('Products/Index', [
'products' => $products,
]);
// With eager loading
// This makes only 2 queries: 1 for products + 1 for all related users
// return Inertia::render('Products/Index', [
// 'products' => Product::with('user:id,name')->latest()->get(),
// ]);
}
Preventing Lazy Loading in Development#
Add this to your AppServiceProvider to detect N+1 problems during development:
# app/Providers/AppServiceProvider.php
public function boot(): void
{
// Detect N+1 query problems in development
// This will throw an exception when lazy loading occurs
if (app()->environment('local')) {
Model::preventLazyLoading();
}
}
Improving Date Display#
Let's install and use dayjs for better date formatting:
npm install dayjs
// resources/js/components/Product.tsx
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
...
<small className="ml-2 text-sm text-gray-600">{dayjs(product.created_at).fromNow()}</small>
...
Filtering User-Specific Products#
You can also filter products to show only those belonging to the authenticated user:
# get only user products
class ProductController extends Controller
{
public function index()
{
return Inertia::render('Products/Index', [
'products' => Auth::user()->products()->with('user:id,name')->latest()->get(),
]);
}
}
Editing Products#
Now let's implement the ability to edit products with proper authorization to ensure users can only edit their own products.
Adding Update Route#
First, let's add the update route to our resource routes:
<?php
...
Route::resource('products', ProductController::class)
->only(['index', 'store', 'update'])
->middleware(['auth', 'verified']);
...
Enhanced Product Component with Editing#
Let's update our Product component to include editing functionality:
// resources/js/components/Product.tsx
import React, { useState } from 'react';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { MoreHorizontal, User } from 'lucide-react';
import { useForm, usePage } from '@inertiajs/react';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { type SharedData } from '@/types';
dayjs.extend(relativeTime);
export interface ProductProps {
id: number;
name: string;
user: {
id: number;
name: string;
};
created_at: string;
updated_at: string;
}
export default function Product(product: ProductProps) {
const { auth } = usePage<SharedData>().props;
const [editing, setEditing] = useState(false);
const { data, setData, patch, clearErrors, reset, errors } = useForm({
name: product.name,
});
const submit = (e: React.FormEvent) => {
e.preventDefault();
patch(route('products.update', product.id), {
onSuccess: () => setEditing(false)
});
};
return (
<div className="flex space-x-2 p-6">
<User className="h-6 w-6 -scale-x-100 text-gray-600" />
<div className="flex-1">
<div className="flex items-center justify-between">
<div>
<span className="text-gray-800">{product.user.name}</span>
<small className="ml-2 text-sm text-gray-600">{dayjs(product.created_at).fromNow()}</small>
{product.created_at !== product.updated_at && (
<small className="text-sm text-gray-600"> · edited</small>
)}
</div>
{product.user.id === auth.user.id && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4 text-gray-400" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditing(true)}>
Edit
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{editing ? (
<form onSubmit={submit}>
<Input
value={data.name}
onChange={e => setData('name', e.target.value)}
className="mt-4"
/>
<InputError message={errors.name} className="mt-2" />
<div className="space-x-2">
<Button type="submit" className="mt-4">Save</Button>
<Button
type="button"
variant="ghost"
className="mt-4"
onClick={() => {
setEditing(false);
reset();
clearErrors();
}}
>
Cancel
</Button>
</div>
</form>
) : (
<p className="mt-4 text-lg text-gray-900">{product.name}</p>
)}
</div>
</div>
);
}
Implementing the Update Controller Method#
Now let's implement the update method in our ProductController:
<?php
class ProductController extends Controller
{
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Product $product): RedirectResponse
{
Gate::authorize('update', $product);
// if ($product->user_id !== $request->user()->id) {
// abort(403, 'Unauthorized action.');
// }
$validated = $request->validate([
'name' => 'required|string|max:255',
]);
$product->update($validated);
return redirect(route('products.index'));
}
}
Authorization with Policies#
For proper authorization, let's create a policy to control who can update products:
php artisan make:policy ProductPolicy --model=Product
# app/Policies/ProductPolicy.php
class ProductPolicy
{
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Product $product): bool
{
return $product->user_id === $user->id;
// return $product->user()->is($user);
}
}
Security Testing Example#
Here's an example of how you can test authorization without proper Gate protection. This demonstrates why authorization is crucial.
Steps to test:
- Open Network Tab in browser
- Edit a product that the user owns
- Copy the X-XSRF-TOKEN from Request Headers
- Run this console command:
fetch('/products/11', {
// Change to a product ID you don't own
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN':
'eyJpdiI6IjRFTy9Ma3Q0Ylc0NUlHRXJMRVcwUVE9PSIsInZhbHVlIjoiR1JoVHVpZithdlFqclArdHExcHdybC83VzJPVzRqbFd3eTJjRFZCUVJmNEhTUENvZXlISW1LTXBIZTdWRU9qaFBKL3VuTWFFOW1saFhYZDRBRnlsSzBBM2dEVGluckpjeUdCU0htZUpHd1FFUFVWT2hiL3JFV0U0anNodzJBZ1YiLCJtYWMiOiI1NjRkYTJhYmFiOWQyOTNmYjQzOGYzZTYyN2IzMjYxZDU0MTc0ZDZjYTcwMGRjMGM3M2JhZWZmODViYjlkNDA4IiwidGFnIjoiIn0=',
'X-Inertia': 'true',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({
name: 'HACKED PRODUCT!',
}),
});
Deleting Products#
Finally, let's implement the delete functionality with proper authorization.
Adding Destroy Route#
Route::resource('products', ProductController::class)
->only(['index', 'store', 'update', 'destroy'])
->middleware(['auth', 'verified']);
Implementing the Destroy Method#
public function destroy(Product $product): RedirectResponse
{
Gate::authorize('delete', $product);
$product->delete();
return redirect(route('products.index'));
}
Adding Delete Policy#
public function delete(User $user, Product $product): bool
{
return $this->update($user, $product);
}
Adding Delete Button to Component#
// resources/js/components/Product.tsx
const { data, setData, patch, clearErrors, reset, errors, delete: destroy } = useForm({
name: product.name,
});
...
<DropdownMenuItem onClick={() => destroy(route('products.destroy', product.id))}>Delete</DropdownMenuItem>
Conclusion#
You now have a complete Laravel + Inertia.js + React application with:
- ✅ Product creation with validation
- ✅ Product listing with relationships
- ✅ Product editing with authorization
- ✅ Product deletion with security
- ✅ N+1 query optimization
- ✅ Proper database relationships
- ✅ User authentication and authorization
This foundation provides you with all the essential patterns for building modern web applications using this powerful stack.
Bonus: Laravel Factory and Seeder for Products#
Laravel factories and seeders provide a powerful way to generate test data for your application. This is especially useful during development and testing phases.
Creating Product Factory#
Generate a factory for the Product model:
php artisan make:factory ProductFactory
Make sure your Product model uses the HasFactory trait to enable factory functionality.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Product extends Model
{
use HasFactory;
protected $fillable = ['name'];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
Update the ProductFactory to define how fake products should be generated:
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
class ProductFactory extends Factory
{
public function definition(): array
{
return [
'user_id' => User::inRandomOrder()->first()?->id ?? User::factory(),
'name' => fake()->words(3, true),
];
}
}
Creating Product Seeder#
Generate a seeder to populate your database with sample products:
php artisan make:seeder ProductSeeder
Update the ProductSeeder:
<?php
namespace Database\Seeders;
use App\Models\Product;
use Illuminate\Database\Seeder;
class ProductSeeder extends Seeder
{
public function run(): void
{
Product::factory(50)->create();
}
}
Running Seeders#
Add the ProductSeeder to your DatabaseSeeder:
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call([
ProductSeeder::class,
]);
}
}
Run the seeders to populate your database:
# Run all seeders
php artisan db:seed
# Run specific seeder
php artisan db:seed --class=ProductSeeder
# Fresh migration with seeding
php artisan migrate:fresh --seed
Using Factories in Tinker#
You can also use factories directly in Tinker for quick testing:
php artisan tinker
# Create a single product
Product::factory()->create();
# Create 5 products
Product::factory()->count(5)->create();
# Create products for a specific user
Product::factory()->count(3)->create(['user_id' => 1]);
Factories and seeders are essential for maintaining consistent test data across different environments and team members.
Bonus: Laravel Middleware for Authorization#
Laravel middleware provides a convenient mechanism for filtering HTTP requests entering your application. Instead of placing authorization logic directly in controllers, middleware offers a cleaner, more reusable approach.
Understanding Middleware#
Middleware acts as a bridge between a request and a response. It's like a series of layers that your request must pass through before reaching your application's core logic. Each middleware can examine the request, modify it, or even terminate it entirely.
Creating Custom Middleware#
Let's create middleware to ensure users can only access their own products:
php artisan make:middleware EnsureProductOwnership
This creates a new middleware file at app/Http/Middleware/EnsureProductOwnership.php:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureProductOwnership
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
$product = $request->route('product');
if ($product && $product->user_id !== auth()->id()) {
abort(403, 'Unauthorized access to product.');
}
return $next($request);
}
}
Registering Middleware in Laravel 12#
In Laravel 12, middleware registration has been simplified. Register your middleware in bootstrap/app.php:
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'product.owner' => \App\Http\Middleware\EnsureProductOwnership::class,
]);
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();
Applying Middleware to Routes#
Now you can apply the middleware to your routes in several ways:
Option 1: Individual Routes#
Route::resource('products', ProductController::class)
->only(['index', 'store', 'update', 'destroy'])
->middleware(['auth', 'verified']);
Route::middleware('product.owner')->group(function () {
Route::patch('/products/{product}', [ProductController::class, 'update'])->name('products.update');
Route::delete('/products/{product}', [ProductController::class, 'destroy'])->name('products.destroy');
});
Option 2: Route Groups#
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/products', [ProductController::class, 'index'])->name('products.index');
Route::post('/products', [ProductController::class, 'store'])->name('products.store');
Route::middleware('product.owner')->group(function () {
Route::patch('/products/{product}', [ProductController::class, 'update'])->name('products.update');
Route::delete('/products/{product}', [ProductController::class, 'destroy'])->name('products.destroy');
});
});
Option 3: Controller Constructor#
<?php
class ProductController extends Controller
{
public function __construct()
{
$this->middleware('product.owner')->only(['update', 'destroy']);
}
// ... rest of controller methods
}
Simplifying Controller Methods#
With middleware handling authorization, you can remove the authorization logic from your controller methods:
Global Middleware#
For middleware that should run on every request, add it to the global middleware stack:
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->append(\App\Http\Middleware\LogRequests::class);
})
Benefits of Middleware Approach#
- Separation of Concerns: Authorization logic is separated from business logic
- Reusability: Same middleware can be applied to multiple routes
- Consistency: Ensures authorization is always checked
- Security: Harder to forget authorization checks
- Maintainability: Centralized authorization logic
- Performance: Can terminate requests early without hitting controllers
Use middleware for cross-cutting concerns like authentication, authorization, logging, and rate limiting. Keep your controllers focused on business logic.
Bonus: Laravel Pail - Real-time Log Monitoring#
Laravel Pail allows you to monitor your application's logs in real-time directly from the console. It works with any log driver and provides filtering options.
Installation and Usage#
# Install Laravel Pail
composer require laravel/pail
# Basic usage
php artisan pail
# Verbose output (avoid truncation)
php artisan pail -v
# Maximum verbosity with stack traces
php artisan pail -vv
Example with Logging#
Add logging to your ProductController:
public function index(): Response
{
$products = Product::with('user:id,name')->latest()->get();
Log::info('Products index accessed', [
'user_id' => auth()->id(),
'products_count' => $products->count(),
'products' => $products->toArray()
]);
return Inertia::render('Products/Index', [
'products' => $products,
]);
}
Now monitor your logs in real-time:
# Monitor all logs with verbose output
php artisan pail -v
# Filter by specific content
php artisan pail --filter="Products index accessed"
Shadcn Tanstack DataTable:#
// resources/js/components/Product.tsx
import { router, useForm, usePage } from '@inertiajs/react';
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
SortingState,
useReactTable,
} from '@tanstack/react-table';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { ArrowUpDown, ChevronLeft, ChevronRight, Edit, Search, Trash2, X, Check } from 'lucide-react';
import { useState } from 'react';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { type SharedData } from '@/types';
dayjs.extend(relativeTime);
export interface ProductProps {
id: number;
name: string;
user: {
id: number;
name: string;
};
created_at: string;
updated_at: string;
}
interface ProductsTableProps {
products: ProductProps[];
}
export default function ProductsTable({ products }: ProductsTableProps) {
const { auth } = usePage<SharedData>().props;
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState('');
const [editingId, setEditingId] = useState<number | null>(null);
// Form for editing
const { data, setData, patch, clearErrors, reset, errors } = useForm({
name: '',
});
const startEdit = (product: ProductProps) => {
setEditingId(product.id);
setData('name', product.name);
clearErrors();
};
const saveEdit = (productId: number) => {
patch(route('products.update', productId), {
onSuccess: () => {
setEditingId(null);
reset();
},
});
};
const cancelEdit = () => {
setEditingId(null);
reset();
clearErrors();
};
const columns: ColumnDef<ProductProps>[] = [
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-medium"
>
Product Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const product = row.original;
const isEditing = editingId === product.id;
if (isEditing) {
return (
<div className="flex items-center space-x-2">
<Input
value={data.name}
onChange={(e) => setData('name', e.target.value)}
className="h-8"
autoFocus
/>
{errors.name && (
<InputError message={errors.name} className="mt-1" />
)}
</div>
);
}
return <div className="font-medium max-w-xs truncate">{row.getValue("name")}</div>;
},
size: 300,
},
{
accessorKey: "user.name",
header: "Created By",
cell: ({ row }) => <div className="text-sm">{row.original.user.name}</div>,
size: 150,
},
{
accessorKey: "created_at",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-medium"
>
Created
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const date = row.getValue("created_at") as string;
return <div className="text-sm text-muted-foreground">{dayjs(date).fromNow()}</div>;
},
size: 120,
},
{
id: "actions",
header: "Actions",
enableHiding: false,
cell: ({ row }) => {
const product = row.original;
const canEdit = product.user.id === auth.user.id;
const isEditing = editingId === product.id;
if (!canEdit) return <div className="w-32"></div>;
if (isEditing) {
return (
<div className="flex space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => saveEdit(product.id)}
>
<Check className="h-4 w-4 mr-1" />
Save
</Button>
<Button
variant="ghost"
size="sm"
onClick={cancelEdit}
>
<X className="h-4 w-4 mr-1" />
Cancel
</Button>
</div>
);
}
return (
<div className="flex space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => startEdit(product)}
>
<Edit className="h-4 w-4 mr-1" />
Edit
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => {
if (confirm('Are you sure you want to delete this product?')) {
router.delete(route('products.destroy', product.id));
}
}}
>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</Button>
</div>
);
},
size: 200,
},
];
const table = useReactTable({
data: products,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onGlobalFilterChange: setGlobalFilter,
globalFilterFn: 'includesString',
state: {
sorting,
columnFilters,
globalFilter,
},
});
return (
<div className="w-full">
{/* Search Input */}
<div className="flex items-center py-4">
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search products..."
value={globalFilter ?? ''}
onChange={(event) => setGlobalFilter(String(event.target.value))}
className="pl-10"
/>
</div>
</div>
<div className="rounded-md border bg-white shadow-sm">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="border-b">
{headerGroup.headers.map((header, index) => {
let className = "text-left font-medium";
// Set specific widths for each column
if (index === 0) className += " w-2/5"; // Product Name - 40%
else if (index === 1) className += " w-1/5"; // Created By - 20%
else if (index === 2) className += " w-1/5"; // Created - 20%
else if (index === 3) className += " w-1/5"; // Actions - 20%
return (
<TableHead key={header.id} className={className}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="border-b hover:bg-gray-50"
>
{row.getVisibleCells().map((cell, index) => {
let className = "py-3";
// Set specific widths for each column
if (index === 0) className += " w-2/5"; // Product Name
else if (index === 1) className += " w-1/5"; // Created By
else if (index === 2) className += " w-1/5"; // Created
else if (index === 3) className += " w-1/5"; // Actions
return (
<TableCell key={cell.id} className={className}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
)
})}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
No products found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Pagination Controls */}
<div className="flex items-center justify-between space-x-2 py-4">
<div className="text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="flex items-center space-x-2">
<p className="text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</p>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
);
}
// resources/js/pages/Products/Index.tsx
import InputError from '@/components/input-error';
import ProductsTable, { ProductProps } from '@/components/Product';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import AppLayout from '@/layouts/app-layout';
import { Head, useForm } from '@inertiajs/react';
import { FormEventHandler } from 'react';
export default function Index({ products }: { products: ProductProps[] }) {
const { data, setData, post, processing, reset, errors } = useForm({
name: '',
});
const submit: FormEventHandler = (e) => {
e.preventDefault();
post(route('products.store'), { onSuccess: () => reset() });
};
return (
<AppLayout>
<Head title="Products" />
<div className="mx-auto max-w-2xl p-4 sm:p-6 lg:p-8">
<form onSubmit={submit} className="flex items-center gap-4">
<Input value={data.name} placeholder="Name of the product" onChange={(e) => setData('name', e.target.value)} />
<Button disabled={processing}>Create</Button>
</form>
<InputError message={errors.name} className="mt-2" />
</div>
<div className="mx-auto mt-6 max-w-7xl px-4 sm:px-6 lg:px-8">
<ProductsTable products={products} />
</div>
</AppLayout>
);
}
Lravel Pagination#
public function index(Request $request)
{
$perPage = $request->get('per_page', 10); // Default 10 items per page
$products = Product::with('user:id,name')
->latest()
->paginate($perPage);
return Inertia::render('Products/Index', [
'products' => $products,
]);
}
// resources/js/types/index.d.ts
export interface PaginatedData<T> {
data: T[];
current_page: number;
first_page_url: string;
from: number;
last_page: number;
last_page_url: string;
links: PaginationLink[];
next_page_url: string | null;
path: string;
per_page: number;
prev_page_url: string | null;
to: number;
total: number;
}
export interface PaginationLink {
url: string | null;
label: string;
active: boolean;
}
// resources/js/components/Paginat.tsx
import { type SharedData, type PaginatedData } from '@/types';
interface ProductsTableProps {
products: PaginatedData<ProductProps>;
}
const table = useReactTable({
data: products.data,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onGlobalFilterChange: setGlobalFilter,
globalFilterFn: 'includesString',
state: {
sorting,
columnFilters,
globalFilter,
},
manualPagination: true, // Tell TanStack Table we're handling pagination manually
pageCount: products.last_page,
});
...
{/* Server-Side Pagination Controls */}
<div className="flex items-center justify-between space-x-2 py-4">
<div className="text-sm text-muted-foreground">
Showing {products.from} to {products.to} of {products.total} results
</div>
<div className="flex items-center space-x-2">
<p className="text-sm font-medium">
Page {products.current_page} of {products.last_page}
</p>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => router.get(products.prev_page_url || '')}
disabled={!products.prev_page_url}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => router.get(products.next_page_url || '')}
disabled={!products.next_page_url}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>