تمرين شامل للمرحلة الثانية—نظام CRUD الخلفي لـ ShopMetrics
يركز التمرين الشامل للمرحلة الثانية على "تسليم الوظائف الأساسية"—جمع كل المعارف من الدروس 8-13 لبناء نظام خلفي يعمل بالكامل.
1. ما ستتعلمه
- مجموعة بيانات ترحيل كاملة: أكثر من 10 جداول، تشمل المفاتيح الأجنبية والفهارس
- طبقة نموذج Eloquent: العلاقات + النطاقات + الدوال Accessor/Mutator
- متحكم الموارد CRUD: قائمة/إنشاء/تعديل/حذف مع التحقق
- تعبئة البيانات: أكثر من 100 مستأجر/مستخدم/طلب محاكاة
- كشف N+1 وتحسينها: Debugbar + تحليل الاستعلامات
2. قصة تسليم Bob للمرحلة الثانية
(1) نقطة الألم: الميزات الخلفية متفرقة عبر دروس مختلفة، مما يجعل من المستحيل تكوين منتج كامل
أكملت Alice جميع دروس المرحلة الثانية، لكن الترحيل في الدرس 8، والنموذج في الدرس 9، والتحقق في الدرس 12—كل درس يعمل بشكل جيد بمفرده، لكن عند الجمع بينهم، تظهر مشاكل: قيود المفاتيح الأجنبية غير متطابقة، وعلاقات النماذج مفقودة، وقواعد التحقق لا تتوافق مع أنواع أعمدة قاعدة البيانات.
(2) حلول التمارين الشاملة
يأخذك هذا الدرس خطوة بخطوة من تصميم قاعدة البيانات إلى صفحات CRUD، ناسجاً كل المعارف في نظام متكامل—حيث تبني كل خطوة على مخرجات الخطوة السابقة لضمان تقدم سلس.
# مخرج المرحلة الثانية: نظام إدارة CRUD كامل
php artisan migrate:fresh --seed
# → أكثر من 10 جداول، أكثر من 100 سجل، جميع عمليات CRUD تعمل
(3) العائد
بعد إكمال التمرين الشامل، أصبح لدى Alice نظام CRUD خلفي كامل الوظائف لـ ShopMetrics—من إنشاء الجداول إلى إدراج وتحديث وحذف والاستعلام عن البيانات، كل ذلك في خطوة واحدة.
3. تصميم قاعدة البيانات
(1) مخطط الكيان-العلاقة متعدد المستأجرين لـ ShopMetrics
erDiagram
Tenant ||--o{ User : "لديه العديد"
Tenant ||--o| Subscription : "لديه واحد"
Tenant ||--o{ Shop : "لديه العديد"
Plan ||--o{ Subscription : "لديه العديد"
Shop ||--o{ Product : "لديه العديد"
Shop ||--o{ Order : "لديه العديد"
User ||--o{ Order : "يُقدّم"
Product }o--o{ Category : "ينتمي للعديد"
Order ||--o{ OrderItem : "يحتوي"
Product ||--o{ OrderItem : "مُضمَّن في"
(2) قائمة الترحيل
| # | ملف الترحيل | اسم الجدول | الحقول الرئيسية |
|---|---|---|---|
| 1 | create_plans_table | plans | name, slug, price, shop_limit, features(json) |
| 2 | create_tenants_table | tenants | name, slug, domain, plan_id (FK), status |
| 3 | create_subscriptions_table | subscriptions | tenant_id (FK), plan_id (FK), stripe_id, status |
| 4 | create_users_table | users | tenant_id (FK), name, email, password, role |
| 5 | create_shops_table | shops | tenant_id (FK), name, slug, status, revenue |
| 6 | create_categories_table | categories | name, slug |
| 7 | create_products_table | products | shop_id (FK), name, sku, price, stock, is_active |
| 8 | create_category_product_table | category_product | category_id (FK), product_id (FK) |
| 9 | create_orders_table | orders | tenant_id (FK), shop_id (FK), user_id (FK), total, status |
| 10 | create_order_items_table | order_items | order_id (FK), product_id (FK), qty, price |
(1) ▶ مثال: ملف الترحيل الأساسي
// database/migrations/2024_01_01_000001_create_plans_table.php
Schema::create('plans', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->decimal('price', 8, 2);
$table->integer('shop_limit')->default(5);
$table->json('features')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
// database/migrations/2024_01_01_000005_create_shops_table.php
Schema::create('shops', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('slug');
$table->text('description')->nullable();
$table->enum('status', ['active', 'suspended', 'closed'])->default('active');
$table->decimal('revenue', 12, 2)->default(0);
$table->timestamps();
$table->softDeletes();
$table->unique(['tenant_id', 'slug']);
$table->index(['tenant_id', 'status']);
});
الناتج:
// تم التنفيذ بنجاح
4. طبقة نموذج Eloquent
(1) قائمة النماذج والعلاقات
| النموذج | العلاقة | النطاق | Accessor/Mutator |
|---|---|---|---|
| Tenant | shops(), users(), subscription() | active() | domain_url |
| Shop | products(), orders(), tenant() | active() | revenue_formatted |
| Product | categories(), shop() | active(), inStock() | price_formatted |
| Order | items(), shop(), user() | completed(), thisMonth() | status_badge |
| OrderItem | product(), order() | — | subtotal |
(1) ▶ مثال: النموذج الأساسي لـ ShopMetrics
// app/Models/Tenant.php
class Tenant extends Model
{
protected $fillable = ['name', 'slug', 'domain', 'plan_id', 'status'];
protected $casts = ['status' => 'string'];
public function shops(): HasMany
{
return $this->hasMany(Shop::class);
}
public function users(): HasMany
{
return $this->hasMany(User::class);
}
public function subscription(): HasOne
{
return $this->hasOne(Subscription::class)->where('status', 'active');
}
public function scopeActive(Builder $query): Builder
{
return $query->where('status', 'active');
}
public function getDomainUrlAttribute(): string
{
return 'https://' . $this->domain;
}
}
// app/Models/Shop.php
class Shop extends Model
{
use SoftDeletes;
protected $fillable = ['tenant_id', 'name', 'slug', 'description', 'status', 'revenue'];
protected $casts = ['revenue' => 'decimal:2', 'deleted_at' => 'datetime'];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function products(): HasMany
{
return $this->hasMany(Product::class);
}
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
public function scopeActive(Builder $query): Builder
{
return $query->where('status', 'active');
}
public function getRevenueFormattedAttribute(): string
{
return '$' . number_format($this->revenue, 2);
}
}
// app/Models/Product.php
class Product extends Model
{
protected $fillable = ['shop_id', 'name', 'sku', 'price', 'stock', 'is_active'];
protected $casts = ['price' => 'decimal:2', 'is_active' => 'boolean'];
public function shop(): BelongsTo
{
return $this->belongsTo(Shop::class);
}
public function categories(): BelongsToMany
{
return $this->belongsToMany(Category::class)->withTimestamps();
}
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
public function scopeInStock(Builder $query): Builder
{
return $query->where('stock', '>', 0);
}
public function getPriceFormattedAttribute(): string
{
return '$' . number_format($this->price, 2);
}
}
الناتج:
// تم التنفيذ بنجاح
5. متحكم الموارد CRUD
(1) قائمة المتحكمات وفئات التحقق
| المتحكم | الطرق | طلب النموذج |
|---|---|---|
| ShopController | index/create/store/show/edit/update/destroy | StoreShopRequest/UpdateShopRequest |
| ProductController | index/create/store/show/edit/update/destroy | StoreProductRequest/UpdateProductRequest |
| OrderController | index/show/update | UpdateOrderRequest |
(1) ▶ مثال: CRUD كامل لـ ShopController
// app/Http/Controllers/ShopController.php
class ShopController extends Controller
{
public function __construct()
{
$this->middleware('auth');
$this->middleware('tenant.resolve');
}
public function index(Request $request): View
{
$shops = Shop::where('tenant_id', tenant()->id)
->withCount(['products', 'orders as recent_orders' => fn ($q) => $q->where('created_at', '>=', now()->subDays(30))])
->when($request->search, fn ($q, $s) => $q->where('name', 'like', "%{$s}%"))
->when($request->status, fn ($q, $s) => $q->where('status', $s))
->orderBy($request->sort ?? 'created_at', $request->direction ?? 'desc')
->paginate(15);
return view('shops.index', compact('shops'));
}
public function create(): View
{
return view('shops.create');
}
public function store(StoreShopRequest $request): RedirectResponse
{
$shop = Shop::create(array_merge(
$request->validated(),
['tenant_id' => tenant()->id],
));
return redirect()->route('shops.show', $shop)
->with('success', 'Shop created successfully.');
}
public function show(Shop $shop): View
{
$shop->load(['products' => fn ($q) => $q->active()->latest()->take(10),
'orders' => fn ($q) => $q->with('user')->latest()->take(10)]);
$stats = [
'total_revenue' => $shop->orders()->completed()->sum('total'),
'total_orders' => $shop->orders()->count(),
'total_products' => $shop->products()->active()->count(),
];
return view('shops.show', compact('shop', 'stats'));
}
public function edit(Shop $shop): View
{
return view('shops.edit', compact('shop'));
}
public function update(UpdateShopRequest $request, Shop $shop): RedirectResponse
{
$shop->update($request->validated());
return redirect()->route('shops.show', $shop)
->with('success', 'Shop updated.');
}
public function destroy(Shop $shop): RedirectResponse
{
$shop->delete();
return redirect()->route('shops.index')
->with('success', 'Shop deleted.');
}
}
الناتج:
// تم التنفيذ بنجاح
6. تنفيذ فئات التحقق
(1) ▶ مثال: فئة طلب النموذج لـ ShopMetrics
// app/Http/Requests/StoreShopRequest.php
class StoreShopRequest extends FormRequest
{
public function authorize(): bool
{
return auth()->user()->role === 'tenant_owner';
}
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'slug' => 'required|alpha_dash|unique:shops,slug,NULL,id,tenant_id,' . tenant()->id,
'description' => 'nullable|string|max:5000',
'status' => 'sometimes|in:active,suspended',
];
}
protected function prepareForValidation(): void
{
$this->merge(['slug' => Str::slug($this->slug ?? $this->name)]);
}
}
// app/Http/Requests/StoreProductRequest.php
class StoreProductRequest extends FormRequest
{
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'sku' => 'required|string|unique:products,sku',
'price' => 'required|numeric|min:0.01|max:999999.99',
'stock' => 'required|integer|min:0',
'description' => 'nullable|string|max:5000',
'is_active' => 'boolean',
'categories' => 'sometimes|array',
'categories.*' => 'exists:categories,id',
'image' => 'sometimes|image|max:2048',
];
}
}
الناتج:
// تم التنفيذ بنجاح
7. تعبئة البيانات
(1) ▶ مثال: بادئ بيانات ShopMetrics الكامل
// database/seeders/ShopMetricsSeeder.php
class ShopMetricsSeeder extends Seeder
{
public function run(): void
{
$starter = Plan::create(['name' => 'Starter', 'slug' => 'starter', 'price' => 29, 'shop_limit' => 5, 'features' => ['basic_analytics']]);
$pro = Plan::create(['name' => 'Pro', 'slug' => 'pro', 'price' => 79, 'shop_limit' => 25, 'features' => ['advanced_analytics', 'api']]);
$enterprise = Plan::create(['name' => 'Enterprise', 'slug' => 'enterprise', 'price' => 199, 'shop_limit' => null, 'features' => ['all']]);
User::factory()->create(['email' => 'admin@shopmetrics.io', 'role' => 'super_admin']);
Category::factory()->count(10)->create();
Tenant::factory()->count(20)->create()->each(function ($tenant) use ($starter, $pro, $enterprise) {
$plan = fake()->randomElement([$starter, $pro, $enterprise]);
Subscription::create(['tenant_id' => $tenant->id, 'plan_id' => $plan->id, 'status' => 'active']);
$owner = User::factory()->for($tenant)->create(['role' => 'tenant_owner']);
$analysts = User::factory()->count(3)->for($tenant)->create(['role' => 'analyst']);
$shopCount = min(fake()->numberBetween(2, 5), $plan->shop_limit ?? 99);
Shop::factory()->count($shopCount)->for($tenant)->create()->each(function ($shop) use ($tenant, $owner, $analysts) {
$products = Product::factory()->count(fake()->numberBetween(10, 30))->for($shop)->create();
$products->each(fn ($p) => $p->categories()->attach(Category::inRandomOrder()->take(rand(1, 3))->pluck('id')));
Order::factory()->count(fake()->numberBetween(20, 60))->for($tenant)->for($shop)->for(fake()->randomElement(array_merge([$owner], $analysts->all())))->create()->each(function ($order) use ($products) {
$items = $products->random(rand(1, 5));
foreach ($items as $product) {
$qty = rand(1, 3);
$order->items()->create(['product_id' => $product->id, 'quantity' => $qty, 'price' => $product->price]);
}
$order->update(['total' => $order->items->sum(fn ($i) => $i->price * $i->quantity)]);
});
});
});
}
}
الناتج:
// تم التنفيذ بنجاح
8. كشف N+1 وتحسينها
(1) تثبيت Debugbar
composer require barryvdh/laravel-debugbar --dev
(2) سيناريوهات N+1 الشائعة وحلولها
| السيناريو | مشكلة N+1 | الحل |
|---|---|---|
| قائمة المتاجر | $shop->products->count() |
withCount('products') |
| قائمة الطلبات | $order->user->name |
with('user') |
| تفاصيل المنتج | $product->categories |
with('categories') |
| لوحة التحكم | الوصول المتزامن لعدة علاقات | with(['user', 'shop.items']) |
(1) ▶ مثال: تحليل الاستعلامات بـ Debugbar
// في بيئة التطوير، يعرض Debugbar عدد الاستعلامات في كل صفحة
// تحسين N+1 خطوة بخطوة:
// الخطوة 1: التحديد — يعرض Debugbar "41 استعلام" في shops.index
// الخطوة 2: إضافة التحميل المسبق
$shops = Shop::with(['products', 'tenant', 'orders'])->get(); // 4 استعلامات
// الخطوة 3: استخدام withCount بدلاً من تحميل العلاقات الكاملة
$shops = Shop::withCount('products', 'orders')->with('tenant')->get(); // 3 استعلامات
// الخطوة 4: التحقق — يعرض Debugbar "3 استعلامات"
الناتج:
// تم التنفيذ بنجاح
9. مثال شامل: نظام CRUD الخلفي لـ ShopMetrics
// ============================================
// شامل: إعداد إدارة ShopMetrics
// يغطي: الترحيل، النماذج، المتحكمات، الطلبات، بادئات البيانات
// ============================================
// أوامر الإعداد (نفّذ بالترتيب)
// 1. php artisan migrate:fresh --seed
// 2. php artisan storage:link
// 3. php artisan serve
// routes/web.php — مسارات الإدارة الكاملة
Route::middleware('auth')->prefix('dashboard')->name('dashboard.')->group(function () {
Route::get('/', [DashboardController::class, 'index'])->name('index');
// متاجر CRUD
Route::resource('shops', ShopController::class);
Route::post('/shops/{shop}/logo', [ShopLogoController::class, 'update'])->name('shops.logo');
// منتجات CRUD (متداخلة تحت المتجر)
Route::resource('shops.products', ProductController::class)->shallow();
// الطلبات (قراءة فقط + تحديث الحالة)
Route::resource('orders', OrderController::class)->only(['index', 'show', 'update']);
Route::post('/orders/{order}/export', ExportOrdersController::class)->name('orders.export');
// الفئات (CRUD بسيط)
Route::resource('categories', CategoryController::class)->except('show');
// التحليلات
Route::get('/analytics', [AnalyticsController::class, 'index'])->name('analytics');
});
// DashboardController مع استعلامات محسّنة
class DashboardController extends Controller
{
public function index(): View
{
$tenant = tenant();
$stats = Cache::remember("dashboard.{$tenant->id}", 300, function () use ($tenant) {
return [
'total_revenue' => $tenant->shops()->sum('revenue'),
'active_shops' => $tenant->shops()->active()->count(),
'monthly_orders' => $tenant->orders()->whereBetween('created_at', [now()->startOfMonth(), now()->endOfMonth()])->count(),
'total_products' => Product::whereHas('shop', fn ($q) => $q->where('tenant_id', $tenant->id))->active()->count(),
];
});
$recentOrders = Order::where('tenant_id', $tenant->id)
->with(['shop', 'user'])
->latest()
->take(5)
->get();
return view('dashboard.index', compact('stats', 'recentOrders'));
}
}
❓ أسئلة شائعة
php artisan migrate:status للتحقق من الترتيب.<x-input>، <x-select>)، واستخدم artisan make:controller --resource لإنشاء الكود الأساسي. التكرار طبيعي؛ تأكد من أن الوظائف تعمل بشكل صحيح قبل التفكير في التجريد.with(['user', 'shop']) يجب أن يكون 3 استعلامات. إذا كان هناك أكثر من المتوقع، تحقق من أي تحميلات كسر متروكة.📖 ملخص
- عند تصميم قاعدة البيانات، ابدأ برسم مخطط ER، ثم اكتب الترحيلات لضمان ترتيب صحيح لتبعيات المفاتيح الأجنبية
- نماذج Eloquent تحدد ثلاثة مستويات من الوظائف: العلاقات، النطاقات، والدوال Accessor/Mutator
- طلب النموذج يغلف التحقق والتفويض، مما يبقي المتحكم بسيطاً
- تُعبأ البيانات بالترتيب التالي: Plan ← Tenant ← User ← Shop ← Product ← Order
- حسّن استعلامات N+1 باستخدام
with()وwithCount()؛ استخدم Debugbar لمراقبة عدد الاستعلامات - يبدأ نظام CRUD بمتحكم الموارد ويُضاف تدريجياً التصفية والبحث والتقسيم
📝 تمارين
-
مهمة أساسية (⭐): أكمل ترحيل الجداول العشرة في لوحة إدارة ShopMetrics وحدد النماذج. شغّل
migrate:fresh --seedللتحقق من إنشاء جميع البيانات بشكل صحيح وعدم وجود أخطاء في المفاتيح الأجنبية. -
تمرين متقدم (⭐⭐): نفِّذ وظائف CRUD كاملة لـ Shop و Product (بما في ذلك التحقق من طلب النموذج، تصفية البحث، والتقسيم)، مع التأكد من أن Debugbar لا يعرض أكثر من 5 استعلامات لكل صفحة.
-
تحدي (⭐⭐⭐): نفِّذ تجميع بيانات محسّن بالتخزين المؤقت للوحة التحكم—خزّن استعلامات الإحصائيات مؤقتاً لمدة 5 دقائق، وامسح التخزين المؤقت تلقائياً عبر أحداث النموذج عند استلام طلب جديد لضمان توازن بين أداء البيانات في الوقت الفعلي وأداء النظام العام.



