DEV Community

Cover image for Full-Stack Laravel, React and MySQL Ecommerce Admin Panel
Durgesh Sahani
Durgesh Sahani

Posted on

Full-Stack Laravel, React and MySQL Ecommerce Admin Panel

In this tutorial series, we build a complete ecommerce admin panel using Laravel, React and MySQL. The goal is not only to copy code, but to understand how a real full-stack application works from request to response.

The flow is simple:

Browser sends request -> React handles UI -> Laravel API handles business logic -> MySQL stores data -> API sends JSON back -> React updates screen.

If you already know basic PHP, HTML, CSS and JavaScript, this project is a good next step because it upgrades those skills into a modern full-stack workflow.

Who This Tutorial Is For

This tutorial is for beginners who already understand basic PHP, HTML, CSS and JavaScript, and now want to learn how modern full-stack projects are structured.

If you are a college student, freelancer, PHP developer, or job seeker trying to move from normal PHP projects into Laravel and React, this project will help you understand how the backend, frontend and database communicate with each other.

We will not build only a small todo app. We will build an ecommerce admin panel because it gives us a real use case:

  • admin login
  • protected API routes
  • categories
  • products
  • image upload
  • MySQL database
  • React dashboard
  • API testing

What We Are Going To Learn

  • how a Laravel REST API and React single page application work together
  • how to set up Laravel API routes and connect MySQL
  • how to design ecommerce tables for categories and products
  • how to create migrations and Eloquent models
  • how to build category CRUD APIs in Laravel
  • how to build product CRUD APIs with image upload
  • how to add authentication using Laravel Sanctum
  • how to create a React admin layout with Vite
  • how to connect React login with Laravel API
  • how to manage categories and products from React
  • how to upload product images from React using FormData
  • how to polish the final dashboard with real API data

Watch The Full Series

Watch the complete series on YouTube:

What We Are Building

We are building an ecommerce admin panel with three main parts:

  • Laravel backend API
  • React frontend admin panel
  • MySQL database

The admin panel includes login, category management, product management with image upload, and a final dashboard showing useful ecommerce summary data.

Required Tools

Before starting, install the following tools:

  • PHP and Composer
  • Node.js and npm
  • MySQL
  • Laravel installer
  • Visual Studio Code
  • Postman

If you are using macOS, you can install many tools using Homebrew. If you are using Windows, you can use Laragon, XAMPP, WAMP, or install PHP, Composer, Node and MySQL separately.

Useful official websites:

  • PHP: https://www.php.net/
  • Composer: https://getcomposer.org/
  • Laravel: https://laravel.com/
  • Node.js: https://nodejs.org/
  • MySQL: https://www.mysql.com/
  • Postman: https://www.postman.com/

For this series, the backend and frontend are kept in one project folder:

ecommerce-admin-panel/
  backend/
  frontend/
Enter fullscreen mode Exit fullscreen mode

What Is Laravel?

Laravel is a PHP framework used to build web applications and APIs. In this project, Laravel is responsible for routes, controllers, validation, authentication, database models, migrations, file upload, and JSON responses.

Instead of writing raw PHP files for every API endpoint, Laravel gives us a clean structure:

  • routes are defined in routes/api.php
  • request logic goes inside controllers
  • database records are represented by Eloquent models
  • table structure is created using migrations

What Is React?

React is a JavaScript library for building user interfaces. In this project, React handles the admin panel screens: login, dashboard, categories and products.

React does not directly talk to MySQL. Instead, React sends HTTP requests to the Laravel API. Laravel validates the request, works with MySQL, and sends JSON back to React.

What Is MySQL?

MySQL is the database used to store our admin data. In this project, MySQL stores users, categories, products, access tokens, and timestamps.

The important idea is this:

React does not store permanent data. Laravel does not store permanent data in controllers. MySQL is the final storage layer.

What Is REST API?

REST API is a way for two applications to communicate using HTTP requests.

In this project:

  • React sends GET, POST, PUT and DELETE requests
  • Laravel receives those requests
  • Laravel validates the data
  • Laravel stores or reads data from MySQL
  • Laravel returns JSON
  • React updates the UI using that JSON

Example:

GET /api/categories
Enter fullscreen mode Exit fullscreen mode

This means React is asking Laravel: "Please send me all categories."

Laravel responds with JSON:

