موارد API في Laravel وتطوير واجهات RESTful
موارد API هي "غلاف البيانات" في Laravel — تتحكم في الحقول المكشوفة للعملاء، وكيفية تنسيقها، وكيفية تداخل العلاقات، مما يضمن أن مخرجات API تتبع معيارًا موحدًا.
1. ما ستتعلمه
- فئة Resource وResourceCollection: تحويل البيانات وتنسيقها
- الموارد المرتبطة المتداخلة: التحميل المشروط للموارد المرتبطة عبر
whenLoaded() - تغليف التقسيم والتصفية والفرز لموارد API
- مواصفات تصميم واجهة RESTful: URIs، والأفعال، ورموز الحالة، وتنسيقات الأخطاء
- التحكم في إصدارات API: مجموعات مسارات v1/v2 ووراثة الموارد
2. قصة حقيقية لمطور واجهة أمامية
(1) مشكلة: تنسيق JSON الذي تعيده API يتسبب في تعطل الواجهة الأمامية
واجهت Alice كابوسًا أثناء التكامل مع API لـ ShopMetrics — أعاد /shops النتيجة {shops: [...]}، لكن /orders أعاد {data: [...]}، و/products أعاد مصفوفة ببساطة. أسماء الحقول لم تكن متسقة أيضًا: بعضها استخدم created_at، وبعضها استخدم createdAt، وأخرى استخدمت createdDate. كما تم كشف كلمات المرور والمعرفات الداخلية في JSON. كان هناك كود تكيف في الواجهة الأمامية أكثر من منطق الأعمال.
(2) حل موارد API
تتحكم موارد API بشكل موحد في تنسيق مخرجات JSON — مما يضمن أسماء حقول متسقة، وإخفاء الحقول الحساسة، وتداخل شروط الارتباط، والالتزام بتنسيقات التقسيم القياسية.
// app/Http/Resources/ShopResource.php
class ShopResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'status' => $this->status,
'products' => ProductResource::collection($this->whenLoaded('products')),
'created_at' => $this->created_at->toISOString(),
];
}
}
(3) العائد
بعد أن بدأت Alice في استخدام موارد API، أصبحت جميع نقاط النهاية موحدة، وتم تقليل كود التكيف في الواجهة الأمامية بنسبة 80%، وتم إخفاء الحقول الحساسة مثل كلمات المرور تلقائيًا.
3. فئة Resource وResourceCollection
(1) إنشاء Resource
php artisan make:resource ShopResource
php artisan make:resource ProductResource
php artisan make:resource OrderResource
# ينشئ: app/Http/Resources/ShopResource.php
(2) فئة Resource (سجل واحد)
// app/Http/Resources/ShopResource.php
class ShopResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'description' => $this->description,
'status' => $this->status,
'revenue' => (float) $this->revenue,
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
];
}
}
// الاستخدام — مورد واحد
return new ShopResource($shop);
// {"data": {"id": 1, "name": "Alice Store", ...}}
(3) ResourceCollection (سجلات متعددة)
// استخدام مجموعة الموارد
return ShopResource::collection($shops);
// {"data": [...], "links": {...}, "meta": {...}}
// مجموعة مخصصة
php artisan make:resource ShopCollection
class ShopCollection extends ResourceCollection
{
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
'meta' => [
'total_shops' => $this->collection->count(),
],
];
}
}
| النوع | تنسيق الإرجاع | مناسب لـ |
|---|---|---|
new Resource($model) |
{data: {...}} |
سجل واحد |
Resource::collection($models) |
{data: [...], links, meta} |
قائمة + تقسيم |
CustomCollection |
تنسيق مخصص | يتطلب meta إضافي |
(1) ▶ مثال:ShopResource لـ ShopMetrics
// app/Http/Resources/ShopResource.php
class ShopResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'description' => $this->whenNotNull($this->description),
'status' => $this->status,
'revenue' => [
'raw' => (float) $this->revenue,
'formatted' => $this->revenue_formatted,
],
'products_count' => $this->whenCounted('products'),
'orders_count' => $this->whenCounted('orders'),
'products' => ProductResource::collection($this->whenLoaded('products')),
'latest_order' => new OrderResource($this->whenLoaded('latestOrder')),
'links' => [
'self' => route('api.v1.shops.show', $this->id),
'products' => route('api.v1.products.index', ['shop_id' => $this->id]),
],
'created_at' => $this->created_at->toISOString(),
];
}
}
الناتج:
// التنفيذ ناجح
4. الموارد المرتبطة المتداخلة
(1) التحميل المشروط عبر whenLoaded()
// تضمين العلاقة فقط إذا تم تحميلها مسبقًا
'products' => ProductResource::collection($this->whenLoaded('products')),
// إذا لم يتم تحميل المنتجات مع with()، فإن هذا يعيد null ويتم حذفه
// Shop::find(1) → لا يوجد مفتاح products في JSON
// Shop::with('products')->find(1) → يتم تضمين المنتجات
// علاقة واحدة
'user' => new UserResource($this->whenLoaded('user')),
// العدد فقط (بدون بيانات)
'products_count' => $this->whenCounted('products'),
(2) تعريف الموارد المتداخلة
// app/Http/Resources/OrderResource.php
class OrderResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'order_number' => $this->order_number,
'status' => $this->status,
'total' => (float) $this->total,
'items' => OrderItemResource::collection($this->whenLoaded('items')),
'shop' => new ShopBriefResource($this->whenLoaded('shop')),
'user' => new UserBriefResource($this->whenLoaded('user')),
'created_at' => $this->created_at->toISOString(),
];
}
}
// مورد مختصر — بيانات حد أدنى للتداخل
class ShopBriefResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
];
}
}
| الطريقة | الوصف | كيفية تجنب المشاكل |
|---|---|---|
whenLoaded() |
الإخراج فقط بعد تحميل الانضمامات | تجنب N+1 مع التحميل الكسول |
whenCounted() |
الإخراج فقط بعد العد | تجنب الاستعلامات الإضافية |
whenNotNull() |
الإخراج فقط إذا لم يكن null | تنظيف الحقول الفارغة |
when() |
إخراج مشروط | تحكم مرن |
| مورد مختصر | استخدام النسخة الخفيفة للتداخل | تجنب الحلقات المتداخلة |
(1) ▶ مثال:الموارد المتداخلة لـ ShopMetrics
// app/Http/Resources/OrderItemResource.php
class OrderItemResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'product' => new ProductBriefResource($this->whenLoaded('product')),
'quantity' => $this->quantity,
'unit_price' => (float) $this->price,
'subtotal' => (float) ($this->price * $this->quantity),
];
}
}
// app/Http/Resources/ProductBriefResource.php
class ProductBriefResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'sku' => $this->sku,
];
}
}
الناتج:
// التنفيذ ناجح
5. مواصفات تصميم واجهة RESTful API
(1) URIs وأفعال HTTP
| العملية | HTTP | URI | الوصف |
|---|---|---|---|
| قائمة | GET | /api/v1/shops | يعيد مجموعة |
| إنشاء | POST | /api/v1/shops | إنشاء مورد |
| تفاصيل | GET | /api/v1/shops/{id} | يعيد سجلاً واحدًا |
| تحديث كامل | PUT | /api/v1/shops/{id} | استبدال المورد |
| تحديث جزئي | PATCH | /api/v1/shops/{id} | تعديل حقول |
| حذف | DELETE | /api/v1/shops/{id} | حذف المورد |
(2) رموز حالة HTTP
| رمز الحالة | المعنى | حالة الاستخدام |
|---|---|---|
| 200 | OK | استجابة ناجحة |
| 201 | Created | تم إنشاء المورد بنجاح |
| 204 | No Content | تم الحذف بنجاح |
| 400 | Bad Request | تنسيق طلب غير صالح |
| 401 | Unauthorized | غير مصدق |
| 403 | Forbidden | بدون صلاحية |
| 404 | Not Found | المورد غير موجود |
| 422 | Unprocessable Entity | فشل التحقق |
| 429 | Too Many Requests | تحديد المعدل |
| 500 | Internal Server Error | خطأ في الخادم |
(3) تنسيق استجابة الخطأ
{
"success": false,
"message": "Validation failed.",
"errors": {
"name": ["The name field is required."],
"email": ["The email must be a valid email address."]
}
}
(1) ▶ مثال:تصميم نقطة نهاية RESTful API لـ ShopMetrics
// routes/api.php
Route::prefix('v1')->middleware('auth:sanctum')->group(function () {
// المتاجر
Route::apiResource('shops', Api\V1\ShopController::class);
Route::get('shops/{shop}/analytics', [Api\V1\ShopAnalyticsController::class, 'show']);
// المنتجات (متداخلة ثم سطحية)
Route::apiResource('shops.products', Api\V1\ProductController::class)->shallow();
// الطلبات
Route::apiResource('orders', Api\V1\OrderController::class)->only(['index', 'show', 'update']);
Route::post('orders/{order}/cancel', [Api\V1\OrderController::class, 'cancel']);
// الفئات
Route::apiResource('categories', Api\V1\CategoryController::class)->only(['index', 'show']);
// التحليلات
Route::get('analytics/overview', [Api\V1\AnalyticsController::class, 'overview']);
});
الناتج:
// التنفيذ ناجح
6. تغليف التصفية والفرز والتقسيم
(1) فئة أساسية لتصفية الاستعلامات
// app/Filters/QueryFilter.php
abstract class QueryFilter
{
public function __construct(protected Request $request) {}
public function apply(Builder $query): Builder
{
foreach ($this->filters() as $filter => $value) {
if (method_exists($this, $filter) && $value !== null) {
$this->$filter($query, $value);
}
}
return $query;
}
protected function filters(): array
{
return $this->request->all();
}
}
(2) فلتر محدد
// app/Filters/ShopFilter.php
class ShopFilter extends QueryFilter
{
public function search(Builder $query, string $value): Builder
{
return $query->where('name', 'like', "%{$value}%");
}
public function status(Builder $query, string $value): Builder
{
return $query->where('status', $value);
}
public function min_revenue(Builder $query, float $value): Builder
{
return $query->where('revenue', '>=', $value);
}
public function sort(Builder $query, string $value): Builder
{
$direction = str_starts_with($value, '-') ? 'desc' : 'asc';
$field = ltrim($value, '-');
return $query->orderBy($field, $direction);
}
}
(1) ▶ مثال:نقاط نهاية API لـ ShopMetrics مع الفلاتر
// app/Http/Controllers/Api/V1/ShopController.php
class ShopController extends Controller
{
public function index(ShopFilter $filter): JsonResponse
{
$shops = Shop::where('tenant_id', tenant()->id)
->filter($filter)
->withCount(['products', 'orders'])
->paginate(request()->integer('per_page', 15));
return ShopResource::collection($shops);
}
public function store(StoreShopRequest $request): JsonResponse
{
$shop = Shop::create(array_merge($request->validated(), ['tenant_id' => tenant()->id]));
return response()->json([
'message' => 'Shop created.',
'data' => new ShopResource($shop),
], 201);
}
public function show(Shop $shop): JsonResponse
{
$shop->load(['products' => fn ($q) => $q->active()->latest()->take(10)]);
return new ShopResource($shop);
}
public function update(UpdateShopRequest $request, Shop $shop): JsonResponse
{
$shop->update($request->validated());
return new ShopResource($shop);
}
public function destroy(Shop $shop): Response
{
$shop->delete();
return response()->noContent();
}
}
الناتج:
// التنفيذ ناجح
7. التحكم في إصدارات API
(1) إصدار مجموعة المسارات
// routes/api.php
Route::prefix('v1')->group(function () {
Route::apiResource('shops', Api\V1\ShopController::class);
});
Route::prefix('v2')->group(function () {
Route::apiResource('shops', Api\V2\ShopController::class);
});
(2) وراثة الموارد
// V1 ShopResource
namespace App\Http\Resources\Api\V1;
class ShopResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'status' => $this->status,
];
}
}
// V2 ShopResource — يرث ويضيف حقولاً
namespace App\Http\Resources\Api\V2;
class ShopResource extends \App\Http\Resources\Api\V1\ShopResource
{
public function toArray(Request $request): array
{
return array_merge(parent::toArray($request), [
'revenue' => (float) $this->revenue,
'products_count' => $this->whenCounted('products'),
'links' => [
'self' => route('api.v2.shops.show', $this->id),
],
]);
}
}
| استراتيجية الإصدار | النهج | الإيجابيات والسلبيات |
|---|---|---|
| بادئة URL | /api/v1/، /api/v2/ |
✅ بسيطة وواضحة |
| رأس | Accept: application/vnd.api.v2+json |
أكثر توافقًا مع REST لكن أكثر تعقيدًا |
| استعلام | ?version=2 |
غير موصى به، ليس RESTful |
(1) ▶ مثال:مسارات إصدارات API لـ ShopMetrics
// routes/api.php
Route::prefix('v1')->middleware('auth:sanctum')->group(function () {
Route::apiResource('shops', Api\V1\ShopController::class);
Route::apiResource('products', Api\V1\ProductController::class);
Route::apiResource('orders', Api\V1\OrderController::class)->only(['index', 'show']);
});
Route::prefix('v2')->middleware('auth:sanctum')->group(function () {
Route::apiResource('shops', Api\V2\ShopController::class);
Route::apiResource('products', Api\V2\ProductController::class);
Route::apiResource('orders', Api\V2\OrderController::class);
// V2 يضيف CRUD كامل للطلبات + التحليلات
Route::get('analytics', [Api\V2\AnalyticsController::class, 'overview']);
});
الناتج:
// التنفيذ ناجح
8. مثال شامل: العملية الكاملة لموارد API لـ ShopMetrics
// ============================================
// شامل: موارد API لـ ShopMetrics
// يغطي: الموارد، العلاقات، التصفية، التقسيم، الإصدارات
// ============================================
// app/Http/Resources/Api/V1/OrderResource.php
class OrderResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'order_number' => $this->order_number,
'status' => $this->status,
'subtotal' => (float) $this->subtotal,
'discount' => (float) $this->discount,
'total' => (float) $this->total,
'items_count' => $this->whenCounted('items'),
'items' => OrderItemResource::collection($this->whenLoaded('items')),
'shop' => new ShopBriefResource($this->whenLoaded('shop')),
'user' => [
'id' => $this->whenLoaded('user')?->id,
'name' => $this->whenLoaded('user')?->name,
],
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
];
}
}
// وحدة تحكم مع خط أنابيب كامل
class OrderController extends Controller
{
public function index(OrderFilter $filter): JsonResponse
{
$orders = Order::where('tenant_id', tenant()->id)
->filter($filter)
->with(['shop', 'user'])
->withCount('items')
->latest()
->paginate(request()->integer('per_page', 15));
return OrderResource::collection($orders);
}
public function show(Order $order): JsonResponse
{
$order->load(['items.product', 'shop', 'user']);
return new OrderResource($order);
}
public function update(UpdateOrderRequest $request, Order $order): JsonResponse
{
$order->update($request->validated());
return new OrderResource($order->fresh()->load('items.product'));
}
}
❓ أسئلة شائعة
Resource وإرجاع JSON النموذج مباشرة؟Resource يسمح بالتحكم الدقيق في حقول الإخراج، والقيم المنسقة، والعلاقات المتداخلة. يجب أن تستخدم واجهات API Resource.whenLoaded والوصول المباشر إلى العلاقة؟whenLoaded يُخرج العلاقة فقط إذا تم تحميلها مسبقًا؛ إذا لم يتم تحميلها، يعيد null ويتم إزالته تلقائيًا من JSON.paginationResponse() الخاصة بـ JsonResource::collection() لتخصيص تنسيق التقسيم.$request->user() أو auth()->user(). تقبل طريقة toArray() في Resource معامل Request، مما يسمح لك بتحديد الحقول المراد إخراجها بناءً على صلاحيات المستخدم.$this->when() في Full Resource لإخراج الحقول ديناميكيًا بناءً على السياق.📖 ملخص
- مورد API: التحكم في كشف الحقول والتنسيق وتداخل العلاقات
- استخدام
whenLoaded()للتحميل المشروط للعلاقات لتجنب التحميل الكسول N+1 - مورد مختصر يُستخدم في المشاهد المتداخلة لتجنب المراجع الدائرية
- واجهات RESTful API تتبع مواصفات URI + فعل HTTP + رمز الحالة
- QueryFilter يغلف منطق التصفية والفرز، مما يبقي وحدة التحكم نظيفة
- إصدارات API تُشار إليها ببادئات URL (/v1/، /v2/)، ويمكن وراثة الموارد وإعادة استخدامها
📝 تمارين
-
تمرين أساسي (⭐): أنشئ ثلاث فئات موارد — ShopResource وProductResource وOrderResource — لـ ShopMetrics. في وحدة التحكم، استبدل الإرجاع المباشر للنموذج لضمان تنسيق JSON متسق.
-
تمرين متقدم (⭐⭐): نفذ فلتر استعلام ShopFilter لدعم معاملات البحث والحالة وmin_revenue والفرز. استخدمه في ShopController::index واختبر وظيفة التصفية باستخدام Postman.
-
تحدي (⭐⭐⭐): صمم إصدارين من API، V1 وV2 — V1 يعيد الحقول الأساسية فقط، بينما V2 يعيد إضافيًا الإيرادات وproducts_count والروابط. نفذ ذلك باستخدام وراثة الموارد، مع التأكد من أن نقطة نهاية V1 لا تكسر العملاء الحاليين.



