Friday, May 2, 2025
Laravel Multi-Vendor Ecommerce System Documentation
Posted by

Authentication
Basic Authentication
Laravel's built-in authentication system provides login, registration, password reset, and email verification out of the box:
# Create a new Project with the starter kit
laravel new multi-vendor-app
Social Authentication
For social authentication with Google and GitHub, you'll need Laravel Socialite:
composer require laravel/socialite
Add Socialite configuration to config/services.php
:
'github' => [
'client_id' => env('GITHUB_CLIENT_ID'),
'client_secret' => env('GITHUB_CLIENT_SECRET'),
'redirect' => env('GITHUB_REDIRECT_URI'),
],
'google' => [
'client_id' => env('GOOGLE_CLIENT_ID'),
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
'redirect' => env('GOOGLE_REDIRECT_URI'),
],
Add these values to your .env
file:
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
GITHUB_REDIRECT_URI=http://your-app.test/auth/github/callback
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GOOGLE_REDIRECT_URI=http://your-app.test/auth/google/callback
Create routes in routes/web.php
:
use App\Http\Controllers\Auth\SocialiteController;
Route::get('/auth/{provider}', [SocialiteController::class, 'redirect'])->name('socialite.redirect');
Route::get('/auth/{provider}/callback', [SocialiteController::class, 'callback'])->name('socialite.callback');
Create the SocialiteController:
php artisan make:controller Auth/SocialiteController
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Laravel\Socialite\Facades\Socialite;
use App\Enums\UserRole;
class SocialiteController extends Controller
{
public function redirect($provider)
{
return Socialite::driver($provider)->redirect();
}
public function callback($provider)
{
try {
$socialUser = Socialite::driver($provider)->user();
// Find existing user or create new one
$user = User::firstOrCreate(
['email' => $socialUser->getEmail()],
[
'name' => $socialUser->getName() ?? $socialUser->getNickname(),
'password' => bcrypt(str()->random(16)),
'role' => UserRole::USER,
]
);
// Login the user
Auth::login($user);
return redirect()->intended('/dashboard');
} catch (\Exception $e) {
return redirect('/login')->withErrors([
'email' => 'Authentication failed. Please try again.',
]);
}
}
}
Authorization
User Roles Implementation
First, create the UserRole enum:
php artisan make:enum UserRole
In app/Enums/UserRole.php
<?php
namespace App\Enums;
enum UserRole: string
{
case USER = 'user';
case ADMIN = 'admin';
case VENDOR = 'vendor';
case DEVELOPER = 'developer';
}
Update the User model to work with the role enum:
<?php
namespace App\Models;
use App\Enums\UserRole;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
protected $fillable = [
'name',
'email',
'password',
'role',
];
protected $hidden = [
'password',
'remember_token',
];
protected $casts = [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'role' => UserRole::class,
];
}
Add the role column to the users table by creating a migration:
php artisan make:migration add_role_to_users_table
In the migration file:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('role')->default('user')->after('email');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('role');
});
}
};
Run the migration:
php artisan migrate
Page Protection with Roles
The most scalable approach for a large application is to use a combination of middleware and policies:
Create role middleware:
php artisan make:middleware CheckRole
In app/Http/Middleware/CheckRole.php
:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CheckRole
{
public function handle(Request $request, Closure $next, string ...$roles): Response
{
if (!$request->user() || !in_array($request->user()->role->value, $roles)) {
abort(403, 'Unauthorized action.');
}
return $next($request);
}
}
Register the middleware in bootstrap/app.php
:
protected $middlewareAliases = [
// Other middleware...
'role' => \App\Http\Middleware\CheckRole::class,
];
Use the middleware in routes:
// Admin routes
Route::prefix('dashboard/admin')->middleware(['auth', 'role:admin,developer'])->group(function () {
Route::get('/users', [AdminController::class, 'users'])->name('admin.users');
// Other admin routes...
});
// Vendor routes
Route::prefix('dashboard/vendor')->middleware(['auth', 'role:vendor,admin,developer'])->group(function () {
Route::get('/products', [VendorController::class, 'products'])->name('vendor.products');
// Other vendor routes...
});
// User routes
Route::prefix('dashboard/user')->middleware(['auth'])->group(function () {
Route::get('/orders', [UserController::class, 'orders'])->name('user.orders');
// Other user routes...
});
Create a policy for more granular control:
php artisan make:policy ProductPolicy --model=Product
In app/Policies/ProductPolicy.php
:
<?php
namespace App\Policies;
use App\Enums\UserRole;
use App\Models\Product;
use App\Models\User;
class ProductPolicy
{
public function viewAny(User $user): bool
{
return true; // Anyone can view products
}
public function view(User $user, Product $product): bool
{
return true; // Anyone can view a product
}
public function create(User $user): bool
{
return in_array($user->role, [UserRole::VENDOR, UserRole::ADMIN, UserRole::DEVELOPER]);
}
public function update(User $user, Product $product): bool
{
return $user->role === UserRole::ADMIN ||
$user->role === UserRole::DEVELOPER ||
($user->role === UserRole::VENDOR && $product->vendor_id === $user->id);
}
public function delete(User $user, Product $product): bool
{
return $user->role === UserRole::ADMIN ||
$user->role === UserRole::DEVELOPER ||
($user->role === UserRole::VENDOR && $product->vendor_id === $user->id);
}
}
Register the policy in app/Providers/AuthServiceProvider.php
:
protected $policies = [
Product::class => ProductPolicy::class,
];
Custom 403 Page
Create a custom 403 error page in resources/views/errors/403.blade.php
:
<!DOCTYPE html>
<html>
<head>
<title>Access Denied</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
</head>
<body class="bg-gray-100 h-screen flex items-center justify-center">
<div class="bg-white p-8 rounded-lg shadow-md max-w-md w-full text-center">
<div class="text-red-500 text-6xl mb-4">403</div>
<h1 class="text-2xl font-bold mb-2">Access Denied</h1>
<p class="text-gray-600 mb-4">You don't have permission to access this resource.</p>
<a href="{{ route('dashboard') }}" class="inline-block bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">
Return to Dashboard
</a>
</div>
</body>
</html>
Page Elements Protection
For the React frontend with Inertia, you can pass the user role to the page props:
// In your controller
public function dashboard()
{
return Inertia::render('Dashboard', [
'user' => auth()->user()->only(['id', 'name', 'email', 'role']),
]);
}
In your React component:
import { usePage } from "@inertiajs/react";
// Fix TypeScript issues with a proper type definition
interface User {
id: number;
name: string;
email: string;
role: string;
}
interface PageProps {
auth: {
user: User;
};
}
const Dashboard = () => {
// Type the usePage hook correctly
const { auth } = usePage<PageProps>().props;
const role = auth.user.role;
// Navigation links based on role
const getNavigationLinks = () => {
const links = [{ name: "Profile", href: "/profile", visible: true }];
if (role === "vendor" || role === "admin" || role === "developer") {
links.push({
name: "Manage Products",
href: "/dashboard/vendor/products",
visible: true,
});
}
if (role === "admin" || role === "developer") {
links.push({
name: "Manage Users",
href: "/dashboard/admin/users",
visible: true,
});
links.push({
name: "Store Categories",
href: "/dashboard/admin/categories",
visible: true,
});
}
if (
role === "user" ||
role === "vendor" ||
role === "admin" ||
role === "developer"
) {
links.push({
name: "Orders",
href: "/dashboard/user/orders",
visible: true,
});
}
return links.filter((link) => link.visible);
};
return (
<div>
<aside className="sidebar">
<nav>
<ul>
{getNavigationLinks().map((link) => (
<li key={link.href}>
<a href={link.href}>{link.name}</a>
</li>
))}
</ul>
</nav>
</aside>
{/* Main content */}
</div>
);
};
export default Dashboard;
For protecting UI elements like buttons:
const CanPerformAction = ({ children, requiredRoles }) => {
const { auth } = usePage<PageProps>().props;
const userRole = auth.user.role;
if (requiredRoles.includes(userRole)) {
return <>{children}</>;
}
return null;
};
// Usage:
<CanPerformAction requiredRoles={["admin", "developer"]}>
<button className="btn btn-danger">Delete User</button>
</CanPerformAction>;
Models, Controllers, and Relationships
When to Use Resource Controllers
Resource controllers are best when:
-
You're building standard CRUD operations
-
You want to follow Laravel conventions
-
You need a clear, consistent structure for new team members
-
You want to use built-in route model binding
-
For custom flows that don't fit the CRUD pattern, it's better to use regular controllers.
Example of Relationships
One-to-Many relationship example (Vendor has many Products):
// vendor model
public function products()
{
return $this->hasMany(Product::class);
}
// product model
public function vendor()
{
return $this->belongsTo(User::class, 'vendor_id');
}
CRUD Cheatsheet
Create a Product
Model and Migration:
php artisan make:model Product -m
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->foreignId('vendor_id')->constrained('users');
$table->foreignId('category_id')->constrained();
$table->string('name');
$table->string('slug')->unique();
$table->text('description');
$table->decimal('price', 10, 2);
$table->string('image')->nullable();
$table->integer('stock')->default(0);
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->softDeletes(); // For soft deletion
});
controller
php artisan make:controller ProductController
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'category_id' => 'required|exists:categories,id',
'description' => 'required|string',
'price' => 'required|numeric|min:0',
'stock' => 'required|integer|min:0',
'is_active' => 'boolean',
'image' => 'nullable|image|mimes:jpg,png,jpeg|max:2048',
]);
// Generate slug
$slug = Str::slug($validated['name']);
// Check for duplicate slug
$count = Product::where('slug', $slug)->count();
if ($count > 0) {
$slug = $slug . '-' . ($count + 1);
}
// Handle image upload
if ($request->hasFile('image')) {
$imagePath = $request->file('image')->store('products', 'public');
$validated['image'] = $imagePath;
}
$validated['slug'] = $slug;
$validated['vendor_id'] = auth()->id();
$product = Product::create($validated);
return redirect()->route('products.index')->with('success', 'Product created successfully');
}
For handling errors in Inertia forms:
import { useForm } from "@inertiajs/react";
const CreateProduct = () => {
const { data, setData, post, errors, processing } = useForm({
name: "",
category_id: "",
description: "",
price: "",
stock: "",
is_active: true,
image: null,
});
const handleSubmit = (e) => {
e.preventDefault();
post(route("products.store"), {
preserveScroll: true,
});
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>Name</label>
<input
type="text"
value={data.name}
onChange={(e) => setData("name", e.target.value)}
/>
{errors.name && <div className="text-red-500">{errors.name}</div>}
</div>
{/* Other form fields */}
<button type="submit" disabled={processing}>
Create Product
</button>
</form>
);
};
For modals, you can use the same form within a modal component:
const ProductModal = ({ show, onClose }) => {
// Same form as above
return (
<div className={`modal ${show ? "block" : "hidden"}`}>
<div className="modal-content">
<span className="close" onClick={onClose}>
×
</span>
{/* Form goes here */}
</div>
</div>
);
};
Listing Products
For admin (all products):
public function index()
{
$products = Product::with('category', 'vendor')->paginate(15);
return inertia('Products/Index', [
'products' => $products
]);
}
For a specific vendor:
public function vendorProducts()
{
$products = Product::where('vendor_id', auth()->id())
->with('category')
->paginate(15);
return inertia('Vendor/Products', [
'products' => $products
]);
}
Get Single Product by Slug
public function show($slug)
{
$product = Product::where('slug', $slug)
->with(['category', 'vendor'])
->firstOrFail();
return inertia('Products/Show', [
'product' => $product
]);
}
Update Product
public function update(Request $request, Product $product)
{
$this->authorize('update', $product);
$validated = $request->validate([
'name' => 'required|string|max:255',
'category_id' => 'required|exists:categories,id',
'description' => 'required|string',
'price' => 'required|numeric|min:0',
'stock' => 'required|integer|min:0',
'is_active' => 'boolean',
'image' => 'nullable|image|mimes:jpg,png,jpeg|max:2048',
]);
// Update slug only if name changes
if ($request->name !== $product->name) {
$slug = Str::slug($validated['name']);
$count = Product::where('slug', $slug)->where('id', '!=', $product->id)->count();
if ($count > 0) {
$slug = $slug . '-' . ($count + 1);
}
$validated['slug'] = $slug;
}
// Handle image update
if ($request->hasFile('image')) {
// Delete old image if exists
if ($product->image) {
Storage::disk('public')->delete($product->image);
}
$imagePath = $request->file('image')->store('products', 'public');
$validated['image'] = $imagePath;
}
$product->update($validated);
return redirect()->route('products.index')->with('success', 'Product updated successfully');
}
Delete Product (with Soft Delete)
public function destroy(Product $product)
{
$this->authorize('delete', $product);
$product->delete(); // This will soft delete with the SoftDeletes trait
return redirect()->route('products.index')->with('success', 'Product deleted successfully');
}
For soft delete implementation, add the trait to the Product model:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Product extends Model
{
use HasFactory, SoftDeletes;
// Rest of the model...
}
To restore soft deleted items:
public function restore($id)
{
$product = Product::withTrashed()->findOrFail($id);
$this->authorize('restore', $product);
$product->restore();
return redirect()->route('products.index')->with('success', 'Product restored successfully');
}
Seeding
Factory Creation
For Category:
php artisan make:factory CategoryFactory
<?php
namespace Database\Factories;
use App\Models\Category;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class CategoryFactory extends Factory
{
protected $model = Category::class;
public function definition(): array
{
$name = $this->faker->unique()->word();
return [
'name' => $name,
'slug' => Str::slug($name),
'description' => $this->faker->sentence(),
];
}
}
For User:
php artisan make:factory UserFactory
<?php
namespace Database\Factories;
use App\Enums\UserRole;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class UserFactory extends Factory
{
protected $model = User::class;
public function definition(): array
{
return [
'name' => $this->faker->name(),
'email' => $this->faker->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => Hash::make('password'),
'role' => UserRole::USER,
'remember_token' => Str::random(10),
];
}
public function admin(): static
{
return $this->state(function (array $attributes) {
return [
'role' => UserRole::ADMIN,
];
});
}
public function vendor(): static
{
return $this->state(function (array $attributes) {
return [
'role' => UserRole::VENDOR,
];
});
}
public function developer(): static
{
return $this->state(function (array $attributes) {
return [
'role' => UserRole::DEVELOPER,
];
});
}
}
For Product:
php artisan make:factory ProductFactory
<?php
namespace Database\Factories;
use App\Models\Category;
use App\Models\Product;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class ProductFactory extends Factory
{
protected $model = Product::class;
public function definition(): array
{
$name = $this->faker->unique()->words(3, true);
return [
'vendor_id' => User::where('role', 'vendor')->inRandomOrder()->first()->id ?? User::factory()->vendor()->create()->id,
'category_id' => Category::inRandomOrder()->first()->id ?? Category::factory()->create()->id,
'name' => $name,
'slug' => Str::slug($name),
'description' => $this->faker->paragraph(),
'price' => $this->faker->randomFloat(2, 5, 1000),
'stock' => $this->faker->numberBetween(0, 100),
'is_active' => $this->faker->boolean(80),
];
}
}
Seeder Creation
For roles/users:
php artisan make:seeder RoleUserSeeder
<?php
namespace Database\Seeders;
use App\Enums\UserRole;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class RoleUserSeeder extends Seeder
{
public function run(): void
{
// Create admin
User::factory()->create([
'name' => 'Admin User',
'email' => 'admin@example.com',
'password' => Hash::make('password'),
'role' => UserRole::ADMIN,
]);
// Create developer
User::factory()->create([
'name' => 'Developer User',
'email' => 'developer@example.com',
'password' => Hash::make('password'),
'role' => UserRole::DEVELOPER,
]);
// Create vendors
User::factory()->count(5)->vendor()->create();
// Create regular users
User::factory()->count(20)->create();
}
}
For categories:
php artisan make:seeder CategorySeeder
<?php
namespace Database\Seeders;
use App\Models\Category;
use Illuminate\Database\Seeder;
use Illuminate\Support\Str;
class CategorySeeder extends Seeder
{
public function run(): void
{
$categories = [
'Electronics',
'Clothing',
'Home & Kitchen',
'Books',
'Sports & Outdoors',
];
foreach ($categories as $category) {
Category::create([
'name' => $category,
'slug' => Str::slug($category),
'description' => fake()->sentence(),
]);
}
// Create additional random categories
Category::factory()->count(5)->create();
}
}
For products:
php artisan make:seeder ProductSeeder
<?php
namespace Database\Seeders;
use App\Models\Product;
use Illuminate\Database\Seeder;
class ProductSeeder extends Seeder
{
public function run(): void
{
Product::factory()->count(50)->create();
}
}
Update the main DatabaseSeeder:
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call([
RoleUserSeeder::class,
CategorySeeder::class,
ProductSeeder::class,
]);
}
}
Run the seeders:
php artisan db:seed
Or run a specific seeder:
php artisan db:seed --class=ProductSeeder