Back to blog

Friday, May 2, 2025

Laravel Multi-Vendor Ecommerce System Documentation

cover

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}>
          &times;
        </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