مقدمه
ساخت فروشگاه اینترنتی یکی از پروژههای کاربردی برای یادگیری و استفاده حرفهای از Laravel است. در یک فروشگاه واقعی، فقط نمایش محصولات کافی نیست؛ باید بتوانیم کاربران را مدیریت کنیم، محصولات و دستهبندیها را ثبت کنیم، سبد خرید بسازیم، سفارشها را ذخیره کنیم، موجودی کالا را کنترل کنیم و پرداختها را بهصورت امن مدیریت کنیم.
Laravel ابزارهای زیادی برای پیادهسازی این قابلیتها در اختیار توسعهدهنده قرار میدهد. Eloquent ORM برای ارتباط با دیتابیس، سیستم احراز هویت برای مدیریت کاربران، Validation برای اعتبارسنجی دادهها، Middleware و Policy برای کنترل دسترسی و Database Transaction برای حفظ یکپارچگی اطلاعات از جمله این امکانات هستند.
در این مقاله، هسته یک فروشگاه اینترنتی را با Laravel 12 طراحی میکنیم. ابتدا ساختار دیتابیس را میسازیم، سپس محصولات را نمایش میدهیم، سبد خرید را پیادهسازی میکنیم و در نهایت منطق اولیه ثبت سفارش و اتصال به درگاه پرداخت را بررسی میکنیم.
این آموزش یک معماری پایه و قابل توسعه ارائه میدهد. یک فروشگاه آماده انتشار در محیط واقعی به تکمیل رابط کاربری، فرایند پرداخت، بازپرداخت، ارسال، تست امنیتی و مدیریت خطا نیز نیاز دارد.
در این آموزش چه چیزی میسازیم؟
در پایان، ساختار اولیه فروشگاهی خواهیم داشت که شامل این بخشها است:
- صفحه فهرست محصولات
- صفحه جزئیات محصول
- دستهبندی محصولات
- سبد خرید
- ثبت سفارش
- ذخیره قیمت محصولات در زمان خرید
- کنترل موجودی
- پنل مدیریت محصولات
- آمادهسازی ساختار برای اتصال درگاه پرداخت
- اصول امنیت و بهینهسازی عملکرد
برای ساده نگه داشتن پروژه، ابتدا یک فروشگاه تکفروشنده طراحی میکنیم. در فروشگاه چندفروشندگی، مانند Marketplace، باید مالکیت فروشندگان، کمیسیون و سفارشهای هر فروشنده نیز مدیریت شوند.
مرحله اول: ایجاد پروژه Laravel
اگر هنوز پروژهای ایجاد نکردهاید، ابتدا یک پروژه Laravel بسازید.
در محیطی که PHP و Composer نصب هستند، دستور زیر را اجرا کنید:
composer create-project laravel/laravel ecommerce
سپس وارد پوشه پروژه شوید:
cd ecommerce
برای اجرای سرور توسعه:
php artisan serve
پس از اجرای دستور، آدرس نمایشدادهشده در ترمینال را در مرورگر باز کنید.
اگر پروژه از قبل ساخته شده است، لازم نیست آن را دوباره ایجاد کنید.
تنظیم اتصال دیتابیس
برای استفاده از MySQL، فایل .env را باز کنید و تنظیمات دیتابیس را متناسب با محیط خود قرار دهید:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=ecommerce
DB_USERNAME=root
DB_PASSWORD=
مقادیر بالا نمونه هستند؛ نام دیتابیس، کاربر و رمز عبور باید با تنظیمات واقعی سیستم شما مطابقت داشته باشند.
پس از ساخت دیتابیس، میتوانید migrationهای پروژه را اجرا کنید:
php artisan migrate
مستندات رسمی راهاندازی Laravel و تنظیم دیتابیس:
https://laravel.com/docs/12.x/installation
مرحله دوم: طراحی دیتابیس فروشگاه
پیش از پیادهسازی کنترلرها، بهتر است ساختار دادهها را مشخص کنیم.
برای یک فروشگاه ساده، جدولهای اصلی عبارتاند از:
| جدول | کاربرد |
|---|---|
users | اطلاعات کاربران |
categories | دستهبندی محصولات |
products | اطلاعات محصولات |
carts | در صورت نیاز، سبدهای خرید ذخیرهشده |
cart_items | ردیفهای سبد خرید |
orders | سفارشهای ثبتشده |
order_items | محصولات هر سفارش |
payments | اطلاعات و وضعیت تراکنشهای پرداخت |
در این مقاله، سبد خرید را ابتدا در Session پیادهسازی میکنیم؛ بنابراین برای نسخه اولیه الزاماً به جدولهای carts و cart_items نیاز نداریم. اگر بخواهید سبد خرید کاربر پس از خروج از حساب یا بین چند دستگاه حفظ شود، بهتر است آن را در دیتابیس ذخیره کنید.
ساخت مدلها و Migrationها
دستورهای زیر را اجرا کنید:
php artisan make:model Category -m
php artisan make:model Product -m
php artisan make:model Order -m
php artisan make:model OrderItem -m
برای اطلاعات پرداخت نیز میتوانیم مدل مستقلی داشته باشیم:
php artisan make:model Payment -m
این مدلها ساختار اولیه را ایجاد میکنند. در ادامه، ستونهای موردنیاز را در فایلهای Migration تعریف میکنیم.
مرحله سوم: ایجاد جدول دستهبندیها
فایل Migration مربوط به categories را باز کنید و ساختار زیر را قرار دهید:
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->foreignId('parent_id')
->nullable()
->constrained('categories')
->nullOnDelete();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
فیلد parent_id امکان ایجاد دستهبندیهای تودرتو را فراهم میکند. برای مثال، دستهبندی «موبایل» میتواند زیرمجموعه دستهبندی «لوازم دیجیتال» باشد.
فیلد slug نیز برای ساخت آدرس خوانا و مناسب موتورهای جستوجو استفاده میشود.
در مدل Category، ارتباط دستهبندی و محصولات را تعریف میکنیم:
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Category extends Model
{
protected $fillable = [
'name',
'slug',
'parent_id',
'is_active',
];
public function products(): HasMany
{
return $this->hasMany(Product::class);
}
}
مرحله چهارم: ایجاد جدول محصولات
هر محصول باید اطلاعاتی مانند نام، توضیحات، قیمت، موجودی و دستهبندی داشته باشد.
Migration جدول products را به شکل زیر تنظیم کنید:
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->foreignId('category_id')
->constrained()
->restrictOnDelete();
$table->string('name');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->unsignedBigInteger('price');
$table->unsignedInteger('stock')->default(0);
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->index(['is_active', 'category_id']);
});
در این مثال، قیمت بهصورت عدد صحیح ذخیره شده است. باید واحد پول را از ابتدا مشخص کنید و در تمام بخشهای سیستم یکسان نگه دارید.
همچنین is_active تعیین میکند که محصول اجازه نمایش عمومی دارد یا خیر.
در مدل Product ارتباطهای لازم را تعریف میکنیم:
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',
'is_active',
];
protected function casts(): array
{
return [
'is_active' => 'boolean',
'price' => 'integer',
'stock' => 'integer',
];
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
}
در این ساختار، هر محصول به یک دستهبندی متصل است. اگر بخواهید هر محصول در چند دستهبندی قرار بگیرد، باید رابطه چندبهچند و جدول واسط طراحی کنید.
مرحله پنجم: ساخت صفحه فهرست محصولات
برای نمایش محصولات، ابتدا یک کنترلر ایجاد کنید:
php artisan make:controller ProductController
سپس در فایل app/Http/Controllers/ProductController.php کد زیر را قرار دهید:
<?php
namespace App\Http\Controllers;
use App\Models\Product;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
class ProductController extends Controller
{
public function index(Request $request): View
{
$products = Product::query()
->where('is_active', true)
->with('category:id,name,slug')
->when(
$request->filled('q'),
function ($query) use ($request) {
$query->where(
'name',
'like',
'%' . $request->input('q') . '%'
);
}
)
->latest()
->paginate(12)
->withQueryString();
return view('products.index', compact('products'));
}
public function show(string $slug): View
{
$product = Product::query()
->where('slug', $slug)
->where('is_active', true)
->with('category:id,name,slug')
->firstOrFail();
return view('products.show', compact('product'));
}
}
در متد index، فقط محصولات فعال نمایش داده میشوند. همچنین جستوجوی ساده بر اساس نام محصول و صفحهبندی با paginate() پیادهسازی شده است.
استفاده از with() باعث میشود اطلاعات دستهبندی محصولات از قبل بارگذاری شود و از اجرای Query اضافی برای هر محصول جلوگیری شود.
در متد show نیز محصول بر اساس slug پیدا میشود. اگر محصول وجود نداشته باشد یا فعال نباشد، پاسخ 404 بازگردانده میشود.
مرحله ششم: تعریف Routeها
فایل routes/web.php را باز کنید و مسیرهای زیر را اضافه کنید:
use App\Http\Controllers\ProductController;
use Illuminate\Support\Facades\Route;
Route::get('/products', [
ProductController::class,
'index',
])->name('products.index');
Route::get('/products/{slug}', [
ProductController::class,
'show',
])->name('products.show');
اکنون مسیر /products فهرست محصولات را نمایش میدهد و مسیر /products/example-product صفحه محصولی با همان slug را باز میکند.
در ادامه، مسیرهای سبد خرید و سفارش را نیز اضافه خواهیم کرد.
مرحله هفتم: ساخت قالب نمایش محصولات
پوشه زیر را ایجاد کنید:
resources/views/products
سپس فایل index.blade.php را بسازید:
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>فروشگاه اینترنتی</title>
</head>
<body>
<h1>محصولات فروشگاه</h1>
<form action="{{ route('products.index') }}" method="GET">
<input
type="search"
name="q"
value="{{ request('q') }}"
placeholder="جستوجوی محصول"
>
<button type="submit">جستوجو</button>
</form>
<div>
@forelse ($products as $product)
<article>
<h2>
<a href="{{ route('products.show', $product->slug) }}">
{{ $product->name }}
</a>
</h2>
<p>{{ $product->category?->name }}</p>
<p>{{ number_format($product->price) }}</p>
<a href="{{ route('products.show', $product->slug) }}">
مشاهده محصول
</a>
</article>
@empty
<p>محصولی برای نمایش وجود ندارد.</p>
@endforelse
</div>
{{ $products->links() }}
</body>
</html>
این قالب، نسخه سادهای برای نمایش و جستوجوی محصولات است. در پروژه واقعی میتوانید از Blade Layout، Tailwind CSS و Vite برای طراحی رابط کاربری حرفهایتر استفاده کنید.
نکته مهم این است که Blade بهصورت پیشفرض با {{ }} خروجی را Escape میکند؛ این روش برای نمایش معمول دادههای متنی مناسبتر از چاپ مستقیم HTML خام است.
مرحله هشتم: ساخت صفحه جزئیات محصول
فایل resources/views/products/show.blade.php را ایجاد کنید:
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ $product->name }}</title>
</head>
<body>
<nav>
<a href="{{ route('products.index') }}">فروشگاه</a>
</nav>
<main>
<h1>{{ $product->name }}</h1>
<p>
دستهبندی:
{{ $product->category?->name }}
</p>
<p>{{ $product->description }}</p>
<p>
قیمت:
{{ number_format($product->price) }}
</p>
@if ($product->stock > 0)
<p>موجود در انبار</p>
@else
<p>ناموجود</p>
@endif
</main>
</body>
</html>
در این مرحله اطلاعات اصلی محصول نمایش داده میشود. هنوز فرم افزودن به سبد خرید را اضافه نکردهایم؛ این قابلیت را در بخش بعد پیادهسازی میکنیم.
مرحله نهم: طراحی سبد خرید با Session
برای یک فروشگاه ساده، Session میتواند گزینه مناسبی برای نگهداری موقت سبد خرید باشد. این روش نیاز به جدول جداگانه سبد خرید را در نسخه اولیه حذف میکند.
در این طراحی، شناسه محصول و تعداد آن را در Session ذخیره میکنیم، نه قیمت نهایی را. هنگام نمایش سبد خرید و ثبت سفارش، قیمت معتبر از دیتابیس خوانده خواهد شد.
ابتدا کنترلر را ایجاد کنید:
php artisan make:controller CartController
سپس کد زیر را در CartController قرار دهید:
<?php
namespace App\Http\Controllers;
use App\Models\Product;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class CartController extends Controller
{
public function index(Request $request): View
{
$cart = $request->session()->get('cart', []);
$products = Product::query()
->whereIn('id', array_keys($cart))
->where('is_active', true)
->get()
->keyBy('id');
$items = [];
$total = 0;
foreach ($cart as $productId => $quantity) {
$product = $products->get($productId);
if (!$product) {
continue;
}
$subtotal = $product->price * $quantity;
$items[] = [
'product' => $product,
'quantity' => $quantity,
'subtotal' => $subtotal,
];
$total += $subtotal;
}
return view('cart.index', compact('items', 'total'));
}
public function add(Request $request, Product $product): RedirectResponse
{
abort_unless($product->is_active, 404);
$data = $request->validate([
'quantity' => ['required', 'integer', 'min:1', 'max:20'],
]);
$cart = $request->session()->get('cart', []);
$currentQuantity = (int) ($cart[$product->id] ?? 0);
$newQuantity = $currentQuantity + $data['quantity'];
if ($newQuantity > $product->stock) {
return back()->withErrors([
'quantity' => 'تعداد درخواستی از موجودی محصول بیشتر است.',
]);
}
$cart[$product->id] = $newQuantity;
$request->session()->put('cart', $cart);
return redirect()->route('cart.index');
}
public function remove(
Request $request,
Product $product
): RedirectResponse {
$cart = $request->session()->get('cart', []);
unset($cart[$product->id]);
$request->session()->put('cart', $cart);
return redirect()->route('cart.index');
}
}
در این کد، متد add محصول را به سبد خرید اضافه میکند، متد index اطلاعات سبد را از دیتابیس بازیابی میکند و متد remove محصول را از سبد حذف میکند.
نکته مهم: بررسی موجودی در این مرحله فقط یک بررسی اولیه است. موجودی ممکن است پس از افزودن محصول به سبد خرید توسط مشتری دیگری تغییر کند. بنابراین کنترل قطعی موجودی باید هنگام ثبت سفارش نیز انجام شود.
در فروشگاههای پرترافیک، بهتر است سیاست مشخصی برای رزرو موجودی، انقضای سبد خرید و جلوگیری از خرید همزمان آخرین واحد کالا داشته باشید.
مرحله دهم: تعریف مسیرهای سبد خرید
در فایل routes/web.php مسیرهای زیر را اضافه کنید:
use App\Http\Controllers\CartController;
Route::get('/cart', [
CartController::class,
'index',
])->name('cart.index');
Route::post('/cart/{product}', [
CartController::class,
'add',
])->name('cart.add');
Route::delete('/cart/{product}', [
CartController::class,
'remove',
])->name('cart.remove');
این مسیرها برای مشاهده، افزودن و حذف محصول از سبد خرید استفاده میشوند.
برای درخواستهای POST و DELETE که از فرمهای وب ارسال میشوند، باید توکن CSRF را نیز در فرم Blade قرار دهید.
مرحله یازدهم: فرم افزودن محصول به سبد خرید
در فایل products/show.blade.php، زیر قیمت محصول، فرم زیر را قرار دهید:
@if ($product->stock > 0)
<form
action="{{ route('cart.add', $product) }}"
method="POST"
>
@csrf
<label for="quantity">تعداد</label>
<input
id="quantity"
type="number"
name="quantity"
min="1"
max="20"
value="1"
required
>
<button type="submit">
افزودن به سبد خرید
</button>
</form>
@endif
در این فرم، شناسه محصول از Route Model Binding به کنترلر میرسد و تعداد انتخابشده توسط کاربر اعتبارسنجی میشود.
با این حال، نباید به محدودیتهای HTML اعتماد کنیم؛ اعتبارسنجی سمت سرور همچنان ضروری است.
مرحله دوازدهم: نمایش سبد خرید
فایل resources/views/cart/index.blade.php را ایجاد کنید:
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>سبد خرید</title>
</head>
<body>
<h1>سبد خرید شما</h1>
@if ($errors->any())
<ul>
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
@endif
@forelse ($items as $item)
<article>
<h2>{{ $item['product']->name }}</h2>
<p>
تعداد:
{{ $item['quantity'] }}
</p>
<p>
مبلغ:
{{ number_format($item['subtotal']) }}
</p>
<form
action="{{ route('cart.remove', $item['product']) }}"
method="POST"
>
@csrf
@method('DELETE')
<button type="submit">حذف از سبد</button>
</form>
</article>
@empty
<p>سبد خرید شما خالی است.</p>
@endforelse
<h2>
مجموع:
{{ number_format($total) }}
</h2>
@if (count($items) > 0)
<a href="{{ route('checkout.index') }}">
ادامه و ثبت سفارش
</a>
@endif
</body>
</html>
این قالب سبد خرید را به شکل ساده نمایش میدهد. در مرحله بعد، مسیر checkout.index را تعریف میکنیم.
مرحله سیزدهم: طراحی دیتابیس سفارشها
سبد خرید موقتی است، اما سفارش باید در دیتابیس ذخیره شود تا اطلاعات خرید حتی پس از پایان Session باقی بمانند.
جدول orders
در Migration مربوط به سفارشها:
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')
->constrained()
->restrictOnDelete();
$table->unsignedBigInteger('total_amount');
$table->string('status')->default('pending');
$table->timestamps();
});
در این مثال، سفارش به یک کاربر متصل است؛ بنابراین در زمان ثبت سفارش باید احراز هویت کاربر الزامی باشد.
جدول order_items
Schema::create('order_items', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')
->constrained()
->cascadeOnDelete();
$table->foreignId('product_id')
->constrained()
->restrictOnDelete();
$table->unsignedInteger('quantity');
$table->unsignedBigInteger('unit_price');
$table->timestamps();
});
فیلد unit_price قیمت محصول در لحظه خرید را ذخیره میکند. این موضوع مهم است؛ زیرا اگر قیمت محصول بعداً تغییر کند، مبلغ سفارش قدیمی نباید تغییر کند.
در پروژه واقعی، بهتر است اطلاعات دیگری مانند تخفیف، مالیات، هزینه ارسال و مبلغ نهایی نیز مطابق نیاز کسبوکار ثبت شوند.
مرحله چهاردهم: تعریف ارتباط مدلهای سفارش
در مدل Order:
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Order extends Model
{
protected $fillable = [
'user_id',
'total_amount',
'status',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
}
در مدل OrderItem:
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OrderItem extends Model
{
protected $fillable = [
'order_id',
'product_id',
'quantity',
'unit_price',
];
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
}
این ارتباطها امکان دسترسی به اطلاعات سفارش و محصولات داخل آن را فراهم میکنند.
مرحله پانزدهم: ثبت سفارش با تراکنش دیتابیس
ثبت سفارش باید با دقت انجام شود؛ زیرا چند عملیات مرتبط وجود دارند:
- بررسی موجودی محصولات
- محاسبه مبلغ نهایی
- ایجاد سفارش
- ثبت ردیفهای سفارش
- کاهش موجودی
اگر یکی از این مراحل شکست بخورد، نباید سفارش ناقص یا موجودی نادرست در دیتابیس باقی بماند.
برای این منظور از DB::transaction() استفاده میکنیم.
ابتدا کنترلر Checkout را ایجاد کنید:
php artisan make:controller CheckoutController
سپس متد ثبت سفارش را در کنترلر قرار دهید:
<?php
namespace App\Http\Controllers;
use App\Models\Order;
use App\Models\Product;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class CheckoutController extends Controller
{
public function store(Request $request): RedirectResponse
{
$cart = $request->session()->get('cart', []);
if (empty($cart)) {
return redirect()
->route('cart.index')
->withErrors([
'cart' => 'سبد خرید شما خالی است.',
]);
}
$order = DB::transaction(function () use ($request, $cart) {
$order = Order::create([
'user_id' => $request->user()->id,
'total_amount' => 0,
'status' => 'pending',
]);
$total = 0;
foreach ($cart as $productId => $quantity) {
$product = Product::query()
->whereKey($productId)
->where('is_active', true)
->lockForUpdate()
->first();
if (!$product) {
throw ValidationException::withMessages([
'cart' => 'یکی از محصولات سبد خرید دیگر در دسترس نیست.',
]);
}
$quantity = (int) $quantity;
if (
$quantity < 1 ||
$quantity > 20 ||
$quantity > $product->stock
) {
throw ValidationException::withMessages([
'cart' => 'تعداد درخواستی با موجودی محصول مطابقت ندارد.',
]);
}
$unitPrice = $product->price;
$order->items()->create([
'product_id' => $product->id,
'quantity' => $quantity,
'unit_price' => $unitPrice,
]);
$product->decrement('stock', $quantity);
$total += $unitPrice * $quantity;
}
$order->update([
'total_amount' => $total,
]);
return $order;
});
$request->session()->forget('cart');
return redirect()->route('checkout.success', $order);
}
}
این نمونه هسته اولیه ثبت سفارش را نشان میدهد، اما هنوز یک فرایند کامل پرداخت آنلاین نیست. در این نسخه موجودی هنگام ایجاد سفارش کاهش پیدا میکند و سفارش با وضعیت pending باقی میماند. در یک فروشگاه واقعی باید فرایند رزرو موجودی، انقضای سفارش، پرداخت ناموفق و آزادسازی موجودی طراحی شود.
همچنین اگر سبد خرید شامل محصولی باشد که دیگر موجود نیست، تراکنش باید لغو شود و خطا به کاربر نمایش داده شود. رفتار دقیق قفل ردیفها نیز به دیتابیس و سطح ایزولیشن تراکنش وابسته است.
مستندات تراکنشهای دیتابیس Laravel:
https://laravel.com/docs/12.x/database#database-transactions
مرحله شانزدهم: تعریف مسیرهای Checkout
در routes/web.php، مسیرهای زیر را تعریف کنید:
use App\Http\Controllers\CheckoutController;
Route::middleware('auth')->group(function () {
Route::post('/checkout', [
CheckoutController::class,
'store',
])->name('checkout.store');
});
برای مشاهده فرم Checkout نیز باید متد index و View مربوطه را بسازید. مسیر ثبت سفارش باید فقط برای کاربران احراز هویتشده قابلدسترسی باشد.
در قالب سبد خرید، فرم ثبت سفارش را میتوانید به این شکل اضافه کنید:
<form action="{{ route('checkout.store') }}" method="POST">
@csrf
<button type="submit">
ثبت سفارش
</button>
</form>
این فرم سفارش را ثبت میکند؛ درگاه پرداخت در مرحله بعد به فرایند اضافه خواهد شد.
مرحله هفدهم: چرا نباید ثبت سفارش را با پرداخت موفق یکی بدانیم؟
در فروشگاه اینترنتی، ثبت سفارش و پرداخت موفق دو مفهوم متفاوت هستند.
ممکن است سفارش ایجاد شود، اما مشتری پرداخت را انجام ندهد. همچنین ممکن است مشتری به صفحه بازگشت درگاه منتقل شود، اما نتیجه پرداخت نیازمند تأیید از سمت سرور باشد.
به همین دلیل بهتر است وضعیتهای سفارش و پرداخت را جداگانه مدیریت کنید.
برای مثال، وضعیتهای سفارش میتوانند شامل موارد زیر باشند:
pending: سفارش ثبت شده و منتظر ادامه فرایند است.processing: سفارش در حال آمادهسازی است.completed: سفارش تکمیل شده است.cancelled: سفارش لغو شده است.
وضعیتهای پرداخت نیز میتوانند جداگانه نگهداری شوند:
pending: پرداخت هنوز تعیین تکلیف نشده است.paid: پرداخت تأیید شده است.failed: پرداخت ناموفق بوده است.refunded: مبلغ بازپرداخت شده است.
این نامها نمونه هستند و باید در کل برنامه با قواعد مشخص استفاده شوند.
ساختار جدول پرداخت
میتوانید Migration جدول payments را به شکل زیر طراحی کنید:
Schema::create('payments', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')
->constrained()
->restrictOnDelete();
$table->string('gateway');
$table->string('authority')->nullable()->unique();
$table->unsignedBigInteger('amount');
$table->string('status')->default('pending');
$table->string('transaction_id')->nullable();
$table->timestamps();
});
فیلدهای واقعی باید مطابق مستندات درگاه پرداخت انتخابی تکمیل شوند. برای مثال، بعضی درگاهها شناسه درخواست، شماره پیگیری، کد نتیجه و اطلاعات امضا دارند.
نکته امنیتی: بازگشت مرورگر به صفحه موفقیت، بهتنهایی دلیل کافی برای تغییر وضعیت پرداخت به paid نیست. سرور باید نتیجه تراکنش را با سازوکار تأیید رسمی درگاه بررسی کند.
همچنین Endpoint تأیید پرداخت باید در برابر اجرای تکراری مقاوم باشد؛ یعنی یک تراکنش تأییدشده نباید چند بار باعث ثبت پرداخت یا تکمیل سفارش شود.
مرحله هجدهم: ساخت پنل مدیریت فروشگاه
یک فروشگاه اینترنتی به پنل مدیریت نیاز دارد تا مدیر بتواند محصولات، دستهبندیها و سفارشها را مدیریت کند.
امکانات اولیه پنل مدیریت عبارتاند از:
- ایجاد و ویرایش محصولات
- فعال یا غیرفعال کردن محصول
- مدیریت دستهبندیها
- مشاهده سفارشها
- تغییر وضعیت سفارش
- مشاهده وضعیت پرداخت
- مدیریت موجودی
- مشاهده گزارشهای پایه فروش
برای محافظت از پنل مدیریت، باید نقش مدیر و مجوزهای مرتبط را تعریف کنید. احراز هویت بهتنهایی کافی نیست؛ زیرا ورود موفق کاربر لزوماً به معنای مجاز بودن او برای مدیریت محصولات یا سفارشها نیست.
میتوانید برای این منظور از Middleware، Gate یا Policy استفاده کنید.
در پروژههای بزرگتر، ابزارهایی مانند Filament نیز میتوانند ساخت پنل مدیریت را سریعتر کنند؛ اما قوانین امنیتی و اعتبارسنجی همچنان باید در سمت سرور اجرا شوند.
مرحله نوزدهم: بهینهسازی عملکرد فروشگاه
با افزایش تعداد محصولات و سفارشها، بهینهسازی اهمیت بیشتری پیدا میکند.
استفاده از Pagination
نمایش تمام محصولات در یک درخواست میتواند حافظه و زمان پاسخ را افزایش دهد. استفاده از paginate() تعداد رکوردهای هر صفحه را کنترل میکند.
جلوگیری از مشکل N+1
هنگام نمایش محصولات همراه با دستهبندی، از with() استفاده کنید:
$products = Product::with('category')
->where('is_active', true)
->paginate(20);
تعریف Index مناسب
فیلدهایی که مرتب در جستوجو یا فیلتر استفاده میشوند، ممکن است به Index نیاز داشته باشند. البته Index بیش از حد نیز هزینه نوشتن و نگهداری دیتابیس را افزایش میدهد؛ بنابراین باید بر اساس Queryهای واقعی طراحی شود.
استفاده از Cache
دستهبندیها یا دادههایی که بهندرت تغییر میکنند میتوانند با سیاست مناسب Cache شوند. در مقابل، قیمت و موجودی باید با توجه به نیاز تجاری و دقت موردنیاز بهروز نگه داشته شوند.
انتقال عملیات زمانبر به Queue
ارسال ایمیل، تولید فاکتور و اعلان سفارش میتوانند به صف منتقل شوند تا درخواست ثبت سفارش منتظر پایان آنها نماند.
در طراحی Queue باید مراقب باشید که Jobهای حساس فقط پس از Commit شدن تراکنش مربوطه اجرا شوند و اجرای مجدد آنها باعث تکرار عملیات حساس نشود.
مرحله بیستم: تستنویسی فروشگاه
پیش از انتشار فروشگاه، سناریوهای مهم باید تست شوند.
موارد پیشنهادی عبارتاند از:
- محصول غیرفعال در فهرست عمومی نمایش داده نشود.
- محصول ناموجود به سبد خرید اضافه نشود.
- قیمت سفارش از دیتابیس محاسبه شود، نه از ورودی کاربر.
- سفارش بدون ورود کاربر ثبت نشود.
- موجودی کافی برای ثبت سفارش وجود داشته باشد.
- در صورت بروز خطا، تراکنش بهدرستی Rollback شود.
- سفارش با پرداخت ناموفق بهعنوان پرداختشده ثبت نشود.
- تأیید تکراری یک پرداخت باعث ثبت دوباره تراکنش نشود.
- کاربر عادی نتواند به امکانات مدیریت دسترسی پیدا کند.
برای تست مسیرهای HTTP میتوانید از Feature Testهای Laravel استفاده کنید و برای منطق محاسبه قیمت یا کمیسیون، تستهای واحد بنویسید.
مستندات رسمی تستنویسی Laravel:
https://laravel.com/docs/12.x/testing
اشتباهات رایج هنگام ساخت فروشگاه با Laravel
اعتماد به قیمت ارسالشده از مرورگر
قیمت باید از دیتابیس خوانده شود. در غیر این صورت کاربر میتواند درخواست را تغییر دهد و مبلغ دلخواه خود را ارسال کند.
نادیده گرفتن تغییر موجودی
موجودی ممکن است بین زمان افزودن محصول به سبد و ثبت سفارش تغییر کند. بنابراین کنترل نهایی باید هنگام ثبت سفارش انجام شود.
یکی دانستن سفارش و پرداخت
ایجاد سفارش به معنی پرداخت موفق نیست. وضعیت سفارش و وضعیت پرداخت باید با قواعد مشخص مدیریت شوند.
حذف فوری اطلاعاتی که سابقه مالی دارند
محصول یا کاربری که در سفارشهای قبلی استفاده شده، ممکن است برای گزارشها و سوابق مالی لازم باشد. بهجای حذف بیملاحظه، باید سیاست غیرفعالسازی و نگهداری سوابق تعریف شود.
نداشتن تست برای مسیرهای خطا
تست کردن فقط خرید موفق کافی نیست. پرداخت ناموفق، موجودی ناکافی، لغو سفارش و اجرای تکراری Callback نیز باید بررسی شوند.
جمعبندی
ساخت فروشگاه اینترنتی با Laravel شامل مجموعهای از بخشهای مرتبط است: محصولات، دستهبندیها، سبد خرید، سفارشها، موجودی، پرداخت و پنل مدیریت.
در این مقاله هسته اولیه فروشگاه را طراحی کردیم و با استفاده از Eloquent، Routeها، Session و تراکنش دیتابیس، ساختار پایه نمایش محصولات و ثبت سفارش را پیادهسازی کردیم.
برای تبدیل این هسته به یک فروشگاه قابل استفاده در محیط واقعی، باید رابط کاربری را کامل کنید، سیستم احراز هویت و مجوزها را دقیق پیادهسازی کنید، درگاه پرداخت را مطابق مستندات رسمی آن متصل کنید و برای بازپرداخت، رزرو موجودی، ارسال سفارش و خطاهای احتمالی سیاست مشخصی داشته باشید.
پیشنهاد عملی: ابتدا مسیر نمایش محصول تا ثبت سفارش را در محیط توسعه کامل کنید. سپس تستهای امنیتی و موجودی را بنویسید و در مرحله بعد اتصال درگاه پرداخت را پیادهسازی کنید. این ترتیب باعث میشود منطق فروشگاه پیش از ورود به پیچیدگیهای پرداخت، ساختار قابلآزمایشی داشته باشد.
سؤالات متداول
آیا Laravel برای ساخت فروشگاه اینترنتی مناسب است؟
بله. Laravel ابزارهایی برای مدیریت دیتابیس، احراز هویت، اعتبارسنجی، مجوزها، صفها و تستنویسی دارد که برای ساخت فروشگاههای اختصاصی مناسب هستند.
برای سبد خرید در Laravel از Session استفاده کنیم یا دیتابیس؟
Session برای یک پیادهسازی ساده مناسب است. اگر نیاز به حفظ سبد خرید بین دستگاهها، ورود دوباره کاربر یا مدیریت پیشرفته سبدها دارید، ذخیرهسازی دیتابیسی انتخاب مناسبتری است.
چگونه از دستکاری قیمت توسط کاربر جلوگیری کنیم؟
قیمت محصول را هنگام نمایش یا ثبت سفارش از دیتابیس بخوانید و مبلغ نهایی را در سمت سرور محاسبه کنید. به مبلغ ارسالی از مرورگر اعتماد نکنید.
چگونه از فروش بیش از موجودی جلوگیری کنیم؟
موجودی باید در زمان ثبت سفارش دوباره بررسی شود. برای جلوگیری از تداخل درخواستهای همزمان، تراکنش دیتابیس و قفل مناسب روی رکورد محصول میتواند به حفظ یکپارچگی کمک کند.
آیا برای فروشگاه اینترنتی باید از درگاه پرداخت استفاده کرد؟
اگر قرار است پرداخت آنلاین انجام شود، باید درگاه مناسبی انتخاب و فرایند درخواست، Callback و تأیید پرداخت سمت سرور پیادهسازی شود. ثبت سفارش و تأیید پرداخت باید دو مرحله مستقل باشند.
تفاوت فروشگاه اینترنتی و Marketplace چیست؟
فروشگاه معمولی اغلب توسط یک کسبوکار اداره میشود، اما Marketplace امکان فعالیت چند فروشنده مستقل را فراهم میکند و به مدیریت مالکیت محصولات، کمیسیون و سفارشهای چندفروشندهای نیاز دارد.
منابع و مستندات رسمی
- مستندات Laravel 12: https://laravel.com/docs/12.x
- Eloquent ORM: https://laravel.com/docs/12.x/eloquent
- ارتباطهای Eloquent: https://laravel.com/docs/12.x/eloquent-relationships
- اعتبارسنجی: https://laravel.com/docs/12.x/validation
- احراز هویت: https://laravel.com/docs/12.x/authentication
- مجوزها: https://laravel.com/docs/12.x/authorization
- تراکنشهای دیتابیس: https://laravel.com/docs/12.x/database#database-transactions
- تستنویسی: https://laravel.com/docs/12.x/testing