{
  "data": [
    {
      "id": 1,
      "name": "Laptops",
      "slug": "laptops"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

What Is Laravel Sanctum?

Laravel Sanctum is used for API authentication. In simple words, after the admin logs in, Laravel creates a token. React stores that token and sends it with every protected API request.

Without token:

Request blocked
Enter fullscreen mode Exit fullscreen mode

With token:

Request allowed
Enter fullscreen mode Exit fullscreen mode

This is how we protect category and product management from public access.

What Is Vite?

Vite is a frontend build tool. It helps us create and run a React app quickly.

In older React tutorials, you may see create-react-app. In this project, we use Vite because it is faster and is commonly used in modern React projects.

Step 1: Create Laravel Backend

First we create the Laravel backend app.

laravel new backend --no-interaction
cd backend
php artisan install:api --without-migration-prompt --no-interaction
Enter fullscreen mode Exit fullscreen mode

The install:api command enables API support and installs Sanctum-related files so we can later protect routes with tokens.

Then we configure MySQL in the .env file:

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=ecommerce_admin
DB_USERNAME=learnwebcoding
DB_PASSWORD=
Enter fullscreen mode Exit fullscreen mode

After the database is ready, run migrations:

php artisan migrate
Enter fullscreen mode Exit fullscreen mode

If you want to create the database from terminal, you can use MySQL commands:

CREATE DATABASE ecommerce_admin;
Enter fullscreen mode Exit fullscreen mode

For local development, make sure the database name, username and password in .env match your MySQL setup.

Step 2: Understand API Routes

Laravel API routes live inside:

backend/routes/api.php
Enter fullscreen mode Exit fullscreen mode

A simple health route can be used to confirm the API is working:

Route::get('/health', function () {
    return response()->json([
        'status' => 'ok',
    ]);
});
Enter fullscreen mode Exit fullscreen mode

When we open /api/health, Laravel returns JSON. This confirms the frontend can later consume Laravel as an API.

Start the Laravel API server:

php artisan serve
Enter fullscreen mode Exit fullscreen mode

Now test:

http://127.0.0.1:8000/api/health
Enter fullscreen mode Exit fullscreen mode

If everything is correct, you should see a JSON response.

Step 3: Database Design

For this ecommerce admin panel, we need two main tables:

  • categories
  • products

The categories table stores product groups:

id
name
slug
description
status
created_at
updated_at
Enter fullscreen mode Exit fullscreen mode

The products table stores the actual catalog items:

id
category_id
name
slug
description
price
stock
image
status
created_at
updated_at
Enter fullscreen mode Exit fullscreen mode

The important relationship is:

One category can have many products, and each product belongs to one category.

Step 4: Create Models and Migrations

Laravel can generate a model and migration together:

php artisan make:model Category -m --no-interaction
php artisan make:model Product -m --no-interaction
Enter fullscreen mode Exit fullscreen mode

In the category migration, we add the category fields:

$table->string('name');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->boolean('status')->default(true);
Enter fullscreen mode Exit fullscreen mode

In the product migration, we add product fields:

$table->foreignId('category_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->decimal('price', 10, 2);
$table->integer('stock')->default(0);
$table->string('image')->nullable();
$table->boolean('status')->default(true);
Enter fullscreen mode Exit fullscreen mode

Then run:

php artisan migrate
Enter fullscreen mode Exit fullscreen mode

Here is the final code for create_categories_table.php:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('categories', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('slug')->unique();
            $table->text('description')->nullable();
            $table->boolean('status')->default(true);

            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('categories');
    }
};
Enter fullscreen mode Exit fullscreen mode

Here is the final code for create_products_table.php:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('products', function (Blueprint $table) {
            $table->id();
            $table->foreignId('category_id')->constrained()->cascadeOnDelete();
            $table->string('name');
            $table->string('slug')->unique();
            $table->text('description')->nullable();
            $table->decimal('price', 10, 2)->default(0);
            $table->unsignedInteger('stock')->default(0);
            $table->string('image')->nullable();
            $table->boolean('status')->default(true);

            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('products');
    }
};
Enter fullscreen mode Exit fullscreen mode

Step 5: Category Model

In app/Models/Category.php, we allow mass assignment for category fields:

protected $fillable = [
    'name',
    'slug',
    'description',
    'status',
];
Enter fullscreen mode Exit fullscreen mode

Then we define the relationship:

public function products()
{
    return $this->hasMany(Product::class);
}
Enter fullscreen mode Exit fullscreen mode

This tells Laravel that a category can have many products.

Here is the final code for app/Models/Category.php:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;


class Category extends Model
{
    //

    protected $fillable = [
        'name',
        'slug',
        'description',
        'status',
    ];

    protected $casts = [
        'status' => 'boolean',
    ];

    public function products(): HasMany
    {
        return $this->hasMany(Product::class);
    }

}
Enter fullscreen mode Exit fullscreen mode

Step 6: Product Model

In app/Models/Product.php, we add fillable fields:

protected $fillable = [
    'category_id',
    'name',
    'slug',
    'description',
    'price',
    'stock',
    'image',
    'status',
];
Enter fullscreen mode Exit fullscreen mode

Then we define the category relationship:

public function category()
{
    return $this->belongsTo(Category::class);
}
Enter fullscreen mode Exit fullscreen mode

This makes it easy to load each product with its category.

Here is the final code for app/Models/Product.php:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;


class Product extends Model
{
    //

    protected $fillable = [
        'category_id',
        'name',
        'slug',
        'description',
        'price',
        'stock',
        'image',
        'status',
    ];

    protected $casts = [
        'price' => 'decimal:2',
        'status' => 'boolean',
    ];

    public function category(): BelongsTo
    {
        return $this->belongsTo(Category::class);
    }

}
Enter fullscreen mode Exit fullscreen mode

Step 7: Build Category REST API

The category API handles:

  • list categories
  • create category
  • show category
  • update category
  • delete category

Create the controller:

php artisan make:controller Api/CategoryController --api --no-interaction
Enter fullscreen mode Exit fullscreen mode

In routes/api.php, register the route:

use App\Http\Controllers\Api\CategoryController;

Route::apiResource('categories', CategoryController::class);
Enter fullscreen mode Exit fullscreen mode

The store method validates incoming data and creates a category:

$validated = $request->validate([
    'name' => ['required', 'string', 'max:255'],
    'slug' => ['required', 'string', 'max:255', 'unique:categories,slug'],
    'description' => ['nullable', 'string'],
    'status' => ['boolean'],
]);

$category = Category::create($validated);

return response()->json([
    'message' => 'Category created successfully.',
    'data' => $category,
], 201);
Enter fullscreen mode Exit fullscreen mode

This same pattern is used for update and delete: validate request, run Eloquent query, return JSON response.

Here is the final code for app/Http/Controllers/Api/CategoryController.php:

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\Category;
use Illuminate\Http\Request;

class CategoryController extends Controller
{
    public function index()
    {
        $categories = Category::latest()->get();

        return response()->json([
            'data' => $categories,
        ]);
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'name' => ['required', 'string', 'max:255'],
            'slug' => ['required', 'string', 'max:255', 'unique:categories,slug'],
            'description' => ['nullable', 'string'],
            'status' => ['required', 'boolean'],
        ]);

        $category = Category::create($validated);

        return response()->json([
            'message' => 'Category created successfully.',
            'data' => $category,
        ], 201);
    }

    public function show(Category $category)
    {
        return response()->json([
            'data' => $category,
        ]);
    }

    public function update(Request $request, Category $category)
    {
        $validated = $request->validate([
            'name' => ['required', 'string', 'max:255'],
            'slug' => ['required', 'string', 'max:255', 'unique:categories,slug,' . $category->id],
            'description' => ['nullable', 'string'],
            'status' => ['required', 'boolean'],
        ]);

        $category->update($validated);

        return response()->json([
            'message' => 'Category updated successfully.',
            'data' => $category,
        ]);
    }

    public function destroy(Category $category)
    {
        $category->delete();

        return response()->json([
            'message' => 'Category deleted successfully.',
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 8: Test Category API With Postman

Before connecting React, test the API using Postman.

Create category:

POST http://127.0.0.1:8000/api/categories
Enter fullscreen mode Exit fullscreen mode

JSON body:

{
  "name": "Laptops",
  "slug": "laptops",
  "description": "Laptop products",
  "status": true
}
Enter fullscreen mode Exit fullscreen mode

List categories:

GET http://127.0.0.1:8000/api/categories
Enter fullscreen mode Exit fullscreen mode

Update category:

PUT http://127.0.0.1:8000/api/categories/1
Enter fullscreen mode Exit fullscreen mode

Delete category:

DELETE http://127.0.0.1:8000/api/categories/1
Enter fullscreen mode Exit fullscreen mode

Postman testing helps confirm Laravel is working before React is added.

Step 9: Build Product REST API With Image Upload

Products are similar to categories, but products also support image upload.

Create the controller:

php artisan make:controller Api/ProductController --api --no-interaction
Enter fullscreen mode Exit fullscreen mode

Register the route:

use App\Http\Controllers\Api\ProductController;

Route::apiResource('products', ProductController::class);
Enter fullscreen mode Exit fullscreen mode

For product image upload, Laravel stores the image file and saves the path in MySQL:

if ($request->hasFile('image')) {
    $validated['image'] = $request->file('image')->store('products', 'public');
}

$product = Product::create($validated);
Enter fullscreen mode Exit fullscreen mode

To make uploaded files accessible from the browser, create the storage link:

php artisan storage:link
Enter fullscreen mode Exit fullscreen mode

Now a product can have name, slug, category, price, stock, status, and image.

When deleting a product, we should also delete the uploaded image from storage:

if ($product->image) {
    Storage::disk('public')->delete($product->image);
}

$product->delete();
Enter fullscreen mode Exit fullscreen mode

This keeps the storage folder clean.

Here is the final code for app/Http/Controllers/Api/ProductController.php:

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class ProductController extends Controller
{
    public function index()
    {
        $products = Product::with('category')->latest()->get();

        return response()->json([
            'data' => $products,
        ]);
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'category_id' => ['required', 'exists:categories,id'],
            'name' => ['required', 'string', 'max:255'],
            'slug' => ['required', 'string', 'max:255', 'unique:products,slug'],
            'description' => ['nullable', 'string'],
            'price' => ['required', 'numeric', 'min:0'],
            'stock' => ['required', 'integer', 'min:0'],
            'image' => ['nullable', 'image', 'max:2048'],
            'status' => ['required', 'boolean'],
        ]);

        if ($request->hasFile('image')) {
            $validated['image'] = $request->file('image')->storeAs(
                'products',
                $validated['slug'] . '.' . $request->file('image')->extension(),
                'public'
            );
        }

        $product = Product::create($validated);

        return response()->json([
            'message' => 'Product created successfully.',
            'data' => $product->load('category'),
        ], 201);
    }

    public function show(Product $product)
    {
        return response()->json([
            'data' => $product->load('category'),
        ]);
    }

    public function update(Request $request, Product $product)
    {
        $validated = $request->validate([
            'category_id' => ['required', 'exists:categories,id'],
            'name' => ['required', 'string', 'max:255'],
            'slug' => ['required', 'string', 'max:255', 'unique:products,slug,' . $product->id],
            'description' => ['nullable', 'string'],
            'price' => ['required', 'numeric', 'min:0'],
            'stock' => ['required', 'integer', 'min:0'],
            'image' => ['nullable', 'image', 'max:2048'],
            'status' => ['required', 'boolean'],
        ]);

        if ($request->hasFile('image')) {
            if ($product->image) {
                Storage::disk('public')->delete($product->image);
            }

            $validated['image'] = $request->file('image')->storeAs(
                'products',
                $validated['slug'] . '.' . $request->file('image')->extension(),
                'public'
            );
        }

        $product->update($validated);

        return response()->json([
            'message' => 'Product updated successfully.',
            'data' => $product->load('category'),
        ]);
    }

    public function destroy(Product $product)
    {
        if ($product->image) {
            Storage::disk('public')->delete($product->image);
        }

        $product->delete();

        return response()->json([
            'message' => 'Product deleted successfully.',
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 10: Authentication With Laravel Sanctum

Authentication protects the admin APIs. We use Laravel Sanctum token authentication.

The login endpoint validates email and password, checks credentials, and creates a token:

$token = $user->createToken('admin-token')->plainTextToken;

return response()->json([
    'message' => 'Login successful.',
    'token' => $token,
    'user' => $user,
]);
Enter fullscreen mode Exit fullscreen mode

Protected routes use Sanctum middleware:

Route::middleware('auth:sanctum')->group(function () {
    Route::apiResource('categories', CategoryController::class);
    Route::apiResource('products', ProductController::class);
});
Enter fullscreen mode Exit fullscreen mode

React must send the token in the Authorization header:

Authorization: Bearer YOUR_TOKEN_HERE
Enter fullscreen mode Exit fullscreen mode

Example auth routes:

Route::post('/login', [AuthController::class, 'login']);

Route::middleware('auth:sanctum')->group(function () {
    Route::get('/user', [AuthController::class, 'user']);
    Route::post('/logout', [AuthController::class, 'logout']);
    Route::apiResource('categories', CategoryController::class);
    Route::apiResource('products', ProductController::class);
});
Enter fullscreen mode Exit fullscreen mode

The main idea is simple:

  • login route is public
  • category and product routes are protected
  • React must send token after login

Here is the final code for app/Http/Controllers/Api/AuthController.php:

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;

use Illuminate\Http\Request;

class AuthController extends Controller
{
    //
    public function login(Request $request)
    {
        $validated = $request->validate([
            'email' => ['required', 'email'],
            'password' => ['required', 'string'],
        ]);

        $user = User::where('email', $validated['email'])->first();

        if (! $user || ! Hash::check($validated['password'], $user->password)) {
            throw ValidationException::withMessages([
                'email' => ['The provided credentials are incorrect.'],
            ]);
        }

        $token = $user->createToken('admin-token')->plainTextToken;

        return response()->json([
            'message' => 'Login successful.',
            'token' => $token,
            'user' => $user,
        ]);
    }

    public function me(Request $request)
    {
        return response()->json([
            'data' => $request->user(),
        ]);
    }

    public function logout(Request $request)
    {
        $request->user()->currentAccessToken()->delete();

        return response()->json([
            'message' => 'Logged out successfully.',
        ]);
    }

}
Enter fullscreen mode Exit fullscreen mode

Here is the final code for routes/api.php:

<?php


use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\CategoryController;
use App\Http\Controllers\Api\ProductController;
use Illuminate\Support\Facades\Route;

Route::get('/health', function () {
    return response()->json([
        'status' => 'success',
        'message' => 'Laravel API is working.',
        'app' => config('app.name'),
        'timestamp' => now()->toISOString(),
    ]);
});

Route::post('/login', [AuthController::class, 'login']);

Route::middleware('auth:sanctum')->group(function () {
    Route::get('/me', [AuthController::class, 'me']);
    Route::post('/logout', [AuthController::class, 'logout']);

    Route::apiResource('categories', CategoryController::class);
    Route::apiResource('products', ProductController::class);
});
Enter fullscreen mode Exit fullscreen mode

Step 11: Create React Frontend With Vite

Now we create the React frontend:

npm create vite@latest frontend -- --template react
cd frontend
npm install
npm run dev
Enter fullscreen mode Exit fullscreen mode

We also install icons for the final UI polish:

npm install lucide-react
Enter fullscreen mode Exit fullscreen mode

Vite gives us a fast React development setup. The React app runs on:

http://127.0.0.1:5173
Enter fullscreen mode Exit fullscreen mode

The Laravel API runs on:

http://127.0.0.1:8000
Enter fullscreen mode Exit fullscreen mode

Step 12: Create API Helper File in React

Inside React, create:

frontend/src/api.js
Enter fullscreen mode Exit fullscreen mode

This file keeps API calls in one place:

const API_URL = 'http://127.0.0.1:8000/api';

async function apiRequest(path, options = {}) {
  const token = options.token;
  const isFormData = options.body instanceof FormData;

  const headers = {
    Accept: 'application/json',
    ...(isFormData ? {} : { 'Content-Type': 'application/json' }),
    ...options.headers,
  };

  if (token) {
    headers.Authorization = `Bearer ${token}`;
  }

  const response = await fetch(`${API_URL}${path}`, {
    ...options,
    headers,
  });

  const data = await response.json();

  if (!response.ok) {
    throw new Error(data.message || 'Request failed.');
  }

  return data;
}
Enter fullscreen mode Exit fullscreen mode

This helper handles JSON requests, FormData requests, authentication token, errors, and final response parsing.

Now we can create small reusable functions:

export function getCategories(token) {
  return apiRequest('/categories', {
    token,
  });
}

export function getProducts(token) {
  return apiRequest('/products', {
    token,
  });
}
Enter fullscreen mode Exit fullscreen mode

This keeps components cleaner because components do not need to repeat the full fetch logic again and again.

Here is the final code for frontend/src/api.js:

const API_URL = 'http://127.0.0.1:8000/api';

async function apiRequest(path, options = {}) {
  const token = options.token;
  const isFormData = options.body instanceof FormData;

  const headers = {
    Accept: 'application/json',
    ...(isFormData ? {} : { 'Content-Type': 'application/json' }),
    ...options.headers,
  };

  if (token) {
    headers.Authorization = `Bearer ${token}`;
  }

  const response = await fetch(`${API_URL}${path}`, {
    ...options,
    headers,
  });

  const data = await response.json();

  if (!response.ok) {
    throw new Error(data.message || 'Request failed.');
  }

  return data;
}

export function loginAdmin(credentials) {
  return apiRequest('/login', {
    method: 'POST',
    body: JSON.stringify(credentials),
  });
}

export function getCategories(token) {
  return apiRequest('/categories', {
    token,
  });
}

export function createCategory(category, token) {
  return apiRequest('/categories', {
    method: 'POST',
    token,
    body: JSON.stringify(category),
  });
}

export function updateCategory(id, category, token) {
  return apiRequest(`/categories/${id}`, {
    method: 'PUT',
    token,
    body: JSON.stringify(category),
  });
}

export function deleteCategory(id, token) {
  return apiRequest(`/categories/${id}`, {
    method: 'DELETE',
    token,
  });
}

export function getProducts(token) {
  return apiRequest('/products', {
    token,
  });
}

export function createProduct(product, token) {
  return apiRequest('/products', {
    method: 'POST',
    token,
    body: product,
  });
}

export function updateProduct(id, product, token) {
  product.append('_method', 'PUT');

  return apiRequest(`/products/${id}`, {
    method: 'POST',
    token,
    body: product,
  });
}

export function deleteProduct(id, token) {
  return apiRequest(`/products/${id}`, {
    method: 'DELETE',
    token,
  });
}
Enter fullscreen mode Exit fullscreen mode

Step 13: React Login Page

The login page sends email and password to Laravel:

export function loginAdmin(credentials) {
  return apiRequest('/login', {
    method: 'POST',
    body: JSON.stringify(credentials),
  });
}
Enter fullscreen mode Exit fullscreen mode

After login, React stores the token:

localStorage.setItem('admin_token', response.token);
Enter fullscreen mode Exit fullscreen mode

Then the user is redirected to the dashboard.

A simple login form has:

  • email state
  • password state
  • loading state
  • error state
  • submit handler

The submit handler calls Laravel:

async function handleLogin(event) {
  event.preventDefault();

  try {
    const response = await loginAdmin({ email, password });
    localStorage.setItem('admin_token', response.token);
    onLogin(response.user);
  } catch (error) {
    setError(error.message);
  }
}
Enter fullscreen mode Exit fullscreen mode

This is the first real connection between React and Laravel authentication.

Here is the final code for frontend/src/Login.jsx:

import { useState } from 'react';
import { loginAdmin } from './api';

function Login({ onLogin }) {
  const [email, setEmail] = useState('admin@example.com');
  const [password, setPassword] = useState('password');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');

  async function handleLogin(event) {
    event.preventDefault();
    setLoading(true);
    setError('');

    try {
      const response = await loginAdmin({ email, password });
      localStorage.setItem('admin_token', response.token);
      onLogin(response.user);
    } catch (error) {
      setError(error.message);
    } finally {
      setLoading(false);
    }
  }

  return (
    <main className="auth-page">
      <section className="auth-card">
        <div className="brand-row login-brand">
          <div className="brand-logo">LW</div>
          <div>
            <p className="eyebrow">Ecommerce Admin</p>
            <h1>Login to dashboard</h1>
          </div>
        </div>

        <p className="muted">
          This form sends credentials to Laravel and stores the Sanctum token in React.
        </p>

        <form className="login-form" onSubmit={handleLogin}>
          <label htmlFor="email">Email address</label>
          <input
            autoFocus
            id="email"
            type="email"
            value={email}
            onChange={(event) => setEmail(event.target.value)}
          />

          <label htmlFor="password">Password</label>
          <input
            id="password"
            type="password"
            value={password}
            onChange={(event) => setPassword(event.target.value)}
          />

          {error && <p className="alert error">{error}</p>}

          <button type="submit" disabled={loading}>
            {loading ? 'Logging in...' : 'Login'}
          </button>
        </form>
      </section>
    </main>
  );
}

export default Login;
Enter fullscreen mode Exit fullscreen mode

Step 14: React Admin Layout

The admin layout includes:

  • sidebar
  • dashboard link
  • categories link
  • products link
  • logout button
  • main content area

In App.jsx, React decides which screen to show:

{activeScreen === 'categories' ? (
  <CategoryManager token={localStorage.getItem('admin_token')} />
) : activeScreen === 'products' ? (
  <ProductManager token={localStorage.getItem('admin_token')} />
) : (
  <Dashboard
    token={localStorage.getItem('admin_token')}
    onOpenProducts={() => setActiveScreen('products')}
    onLogout={handleLogout}
  />
)}
Enter fullscreen mode Exit fullscreen mode

This keeps the app simple while still giving us multiple admin screens.

The logout function removes the token:

function handleLogout() {
  localStorage.removeItem('admin_token');
  setActiveScreen('dashboard');
  setUser(null);
}
Enter fullscreen mode Exit fullscreen mode

After logout, React shows the login page again.

Here is the final code for frontend/src/App.jsx:

import { useState } from 'react';
import './App.css';
import Login from './Login';
import CategoryManager from './CategoryManager';
import ProductManager from './ProductManager';
import Dashboard from './Dashboard';

function App() {
  const [user, setUser] = useState(null);
  const [activeScreen, setActiveScreen] = useState('dashboard');

  function handleLogout() {
    localStorage.removeItem('admin_token');
    setActiveScreen('dashboard');
    setUser(null);
  }

  if (!user) {
    return <Login onLogin={setUser} />;
  }

  return (
    <div className="admin-layout">
      <aside className="sidebar">
        <div className="brand">
          <div className="brand-logo">LW</div>
          <div>
            <h2>Learn Admin</h2>
            <p>Ecommerce Panel</p>
          </div>
        </div>

        <nav className="nav-list" aria-label="Admin navigation">
          <button
            className={activeScreen === 'dashboard' ? 'nav-link active' : 'nav-link'}
            type="button"
            onClick={() => setActiveScreen('dashboard')}
          >
            Dashboard
          </button>
          <button
            className={activeScreen === 'categories' ? 'nav-link active' : 'nav-link'}
            type="button"
            onClick={() => setActiveScreen('categories')}
          >
            Categories
          </button>
          <button
            className={activeScreen === 'products' ? 'nav-link active' : 'nav-link'}
            type="button"
            onClick={() => setActiveScreen('products')}
          >
            Products
          </button>
        </nav>
      </aside>

      <main className="main-content">
        {activeScreen === 'categories' ? (
          <CategoryManager token={localStorage.getItem('admin_token')} />
        ) : activeScreen === 'products' ? (
          <ProductManager token={localStorage.getItem('admin_token')} />
        ) : (
          <Dashboard
            token={localStorage.getItem('admin_token')}
            onOpenProducts={() => setActiveScreen('products')}
            onLogout={handleLogout}
          />
        )}
      </main>
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Step 15: Category Management in React

Category management includes:

  • list categories
  • create category
  • edit category
  • update category
  • delete category

React loads categories using:

export function getCategories(token) {
  return apiRequest('/categories', {
    token,
  });
}
Enter fullscreen mode Exit fullscreen mode

Create category:

export function createCategory(category, token) {
  return apiRequest('/categories', {
    method: 'POST',
    token,
    body: JSON.stringify(category),
  });
}
Enter fullscreen mode Exit fullscreen mode

The category form uses controlled inputs, which means React state is the source of truth:

<input
  id="name"
  value={form.name}
  onChange={(event) => setForm({ ...form, name: event.target.value })}
/>
Enter fullscreen mode Exit fullscreen mode

When the form is submitted, React calls Laravel, reloads the list, and shows a success message.

The save function chooses create or update:

async function handleSaveCategory(event) {
  event.preventDefault();
  setSaving(true);
  setError('');
  setMessage('');

  try {
    if (editingId) {
      await updateCategory(editingId, form, token);
      setMessage('Category updated successfully.');
    } else {
      await createCategory(form, token);
      setMessage('Category created successfully.');
    }

    resetForm();
    await loadCategories();
  } catch (error) {
    setError(error.message);
  } finally {
    setSaving(false);
  }
}
Enter fullscreen mode Exit fullscreen mode

This is the same CRUD idea from normal PHP projects, but now the UI and API are separated.

Here is the final code for frontend/src/CategoryManager.jsx:

import { useEffect, useRef, useState } from 'react';
import {
  createCategory,
  deleteCategory,
  getCategories,
  updateCategory,
} from './api';

const emptyForm = {
  name: '',
  slug: '',
  description: '',
  status: true,
};

function CategoryManager({ token }) {
  const [categories, setCategories] = useState([]);
  const [form, setForm] = useState(emptyForm);
  const [editingId, setEditingId] = useState(null);
  const [saving, setSaving] = useState(false);
  const [error, setError] = useState('');
  const [message, setMessage] = useState('');
  const nameInputRef = useRef(null);

  async function loadCategories() {
    const response = await getCategories(token);
    setCategories(response.data || []);
  }

  useEffect(() => {
    if (token) {
      loadCategories();
    }
  }, [token]);

  useEffect(() => {
    if (editingId) {
      nameInputRef.current?.focus();
    }
  }, [editingId]);

  function resetForm() {
    setForm(emptyForm);
    setEditingId(null);
  }

  function handleEdit(category) {
    setError('');
    setMessage('');
    setEditingId(category.id);
    setForm({
      name: category.name,
      slug: category.slug,
      description: category.description || '',
      status: Boolean(category.status),
    });
  }

  async function handleSaveCategory(event) {
    event.preventDefault();
    setSaving(true);
    setError('');
    setMessage('');

    try {
      if (editingId) {
        await updateCategory(editingId, form, token);
        setMessage('Category updated successfully.');
      } else {
        await createCategory(form, token);
        setMessage('Category created successfully.');

      }

      resetForm();
      await loadCategories();
    } catch (error) {
      setError(error.message);
    } finally {
      setSaving(false);
    }
  }

  async function handleDelete(id) {
    setError('');
    setMessage('');
    await deleteCategory(id, token);
    setMessage('Category deleted successfully.');
    await loadCategories();
  }

  return (
    <section className="category-page">
      <header className="page-header">
        <div>
          <p className="eyebrow">Catalog</p>
          <h1>Category Management</h1>
        </div>
      </header>

      <div className="content-grid">
        <form className="panel category-form" onSubmit={handleSaveCategory}>
          <h2>{editingId ? 'Edit category' : 'Create category'}</h2>

          <label htmlFor="name">Name</label>
          <input
            id="name"
            ref={nameInputRef}
            value={form.name}
            onChange={(event) => setForm({ ...form, name: event.target.value })}
            placeholder="Laptops"
          />

          <label htmlFor="slug">Slug</label>
          <input
            id="slug"
            value={form.slug}
            onChange={(event) => setForm({ ...form, slug: event.target.value })}
            placeholder="laptops"
          />

          <label htmlFor="description">Description</label>
          <textarea
            id="description"
            value={form.description}
            onChange={(event) => setForm({ ...form, description: event.target.value })}
            placeholder="Laptop products"
          />

          <label className="checkbox-row">
            <input
              type="checkbox"
              checked={form.status}
              onChange={(event) => setForm({ ...form, status: event.target.checked })}
            />
            Active category
          </label>

          {error && <p className="alert error">{error}</p>}
          {message && <p className="alert success">{message}</p>}

          <div className="button-row">
            <button type="submit" disabled={saving}>
              {saving ? 'Saving...' : editingId ? 'Update Category' : 'Create Category'}
            </button>
            {editingId && (
              <button type="button" className="secondary-button" onClick={resetForm}>
                Cancel
              </button>
            )}
          </div>
        </form>

        <section className="panel category-list">
          <div className="list-header">
            <h2>Categories</h2>
            <span>{categories.length} total</span>
          </div>

          {categories.map((category) => (
            <article className="category-row" key={category.id}>
              <div>
                <strong>{category.name}</strong>
                <span>{category.slug}</span>
              </div>
              <small>{category.status ? 'Active' : 'Inactive'}</small>
              <div className="row-actions">
                <button type="button" className="secondary-button" onClick={() => handleEdit(category)}>
                  Edit
                </button>
                <button type="button" className="danger-button" onClick={() => handleDelete(category.id)}>
                  Delete
                </button>
              </div>
            </article>
          ))}
        </section>
      </div>
    </section>
  );
}

export default CategoryManager;
Enter fullscreen mode Exit fullscreen mode

Step 16: Product Management in React

Product management is similar to category management, but products use FormData because of image upload.

function buildProductFormData() {
  const productData = new FormData();

  productData.append('category_id', form.category_id);
  productData.append('name', form.name);
  productData.append('slug', form.slug);
  productData.append('description', form.description);
  productData.append('price', form.price);
  productData.append('stock', form.stock);
  productData.append('status', form.status ? '1' : '0');

  if (form.image) {
    productData.append('image', form.image);
  }

  return productData;
}
Enter fullscreen mode Exit fullscreen mode

For update with file upload, React sends a POST request and appends _method as PUT:

export function updateProduct(id, product, token) {
  product.append('_method', 'PUT');

  return apiRequest(`/products/${id}`, {
    method: 'POST',
    token,
    body: product,
  });
}
Enter fullscreen mode Exit fullscreen mode

This works nicely with Laravel file upload handling.

Product create, edit and delete follow the same pattern:

  • build form data
  • send request to Laravel
  • show success or error message
  • reload product list

The image field is special because it stores a file object:

<input
  id="image"
  type="file"
  onChange={(event) => setForm({ ...form, image: event.target.files[0] })}
/>
Enter fullscreen mode Exit fullscreen mode

This file object is later appended into FormData.

Here is the final code for frontend/src/ProductManager.jsx:

import { useEffect, useRef, useState } from 'react';
import {
  createProduct,
  deleteProduct,
  getCategories,
  getProducts,
  updateProduct,
} from './api';

const emptyForm = {
  category_id: '',
  name: '',
  slug: '',
  description: '',
  price: '',
  stock: '',
  image: null,
  status: true,
};

function ProductManager({ token }) {
  const [products, setProducts] = useState([]);
  const [categories, setCategories] = useState([]);
  const [form, setForm] = useState(emptyForm);
  const [editingId, setEditingId] = useState(null);
  const [saving, setSaving] = useState(false);
  const [error, setError] = useState('');
  const [message, setMessage] = useState('');
  const nameInputRef = useRef(null);

  async function loadProductData() {
    const [productResponse, categoryResponse] = await Promise.all([
      getProducts(token),
      getCategories(token),
    ]);

    const categoryList = categoryResponse.data || [];

    setProducts(productResponse.data || []);
    setCategories(categoryList);
    setForm((currentForm) => ({
      ...currentForm,
      category_id: currentForm.category_id || categoryList[0]?.id || '',
    }));
  }

  useEffect(() => {
    if (token) {
      loadProductData();
    }
  }, [token]);

  useEffect(() => {
    if (editingId) {
      nameInputRef.current?.focus();
    }
  }, [editingId]);

  function resetForm() {
    setForm({
      ...emptyForm,
      category_id: categories[0]?.id || '',
    });
    setEditingId(null);
  }

  function buildProductFormData() {
    const productData = new FormData();

    productData.append('category_id', form.category_id);
    productData.append('name', form.name);
    productData.append('slug', form.slug);
    productData.append('description', form.description);
    productData.append('price', form.price);
    productData.append('stock', form.stock);
    productData.append('status', form.status ? '1' : '0');

    if (form.image) {
      productData.append('image', form.image);
    }

    return productData;
  }

  function handleEdit(product) {
    setError('');
    setMessage('');
    setEditingId(product.id);
    setForm({
      category_id: product.category_id,
      name: product.name,
      slug: product.slug,
      description: product.description || '',
      price: product.price,
      stock: product.stock,
      image: null,
      status: Boolean(product.status),
    });
  }

  async function handleSaveProduct(event) {
    event.preventDefault();
    setSaving(true);
    setError('');
    setMessage('');

    try {
      const productData = buildProductFormData();

      if (editingId) {
        await updateProduct(editingId, productData, token);
        setMessage('Product updated successfully.');
      } else {
        await createProduct(productData, token);
        setMessage('Product created successfully.');
      }

      resetForm();
      await loadProductData();
    } catch (error) {
      setError(error.message);
    } finally {
      setSaving(false);
    }
  }

  async function handleDelete(id) {
    setError('');
    setMessage('');
    await deleteProduct(id, token);
    setMessage('Product deleted successfully.');
    await loadProductData();
  }

  return (
    <section className="product-page">
      <header className="page-header">
        <div>
          <p className="eyebrow">Catalog</p>
          <h1>Product Management</h1>
        </div>
      </header>

      <div className="content-grid product-grid">
        <form className="panel category-form product-form" onSubmit={handleSaveProduct}>
          <h2>{editingId ? 'Edit product' : 'Create product'}</h2>

          <label htmlFor="category_id">Category</label>
          <select
            id="category_id"
            value={form.category_id}
            onChange={(event) => setForm({ ...form, category_id: event.target.value })}
          >
            {categories.map((category) => (
              <option value={category.id} key={category.id}>
                {category.name}
              </option>
            ))}
          </select>

          <label htmlFor="product_name">Name</label>
          <input
            id="product_name"
            ref={nameInputRef}
            value={form.name}
            onChange={(event) => setForm({ ...form, name: event.target.value })}
            placeholder="Wireless Mouse"
          />

          <label htmlFor="product_slug">Slug</label>
          <input
            id="product_slug"
            value={form.slug}
            onChange={(event) => setForm({ ...form, slug: event.target.value })}
            placeholder="wireless-mouse"
          />

          <label htmlFor="product_description">Description</label>
          <textarea
            id="product_description"
            value={form.description}
            onChange={(event) => setForm({ ...form, description: event.target.value })}
            placeholder="Compact wireless mouse"
          />

          <div className="form-row">
            <div>
              <label htmlFor="price">Price</label>
              <input
                id="price"
                type="number"
                value={form.price}
                onChange={(event) => setForm({ ...form, price: event.target.value })}
                placeholder="49.99"
              />
            </div>
            <div>
              <label htmlFor="stock">Stock</label>
              <input
                id="stock"
                type="number"
                value={form.stock}
                onChange={(event) => setForm({ ...form, stock: event.target.value })}
                placeholder="30"
              />
            </div>
          </div>

          <label htmlFor="image">Image</label>
          <input
            id="image"
            type="file"
            onChange={(event) => setForm({ ...form, image: event.target.files[0] })}
          />

          <label className="checkbox-row">
            <input
              type="checkbox"
              checked={form.status}
              onChange={(event) => setForm({ ...form, status: event.target.checked })}
            />
            Active product
          </label>

          {error && <p className="alert error">{error}</p>}
          {message && <p className="alert success">{message}</p>}

          <div className="button-row">
            <button type="submit" disabled={saving}>
              {saving ? 'Saving...' : editingId ? 'Update Product' : 'Create Product'}
            </button>
            {editingId && (
              <button type="button" className="secondary-button" onClick={resetForm}>
                Cancel
              </button>
            )}
          </div>
        </form>

        <section className="panel product-list">
          <div className="list-header">
            <h2>Products</h2>
            <span>{products.length} total</span>
          </div>

          {products.map((product) => (
            <article className="product-row" key={product.id}>
              <div className="product-info">
                <div className="product-thumb">
                  {product.image ? (
                    <img src={`http://127.0.0.1:8000/storage/${product.image}`} alt={product.name} />
                  ) : (
                    <span>No image</span>
                  )}
                </div>
                <div>
                  <strong>{product.name}</strong>
                  <span>{product.category?.name}</span>
                </div>
              </div>
              <div className="price-stock">
                <span>${product.price}</span>
                <small>{product.stock} in stock</small>
              </div>
              <div className="row-actions">
                <button type="button" className="secondary-button" onClick={() => handleEdit(product)}>
                  Edit
                </button>
                <button type="button" className="danger-button" onClick={() => handleDelete(product.id)}>
                  Delete
                </button>
              </div>
            </article>
          ))}
        </section>
      </div>
    </section>
  );
}

export default ProductManager;
Enter fullscreen mode Exit fullscreen mode

Step 17: Show Uploaded Product Image

When Laravel stores the image path, React can show the image using the Laravel storage URL:

{product.image ? (
  <img
    src={`http://127.0.0.1:8000/storage/${product.image}`}
    alt={product.name}
  />
) : (
  <span>No image</span>
)}
Enter fullscreen mode Exit fullscreen mode

This confirms image upload is working end to end:

React form -> Laravel upload -> storage folder -> MySQL image path -> React image preview.

Step 18: Final Dashboard

The final dashboard loads categories and products from Laravel and calculates useful summary values:

const [categoryResponse, productResponse] = await Promise.all([
  getCategories(token),
  getProducts(token),
]);

const categories = categoryResponse.data || [];
const products = productResponse.data || [];
const activeProducts = products.filter((product) => product.status).length;
const stock = products.reduce((total, product) => total + Number(product.stock || 0), 0);
Enter fullscreen mode Exit fullscreen mode

Then React displays:

  • total categories
  • total products
  • active products
  • stock units
  • inventory value
  • recent products

This gives the admin panel a proper starting screen instead of an empty page.

We also use icons to make the UI feel more polished:

import {
  Boxes,
  DollarSign,
  Layers,
  PackageCheck,
  ShoppingBag,
} from 'lucide-react';
Enter fullscreen mode Exit fullscreen mode

The dashboard is not only decorative. It proves that React can load real API data and calculate useful information from it.

Here is the final code for frontend/src/Dashboard.jsx:

import { useEffect, useState } from 'react';
import {
  Boxes,
  DollarSign,
  Layers,
  LogOut,
  PackageCheck,
  PackagePlus,
  RefreshCw,
  ShoppingBag,
} from 'lucide-react';
import { getCategories, getProducts } from './api';

const moneyFormatter = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD',
});

const emptySummary = {
  categories: 0,
  products: 0,
  activeProducts: 0,
  stock: 0,
  inventoryValue: 0,
};

function Dashboard({ token, onOpenProducts, onLogout }) {
  const [summary, setSummary] = useState(emptySummary);
  const [recentProducts, setRecentProducts] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');

  async function loadDashboard() {
    setLoading(true);
    setError('');

    try {
      const [categoryResponse, productResponse] = await Promise.all([
        getCategories(token),
        getProducts(token),
      ]);

      const categories = categoryResponse.data || [];
      const products = productResponse.data || [];
      const activeProducts = products.filter((product) => product.status).length;
      const stock = products.reduce((total, product) => total + Number(product.stock || 0), 0);
      const inventoryValue = products.reduce((total, product) => {
        return total + Number(product.price || 0) * Number(product.stock || 0);
      }, 0);

      setSummary({
        categories: categories.length,
        products: products.length,
        activeProducts,
        stock,
        inventoryValue,
      });
      setRecentProducts(products.slice(0, 5));
    } catch (error) {
      setError(error.message);
    } finally {
      setLoading(false);
    }
  }

  useEffect(() => {
    if (token) {
      loadDashboard();
    }
  }, [token]);

  const statCards = [
    { label: 'Categories', value: summary.categories, note: 'Product groups', Icon: Layers },
    { label: 'Products', value: summary.products, note: 'Catalog items', Icon: ShoppingBag },
    { label: 'Active Products', value: summary.activeProducts, note: 'Visible in store', Icon: PackageCheck },
    { label: 'Stock Units', value: summary.stock, note: 'Total inventory', Icon: Boxes },
  ];

  return (
    <section className="dashboard-page">
      <header className="page-header">
        <div>
          <p className="eyebrow">Dashboard</p>
          <h1>Ecommerce Overview</h1>
        </div>
        <div className="dashboard-actions">
          <button type="button" onClick={onOpenProducts}>
            <PackagePlus size={18} />
            Add Product
          </button>
          <button type="button" className="secondary-button" onClick={loadDashboard}>
            <RefreshCw size={18} />
            Refresh
          </button>
          <button type="button" className="secondary-button" onClick={onLogout}>
            <LogOut size={18} />
            Log Out
          </button>
        </div>
      </header>

      {error && <p className="alert error">{error}</p>}
      {loading && <p className="panel muted">Loading dashboard...</p>}

      <section className="dashboard-grid" aria-label="Dashboard summary">
        {statCards.map((stat) => {
          const Icon = stat.Icon;

          return (
            <article className="dashboard-card" key={stat.label}>
              <Icon size={24} />
              <span>{stat.label}</span>
              <strong>{stat.value}</strong>
              <small>{stat.note}</small>
            </article>
          );
        })}
        <article className="dashboard-card highlight-card">
          <DollarSign size={24} />
          <span>Inventory Value</span>
          <strong>{moneyFormatter.format(summary.inventoryValue)}</strong>
          <small>Based on product price and stock</small>
        </article>
      </section>

      <section className="dashboard-main-grid">
        <article className="panel recent-products-panel">
          <div className="list-header">
            <h2>Recent Products</h2>
            <span>{recentProducts.length} shown</span>
          </div>

          <div className="recent-table">
            {recentProducts.map((product) => (
              <div className="recent-row" key={product.id}>
                <strong>{product.name}</strong>
                <span>{product.category?.name || 'No category'}</span>
                <span>{moneyFormatter.format(Number(product.price || 0))}</span>
                <small>{product.stock} in stock</small>
              </div>
            ))}
          </div>
        </article>

        <aside className="panel final-note">
          <p className="eyebrow">Final polish</p>
          <h2>Full stack flow is complete</h2>
          <p>React talks to Laravel, Laravel validates and stores data, and MySQL keeps the ecommerce records safe.</p>
        </aside>
      </section>
    </section>
  );
}

export default Dashboard;
Enter fullscreen mode Exit fullscreen mode

Here is the final code for frontend/src/App.css:

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  background: #eef2f7;
  color: #172033;
  font-family: Arial, Helvetica, sans-serif;
}

button,
a {
  font: inherit;
}

.admin-layout {
  min-height: 100vh;
  display: grid;
  grid-template-columns: 240px 1fr;
}

.sidebar {
  background: #172033;
  color: #ffffff;
  padding: 24px;
}

.brand {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-bottom: 32px;
}

.brand-logo {
  width: 44px;
  height: 44px;
  border-radius: 8px;
  display: grid;
  place-items: center;
  background: #e11d48;
  font-weight: 700;
}

.brand h2,
.brand p {
  margin: 0;
}

.brand p {
  color: #a7b4c8;
  font-size: 14px;
}

.nav-list {
  display: grid;
  gap: 10px;
}

.nav-link {
  color: #cbd5e1;
  text-decoration: none;
  padding: 12px;
  border-radius: 8px;
}

.nav-link.active,
.nav-link:hover {
  background: #263449;
  color: #ffffff;
}

.main-content {
  padding: 32px;
}

.page-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 16px;
  margin-bottom: 24px;
}

.page-header p,
.page-header h1 {
  margin: 0;
}

.page-header p {
  color: #64748b;
  font-weight: 700;
  text-transform: uppercase;
}

.page-header button {
  border: 0;
  border-radius: 8px;
  background: #e11d48;
  color: #ffffff;
  padding: 12px 16px;
  font-weight: 700;
}

.card-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 18px;
}

.info-card {
  background: #ffffff;
  border: 1px solid #d8e1ee;
  border-radius: 8px;
  padding: 22px;
}

.info-card span {
  color: #64748b;
}

.info-card strong {
  display: block;
  margin: 10px 0;
  font-size: 36px;
}

.info-card p {
  margin: 0;
  color: #64748b;
}

@media (max-width: 760px) {
  .admin-layout,
  .card-grid {
    grid-template-columns: 1fr;
  }

  .page-header {
    align-items: flex-start;
    flex-direction: column;
  }
}


.auth-page {
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 24px;
}


.auth-card {
  width: min(100%, 460px);
  background: #ffffff;
  border: 1px solid #d8e1ee;
  border-radius: 8px;
  padding: 28px;
  box-shadow: 0 16px 40px rgba(23, 32, 51, 0.08);
}

.login-brand {
  margin-bottom: 18px;
}

.eyebrow {
  margin: 0 0 6px;
  color: #64748b;
  font-size: 13px;
  font-weight: 700;
  text-transform: uppercase;
}

.auth-card h1 {
  margin: 0;
  font-size: 30px;
}

.muted {
  color: #64748b;
  line-height: 1.6;
}


.login-form {
  display: grid;
  gap: 12px;
  margin-top: 22px;
}

.login-form label {
  font-weight: 700;
}

.login-form input {
  width: 100%;
  border: 1px solid #cbd5e1;
  border-radius: 8px;
  padding: 12px 14px;
}


button:disabled {
  cursor: not-allowed;
  opacity: 0.7;
}

button {
  cursor: pointer;
  transition: transform 0.12s ease, box-shadow 0.12s ease;
}

button:active {
  transform: translateY(1px) scale(0.98);
  box-shadow: none;
}

.alert {
  margin: 0;
  border-radius: 8px;
  padding: 10px 12px;
}

.alert.error {
  background: #fee2e2;
  color: #991b1b;
}

.header-actions {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
}

.secondary-button {
  background: #e2e8f0 !important;
  color: #172033 !important;
}


.panel {
  background: #ffffff;
  border: 1px solid #d8e1ee;
  border-radius: 8px;
  padding: 24px;
  box-shadow: 0 16px 40px rgba(23, 32, 51, 0.08);
}

.nav-link {
  border: 0;
  text-align: left;
  background: transparent;
}


.content-grid {
  display: grid;
  grid-template-columns: minmax(280px, 420px) 1fr;
  gap: 20px;
  align-items: start;
}

.category-form {
  display: grid;
  gap: 12px;
}


.category-form label {
  font-weight: 700;
}

.category-form input,
.category-form textarea {
  width: 100%;
  border: 1px solid #cbd5e1;
  border-radius: 8px;
  padding: 12px 14px;
}

.category-form textarea {
  min-height: 96px;
  resize: vertical;
}

.checkbox-row,
.button-row,
.row-actions {
  display: flex;
  gap: 10px;
  align-items: center;
  flex-wrap: wrap;
}

.checkbox-row input {
  width: auto;
}


.alert.success {
  background: #dcfce7;
  color: #166534;
}

.danger-button {
  background: #fee2e2;
  color: #991b1b;
}

.list-header,
.category-row {
  display: flex;
  justify-content: space-between;
  gap: 16px;
  align-items: center;
}

.list-header {
  margin-bottom: 14px;
}

.list-header h2 {
  margin: 0;
}

.category-row {
  border-top: 1px solid #e2e8f0;
  padding: 14px 0;
}

.category-row strong,
.category-row span {
  display: block;
}

.category-row span,
.list-header span {
  color: #64748b;
}

.category-row small {
  border-radius: 999px;
  background: #dcfce7;
  color: #166534;
  padding: 5px 10px;
  font-weight: 700;
}

@media (max-width: 860px) {
  .content-grid {
    grid-template-columns: 1fr;
  }

  .category-row {
    align-items: flex-start;
    flex-direction: column;
  }
}


.product-grid {
  grid-template-columns: minmax(300px, 460px) 1fr;
}

.category-form select,
.category-form input[type="file"] {
  width: 100%;
  border: 1px solid #cbd5e1;
  border-radius: 8px;
  padding: 12px 14px;
}

.form-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
}

.product-list {
  display: grid;
  gap: 14px;
}

.product-row {
  border-top: 1px solid #e2e8f0;
  display: grid;
  grid-template-columns: 1fr auto auto;
  gap: 16px;
  align-items: center;
  padding: 14px 0;
}

.product-info {
  display: flex;
  gap: 12px;
  align-items: center;
}

.product-thumb {
  width: 58px;
  height: 58px;
  border-radius: 8px;
  display: grid;
  place-items: center;
  overflow: hidden;
  background: #e2e8f0;
  color: #64748b;
  font-size: 12px;
}

.product-thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.product-info strong,
.product-info span,
.price-stock span,
.price-stock small {
  display: block;
}

.product-info span,
.price-stock small {
  color: #64748b;
}

.price-stock {
  text-align: right;
}

@media (max-width: 980px) {
  .product-grid,
  .product-row,
  .form-row {
    grid-template-columns: 1fr;
  }

  .price-stock {
    text-align: left;
  }
}


.dashboard-actions {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
}

.dashboard-actions button {
  display: inline-flex;
  align-items: center;
  gap: 8px;
}

.dashboard-grid {
  display: grid;
  grid-template-columns: repeat(5, minmax(0, 1fr));
  gap: 16px;
  margin-bottom: 20px;
}

.dashboard-card {
  background: #ffffff;
  border: 1px solid #d8e1ee;
  border-radius: 8px;
  padding: 20px;
}

.dashboard-card svg {
  color: #0f8db3;
  margin-bottom: 12px;
}

.dashboard-card span,
.dashboard-card small {
  color: #64748b;
}

.dashboard-card strong {
  display: block;
  margin: 10px 0;
  font-size: 30px;
}

.highlight-card {
  border-color: #bae6fd;
  background: #f0f9ff;
}

.dashboard-main-grid {
  display: grid;
  grid-template-columns: 1fr 320px;
  gap: 20px;
  align-items: start;
}

.recent-table {
  display: grid;
}

.recent-row {
  border-top: 1px solid #e2e8f0;
  display: grid;
  grid-template-columns: 1.5fr 1fr auto auto;
  gap: 14px;
  align-items: center;
  padding: 14px 0;
}

.recent-row span,
.recent-row small,
.final-note p {
  color: #64748b;
}

.final-note h2 {
  margin-top: 0;
}

@media (max-width: 1100px) {
  .dashboard-grid,
  .dashboard-main-grid,
  .recent-row {
    grid-template-columns: 1fr;
  }
}
Enter fullscreen mode Exit fullscreen mode

Full API Endpoints

Here are the important API endpoints we build in this project:

POST   /api/login
GET    /api/user
POST   /api/logout

GET    /api/categories
POST   /api/categories
GET    /api/categories/{category}
PUT    /api/categories/{category}
DELETE /api/categories/{category}

GET    /api/products
POST   /api/products
GET    /api/products/{product}
PUT    /api/products/{product}
DELETE /api/products/{product}
Enter fullscreen mode Exit fullscreen mode

For product update with image upload, React sends:

POST /api/products/{product}
_method=PUT
Enter fullscreen mode Exit fullscreen mode

This is a common Laravel-friendly way to update records with multipart form data.

Common Issues and Fixes

CORS or API Not Connecting

Make sure Laravel is running:

php artisan serve
Enter fullscreen mode Exit fullscreen mode

And React should call:

http://127.0.0.1:8000/api
Enter fullscreen mode Exit fullscreen mode

Database Error

Check .env:

DB_DATABASE=ecommerce_admin
DB_USERNAME=learnwebcoding
DB_PASSWORD=
Enter fullscreen mode Exit fullscreen mode

Then run:

php artisan migrate
Enter fullscreen mode Exit fullscreen mode

Image Not Showing

Make sure storage link is created:

php artisan storage:link
Enter fullscreen mode Exit fullscreen mode

And image URL should use:

http://127.0.0.1:8000/storage/products/image-name.jpg
Enter fullscreen mode Exit fullscreen mode

Unauthorized Request

If Laravel returns unauthorized, check that React is sending the token:

Authorization: Bearer token_here
Enter fullscreen mode Exit fullscreen mode

Also make sure the token is saved after login:

localStorage.setItem('admin_token', response.token);
Enter fullscreen mode Exit fullscreen mode

Full Series Roadmap

This consolidated tutorial is based on the complete video series:

  1. Course Introduction & Project Overview
  2. Laravel API Setup & MySQL Database
  3. Database Design, Migrations & Models
  4. Build Categories REST API
  5. Build Products REST API With Image Upload
  6. Authentication With Laravel Sanctum
  7. React Admin Setup & Layout
  8. Connect React Login With Laravel API
  9. Category Management In React
  10. Product Management In React
  11. Dashboard & Final Polish

Final Project Flow

Here is the complete full-stack flow:

  1. Admin opens React app.
  2. React shows login page.
  3. Login request goes to Laravel.
  4. Laravel validates user and returns Sanctum token.
  5. React stores token in local storage.
  6. React sends category and product API requests with the token.
  7. Laravel validates requests and runs Eloquent queries.
  8. MySQL stores categories, products, and user data.
  9. Laravel returns JSON.
  10. React updates the screen.

That is the core of a real Laravel and React full-stack application.

What You Should Practice Next

After completing this project, try improving it with:

  • better validation messages
  • pagination
  • product search
  • category filters
  • role-based admin users
  • dashboard charts
  • deployment

These features can turn the learning project into a more production-ready application.

GitHub Repository

You can also check the complete source code here:

https://github.com/durgesh-sahani/full-stack-laravel-react-mysql/ecommerce-admin-panel

Conclusion

In this full-stack series, we built an ecommerce admin panel using Laravel, React and MySQL. We started from API setup, database design, migrations and models, then created category and product APIs, added image upload, protected everything with Sanctum authentication, and finally connected React screens for login, category management, product management and dashboard polish.

If you are moving from basic PHP, HTML, CSS and JavaScript into modern full-stack development, this project gives you a clear practical path: backend API, database, frontend UI, authentication, CRUD operations and real browser testing.

Top comments (0)