Introduction to Laravel InertiaJS ReactJS

--- views
Banner image for Introduction to Laravel InertiaJS ReactJS

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:

laravel-installation
# 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:

docker-compose.yml
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:

.env
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:

docker-commands
# 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:

create-product-model
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:

routes/web.php
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:

route-list
php artisan route:list
php artisan route:list --path=products

Let's update the ProductController to return a basic response:

app/Http/Controllers/ProductController.php
<?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:

app/Http/Controllers/ProductController.php
<?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:

resources/js/Pages/Products/Index.tsx
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
// 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:

products-migration
<?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:

store-method
<?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#

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#

product-model
<?php

class Product extends Model
{
    protected $fillable = ['name'];
    // protected $guarded = []; // if you want to allow all fields (not recommended)
}

Running Migrations#

migration-commands
php artisan migrate
php artisan migrate:rollback
Development Tip

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:

tinker-testing
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:

display-products-controller
<?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:

product-belongs-to-user
<?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:

product-component
// 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:

updated-index-page
// 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#

n-plus-one-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:

prevent-lazy-loading
# 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:

install-dayjs
npm install dayjs

dayjs-usage
// 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:

user-specific-products
# 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:

add-update-route
<?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:

enhanced-product-component
// 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"> &middot; 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:

update-controller-method
<?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:

create-policy
php artisan make:policy ProductPolicy --model=Product
product-policy
# 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#

Security Testing

Here's an example of how you can test authorization without proper Gate protection. This demonstrates why authorization is crucial.

Steps to test:

  1. Open Network Tab in browser
  2. Edit a product that the user owns
  3. Copy the X-XSRF-TOKEN from Request Headers
  4. Run this console command:
security-test
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#

add-destroy-route
Route::resource('products', ProductController::class)
    ->only(['index', 'store', 'update', 'destroy'])
    ->middleware(['auth', 'verified']);

Implementing the Destroy Method#

destroy-method
public function destroy(Product $product): RedirectResponse
{
    Gate::authorize('delete', $product);
    $product->delete();
    return redirect(route('products.index'));
}

Adding Delete Policy#

delete-policy
public function delete(User $user, Product $product): bool
{
    return $this->update($user, $product);
}

Adding Delete Button to Component#

delete-button
// 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:

create-factory
php artisan make:factory ProductFactory
Important Note

Make sure your Product model uses the HasFactory trait to enable factory functionality.

app/Models/Product.php
<?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:

database/factories/ProductFactory.php
<?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:

create-seeder
php artisan make:seeder ProductSeeder

Update the ProductSeeder:

database/seeders/ProductSeeder.php
<?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:

database/seeders/DatabaseSeeder.php
<?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-seeders
# 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:

tinker-factories
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]);
Development Tip

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:

create-middleware
php artisan make:middleware EnsureProductOwnership

This creates a new middleware file at app/Http/Middleware/EnsureProductOwnership.php:

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:

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#

routes/web.php
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#

routes/web.php
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#

app/Http/Controllers/ProductController.php
<?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:

global-middleware
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->append(\App\Http\Middleware\LogRequests::class);
})

Benefits of Middleware Approach#

  1. Separation of Concerns: Authorization logic is separated from business logic
  2. Reusability: Same middleware can be applied to multiple routes
  3. Consistency: Ensures authorization is always checked
  4. Security: Harder to forget authorization checks
  5. Maintainability: Centralized authorization logic
  6. Performance: Can terminate requests early without hitting controllers
Best Practice

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#

pail-setup
# 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:

ProductController.php
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:

pail-monitoring
# Monitor all logs with verbose output
php artisan pail -v

# Filter by specific content
php artisan pail --filter="Products index accessed"

Shadcn Tanstack DataTable:#

shadcn 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>
    );
}
Index.tsx
// 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#

ProductController.php
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,
    ]);
}

index.d.ts
// 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;
}

Product.tsx
// 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>