العودة للمدونة
laravelنُشر في August 18, 2026 · 150 دقيقة قراءة

دليل Laravel 13 المتقدم لبناء API بأنماط احترافية

دليل Laravel 13 متقدم لتعلّم تعدد الإصدارات والمصادقة والفلترة والتفويض والاختبار وتوثيق API.

دليل Laravel 13 المتقدم لبناء API بأنماط احترافية

دليل Laravel 13 المتقدم لبناء API

ينقل هذا الدليل Laravel API من أول استجابة JSON إلى خدمة متعددة الإصدارات تدعم المصادقة والفلترة والتفويض والاختبار والتوثيق. ينصب التركيز على القرارات والتنفيذ الكامل اللازمين لبناء عقد API مستقر، وليس على خصائص متفرقة في إطار العمل.

نطاق الدليل: هذا دليل تعليمي تطبيقي لأنماط Laravel API المتقدمة. يشرح كيف تعمل أجزاء إطار العمل معًا، ولا يفرض بنية واحدة تصلح لكل التطبيقات.

نُظم الدليل كمرجع تقني متدرج. يعالج كل قسم جانباً من جوانب تصميم API، ويعرض المحتوى الكامل لكل ملف تطبيق أُنشئ أو تغير في تلك المرحلة. يستخدم التنفيذ Laravel 13 وPHP 8.3 حتى تبقى جميع الأمثلة متوافقة مع بعضها.

ملاحظة Laravel 13: يتطلب Laravel 13 إصدار PHP 8.3 أو أحدث. يسجل هيكل التطبيق الحديث مسارات API ومعالجة الاستثناءات في bootstrap/app.php؛ لذلك استُبدلت أنماط RouteServiceProvider وAuthServiceProvider وapp/Exceptions/Handler.php القديمة في هذا الدليل.

الإصدارات المختبرة: Laravel 13.26.1 وPHP 8.3+ وSanctum 4.3.3 وScribe 5.11.0. وهي الإصدارات المثبّتة في ملف القفل للمشروع المرجعي.

المتطلبات والإعداد الأولي

  • PHP 8.3 أو أحدث، وComposer، وMySQL، وGit
  • معرفة أساسية بمسارات Laravel والمتحكمات وEloquent والترحيلات والتحقق
  • Postman أو أي عميل HTTP آخر
composer create-project laravel/laravel:^13.0 orders-hub
cd orders-hub
php artisan install:api
php artisan migrate

اضبط قاعدة بيانات MySQL باسم orders_hub في .env، ثم طبّق الملفات المعروضة قسماً بعد قسم.

يأتي Laravel 13 بمتحكم أساسي مصغر. ولأن الأقسام اللاحقة تستخدم دوال التفويض داخل المتحكمات، فعّل Traits المطلوبة من البداية:

app/Http/Controllers/Controller.php

<?php

namespace App\Http\Controllers;

use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests;

abstract class Controller
{
    use AuthorizesRequests, ValidatesRequests;
}

خارطة الدليل

  1. استجابات JSON موحدة وحالات HTTP صحيحة
  2. اختبار API باستخدام Postman
  3. تصميم عناوين قائمة على الموارد
  4. هيكلة API متعدد الإصدارات
  5. المصادقة بالرموز باستخدام Laravel Sanctum
  6. إلغاء الرموز وتسجيل الخروج الآمن
  7. تصميم استجابات مستقرة
  8. الحقول والعلاقات الشرطية
  9. تحميل العلاقات عند الطلب
  10. فلاتر استعلام قابلة لإعادة الاستخدام
  11. الموارد المتداخلة وفلترة العلاقات
  12. ترتيب آمن يتحكم به العميل
  13. إنشاء الموارد باستخدام POST
  14. حذف الموارد باستخدام DELETE
  15. الاستبدال الكامل باستخدام PUT
  16. التحديث الجزئي باستخدام PATCH
  17. تفويض الموارد باستخدام Policies
  18. التحكم في الوصول بصلاحيات Token
  19. صلاحيات دقيقة على مستوى الحقول
  20. إدارة طلبيات العملاء
  21. إدارة المستخدمين بأمان
  22. تطبيق مبدأ الحد الأدنى من الصلاحيات
  23. معالجة موحدة لأخطاء API
  24. إنشاء توثيق API باستخدام Scribe
  25. استخدام تنسيق واحد لجميع الاستجابات
  26. اختبار تنسيق الاستجابة
  27. قائمة ما بعد الدليل

كيف تقرأ هذا الدليل

كل قسم يذكر ما ستحصل عليه، ثم يولّد الأصناف، ثم يعرض الكود، وينتهي بطلب يمكنك تنفيذه.

الملفات الجديدة تُعرض كاملة، والملفات التي رأيتها من قبل تظهر على هيئة diff: + سطر مضاف و- سطر محذوف.

التحقق من كل نقطة

php artisan migrate:fresh --seed
php artisan route:list --path=api
php artisan serve

أرسل دائماً Accept: application/json. للمسارات المحمية، سجّل الدخول أولاً ثم أرسل رمز Sanctum بالشكل Authorization: Bearer YOUR_TOKEN.

1. استجابات JSON موحدة وحالات HTTP صحيحة

في الأساس الأول لبناء API سنبدأ بتطبيق Laravel جديد، ثم نضيف مسار API بسيطاً يعيد استجابة JSON منظمة مع حالة HTTP الصحيحة 200 OK.

يستهدف التنفيذ Laravel 13 وPHP بإصدار ^8.3 وSanctum بإصدار ^4.3.

ما الذي سنبنيه؟

عند إرسال طلب GET /api/login سنحصل على:

{
  "message": "Hello, Login!",
  "status": 200
}

محتوى الاستجابة بصيغة JSON، وحالة استجابة HTTP الفعلية هي أيضاً 200.

1. إنشاء المشروع

إذا أردت تنفيذ الدليل من مشروع نظيف، أنشئ تطبيق Laravel 13:

composer create-project laravel/laravel:^13.0 orders-hub
cd orders-hub
php artisan install:api

يستخدم المشروع المرجعي قاعدة بيانات MySQL باسم orders_hub. حدّث ملف .env ببيانات جهازك:

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=orders_hub
DB_USERNAME=root
DB_PASSWORD=

لا يتعامل هذا التنفيذ الأولي مع قاعدة البيانات بعد، ولذلك سيعمل المسار قبل الحاجة إلى تشغيل migrations.

2. إنشاء Trait للاستجابات

أنشئ الـTrait عبر Artisan:

php artisan make:trait Traits/ApiResponses

أبقِ البادئة Traits/. فبدونها يضع make:trait الملف داخل app/ مباشرة، لأن المجلدين app/Concerns وapp/Traits غير موجودين في مشروع جديد.

هذا هو محتوى الملف كاملاً.

app/Traits/ApiResponses.php

<?php

namespace App\Traits;

use Illuminate\Http\JsonResponse;

trait ApiResponses
{
    /**
     * Return a successful response.
     */
    protected function ok(string $message): JsonResponse
    {
        return $this->success($message, 200);
    }

    /**
     * Return a success response whose payload status matches the HTTP status.
     */
    protected function success(string $message, int $statusCode = 200): JsonResponse
    {
        return response()->json([
            'message' => $message,
            'status' => $statusCode
        ], $statusCode);
    }
}

الدالة ok() اختصار لاستجابة ناجحة بحالة 200، وهي تستدعي success() التي تنشئ محتوى JSON وترسل رمز الحالة نفسه إلى Laravel.

المعامل الثاني في response()->json() مهم؛ فمن دونه قد يحتوي جسم الاستجابة على الرقم 200 بينما تكون حالة HTTP مختلفة. جمع القيمتين في الدالة نفسها يحافظ على اتساق الاستجابة.

3. إنشاء متحكم المصادقة

أنشئ المتحكم:

php artisan make:controller AuthController

استبدل محتواه بكود التنفيذ الكامل.

app/Http/Controllers/AuthController.php

<?php

namespace App\Http\Controllers;

use App\Traits\ApiResponses;
use Illuminate\Http\JsonResponse;

class AuthController extends Controller
{
    use ApiResponses;

    /**
     * Authenticate the user.
     */
    public function login(): JsonResponse
    {
        return $this->ok('Hello, Login!');
    }
}

يستورد المتحكم ApiResponses ويستخدمه، فتكون دوال الـTrait المحمية متاحة داخل المتحكم. وتُصرّح الدالة login() بنوع الإرجاع JsonResponse، وهو ما يوثّق عقد الدالة دون الحاجة إلى أي تعليق إضافي.

4. تسجيل مسار API

هذا هو ملف مسارات API الكامل في هذه المرحلة.

routes/api.php

<?php

use App\Http\Controllers\AuthController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are registered by bootstrap/app.php and all of them will
| be assigned to the "api" middleware group. Make something great!
|
*/

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

Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
    return $request->user();
});

يضيف Laravel البادئة /api تلقائياً للمسارات الموجودة هنا. لذلك يصبح Route::get('/login', ...) متاحاً على العنوان /api/login.

يبقى مسار Laravel الافتراضي والمحمي /api/user في الملف، لكننا لن نستخدمه في هذه المرحلة.

5. تشغيل الـAPI واختباره

شغّل خادم Laravel المحلي:

php artisan serve

اختبر المسار من نافذة terminal أخرى:

curl -i http://127.0.0.1:8000/api/login

الأجزاء المهمة في النتيجة هي:

HTTP/1.1 200 OK
Content-Type: application/json

{"message":"Hello, Login!","status":200}

ويمكنك التأكد من تسجيل المسار باستخدام:

php artisan route:list --path=api/login

هيكل ملفات التنفيذ الكامل

هذه فقط هي ملفات التطبيق الخاصة التي أُضيفت أو تغيرت في هذه المرحلة، أما بقية الملفات فهي هيكل Laravel 13 الافتراضي.

app/
├── Http/
│   └── Controllers/
│       └── AuthController.php
└── Traits/
    └── ApiResponses.php
routes/
└── api.php

لماذا نستخدم Trait للاستجابات؟

مع نمو الـAPI ستحتاج متحكمات كثيرة إلى إعادة استجابات بالشكل نفسه. وضع هذا الشكل في مكان مركزي يمنع الاختلافات الصغيرة، ويوفر مكاناً واحداً لإضافة دوال مساعدة لاحقاً مثل created() وerror() وnoContent().

هكذا تؤسس هذه المرحلة القاعدة التي سنبني عليها: مسار API، ومحتوى JSON منظم، وحالة HTTP صحيحة بقيمة 200.


2. اختبار API باستخدام Postman

يتحول تسجيل الدخول إلى مسار POST ويمرّ بالتحقق قبل المتحكم. البيانات الصحيحة تعيد 200، والحقل الناقص يعيد 422 مع قائمة الأخطاء.

توليد الأصناف

php artisan make:request ApiLoginRequest

الطلبات توضع في app/Http/Requests، لذا يكفي اسم الصنف.

الكود

app/Http/Controllers/AuthController.php — التغييرات

تمرير ApiLoginRequest كنوع للمعامل هو ما يشغّل التحقق. أما register() فهي مؤقتة لما بعد.

namespace App\Http\Controllers;

use App\Http\Requests\ApiLoginRequest;
use App\Traits\ApiResponses;
use Illuminate\Http\JsonResponse;

    /**
     * Authenticate the user.
     */
    public function login(): JsonResponse
    public function login(ApiLoginRequest $request): JsonResponse
    {
        return $this->ok('Hello, Login!');
        return $this->ok($request->get('email'));
    }

    /**
     * Register a new user.
     */
    public function register(): JsonResponse
    {
        return $this->ok('register');
    }
}

app/Http/Requests/ApiLoginRequest.php

القواعد صارت هنا بدل المتحكم. وتعيد authorize() القيمة true لأن الدخول متاح للجميع.

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class ApiLoginRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'email' => 'required',
            'password' => 'required'
        ];
    }
}

routes/api.php — التغييرات

تحوّل الدخول من GET إلى POST. بيانات الاعتماد لا مكان لها في العنوان.

|
*/

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

Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
    return $request->user();

التحقق

curl -i -X POST http://127.0.0.1:8000/api/login \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"secret1234"}'

ستحصل على 200 ومعها بريدك. أرسله دون password فتحصل على 422.


3. تصميم عناوين قائمة على الموارد

يبدأ الـAPI بإعادة صفوف حقيقية. مئة طلب موزّعة على عشرة مستخدمين، تُعرض في GET /api/orders.

حالة الطلب واحدة من pending أو paid أو shipped أو cancelled. والدليل كله يستخدم هذه المجموعة.

توليد الأصناف

php artisan make:model Order -mf

الراية -m تضيف الهجرة و-f تضيف المصنع. أما DatabaseSeeder فموجود مسبقاً، وتكتفي بتعديله.

الكود

app/Models/Order.php

الخاصية $fillable هي ما يجعل Order::create() تقبل مصفوفة. أما العلاقة فتأتي في القسم الثامن.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Order extends Model
{
    use HasFactory;

    protected $fillable = ['reference', 'status', 'notes', 'user_id'];
}

database/factories/OrderFactory.php

المراجع تأتي بصيغة ORD-12345، والحالة واحدة من الكلمات الأربع أعلاه.

<?php

namespace Database\Factories;

use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Order>
 */
class OrderFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'user_id' => User::factory(),
            'reference' => strtoupper(fake()->bothify('ord-#####')),
            'notes' => fake()->paragraph(),
            'status' => fake()->randomElement(['pending', 'paid', 'shipped', 'cancelled']),
        ];
    }
}

database/migrations/2024_01_27_055742_create_orders_table.php

العمود user_id مفتاح خارجي، وnotes نص حر.

<?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('orders', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained();
            $table->string('reference');
            $table->text('notes');
            $table->string('status');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('orders');
    }
};

database/seeders/DatabaseSeeder.php

عشرة مستخدمين أولاً، ثم مئة طلب موزّعة عليهم.

<?php

namespace Database\Seeders;

// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    /**
     * Seed the application's database.
     */
    public function run(): void
    {
        $users = \App\Models\User::factory(10)->create();

        \App\Models\Order::factory(100)
            ->recycle($users)
            ->create();

        // \App\Models\User::factory()->create([
        //     'name' => 'Test User',
        //     'email' => 'test@example.com',
        // ]);
    }
}

routes/api.php — التغييرات

مسار مؤقت. سيستبدله القسم الرابع بمتحكم.

<?php

use App\Http\Controllers\AuthController;
use App\Models\Order;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

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

Route::get('/orders', function() {
    return Order::all();
});

Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
    return $request->user();
});

التحقق

php artisan migrate:fresh --seed
curl -s http://127.0.0.1:8000/api/orders -H "Accept: application/json"

ستعود إليك مئة طلب بصيغة JSON.


4. هيكلة API متعدد الإصدارات

تنتقل الطلبات نفسها إلى /api/v1/orders. كل مسارات الإصدار الأول في ملف مستقل، فتصبح إضافة v2 لاحقاً إضافة ملف.

توليد الأصناف

php artisan make:controller Api/V1/OrderController --api --model=Order
php artisan make:request Api/V1/StoreOrderRequest
php artisan make:request Api/V1/UpdateOrderRequest

بادئة المسار تحدد مساحة الأسماء، فيصبح Api/V1/OrderController هو App\Http\Controllers\Api\V1\OrderController. وتتجاوز --api الدالتين create وedit اللتين تحتاجهما نماذج HTML فقط.

الكود

app/Http/Controllers/Api/V1/OrderController.php

لا تحتوي سوى index() على جسم حتى الآن، وتُستكمل البقية من القسم الثالث عشر.

<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Models\Order;
use App\Http\Requests\Api\V1\StoreOrderRequest;
use App\Http\Requests\Api\V1\UpdateOrderRequest;

class OrderController extends Controller
{
    /**
     * Display a listing of the resource.
     */
    public function index()
    {
        return Order::all();
    }

    /**
     * Store a newly created resource in storage.
     */
    public function store(StoreOrderRequest $request)
    {
        //
    }

    /**
     * Display the specified resource.
     */
    public function show(Order $order)
    {
        //
    }

    /**
     * Update the specified resource in storage.
     */
    public function update(UpdateOrderRequest $request, Order $order)
    {
        //
    }

    /**
     * Remove the specified resource from storage.
     */
    public function destroy(Order $order)
    {
        //
    }
}

app/Http/Requests/Api/V1/StoreOrderRequest.php

أصناف مولّدة. تعيد authorize() القيمة false، لذا تُجيب الكتابة بـ403 إلى أن تصل السياسات في القسم السابع عشر.

<?php

namespace App\Http\Requests\Api\V1;

use Illuminate\Foundation\Http\FormRequest;

class StoreOrderRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return false;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            //
        ];
    }
}

app/Http/Requests/Api/V1/UpdateOrderRequest.php

الصنف المولّد نفسه. ويحصل الطلبان على قواعد حقيقية في القسم الثالث عشر.

<?php

namespace App\Http\Requests\Api\V1;

use Illuminate\Foundation\Http\FormRequest;

class UpdateOrderRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return false;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            //
        ];
    }
}

bootstrap/app.php

تجعل shouldRenderJsonWhen() الأخطاء تحت api/* تُعرض بصيغة JSON بدل صفحة HTML.

<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware): void {
        //
    })
    ->withExceptions(function (Exceptions $exceptions): void {
        $exceptions->shouldRenderJsonWhen(
            fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
        );
    })->create();

routes/api.php — التغييرات

تحل مجموعة v1 محل مسار /orders المؤقت. ويبقى الدخول والتسجيل كما هما.

use App\Http\Controllers\AuthController;
use App\Models\Order;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::prefix('v1')->group(base_path('routes/api_v1.php'));

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

Route::get('/orders', function() {
    return Order::all();
});

Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
    return $request->user();
});

routes/api_v1.php

يسجّل apiResource الدوال index وstore وshow وupdate وdestroy دفعة واحدة.

<?php

use App\Http\Controllers\Api\V1\OrderController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are registered by bootstrap/app.php and all of them will
| be assigned to the "api" middleware group. Make something great!
|
*/

Route::apiResource('orders', OrderController::class);

Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
    return $request->user();
});

التحقق

php artisan route:list --path=api/v1
curl -s http://127.0.0.1:8000/api/v1/orders -H "Accept: application/json"

تظهر خمسة مسارات لـorders تحت v1، ويعيد الطلب الطلبات المزروعة.


5. المصادقة بالرموز باستخدام Laravel Sanctum

يعيد تسجيل الدخول رمز Sanctum، وتتوقف مسارات الطلبات عن الاستجابة للغرباء. غياب الرمز يعني 401.

توليد الأصناف

php artisan make:controller Api/AuthController
php artisan make:request Api/LoginUserRequest

جاء Sanctum مع php artisan install:api في القسم الأول.

الكود

app/Models/User.php — التغييرات

تأتي createToken() من سمة HasApiTokens الخاصة بـ Sanctum. بدونها لا يملك النموذج هذه الدالة أصلًا، ويسقط تسجيل الدخول بخطأ BadMethodCallException.

use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;

#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
    /** @use HasFactory<UserFactory> */
    use HasFactory, Notifiable;
    use HasApiTokens, HasFactory, Notifiable;

app/Http/Controllers/Api/AuthController.php

تتحقق Auth::attempt() من بيانات الاعتماد، ثم تُصدر createToken() الرمز.

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\Api\LoginUserRequest;
use App\Models\User;
use App\Traits\ApiResponses;
use Illuminate\Support\Facades\Auth;

class AuthController extends Controller
{
    use ApiResponses;

    /**
     * Authenticate the user and issue an API token.
     */
    public function login(LoginUserRequest $request)
    {
        if (! Auth::attempt($request->only('email', 'password'))) {
            return $this->error('Invalid credentials', 401);
        }

        $user = User::firstWhere('email', $request->email);

        return $this->ok(
            'Authenticated',
            [
                'token' => $user->createToken('API token for ' . $user->email)->plainTextToken
            ]
            );
    }
}

app/Http/Requests/Api/LoginUserRequest.php

أدق من الطلب القديم: بريد صحيح وكلمة مرور من ثمانية محارف فأكثر.

<?php

namespace App\Http\Requests\Api;

use Illuminate\Foundation\Http\FormRequest;

class LoginUserRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'email' => ['required', 'string', 'email'],
            'password' => ['required', 'string', 'min:8'],
        ];
    }
}

app/Traits/ApiResponses.php — التغييرات

يُعرض الملف كاملاً لتوضيح السياق، مع تمييز الأسطر التي تغيرت في هذه الخطوة. تكسب success() مفتاح data ليحمل الرمز، وتعيد error() حالات الفشل بالبنية نفسها.

<?php

namespace App\Traits;

use Illuminate\Http\JsonResponse;

trait ApiResponses
{
    /**
     * Return a successful response.
     */
    protected function ok(string $message): JsonResponse
    protected function ok(string $message, array $data): JsonResponse
    {
        return $this->success($message, 200);
        return $this->success($message, $data, 200);
    }

    /**
     * Return a success response whose payload status matches the HTTP status.
     * Return a success response with the given payload.
     */
    protected function success(string $message, int $statusCode = 200): JsonResponse
    protected function success(string $message, array $data, int $statusCode = 200): JsonResponse
    {
        return response()->json([
            'data' => $data,
            'message' => $message,
            'status' => $statusCode
        ], $statusCode);
    }

    /**
     * Return an error response.
     */
    protected function error(string $message, int $statusCode): JsonResponse
    {
        return response()->json([
            'message' => $message,
            'status' => $statusCode
        ], $statusCode);
    }
}

routes/api.php — التغييرات

صار الدخول يشير إلى المتحكم الجديد. أما مسار register المؤقت من القسم 2 فيرحل مع المتحكم القديم — إنشاء المستخدمين له قسم خاص لاحقًا، ولا يصح ترك مسار يشير إلى دالة لم تعد موجودة.

<?php

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

Route::prefix('v1')->group(base_path('routes/api_v1.php'));

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

حذف الملفات التي حلّ محلها غيرها

المتحكم والطلب المؤقتان من القسم 2 استبدلهما نظيراهما داخل Api\.

rm app/Http/Controllers/AuthController.php
rm app/Http/Requests/ApiLoginRequest.php

routes/api_v1.php — التغييرات

الوسيط auth:sanctum هو ما يفعّل استجابة 401.

|
*/

Route::apiResource('orders', OrderController::class);
Route::middleware('auth:sanctum')->apiResource('orders', OrderController::class);

Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
    return $request->user();

بيانات الاعتماد الخاطئة تعيد 401 لا 422. فالبيانات سليمة، والفاشل هو الدخول.

التحقق

php artisan tinker --execute="echo App\Models\User::first()->email;"

curl -s -X POST http://127.0.0.1:8000/api/login \
  -H "Accept: application/json" -H "Content-Type: application/json" \
  -d '{"email":"SEEDED_EMAIL","password":"password"}'

curl -i http://127.0.0.1:8000/api/v1/orders -H "Accept: application/json"

كل المستخدمين المزروعين يستخدمون كلمة المرور password. يعود الرمز في data.token. وبدونه يعيد مسار الطلبات 401، ومعه عبر -H "Authorization: Bearer YOUR_TOKEN" يعمل.


6. إلغاء الرموز وتسجيل الخروج الآمن

يحذف POST /api/logout الرمز الذي نفّذ الطلب. وتبقى الأجهزة الأخرى مسجّلة الدخول.

الكود

app/Http/Controllers/Api/AuthController.php — التغييرات

تعيد currentAccessToken() رمز هذا الطلب. احذفه فينتهي.

use App\Http\Requests\Api\LoginUserRequest;
use App\Models\User;
use App\Traits\ApiResponses;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class AuthController extends Controller

        return $this->ok(
            'Authenticated',
            [
                'token' => $user->createToken('API token for ' . $user->email)->plainTextToken
                'token' => $user->createToken(
                    'API token for ' . $user->email,
                    ['*'],
                    now()->addMonth())->plainTextToken
            ]
            );
    }

    /**
     * Revoke the API token used for the current request.
     */
    public function logout(Request $request)
    {
        $request->user()->currentAccessToken()->delete();

        return $this->ok('');
    }
}

app/Traits/ApiResponses.php — التغييرات

صارت $data بقيمة افتراضية فارغة، فلا تحتاج الاستجابة الخالية إلى تمرير أي شيء.

    /**
     * Return a successful response.
     */
    protected function ok(string $message, array $data): JsonResponse
    protected function ok(string $message, array $data = []): JsonResponse
    {
        return $this->success($message, $data, 200);
    }

    /**
     * Return a success response with the given payload.
     */
    protected function success(string $message, array $data, int $statusCode = 200): JsonResponse
    protected function success(string $message, array $data = [], int $statusCode = 200): JsonResponse
    {
        return response()->json([
            'data' => $data,

routes/api.php — التغييرات

الخروج خلف auth:sanctum. لا يمكنك إلغاء رمز دون إرساله.

Route::post('/login', [AuthController::class, 'login']);
Route::middleware('auth:sanctum')->post('/logout', [AuthController::class, 'logout']);

Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
    return $request->user();

تنتهي صلاحية الرموز بعد شهر أيضاً، وتُحدَّد عند الإصدار.

التحقق

curl -i -X POST http://127.0.0.1:8000/api/logout \
  -H "Accept: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN"

curl -i http://127.0.0.1:8000/api/v1/orders \
  -H "Accept: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN"

يعيد الخروج 200. أعد استخدام الرمز نفسه فتحصل على 401.


7. تصميم استجابات مستقرة

تتوقف الطلبات عن كونها صفوفاً خاماً. يعود كل طلب بالحقول type وid وattributes وrelationships وlinks، والقائمة مقسّمة إلى صفحات.

توليد الأصناف

php artisan make:resource V1/OrderResource

الموارد توضع في app/Http/Resources، فتضع البادئة V1/ هذا المورد في App\Http\Resources\V1.

الكود

app/Http/Controllers/Api/V1/OrderController.php — التغييرات

تقسّم index() النتائج إلى صفحات، وتعيد show() مورداً واحداً.

use App\Models\Order;
use App\Http\Requests\Api\V1\StoreOrderRequest;
use App\Http\Requests\Api\V1\UpdateOrderRequest;
use App\Http\Resources\V1\OrderResource;

class OrderController extends Controller
{

     */
    public function index()
    {
        return Order::all();
        return OrderResource::collection(Order::paginate());
    }

    /**

     */
    public function show(Order $order)
    {
        //
        return new OrderResource($order);
    }

    /**

app/Http/Resources/V1/OrderResource.php

المورد يحدد أسماء الحقول العلنية. فيخرج created_at باسم createdAt.

<?php

namespace App\Http\Resources\V1;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class OrderResource extends JsonResource
{
    // public static $wrap = 'order';
    /**
     * Transform the resource into an array.
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return [
            'type' => 'order',
            'id' => $this->id,
            'attributes' => [
                'reference' => $this->reference,
                'notes' => $this->notes,
                'status' => $this->status,
                'createdAt' => $this->created_at,
                'updatedAt' => $this->updated_at
            ],
            'relationships' => [
                'customer' => [
                    'data' => [
                        'type' => 'user',
                        'id' => $this->user_id
                    ],
                    'links' => [
                        ['self' => 'todo']
                    ]
                ]
            ],
            'links' => [
                ['self' => route('orders.show', ['order' => $this->id])]
            ]
        ];
    }
}

غيّر اسم عمود لاحقاً ولن يتغير سوى هذا الملف.

التحقق

curl -s "http://127.0.0.1:8000/api/v1/orders?page=1" \
  -H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"

يحمل كل عنصر type وid وattributes ورابط self، إضافة إلى links وmeta للصفحات.


8. الحقول والعلاقات الشرطية

صنف مورد واحد يعطي مخرجات مختلفة حسب المسار. الطلب المفرد يُظهر notes، والقائمة تخفيها.

توليد الأصناف

php artisan make:controller Api/V1/UsersController --api --model=User
php artisan make:request Api/V1/StoreUserRequest
php artisan make:request Api/V1/UpdateUserRequest
php artisan make:resource V1/UserResource

الكود

app/Http/Controllers/Api/V1/UsersController.php

الجانب الخاص بالمستخدمين من النمط نفسه.

<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Models\User;
use App\Http\Requests\Api\V1\StoreUserRequest;
use App\Http\Requests\Api\V1\UpdateUserRequest;
use App\Http\Resources\V1\UserResource;

class UsersController extends Controller
{
    /**
     * Display a listing of the resource.
     */
    public function index()
    {
        return UserResource::collection(User::paginate());
    }

    /**
     * Store a newly created resource in storage.
     */
    public function store(StoreUserRequest $request)
    {
        //
    }

    /**
     * Display the specified resource.
     */
    public function show(User $user)
    {
        return new UserResource($user);
    }

    /**
     * Update the specified resource in storage.
     */
    public function update(UpdateUserRequest $request, User $user)
    {
        //
    }

    /**
     * Remove the specified resource from storage.
     */
    public function destroy(User $user)
    {
        //
    }
}

app/Http/Requests/Api/V1/StoreUserRequest.php

صنف مؤقت الآن، وتأتي القواعد في القسم الحادي والعشرين.

<?php

namespace App\Http\Requests\Api\V1;

use Illuminate\Foundation\Http\FormRequest;

class StoreUserRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return false;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            //
        ];
    }
}

app/Http/Requests/Api/V1/UpdateUserRequest.php

الصنف المؤقت نفسه للتحديث.

<?php

namespace App\Http\Requests\Api\V1;

use Illuminate\Foundation\Http\FormRequest;

class UpdateUserRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return false;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            //
        ];
    }
}

app/Http/Resources/V1/OrderResource.php — التغييرات

تُخفي when() الحقل notes ما لم يكن المسار orders.show.

            'id' => $this->id,
            'attributes' => [
                'reference' => $this->reference,
                'notes' => $this->notes,
                'notes' => $this->when(
                    $request->routeIs('orders.show'),
                    $this->notes
                ),
                'status' => $this->status,
                'createdAt' => $this->created_at,
                'updatedAt' => $this->updated_at

                    ]
                ]
            ],
            'includes' => [
                new UserResource($this->user)
            ],
            'links' => [
                ['self' => route('orders.show', ['order' => $this->id])]
            ]

app/Http/Resources/V1/UserResource.php

تضيف mergeWhen() الأزمنة في مسارات users.* فقط.

<?php

namespace App\Http\Resources\V1;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class UserResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return [
            'type' => 'user',
            'id' => $this->id,
            'attributes' => [
                'name' => $this->name,
                'email' => $this->email,
                $this->mergeWhen($request->routeIs('users.*'), [
                    'emailVerifiedAt' => $this->email_verified_at,
                    'createdAt' => $this->created_at,
                    'updatedAt' => $this->updated_at,
                ])
            ]
        ];
    }
}

app/Models/Order.php — التغييرات

يضيف علاقة user() التي يقرأها مورد الطلب.

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

class Order extends Model
{
    use HasFactory;

    protected $fillable = ['reference', 'status', 'notes', 'user_id'];

    /**
     * Get the user that placed the order.
     */
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

routes/api_v1.php — التغييرات

يسجّل مورد المستخدمين خلف auth:sanctum.

<?php

use App\Http\Controllers\Api\V1\OrderController;
use App\Http\Controllers\Api\V1\UsersController;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

*/

Route::middleware('auth:sanctum')->apiResource('orders', OrderController::class);
Route::middleware('auth:sanctum')->apiResource('users', UsersController::class);

Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
    return $request->user();

routes/api.php — التغييرات

نسخ القسم الرابع مسار /user إلى routes/api_v1.php بدل أن ينقله، فظل GET /api/user وGET /api/v1/user يعطيان النتيجة نفسها منذ ذلك الحين. وبعد أن صار الجانب المُصدَّر يملك سطح المستخدمين كاملًا، نحذف التوأم غير المُصدَّر. ويرحل معه Request إذ لم يعد شيء في هذا الملف يستخدمه.

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

Route::prefix('v1')->group(base_path('routes/api_v1.php'));

Route::post('/login', [AuthController::class, 'login']);
Route::middleware('auth:sanctum')->post('/logout', [AuthController::class, 'logout']);

Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
    return $request->user();
});

صار api.php مقتصرًا على مسارات الرموز ونقطة تركيب v1. وكل ما يقرأه العميل أو يكتبه يقع خلف إصدار.

صنف لكل نموذج أفضل من صنف لكل نقطة نهاية. غيّر اسم حقل مرة واحدة فيتبعه المساران.

التحقق

curl -s http://127.0.0.1:8000/api/v1/orders \
  -H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"

curl -s http://127.0.0.1:8000/api/v1/orders/1 \
  -H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"

curl -i http://127.0.0.1:8000/api/user \
  -H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"

القائمة بلا مفتاح notes، والطلب المفرد يحتويه. ويُظهر /api/v1/users/1 أزمنة المستخدم الإضافية. أما /api/user القديم فيعيد الآن 404 — استخدم /api/v1/user.


9. تحميل العلاقات عند الطلب

يضمّن ?include=customer بيانات العميل داخل كل طلب. وبدونه لا يُنفَّذ أي استعلام إضافي.

توليد الأصناف

php artisan make:controller Api/V1/ApiController

متحكم عادي لا متحكم مورد. يضم دوال مساعدة مشتركة فقط.

الكود

app/Http/Controllers/Api/V1/ApiController.php

تقرأ include() معامل الاستعلام وتجيب بنعم أو لا.

<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;

class ApiController extends Controller
{
    /**
     * Determine whether the given relationship was requested via the include parameter.
     */
    public function include(string $relationship) : bool
    {
        $param = request()->get('include');

        if (!isset($param)) {
            return false;
        }

        $includeValues = explode(',', strtolower($param));

        return in_array(strtolower($relationship), $includeValues);
    }
}

app/Http/Controllers/Api/V1/OrderController.php — التغييرات

يحمّل العلاقة عبر with() عند طلب العميل فقط.

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Models\Order;
use App\Http\Requests\Api\V1\StoreOrderRequest;
use App\Http\Requests\Api\V1\UpdateOrderRequest;
use App\Http\Resources\V1\OrderResource;

class OrderController extends Controller
class OrderController extends ApiController
{
    /**
     * Display a listing of the resource.
     */
    public function index()
    {
        if ($this->include('customer')) {
            return OrderResource::collection(Order::with('user')->paginate());
        }

        return OrderResource::collection(Order::paginate());
    }

     */
    public function show(Order $order)
    {
        if ($this->include('customer')) {
            return new OrderResource($order->load('user'));
        }

        return new OrderResource($order);
    }

app/Http/Controllers/Api/V1/UsersController.php — التغييرات

الأمر نفسه للمستخدمين وطلباتهم.

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Models\User;
use App\Http\Requests\Api\V1\StoreUserRequest;
use App\Http\Requests\Api\V1\UpdateUserRequest;
use App\Http\Resources\V1\UserResource;

class UsersController extends Controller
class UsersController extends ApiController
{
    /**
     * Display a listing of the resource.
     */
    public function index()
    {
        if ($this->include('orders')) {
            return UserResource::collection(User::with('orders')->paginate());
        }

        return UserResource::collection(User::paginate());
    }

     */
    public function show(User $user)
    {
        if ($this->include('orders')) {
            return new UserResource($user->load('orders'));
        }

        return new UserResource($user);
    }

app/Http/Resources/V1/OrderResource.php — التغييرات

تُبقي whenLoaded() مفتاح includes خارج الاستجابة ما لم تكن العلاقة محمّلة.

                        'id' => $this->user_id
                    ],
                    'links' => [
                        ['self' => 'todo']
                        'self' => route('users.show', ['user' => $this->user_id])
                    ]
                ]
            ],
            'includes' => [
                new UserResource($this->user)
            ],
            'includes' => new UserResource($this->whenLoaded('user')),
            'links' => [
                ['self' => route('orders.show', ['order' => $this->id])]
                'self' => route('orders.show', ['order' => $this->id])
            ]
        ];
    }

app/Http/Resources/V1/UserResource.php — التغييرات

يضيف includes للطلبات المحمّلة ورابط self.

                    'createdAt' => $this->created_at,
                    'updatedAt' => $this->updated_at,
                ])
            ],
            'includes' => OrderResource::collection($this->whenLoaded('orders')),
            'links' => [
                'self' => route('users.show', ['user' => $this->id])
            ]
        ];
    }

app/Models/User.php — التغييرات

يضيف علاقة orders()، وهي النصف المقابل لعلاقة belongsTo التي أُضيفت إلى Order في القسم الثامن.

// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;

    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }

    /**
     * Get the orders placed by the user.
     */
    public function orders() : HasMany
    {
        return $this->hasMany(Order::class);
    }
}

بدون whenLoaded() كانت صفحة الطلبات ستُطلق استعلاماً لكل صف. وعدّ الاستعلامات يُظهر الفرق جليًا: صفحة من 15 طلبية تكلّف استعلامين بدون include، وثلاثة معه. أما includes غير المشروط في القسم الثامن فكان يكلّف 17.

التحقق

curl -s "http://127.0.0.1:8000/api/v1/orders?include=customer" \
  -H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"

curl -s "http://127.0.0.1:8000/api/v1/orders" \
  -H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"

الاستجابة الأولى فيها كتلة includes، والثانية بلا شيء.


10. فلاتر استعلام قابلة لإعادة الاستخدام

ترشيح من سلسلة الاستعلام: ?filter[status]=pending، أو مدى تاريخي، أو ?filter[reference]=ORD-1*.

توليد الأصناف

php artisan make:class Http/Filters/V1/QueryFilter
php artisan make:class Http/Filters/V1/OrderFilter

المرشّحات ليست مفهوماً في Laravel، فلا مولّد لها. وينشئ make:class صنفاً عادياً في المسار الذي تحدده.

الكود

app/Http/Controllers/Api/V1/OrderController.php — التغييرات

تستقبل index() النوع OrderFilter، ويحلّه Laravel وقد وُضع الطلب بداخله.

namespace App\Http\Controllers\Api\V1;

use App\Http\Filters\V1\OrderFilter;
use App\Models\Order;
use App\Http\Requests\Api\V1\StoreOrderRequest;
use App\Http\Requests\Api\V1\UpdateOrderRequest;

    /**
     * Display a listing of the resource.
     */
    public function index()
    public function index(OrderFilter $filters)
    {
        if ($this->include('customer')) {
            return OrderResource::collection(Order::with('user')->paginate());
        }

        return OrderResource::collection(Order::paginate());
        return OrderResource::collection(Order::filter($filters)->paginate());
    }

    /**

    public function show(Order $order)
    {
        if ($this->include('customer')) {
            return new OrderResource($order->load('user'));
            return new OrderResource($order->load('customer'));
        }

        return new OrderResource($order);

app/Http/Filters/V1/QueryFilter.php

تستدعي apply() الدالة المطابقة لكل مفتاح استعلام. لا دالة، لا أثر.

<?php

namespace App\Http\Filters\V1;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;

abstract class QueryFilter
{
    protected $builder;
    protected $request;

    /**
     * Create a new filter instance for the current request.
     */
    public function __construct(Request $request)
    {
        $this->request = $request;
    }

    /**
     * Apply the given filters to the builder.
     */
    protected function filter($arr)
    {
        foreach($arr as $key => $value) {
            if (method_exists($this, $key)) {
                $this->$key($value);
            }
        }

        return $this->builder;
    }

    /**
     * Apply the request query parameters to the given builder.
     */
    public function apply(Builder $builder)
    {
        $this->builder = $builder;

        foreach($this->request->all() as $key => $value) {
            if (method_exists($this, $key)) {
                $this->$key($value);
            }
        }

        return $builder;
    }
}

app/Http/Filters/V1/OrderFilter.php

دالة لكل مرشّح مدعوم. ويتحول * إلى حرف بدل في reference.

وinclude() هي الدالة الوحيدة التي تستقبل اسم علاقة لا قيمة عمود، لذا تتحقق من كل اسم عبر isRelation() أولًا. فلو سلّمت with() اسمًا لا يعرّفه النموذج رمى Eloquent الخطأ Call to undefined relationship، ووصل إلى العميل على هيئة 500 — انهيار غير معالَج سببه مجرد خطأ مطبعي في معامل استعلام. وتصفية القائمة تجعل الاسم المجهول يتصرف كأي معامل غير معروف: يُتجاهل.

<?php

namespace App\Http\Filters\V1;

class OrderFilter extends QueryFilter
{
    /**
     * Filter by creation date, or by a date range when two dates are given.
     */
    public function createdAt($value)
    {
        $dates = explode(',', $value);

        if (count($dates) > 1) {
            return $this->builder->whereBetween('created_at', $dates);
        }

        return $this->builder->whereDate('created_at', $value);
    }

    /**
     * Eager load the requested relationships, ignoring any the model does not define.
     */
    public function include($value)
    {
        if (!is_string($value)) {
            return $this->builder;
        }

        $model = $this->builder->getModel();

        $relations = array_filter(
            explode(',', $value),
            fn ($relation) => $model->isRelation($relation)
        );

        return $this->builder->with($relations);
    }

    /**
     * Filter by one or more statuses.
     */
    public function status($value)
    {
        return $this->builder->whereIn('status', explode(',', $value));
    }

    /**
     * Filter by reference, where * acts as a wildcard.
     */
    public function reference($value)
    {
        $likeStr = str_replace('*', '%', $value);
        return $this->builder->where('reference', 'like', $likeStr);
    }

    /**
     * Filter by update date, or by a date range when two dates are given.
     */
    public function updatedAt($value)
    {
        $dates = explode(',', $value);

        if (count($dates) > 1) {
            return $this->builder->whereBetween('updated_at', $dates);
        }

        return $this->builder->whereDate('updated_at', $value);
    }
}

app/Http/Resources/V1/OrderResource.php — التغييرات

صار اسم العلاقة customer، فتبعتها whenLoaded().

                    ]
                ]
            ],
            'includes' => new UserResource($this->whenLoaded('user')),
            'includes' => new UserResource($this->whenLoaded('customer')),
            'links' => [
                'self' => route('orders.show', ['order' => $this->id])
            ]

app/Models/Order.php — التغييرات

النطاق filter يتيح كتابة Order::filter($filters). وتُعاد تسمية العلاقة إلى customer في الجولة نفسها ليطابق المصطلح العلني ما يظهر في الاستجابة — أما العمود فيبقى user_id، ولذلك وجب ذكره صراحةً عند إعادة التسمية.

<?php

namespace App\Models;

use App\Http\Filters\V1\QueryFilter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Order extends Model
{
    /** @use HasFactory<\Database\Factories\OrderFactory> */
    use HasFactory;

    protected $fillable = ['reference', 'status', 'notes', 'user_id'];

    /**
     * Get the user that placed the order.
     * Get the customer that placed the order.
     */
    public function user(): BelongsTo
    public function customer(): BelongsTo
    {
        return $this->belongsTo(User::class);
        return $this->belongsTo(User::class, 'user_id');
    }

    /**
     * Apply the given query filters to the builder.
     */
    public function scopeFilter(Builder $builder, QueryFilter $filters)
    {
        return $filters->apply($builder);
    }
}

صنف المرشّح هو قائمة السماح. المعامل بلا دالة لا يفعل شيئاً، وهذه هي الطريقة الآمنة للفشل.

التحقق

curl -s "http://127.0.0.1:8000/api/v1/orders?filter[status]=pending" \
  -H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"

curl -s "http://127.0.0.1:8000/api/v1/orders?filter[reference]=ORD-1*" \
  -H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"

النداء الأول يعيد الطلبات قيد الانتظار فقط، والثاني يعيد كل مرجع يبدأ بـORD-1.


11. الموارد المتداخلة وفلترة العلاقات

عنوانان جديدان: /api/v1/customers و/api/v1/customers/{customer}/orders.

توليد الأصناف

php artisan make:controller Api/V1/CustomersController --api --model=User
php artisan make:controller Api/V1/CustomerOrdersController

المتحكم المتداخل لا يحتاج --api. يبدأ بدالة index وحدها، وتصل بقية الأفعال حين تحتاجها الأقسام اللاحقة: store في القسم 13، وdestroy في القسم 14، وreplace في القسم 15، وupdate في القسم 16. سجّل index وحده عند هذه النقطة؛ فإتاحة مسارات Resource قبل وجود دوالها في Controller تجعل طلبات تبدو صحيحة تنتهي باستجابة 500.

الكود

app/Http/Controllers/Api/V1/CustomerOrdersController.php

يرشّح بـuser_id ثم يمرّر الاستعلام إلى OrderFilter نفسه.

<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Http\Filters\V1\OrderFilter;
use App\Http\Resources\V1\OrderResource;
use App\Models\Order;

class CustomerOrdersController extends Controller
{
    /**
     * Display a listing of the resource.
     */
    public function index($customer_id, OrderFilter $filters)
    {
        return OrderResource::collection(
            Order::where('user_id', $customer_id)->filter($filters)->paginate()
        );
    }
}

app/Http/Controllers/Api/V1/CustomersController.php

المستخدمون بوصفهم عملاء، مع ?include=orders.

<?php

namespace App\Http\Controllers\Api\V1;

use App\Models\User;
use App\Http\Requests\Api\V1\StoreUserRequest;
use App\Http\Requests\Api\V1\UpdateUserRequest;
use App\Http\Resources\V1\UserResource;

class CustomersController extends ApiController
{
    /**
     * Display a listing of the resource.
     */
    public function index()
    {
        if ($this->include('orders')) {
            return UserResource::collection(User::with('orders')->paginate());
        }

        return UserResource::collection(User::paginate());
    }

    /**
     * Store a newly created resource in storage.
     */
    public function store(StoreUserRequest $request)
    {
        //
    }

    /**
     * Display the specified resource.
     */
    public function show(User $customer)
    {
        if ($this->include('orders')) {
            return new UserResource($customer->load('orders'));
        }

        return new UserResource($customer);
    }

    /**
     * Update the specified resource in storage.
     */
    public function update(UpdateUserRequest $request, User $user)
    {
        //
    }

    /**
     * Remove the specified resource from storage.
     */
    public function destroy(User $user)
    {
        //
    }
}

app/Http/Resources/V1/OrderResource.php — التغييرات

روابط العلاقات وself صارت تشير إلى مسارات حقيقية.

                        'id' => $this->user_id
                    ],
                    'links' => [
                        'self' => route('users.show', ['user' => $this->user_id])
                        'self' => route('customers.show', ['customer' => $this->user_id])
                    ]
                ]
            ],

app/Http/Resources/V1/UserResource.php — التغييرات

الحقول والروابط صارت تتبع مسارات customers.*.

            'attributes' => [
                'name' => $this->name,
                'email' => $this->email,
                $this->mergeWhen($request->routeIs('users.*'), [
                $this->mergeWhen($request->routeIs('customers.*'), [
                    'emailVerifiedAt' => $this->email_verified_at,
                    'createdAt' => $this->created_at,
                    'updatedAt' => $this->updated_at,

            ],
            'includes' => OrderResource::collection($this->whenLoaded('orders')),
            'links' => [
                'self' => route('users.show', ['user' => $this->id])
                'self' => route('customers.show', ['customer' => $this->id])
            ]
        ];
    }

routes/api_v1.php — التغييرات

يسجّل customers والمسار المتداخل customers.orders.

<?php

use App\Http\Controllers\Api\V1\OrderController;
use App\Http\Controllers\Api\V1\UsersController;

use App\Http\Controllers\Api\V1\CustomersController;
use App\Http\Controllers\Api\V1\CustomerOrdersController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

*/

Route::middleware('auth:sanctum')->apiResource('orders', OrderController::class);
Route::middleware('auth:sanctum')->apiResource('users', UsersController::class);
Route::middleware('auth:sanctum')->apiResource('customers', CustomersController::class);
Route::middleware('auth:sanctum')->apiResource('customers.orders', CustomerOrdersController::class)->only('index');

Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
    return $request->user();

حذف الملف الذي حلّ محله غيره

حلّ customers محل مورد users في ملف المسارات، فلم يبقَ شيء يشير إلى UsersController. ويستحدث القسم 21 متحكمًا منفصلًا باسم UserController لإدارة المستخدمين بدل إحياء هذا، فهو ميت من هنا فصاعدًا.

rm app/Http/Controllers/Api/V1/UsersController.php

أما UserResource فيبقى — إذ يعيده CustomersController، وهو سبب ظهور بنية العميل نفسها تحت الاسمين.

بنية الطلب واحدة في المسارين، فيقرأ العميل أياً منهما بالطريقة نفسها.

التحقق

php artisan route:list --path=api/v1/customers

curl -s "http://127.0.0.1:8000/api/v1/customers/1/orders?filter[status]=pending" \
  -H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"

curl -s http://127.0.0.1:8000/api/v1/customers/1 \
  -H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"

تظهر المسارات المتداخلة في القائمة، ويعيد الطلب طلبات ذلك العميل قيد الانتظار. وصارت كل روابط self تشير إلى /api/v1/customers/...، واختفى /api/v1/users.


12. ترتيب آمن يتحكم به العميل

ترتيب من سلسلة الاستعلام: ?sort=status، و?sort=-createdAt للتنازلي، أو الاثنان معاً.

توليد الأصناف

php artisan make:class Http/Filters/V1/CustomerFilter

الكود

app/Http/Controllers/Api/V1/CustomersController.php — التغييرات

تستقبل index() النوع CustomerFilter، فيصبح ترشيح العملاء وترتيبهم كالطلبات.

namespace App\Http\Controllers\Api\V1;

use App\Http\Filters\V1\CustomerFilter;
use App\Models\User;
use App\Http\Requests\Api\V1\StoreUserRequest;
use App\Http\Requests\Api\V1\UpdateUserRequest;

    /**
     * Display a listing of the resource.
     */
    public function index()
    public function index(CustomerFilter $filters)
    {
        if ($this->include('orders')) {
            return UserResource::collection(User::with('orders')->paginate());
        }

        return UserResource::collection(User::paginate());
        return UserResource::collection(User::filter($filters)->paginate());
    }

    /**

app/Http/Filters/V1/CustomerFilter.php

يحدد ما يجوز ترتيب العملاء به. وتحرس include() اسم العلاقة تمامًا كما يفعل OrderFilter في القسم العاشر — إذ إن with() غير المتحقَّق منه يحوّل ?include=bogus إلى 500 هنا أيضًا.

<?php

namespace App\Http\Filters\V1;

class CustomerFilter extends QueryFilter
{
    protected $sortable = [
        'name',
        'email',
        'createdAt' => 'created_at',
        'updatedAt' => 'updated_at'
    ];

    /**
     * Filter by creation date, or by a date range when two dates are given.
     */
    public function createdAt($value)
    {
        $dates = explode(',', $value);

        if (count($dates) > 1) {
            return $this->builder->whereBetween('created_at', $dates);
        }

        return $this->builder->whereDate('created_at', $value);
    }

    /**
     * Eager load the requested relationships, ignoring any the model does not define.
     */
    public function include($value)
    {
        if (!is_string($value)) {
            return $this->builder;
        }

        $model = $this->builder->getModel();

        $relations = array_filter(
            explode(',', $value),
            fn ($relation) => $model->isRelation($relation)
        );

        return $this->builder->with($relations);
    }

    /**
     * Filter by one or more identifiers.
     */
    public function id($value)
    {
        return $this->builder->whereIn('id', explode(',', $value));
    }

    /**
     * Filter by email, where * acts as a wildcard.
     */
    public function email($value)
    {
        $likeStr = str_replace('*', '%', $value);
        return $this->builder->where('email', 'like', $likeStr);
    }

    /**
     * Filter by name, where * acts as a wildcard.
     */
    public function name($value)
    {
        $likeStr = str_replace('*', '%', $value);
        return $this->builder->where('name', 'like', $likeStr);
    }

    /**
     * Filter by update date, or by a date range when two dates are given.
     */
    public function updatedAt($value)
    {
        $dates = explode(',', $value);

        if (count($dates) > 1) {
            return $this->builder->whereBetween('updated_at', $dates);
        }

        return $this->builder->whereDate('updated_at', $value);
    }
}

app/Http/Filters/V1/QueryFilter.php — التغييرات

تفحص sort() كل مفتاح مقابل $sortable قبل وصوله إلى orderBy(). والإشارة - في البداية تعني تنازلياً.

{
    protected $builder;
    protected $request;
    protected $sortable = [];

    /**
     * Create a new filter instance for the current request.

    public function __construct(Request $request)
    {
        $this->request = $request;
    }

    /**
     * Apply the request query parameters to the given builder.
     */
    public function apply(Builder $builder)
    {
        $this->builder = $builder;

        foreach($this->request->all() as $key => $value) {
            if (method_exists($this, $key)) {
                $this->$key($value);
            }
        }

        return $builder;
    }

    /**

    }

    /**
     * Apply the request query parameters to the given builder.
     * Sort the query by the requested columns.
     */
    public function apply(Builder $builder)
    protected function sort($value)
    {
        $this->builder = $builder;
        $sortAttributes = explode(',', $value);

        foreach($this->request->all() as $key => $value) {
            if (method_exists($this, $key)) {
                $this->$key($value);
        foreach($sortAttributes as $sortAttribute) {
            $direction = 'asc';

            if (strpos($sortAttribute, '-') === 0) {
                $direction = 'desc';
                $sortAttribute = substr($sortAttribute, 1);
            }

            if (!in_array($sortAttribute, $this->sortable) && !array_key_exists($sortAttribute, $this->sortable)) {
                continue;
            }

            $columnName = $this->sortable[$sortAttribute] ?? null;

            if ($columnName === null) {
                $columnName = $sortAttribute;
            }

            $this->builder->orderBy($columnName, $direction);
        }

        return $builder;
    }
}

app/Http/Filters/V1/OrderFilter.php — التغييرات

القائمة نفسها للطلبات. والخريطة تترجم createdAt إلى created_at.

class OrderFilter extends QueryFilter
{
    protected $sortable = [
        'reference',
        'status',
        'createdAt' => 'created_at',
        'updatedAt' => 'updated_at'
    ];

    /**
     * Filter by creation date, or by a date range when two dates are given.
     */

app/Models/User.php — التغييرات

يضيف النطاق filter إلى User، وهو النطاق نفسه الذي ناله Order في القسم العاشر.

namespace App\Models;

// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Http\Filters\V1\QueryFilter;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;

    {
        return $this->hasMany(Order::class);
    }

    /**
     * Apply the given query filters to the builder.
     */
    public function scopeFilter(Builder $builder, QueryFilter $filters)
    {
        return $filters->apply($builder);
    }
}

تمرير المفتاح كما هو إلى orderBy() يتيح الترتيب بأي عمود. والقائمة هي المقصود.

التحقق

curl -s "http://127.0.0.1:8000/api/v1/orders?sort=-createdAt,status" \
  -H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"

curl -s "http://127.0.0.1:8000/api/v1/orders?sort=password" \
  -H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"

النداء الأول يرتّب الأحدث أولاً ثم بالحالة، والثاني يُتجاهل لأن password ليس في القائمة.


13. إنشاء الموارد باستخدام POST

يستقبل POST /api/v1/orders بنية المستند نفسها التي يعيدها الـAPI، وينشئ الطلب.

الكود

app/Http/Controllers/Api/V1/ApiController.php — التغييرات

يضم ApiResponses لتستطيع المتحكمات الوارثة استدعاء error().

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Traits\ApiResponses;

class ApiController extends Controller
{
    use ApiResponses;

    /**
     * Determine whether the given relationship was requested via the include parameter.
     */

app/Http/Controllers/Api/V1/CustomerOrdersController.php — التغييرات

النسخة المتداخلة تأخذ user_id من العنوان.

use App\Http\Controllers\Controller;
use App\Http\Filters\V1\OrderFilter;
use App\Http\Requests\Api\V1\StoreOrderRequest;
use App\Http\Resources\V1\OrderResource;
use App\Models\Order;

            Order::where('user_id', $customer_id)->filter($filters)->paginate()
        );
    }

    /**
     * Store a newly created resource in storage.
     */
    public function store($customer_id, StoreOrderRequest $request)
    {
        $model = [
            'reference' => $request->input('data.attributes.reference'),
            'notes' => $request->input('data.attributes.notes'),
            'status' => $request->input('data.attributes.status'),
            'user_id' => $customer_id
        ];

        return new OrderResource(Order::create($model));
    }
}

app/Http/Controllers/Api/V1/OrderController.php — التغييرات

يتأكد من وجود العميل، ثم يحوّل الطلب إلى أعمدة.

use App\Http\Requests\Api\V1\StoreOrderRequest;
use App\Http\Requests\Api\V1\UpdateOrderRequest;
use App\Http\Resources\V1\OrderResource;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;

class OrderController extends ApiController
{

     */
    public function store(StoreOrderRequest $request)
    {
        //
        try {
            User::findOrFail($request->input('data.relationships.customer.data.id'));
        } catch (ModelNotFoundException $exception) {
            return $this->error('The provided customer id does not exist.', 404);
        }

        $model = [
            'reference' => $request->input('data.attributes.reference'),
            'notes' => $request->input('data.attributes.notes'),
            'status' => $request->input('data.attributes.status'),
            'user_id' => $request->input('data.relationships.customer.data.id')
        ];

        return new OrderResource(Order::create($model));
    }

    /**

app/Http/Requests/Api/V1/StoreOrderRequest.php — التغييرات

يجب أن تكون status إحدى pending أو paid أو shipped أو cancelled. ولا يُشترط معرّف العميل إلا في orders.store، لأن المسار المتداخل يحمله في العنوان.

     */
    public function authorize(): bool
    {
        return false;
        return true;
    }

    /**

     */
    public function rules(): array
    {
        $rules = [
            'data.attributes.reference' => 'required|string',
            'data.attributes.notes' => 'required|string',
            'data.attributes.status' => 'required|string|in:pending,paid,shipped,cancelled',
        ];

        if ($this->routeIs('orders.store')) {
            $rules['data.relationships.customer.data.id'] = 'required|integer';
        }

        return $rules;
    }

    /**
     * Get the error messages for the defined validation rules.
     */
    public function messages()
    {
        return [
            //
            'data.attributes.status' => 'The data.attributes.status value is invalid. Please use pending, paid, shipped, or cancelled.'
        ];
    }
}

قراءة طلب وتغيير حقل فيه وإعادة إرساله لا تحتاج إلى ترجمة بين صيغتين.

وسّع تسجيل الـResource المتداخل بالتزامن مع إضافة الدالة إلى Controller، حتى يبقى كل Route مسجّل قابلًا للتنفيذ:

Route::middleware('auth:sanctum')->apiResource(
    'customers.orders',
    CustomerOrdersController::class,
)->only(['index', 'store']);

التحقق

curl -s -X POST http://127.0.0.1:8000/api/v1/orders \
  -H "Accept: application/json" -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{
        "data": {
          "attributes": {
            "reference": "ORD-90001",
            "notes": "Created from the guide",
            "status": "pending"
          },
          "relationships": { "customer": { "data": { "id": 1 } } }
        }
      }'

يعود إليك الطلب الجديد. أرسل "status": "unknown" فتحصل على 422، وأرسل معرّف عميل 99999 فتحصل على 404.


14. حذف الموارد باستخدام DELETE

يحذف DELETE /api/v1/orders/{id} الطلب. والطلب غير الموجود يعيد JSON لا صفحة خطأ HTML.

بعد إضافة دالة destroy() المتداخلة أدناه، أضف Route الخاص بها إلى نقطة التحقق نفسها:

Route::middleware('auth:sanctum')->apiResource(
    'customers.orders',
    CustomerOrdersController::class,
)->only(['index', 'store', 'destroy']);

الكود

app/Http/Controllers/Api/V1/CustomerOrdersController.php — التغييرات

الأمر نفسه في المسار المتداخل.

والوراثة من ApiController هي ما يضع ok() وerror() في المتناول — فقد وصلت السمة إليه في القسم الثالث عشر.

وبهذا يُغلق أيضًا بابٌ بقي مفتوحًا في القسم السابق. فدالة store() تأخذ معرّف العميل من العنوان مباشرة وتسلّمه إلى Order::create()، ومن ثمّ كان المعرّف غير الموجود يصل إلى قاعدة البيانات ويعود بالخطأ 500 SQLSTATE[23000]: Integrity constraint violation. أما store() في المستوى الأعلى فكانت تحرس من ذلك أصلًا، والآن صار بوسع المتداخلة أن تجيب بالطريقة نفسها.

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Http\Filters\V1\OrderFilter;
use App\Http\Requests\Api\V1\StoreOrderRequest;
use App\Http\Resources\V1\OrderResource;
use App\Models\Order;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;

class CustomerOrdersController extends Controller
class CustomerOrdersController extends ApiController
{
    /**
     * Display a listing of the resource.

    public function store($customer_id, StoreOrderRequest $request)
    {
        try {
            User::findOrFail($customer_id);
        } catch (ModelNotFoundException $exception) {
            return $this->error('The provided customer id does not exist.', 404);
        }

        $model = [

        return new OrderResource(Order::create($model));
    }

    /**
     * Remove the specified resource from storage.
     */
    public function destroy($customer_id, $order_id)
    {
        try {
            $order = Order::findOrFail($order_id);

            if ($order->user_id == $customer_id) {
                $order->delete();
                return $this->ok('Order successfully deleted');
            }

            return $this->error('Order cannot be found.', 404);

        } catch (ModelNotFoundException $exception) {
            return $this->error('Order cannot be found.', 404);
        }
    }
}

app/Http/Controllers/Api/V1/OrderController.php — التغييرات

استخدام findOrFail() داخل try يتيح للمتحكم أن يجيب بـ404 بصيغته الخاصة. ولهذا صارت الدالتان تستقبلان معرّفاً بدل نموذج مربوط.

    /**
     * Display the specified resource.
     */
    public function show(Order $order)
    public function show($order_id)
    {
        if ($this->include('customer')) {
            return new OrderResource($order->load('customer'));
        try {
            $order = Order::findOrFail($order_id);

            if ($this->include('customer')) {
                return new OrderResource($order->load('customer'));
            }

            return new OrderResource($order);
        } catch (ModelNotFoundException $exception) {
            return $this->error('Order cannot be found.', 404);
        }

        return new OrderResource($order);
    }

    /**

    /**
     * Remove the specified resource from storage.
     */
    public function destroy(Order $order)
    public function destroy($order_id)
    {
        //
        try {
            $order = Order::findOrFail($order_id);
            $order->delete();

            return $this->ok('Order successfully deleted');
        } catch (ModelNotFoundException $exception) {
            return $this->error('Order cannot be found.', 404);
        }
    }
}

ينقل القسم الثالث والعشرون هذه المعالجة إلى مكان واحد، فتُحذف كتل try من المتحكمات من جديد.

التحقق

curl -i -X DELETE http://127.0.0.1:8000/api/v1/orders/1 \
  -H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"

curl -i -X DELETE http://127.0.0.1:8000/api/v1/orders/1 \
  -H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"

curl -i -X DELETE http://127.0.0.1:8000/api/v1/customers/9999/orders/2 \
  -H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"

النداء الأول يحذف الطلب، والثاني يعيد 404 مع Order cannot be found. والثالث يعيد 404 كذلك — إذ لا يحذف المسار المتداخل طلبًا إلا إذا كان المعرّف في العنوان هو مالكه، فتوجيه عميل خاطئ إلى طلب موجود يُقرأ كأنه مفقود لا أن يحذفه.


15. الاستبدال الكامل باستخدام PUT

يستبدل PUT الطلب كاملاً. أغفل حقلاً واحداً فيفشل الطلب.

توليد الأصناف

php artisan make:request Api/V1/ReplaceOrderRequest

الكود

app/Http/Controllers/Api/V1/CustomerOrdersController.php — التغييرات

ينال المسار المتداخل replace() أيضًا. قيّد البحث بمعرّفي URL من البداية كما تفعل destroy(). فالطلب الذي يُطلب عبر URL لعميل آخر يجب أن يعامل كطلب غير موجود ويعيد 404، لا أن تنتهي الدالة باستجابة 200 فارغة.

namespace App\Http\Controllers\Api\V1;

use App\Http\Filters\V1\OrderFilter;
use App\Http\Requests\Api\V1\ReplaceOrderRequest;
use App\Http\Requests\Api\V1\StoreOrderRequest;
use App\Http\Resources\V1\OrderResource;
use App\Models\Order;

    }

    /**
     * Replace the specified resource in storage.
     */
    public function replace(ReplaceOrderRequest $request, $customer_id,  $order_id)
    {
        // PUT
        try {
            $order = Order::where('id', $order_id)
                ->where('user_id', $customer_id)
                ->firstOrFail();

                $model = [
                    'reference' => $request->input('data.attributes.reference'),
                    'notes' => $request->input('data.attributes.notes'),
                    'status' => $request->input('data.attributes.status'),
                    'user_id' => $request->input('data.relationships.customer.data.id')
                ];

            $order->update($model);
            return new OrderResource($order);

        } catch (ModelNotFoundException $exception) {
            return $this->error('Order cannot be found.', 404);
        }
    }

    /**
     * Remove the specified resource from storage.
     */
    public function destroy($customer_id, $order_id)

app/Http/Controllers/Api/V1/OrderController.php — التغييرات

تكتب replace() فوق كل الأعمدة وتعيد المورد المحدَّث.

namespace App\Http\Controllers\Api\V1;

use App\Http\Filters\V1\OrderFilter;
use App\Http\Requests\Api\V1\ReplaceOrderRequest;
use App\Models\Order;
use App\Http\Requests\Api\V1\StoreOrderRequest;
use App\Http\Requests\Api\V1\UpdateOrderRequest;

    /**
     * Update the specified resource in storage.
     */
    public function update(UpdateOrderRequest $request, Order $order)
    public function update(UpdateOrderRequest $request, $order_id)
    {
        //
        // PATCH
    }

    /**
     * Replace the specified resource in storage.
     */
    public function replace(ReplaceOrderRequest $request, $order_id)
    {
        // PUT
        try {
            $order = Order::findOrFail($order_id);

            $model = [
                'reference' => $request->input('data.attributes.reference'),
                'notes' => $request->input('data.attributes.notes'),
                'status' => $request->input('data.attributes.status'),
                'user_id' => $request->input('data.relationships.customer.data.id')
            ];

            $order->update($model);

            return new OrderResource($order);
        } catch (ModelNotFoundException $exception) {
            return $this->error('Order cannot be found.', 404);
        }
    }

    /**

app/Http/Requests/Api/V1/ReplaceOrderRequest.php

قواعد الإنشاء نفسها مع وضع required على الجميع. وهذا هو الفارق الوحيد.

<?php

namespace App\Http\Requests\Api\V1;

use Illuminate\Foundation\Http\FormRequest;

class ReplaceOrderRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        $rules = [
            'data.attributes.reference' => 'required|string',
            'data.attributes.notes' => 'required|string',
            'data.attributes.status' => 'required|string|in:pending,paid,shipped,cancelled',
            'data.relationships.customer.data.id' => 'required|integer',
        ];

        return $rules;
    }

    /**
     * Get the error messages for the defined validation rules.
     */
    public function messages()
    {
        return [
            'data.attributes.status' => 'The data.attributes.status value is invalid. Please use pending, paid, shipped, or cancelled.'
        ];
    }
}

app/Http/Resources/V1/OrderResource.php — التغييرات

صار notes يظهر في كل مكان عدا القائمتين.

            'attributes' => [
                'reference' => $this->reference,
                'notes' => $this->when(
                    $request->routeIs('orders.show'),
                    !$request->routeIs(['orders.index', 'customers.orders.index']),
                    $this->notes
                ),
                'status' => $this->status,

routes/api_v1.php — التغييرات

يربط Laravel كلاً من PUT وPATCH بـupdate افتراضياً. وإسقاط update مع إضافة Route::put صراحةً يفصل بينهما.

|
*/

Route::middleware('auth:sanctum')->apiResource('orders', OrderController::class);
Route::middleware('auth:sanctum')->apiResource('customers', CustomersController::class);
Route::middleware('auth:sanctum')->apiResource('customers.orders', CustomerOrdersController::class);
Route::middleware('auth:sanctum')->group(function() {
    Route::apiResource('orders', OrderController::class)->except(['update']);
    Route::put('orders/{order}', [OrderController::class, 'replace']);

Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
    return $request->user();
    Route::apiResource('customers', CustomersController::class);
    Route::apiResource('customers.orders', CustomerOrdersController::class)->except(['show', 'update']);
    Route::put('customers/{customer}/orders/{order}', [CustomerOrdersController::class, 'replace']);

    Route::get('/user', function (Request $request) {
        return $request->user();
    });
});

التحقق

curl -i -X PUT http://127.0.0.1:8000/api/v1/orders/2 \
  -H "Accept: application/json" -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"data":{"attributes":{"reference":"ORD-90002","notes":"Replaced","status":"paid"},"relationships":{"customer":{"data":{"id":1}}}}}'

php artisan route:list --path=api/v1/orders

الجسم الكامل يعيد الطلب بعد استبداله. احذف notes فتحصل على 422.

وقائمة المسارات جديرة بالنظر. فقد اختفى السطر PUT|PATCH … orders.update وحلّ محله سطر PUT يشير إلى replace. ولا يستجيب شيء لـ PATCH بعد، فيعيد 405 إلى أن يعيده القسم التالي — إذ إن update() موجودة لكنها ما تزال هيكلًا فارغًا، ولا يصل إليها أي مسار.


16. التحديث الجزئي باستخدام PATCH

يغيّر PATCH الحقول التي ترسلها فقط، ويترك الباقي كما هو.

توليد الأصناف

php artisan make:request Api/V1/BaseOrderRequest

الكود

app/Http/Controllers/Api/V1/CustomerOrdersController.php — التغييرات

الانتقال نفسه إلى mappedAttributes()، مع إضافة update(). يبقى معرّف العميل المتداخل مأخوذًا من URL، لذلك ادمجه مع الحقول المعيّنة في التغيير نفسه. إسقاطه أثناء إعادة الهيكلة يترك user_id بلا قيمة ويحوّل POST متداخلًا كان يعمل إلى خطأ قاعدة بيانات 500.

use App\Http\Filters\V1\OrderFilter;
use App\Http\Requests\Api\V1\ReplaceOrderRequest;
use App\Http\Requests\Api\V1\StoreOrderRequest;
use App\Http\Requests\Api\V1\UpdateOrderRequest;
use App\Http\Resources\V1\OrderResource;
use App\Models\Order;
use Illuminate\Database\Eloquent\ModelNotFoundException;

     */
    public function store($customer_id, StoreOrderRequest $request)
    {
        $model = [
            'reference' => $request->input('data.attributes.reference'),
            'notes' => $request->input('data.attributes.notes'),
            'status' => $request->input('data.attributes.status'),
            'user_id' => $customer_id
        ];

        return new OrderResource(Order::create($model));
        return new OrderResource(Order::create(
            $request->mappedAttributes() + ['user_id' => $customer_id]
        ));
    }

    /**

            $model = [
                'reference' => $request->input('data.attributes.reference'),
                'notes' => $request->input('data.attributes.notes'),
                'status' => $request->input('data.attributes.status'),
                'user_id' => $request->input('data.relationships.customer.data.id')
            ];
            $order->update($model);
            $order->update($request->mappedAttributes());
        } catch (ModelNotFoundException $exception) {
            return $this->error('Order cannot be found.', 404);
        }
    }

    /**
     * Update the specified resource in storage.
     */
    public function update(UpdateOrderRequest $request, $customer_id,  $order_id)
    {
        // PUT
        try {
            $order = Order::where('id', $order_id)
                ->where('user_id', $customer_id)
                ->firstOrFail();

            $order->update($request->mappedAttributes());
            return new OrderResource($order);

وكما في replace()، يقيّد update() البحث بالمعرّفين معًا؛ لذلك يعيد تعديل طلب عبر URL لعميل خاطئ استجابة 404 عند نقطة التحقق هذه.

app/Http/Controllers/Api/V1/OrderController.php — التغييرات

تمرّر update() نتيجة mappedAttributes() إلى النموذج مباشرة.

            return $this->error('The provided customer id does not exist.', 404);
        }

        $model = [
            'reference' => $request->input('data.attributes.reference'),
            'notes' => $request->input('data.attributes.notes'),
            'status' => $request->input('data.attributes.status'),
            'user_id' => $request->input('data.relationships.customer.data.id')
        ];

        return new OrderResource(Order::create($model));
        return new OrderResource(Order::create($request->mappedAttributes()));
    }

    /**

    public function update(UpdateOrderRequest $request, $order_id)
    {
        // PATCH
        try {
            $order = Order::findOrFail($order_id);

            $order->update($request->mappedAttributes());

            return new OrderResource($order);
        } catch (ModelNotFoundException $exception) {
            return $this->error('Order cannot be found.', 404);
        }
    }

    /**

        try {
            $order = Order::findOrFail($order_id);

            $model = [
                'reference' => $request->input('data.attributes.reference'),
                'notes' => $request->input('data.attributes.notes'),
                'status' => $request->input('data.attributes.status'),
                'user_id' => $request->input('data.relationships.customer.data.id')
            ];

            $order->update($model);
            $order->update($request->mappedAttributes());

            return new OrderResource($order);
        } catch (ModelNotFoundException $exception) {

app/Http/Requests/Api/V1/BaseOrderRequest.php

تُبقي mappedAttributes() المفاتيح الموجودة فعلاً في الطلب. وهذا ما يجعل التحديث الجزئي ممكناً.

<?php

namespace App\Http\Requests\Api\V1;

use Illuminate\Foundation\Http\FormRequest;

class BaseOrderRequest extends FormRequest
{
    /**
     * Map the request attributes to their database columns.
     */
    public function mappedAttributes()
    {
        $attributeMap = [
            'data.attributes.reference' => 'reference',
            'data.attributes.notes' => 'notes',
            'data.attributes.status' => 'status',
            'data.attributes.createdAt' => 'created_at',
            'data.attributes.updatedAt' => 'updated_at',
            'data.relationships.customer.data.id' => 'user_id',
        ];

        $attributesToUpdate = [];
        foreach ($attributeMap as $key => $attribute) {
            if ($this->has($key)) {
                $attributesToUpdate[$attribute] = $this->input($key);
            }
        }

        return $attributesToUpdate;
    }

    /**
     * Get the error messages for the defined validation rules.
     */
    public function messages()
    {
        return [
            'data.attributes.status' => 'The data.attributes.status value is invalid. Please use pending, paid, shipped, or cancelled.'
        ];
    }
}

app/Http/Requests/Api/V1/ReplaceOrderRequest.php — التغييرات

صار يرث BaseOrderRequest، فتأتي الرسائل من الأب.

namespace App\Http\Requests\Api\V1;

use Illuminate\Foundation\Http\FormRequest;

class ReplaceOrderRequest extends FormRequest
class ReplaceOrderRequest extends BaseOrderRequest
{
    /**
     * Determine if the user is authorized to make this request.

        return $rules;
    }

    /**
     * Get the error messages for the defined validation rules.
     */
    public function messages()
    {
        return [
            'data.attributes.status' => 'The data.attributes.status value is invalid. Please use pending, paid, shipped, or cancelled.'
        ];
    }
}

app/Http/Requests/Api/V1/StoreOrderRequest.php — التغييرات

الأب نفسه والرسائل المشتركة نفسها.

namespace App\Http\Requests\Api\V1;

use Illuminate\Foundation\Http\FormRequest;

class StoreOrderRequest extends FormRequest
class StoreOrderRequest extends BaseOrderRequest
{
    /**
     * Determine if the user is authorized to make this request.

        return $rules;
    }

    /**
     * Get the error messages for the defined validation rules.
     */
    public function messages()
    {
        return [
            'data.attributes.status' => 'The data.attributes.status value is invalid. Please use pending, paid, shipped, or cancelled.'
        ];
    }
}

app/Http/Requests/Api/V1/UpdateOrderRequest.php

كل القواعد sometimes، فيُتجاوز الحقل الغائب بدل رفضه.

<?php

namespace App\Http\Requests\Api\V1;

use Illuminate\Foundation\Http\FormRequest;

class UpdateOrderRequest extends FormRequest
class UpdateOrderRequest extends BaseOrderRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return false;
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            //
        $rules = [
            'data.attributes.reference' => 'sometimes|string',
            'data.attributes.notes' => 'sometimes|string',
            'data.attributes.status' => 'sometimes|string|in:pending,paid,shipped,cancelled',
            'data.relationships.customer.data.id' => 'sometimes|integer',
        ];

        return $rules;
    }
}

routes/api_v1.php — التغييرات

مسار Route::patch صريح إلى جانب مسار PUT من القسم السابق.

Route::middleware('auth:sanctum')->group(function() {
    Route::apiResource('orders', OrderController::class)->except(['update']);
    Route::put('orders/{order}', [OrderController::class, 'replace']);
    Route::patch('orders/{order}', [OrderController::class, 'update']);

    Route::apiResource('customers', CustomersController::class);
    Route::apiResource('customers.orders', CustomerOrdersController::class)->except(['show', 'update']);
    Route::put('customers/{customer}/orders/{order}', [CustomerOrdersController::class, 'replace']);
    Route::patch('customers/{customer}/orders/{order}', [CustomerOrdersController::class, 'update']);

    Route::get('/user', function (Request $request) {
        return $request->user();

مشاركة القواعد عبر أب واحد تُبقي PUT وPATCH متوافقين كلما أُضيف حقل.

التحقق

curl -s -X PATCH http://127.0.0.1:8000/api/v1/orders/2 \
  -H "Accept: application/json" -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"data":{"attributes":{"status":"shipped"}}}'

تتغير الحالة ويبقى reference وnotes كما هما. والجسم نفسه عبر PUT كان سيفشل.


17. تفويض الموارد باستخدام Policies

ملاحظة Laravel 13: يدعم Laravel 13 أيضًا Authorization Attributes في Controllers. يُبقي هذا الدليل فحوصات Policy صريحة حتى يظل تدفق التفويض وتدرّج تطبيقه واضحين أثناء التعلم.

لم يعد يحدّث الطلب إلا العميل المالك له. وغيره يحصل على 403.

توليد الأصناف

php artisan make:policy V1/OrderPolicy --model=Order

الكود

app/Http/Controllers/Api/V1/ApiController.php — التغييرات

تغلّف isAble() الدالة authorize() وتمرّر معها صنف السياسة الخاص بالإصدار.

class ApiController extends Controller
{
    use ApiResponses;

    protected $policyClass;

    /**
     * Determine whether the given relationship was requested via the include parameter.

        return in_array(strtolower($relationship), $includeValues);
    }

    /**
     * Determine whether the current token grants the given ability.
     */
    public function isAble($ability, $targetModel)
    {
        return $this->authorize($ability, [$targetModel, $this->policyClass]);
    }
}

app/Http/Controllers/Api/V1/OrderController.php — التغييرات

تستدعي update() السياسة وتحوّل AuthorizationException إلى 403 بصيغة الـAPI.

use App\Http\Requests\Api\V1\UpdateOrderRequest;
use App\Http\Resources\V1\OrderResource;
use App\Models\User;
use App\Policies\V1\OrderPolicy;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;

class OrderController extends ApiController
{
    protected $policyClass = OrderPolicy::class;
    /**
     * Display a listing of the resource.
     */

        try {
            $order = Order::findOrFail($order_id);

            // policy
            $this->isAble('update', $order);

            $order->update($request->mappedAttributes());

            return new OrderResource($order);
        } catch (ModelNotFoundException $exception) {
            return $this->error('Order cannot be found.', 404);
        } catch (AuthorizationException $ex) {
            return $this->error('You are not authorized to update that resource', 403);
        }
    }

app/Policies/V1/OrderPolicy.php

تقارن المستخدم بـuser_id الخاص بالطلب.

<?php

namespace App\Policies\V1;

use App\Models\Order;
use App\Models\User;

class OrderPolicy
{
    /**
     * Create a new policy instance.
     */
    public function __construct()
    {
        //
    }

    /**
     * Determine whether the user can update the order.
     */
    public function update(User $user, Order $order)
    {
        // TODO check for token ability
        return $user->id === $order->user_id;
    }
}

app/Providers/AppServiceProvider.php

السياسة في مساحة أسماء V1، ولن يجدها Laravel اصطلاحاً. وGate::policy() تربطها.

<?php

namespace App\Providers;

use App\Models\Order;
use App\Policies\V1\OrderPolicy;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        //
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        Gate::policy(Order::class, OrderPolicy::class);
    }
}

التحقق يسأل: هل البيانات سليمة؟ والسياسة تسأل: هل يحق لهذا المستخدم لمس هذا السجل؟

ولم تُحرَس حتى الآن سوى update(). أما replace() وdestroy() وكل دوال المتحكم المتداخل فما تزال تعمل لأي حامل رمز صالح، ولذلك ينجح عند هذه النقطة كلٌّ من PUT وDELETE على طلب يخصّ غيرك. ويضيف القسم الثامن عشر الفحص نفسه إلى store وreplace وdelete، ويتولى القسم العشرون المتحكم المتداخل. فلا تقرأ هذا القسم على أنه أغلق المورد بالكامل — إنما أغلق فعلًا واحدًا.

التحقق

curl -i -X PATCH http://127.0.0.1:8000/api/v1/orders/1 \
  -H "Accept: application/json" -H "Content-Type: application/json" \
  -H "Authorization: Bearer TOKEN_OF_ANOTHER_CUSTOMER" \
  -d '{"data":{"attributes":{"status":"shipped"}}}'

curl -i -X DELETE http://127.0.0.1:8000/api/v1/orders/1 \
  -H "Accept: application/json" \
  -H "Authorization: Bearer TOKEN_OF_ANOTHER_CUSTOMER"

رمز عميل آخر يحصل على 403، ورمز المالك يحصل على الطلب بعد تحديثه. أما DELETE فما يزال يعيد 200 لأي أحد — وتلك هي الثغرة التي يسدّها القسم الثامن عشر.

وجرّب تعطيل سطر Gate::policy() ثم أعد المحاولة برمز المالك: يفشل الطلب بـ 403 أيضًا. إذ يبحث Laravel عن App\Policies\OrderPolicy بحكم العُرف، وهذه السياسة تقع في App\Policies\V1، فبغير التسجيل لا يجد سياسة فيمنع.


18. التحكم في الوصول بصلاحيات Token

رمز المدير يتصرف بأي طلب، ورمز العميل بطلباته فقط. نقاط النهاية نفسها، والإجابات مختلفة.

توليد الأصناف

php artisan make:class Permissions/V1/Abilities

يُضاف is_manager إلى هجرة المستخدمين القائمة، فنفّذ بعدها php artisan migrate:fresh --seed.

الكود

app/Http/Controllers/Api/AuthController.php — التغييرات

صارت createToken() تتلقى قائمة الصلاحيات بدل ['*'].

use App\Http\Controllers\Controller;
use App\Http\Requests\Api\LoginUserRequest;
use App\Models\User;
use App\Permissions\V1\Abilities;
use App\Traits\ApiResponses;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

            [
                'token' => $user->createToken(
                    'API token for ' . $user->email,
                    ['*'],
                    Abilities::getAbilities($user),
                    now()->addMonth())->plainTextToken
            ]
            );

app/Http/Controllers/Api/V1/OrderController.php — التغييرات

تمرّ store() عبر السياسة كذلك.

    {
        try {
            User::findOrFail($request->input('data.relationships.customer.data.id'));

            // policy
            $this->isAble('store', Order::class);

            return new OrderResource(Order::create($request->mappedAttributes()));
        } catch (ModelNotFoundException $exception) {
            return $this->error('The provided customer id does not exist.', 404);
        } catch (AuthorizationException $ex) {
            return $this->error('You are not authorized to create that resource', 403);
        }

        return new OrderResource(Order::create($request->mappedAttributes()));
    }

    /**

        try {
            $order = Order::findOrFail($order_id);

            // policy
            $this->isAble('replace', $order);

            $order->update($request->mappedAttributes());

            return new OrderResource($order);

    {
        try {
            $order = Order::findOrFail($order_id);

            // policy
            $this->isAble('delete', $order);

            $order->delete();

            return $this->ok('Order successfully deleted');

app/Permissions/V1/Abilities.php

كل صلاحية ثابت مستقل، مع القائمة التي ينالها كل نوع من المستخدمين.

<?php

namespace App\Permissions\V1;

use App\Models\User;

final class Abilities
{
    public const CreateOrder = 'order:create';
    public const UpdateOrder = 'order:update';
    public const ReplaceOrder = 'order:replace';
    public const DeleteOrder = 'order:delete';

    public const UpdateOwnOrder = 'order:own:update';
    public const DeleteOwnOrder = 'order:own:delete';

    public const CreateUser = 'user:create';
    public const UpdateUser = 'user:update';
    public const ReplaceUser = 'user:replace';
    public const DeleteUser = 'user:delete';

    /**
     * Get the token abilities granted to the given user.
     */
    public static function getAbilities(User $user)
    {
        if ($user->is_manager) {
            return [
                self::CreateOrder,
                self::UpdateOrder,
                self::ReplaceOrder,
                self::DeleteOrder,
                self::CreateUser,
                self::UpdateUser,
                self::ReplaceUser,
                self::DeleteUser,
            ];
        } else {
            return [
                self::CreateOrder,
                self::UpdateOwnOrder,
                self::DeleteOwnOrder
            ];
        }
    }
}

app/Policies/V1/OrderPolicy.php — التغييرات

القرار لـtokenCan(). الصلاحية العامة تمنح الإجراء، وصلاحية own تمنحه على سجلاتك أنت.

use App\Models\Order;
use App\Models\User;
use App\Permissions\V1\Abilities;

class OrderPolicy
{

    }

    /**
     * Determine whether the user can delete the order.
     */
    public function delete(User $user, Order $order)
    {
        if ($user->tokenCan(Abilities::DeleteOrder)) {
            return true;
        } else if ($user->tokenCan(Abilities::DeleteOwnOrder)) {
            return $user->id === $order->user_id;
        }

        return false;
    }

    /**
     * Determine whether the user can replace the order.
     */
    public function replace(User $user, Order $order)
    {
        if ($user->tokenCan(Abilities::ReplaceOrder)) {
            return true;
        }

        return false;
    }

    /**
     * Determine whether the user can create the order.
     */
    public function store(User $user)
    {
        if ($user->tokenCan(Abilities::CreateOrder)) {
            return true;
        }

        return false;
    }

    /**
     * Determine whether the user can update the order.
     */
    public function update(User $user, Order $order)
    {
        // TODO check for token ability
        return $user->id === $order->user_id;
        if ($user->tokenCan(Abilities::UpdateOrder)) {
            return true;
        } else if ($user->tokenCan(Abilities::UpdateOwnOrder)) {
            return $user->id === $order->user_id;
        }

        return false;
    }
}

database/migrations/0001_01_01_000000_create_users_table.php — التغييرات

يضيف العمود is_manager. سطر واحد داخل كتلة users القائمة — واترك بقية الملف كما هي، فهذه الهجرة تنشئ أيضاً جدولَي password_reset_tokens وsessions.

        Schema::create('users', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('email')->unique();
            $table->timestamp('email_verified_at')->nullable();
            $table->string('password');
            $table->boolean('is_manager')->default(false);
            $table->rememberToken();
            $table->timestamps();
        });

database/seeders/DatabaseSeeder.php — التغييرات

يزرع manager@manager.com بكلمة المرور password.

            ->recycle($users)
            ->create();

        User::create([
            'email' => 'manager@manager.com',
            'password' => bcrypt('password'),
            'name' => 'The Manager',
            'is_manager' => true
        ]);

        // User::factory()->create([
        //     'name' => 'Test User',
        //     'email' => 'test@example.com',

ولا يرد is_manager في قائمة #[Fillable] بالنموذج، ولا حاجة به إليها: إذ يغلّف db:seed تنفيذه بـ Model::unguarded()، فتتعطل حماية الإسناد الجماعي أثناء الزرع. أما لو ضبطت الراية نفسها عبر User::create() في كود التطبيق العادي لسقطت بلا تنبيه. ويجعلها القسم الحادي والعشرون قابلة للإسناد صراحةً حين يحتاج إليها.

الصلاحيات داخل الرمز نفسه، فتغيير الدور يعني إصدار رمز جديد، ويبقى كود السياسات كما هو.

التحقق

php artisan migrate:fresh --seed

curl -s -X POST http://127.0.0.1:8000/api/login \
  -H "Accept: application/json" -H "Content-Type: application/json" \
  -d '{"email":"manager@manager.com","password":"password"}'

بحساب المدير ينجح PATCH على أي طلب. وبحساب عميل مزروع يعيد طلب غيره 403.

وهذه الصورة الكاملة عند هذه النقطة، وكل سطر فيها جدير بالتجربة:

الإجراءالمديرالعميل على طلبهالعميل على طلب غيره
POST /orders201201
PATCH /orders/{id}200200403
PUT /orders/{id}200403403
DELETE /orders/{id}200200403

ولا يستطيع العميل تنفيذ PUT حتى على طلبه هو، لأن replace() لا تقبل إلا الصلاحية العامة ReplaceOrder ولا تُمنح لأي عميل. وهذا مقصود — فالاستبدال الكامل يشمل معرّف العميل، ومن ثمّ فهو أداة مدير. أما مسارات customers.orders المتداخلة فما تزال بلا حراسة، ويتناولها القسم العشرون.


19. صلاحيات دقيقة على مستوى الحقول

للعميل أن ينشئ طلباً، لكن لنفسه فقط. والفحص يقع أثناء التحقق لا بعد الإدراج.

الكود

app/Http/Controllers/Api/V1/OrderController.php — التغييرات

تسأل store() السياسة أولاً وتعيد 403 عند الرفض.

use App\Http\Requests\Api\V1\StoreOrderRequest;
use App\Http\Requests\Api\V1\UpdateOrderRequest;
use App\Http\Resources\V1\OrderResource;
use App\Models\User;
use App\Policies\V1\OrderPolicy;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;

    public function store(StoreOrderRequest $request)
    {
        try {
            User::findOrFail($request->input('data.relationships.customer.data.id'));

            // policy
           // policy
            $this->isAble('store', Order::class);

            return new OrderResource(Order::create($request->mappedAttributes()));
        } catch (ModelNotFoundException $exception) {
            return $this->error('The provided customer id does not exist.', 404);
        } catch (AuthorizationException $ex) {
            return $this->error('You are not authorized to create that resource', 403);
        }
    }

وتغطي قاعدة exists:users,id في كلاس الطلب ما كانت تغطيه User::findOrFail()، فيرحل البحث ومعه معالجة ModelNotFoundException. وبذلك يتغير الجواب عن معرّف عميل مجهول من 404 إلى 422 — أي فشل تحقق، وهو ما كان عليه في الأصل.

app/Http/Requests/Api/V1/StoreOrderRequest.php — التغييرات

مع صلاحية own وحدها تُضاف size: بمعرّف المستدعي إلى قاعدة معرّف العميل، فتفشل أي قيمة أخرى.

<?php

namespace App\Http\Requests\Api\V1;

use App\Permissions\V1\Abilities;

class StoreOrderRequest extends BaseOrderRequest
{

            'data.attributes.reference' => 'required|string',
            'data.attributes.notes' => 'required|string',
            'data.attributes.status' => 'required|string|in:pending,paid,shipped,cancelled',
            'data.relationships.customer.data.id' => 'required|integer|exists:users,id'
        ];

        $user = $this->user();

        if ($this->routeIs('orders.store')) {
            $rules['data.relationships.customer.data.id'] = 'required|integer';
            if ($user->tokenCan(Abilities::CreateOwnOrder)) {
                $rules['data.relationships.customer.data.id'] .= '|size:' . $user->id;
            }
        }

        return $rules;

app/Http/Requests/Api/V1/UpdateOrderRequest.php — التغييرات

لا يستطيع العميل نقل طلب إلى غيره، فالحقل prohibited بالنسبة له.

<?php

namespace App\Http\Requests\Api\V1;

use App\Permissions\V1\Abilities;

class UpdateOrderRequest extends BaseOrderRequest
{

            'data.relationships.customer.data.id' => 'sometimes|integer',
        ];

        if ($this->user()->tokenCan(Abilities::UpdateOwnOrder)) {
            $rules['data.relationships.customer.data.id'] = 'prohibited';
        }

        return $rules;
    }
}

app/Permissions/V1/Abilities.php — التغييرات

تضيف CreateOwnOrder، فيصبح الإنشاء للنفس صلاحية منفصلة.

    public const ReplaceOrder = 'order:replace';
    public const DeleteOrder = 'order:delete';

    public const CreateOwnOrder = 'order:own:create';
    public const UpdateOwnOrder = 'order:own:update';
    public const DeleteOwnOrder = 'order:own:delete';

     */
    public static function getAbilities(User $user)
    {
        // don't assign '*'
        if ($user->is_manager) {
            return [
                self::CreateOrder,

            ];
        } else {
            return [
                self::CreateOrder,
                self::CreateOwnOrder,
                self::UpdateOwnOrder,
                self::DeleteOwnOrder
            ];

app/Policies/V1/OrderPolicy.php — التغييرات

تقبل store() الصلاحية العامة أو صلاحية own.

     */
    public function store(User $user)
    {
        if ($user->tokenCan(Abilities::CreateOrder)) {
            return true;
        }

        return false;
        return $user->tokenCan(Abilities::CreateOrder) ||
               $user->tokenCan(Abilities::CreateOwnOrder);
    }

    /**

السياسة تُخوِّل الإجراء، ولا تحكم على قيمة حقل بعينه، فمكان ذلك الفحص صنف الطلب.

التحقق

curl -i -X POST http://127.0.0.1:8000/api/v1/orders \
  -H "Accept: application/json" -H "Content-Type: application/json" \
  -H "Authorization: Bearer CUSTOMER_TOKEN" \
  -d '{"data":{"attributes":{"reference":"ORD-90003","notes":"Mine","status":"pending"},"relationships":{"customer":{"data":{"id":999}}}}}'

برمز عميل ومعرّف غيره تحصل على 422، وبمعرّفك أنت يُنشأ الطلب.

POST /orders بمعرّف عميلالمديرالعميل
معرّفه هو201201
معرّف عميل آخر201422
معرّف غير موجود422422

والسطر الأخير هو التغيّر في السلوك: كان القسم الثالث عشر يجيب فيه بـ 404، ومن هنا فصاعداً يجيب بـ 422.

ونال PATCH قاعدة مقابلة. فالعميل الذي يرسل data.relationships.customer.data.id يحصل على 422 مهما كانت القيمة، لأن prohibited ترفض وجود الحقل لا محتواه — فله أن يعدّل طلبه، لا أن يسلّمه لغيره. أما المدير فيرسل الحقل نفسه فيعيد إسناد الطلب ويحصل على 200.


20. إدارة طلبيات العملاء

تكتسب المسارات المتداخلة الآن فحوصات Policy نفسها الموجودة في المستوى الأعلى، مع الحفاظ على البحث المقيّد بالعميل الذي أضفناه في الأقسام السابقة.

الكود

app/Http/Controllers/Api/V1/CustomerOrdersController.php

يواصل البحث استخدام المعرّفين معًا، فيظهر طلب عميل آخر بوصفه 404 بدل أن يُعدّل عبر URL خاطئ. يضيف القسم 20 التفويض من دون إضعاف حدّ الملكية هذا.

والملف معروض بكامله، فانتبه إلى تغييرين. لم تعد هناك حاجة إلى حارس User::findOrFail($customer_id) الذي أُضيف في القسم الرابع عشر: إذ تضع prepareForValidation() عميلَ المسار في الحمولة وتتحقق منه exists:users,id، فيصير العميل المجهول 422 قبل أن يعمل Controller — وهي المقايضة نفسها التي أجراها القسم التاسع عشر في المستوى الأعلى. ويمكن الآن استبدال دمج المصفوفة المؤقت من القسم 16 بالتعيين القابل لإعادة الاستخدام 'customer' => 'user_id'؛ كلاهما يبقي Endpoint المتداخل عاملًا، لكن الصيغة الجديدة تجمع الترجمة داخل FormRequest.

<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Filters\V1\OrderFilter;
use App\Http\Requests\Api\V1\ReplaceOrderRequest;
use App\Http\Requests\Api\V1\StoreOrderRequest;
use App\Http\Requests\Api\V1\UpdateOrderRequest;
use App\Http\Resources\V1\OrderResource;
use App\Models\Order;
use App\Policies\V1\OrderPolicy;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;

class CustomerOrdersController extends ApiController
{
    protected $policyClass = OrderPolicy::class;

    /**
     * Display a listing of the resource.
     */
    public function index($customer_id, OrderFilter $filters)
    {
        return OrderResource::collection(
            Order::where('user_id', $customer_id)->filter($filters)->paginate()
        );
    }

    /**
     * Store a newly created resource in storage.
     */
    public function store($customer_id, StoreOrderRequest $request)
    public function store(StoreOrderRequest $request, $customer_id)
    {
        return new OrderResource(Order::create($request->mappedAttributes()));
        try {
            // policy
             $this->isAble('store', Order::class);

             return new OrderResource(Order::create($request->mappedAttributes([
                'customer' => 'user_id'
             ])));
         } catch (AuthorizationException $ex) {
             return $this->error('You are not authorized to create that resource', 403);
         }
    }

    /**
     * Replace the specified resource in storage.
     */
    public function replace(ReplaceOrderRequest $request, $customer_id,  $order_id)
    {
        // PUT
        try {
            $order = Order::where('id', $order_id)
                            ->where('user_id', $customer_id)
                            ->firstOrFail();

            $this->isAble('replace', $order);

            $order->update($request->mappedAttributes());
            return new OrderResource($order);

        } catch (ModelNotFoundException $exception) {
            return $this->error('Order cannot be found.', 404);
        } catch (AuthorizationException $ex) {
            return $this->error('You are not authorized to update that resource', 403);
        }
    }

    /**
     * Update the specified resource in storage.
     */
    public function update(UpdateOrderRequest $request, $customer_id,  $order_id)
    {
        // PUT
        try {
            $order = Order::where('id', $order_id)
                            ->where('user_id', $customer_id)
                            ->firstOrFail();

            $this->isAble('update', $order);

            $order->update($request->mappedAttributes());
            return new OrderResource($order);
        } catch (ModelNotFoundException $exception) {
            return $this->error('Order cannot be found.', 404);
        } catch (AuthorizationException $ex) {
            return $this->error('You are not authorized to update that resource', 403);
        }
    }

    /**
     * Remove the specified resource from storage.
     */
    public function destroy($customer_id, $order_id)
    {
        try {
            $order = Order::findOrFail($order_id);
            $order = Order::where('id', $order_id)
                            ->where('user_id', $customer_id)
                            ->firstOrFail();

            if ($order->user_id == $customer_id) {
                $order->delete();
                return $this->ok('Order successfully deleted');
            }

            return $this->error('Order cannot be found.', 404);
            $this->isAble('delete', $order);

            $order->delete();
            return $this->ok('Order successfully deleted');
        } catch (ModelNotFoundException $exception) {
            return $this->error('Order cannot be found.', 404);
        } catch (AuthorizationException $ex) {
            return $this->error('You are not authorized to delete that resource', 403);
        }
    }
}

app/Http/Requests/Api/V1/BaseOrderRequest.php — التغييرات

صارت mappedAttributes() تقبل تعيينات إضافية، فيتحول مفتاح customer المتداخل إلى user_id.

    /**
     * Map the request attributes to their database columns.
     */
    public function mappedAttributes()
    public function mappedAttributes(array $otherAttributes = [])
    {
        $attributeMap = [
        $attributeMap = array_merge([
            'data.attributes.reference' => 'reference',
            'data.attributes.notes' => 'notes',
            'data.attributes.status' => 'status',
            'data.attributes.createdAt' => 'created_at',
            'data.attributes.updatedAt' => 'updated_at',
            'data.relationships.customer.data.id' => 'user_id',
        ];
        ], $otherAttributes);

        $attributesToUpdate = [];
        foreach ($attributeMap as $key => $attribute) {

app/Http/Requests/Api/V1/StoreOrderRequest.php — التغييرات

تنسخ prepareForValidation() العميل من المسار إلى بيانات الطلب، فتغطي مجموعة قواعد واحدة المسارين.

     */
    public function rules(): array
    {
        $customerIdAttr = $this->routeIs('orders.store') ? 'data.relationships.customer.data.id' : 'customer';

        $rules = [
            'data.attributes.reference' => 'required|string',
            'data.attributes.notes' => 'required|string',
            'data.attributes.status' => 'required|string|in:pending,paid,shipped,cancelled',
            'data.relationships.customer.data.id' => 'required|integer|exists:users,id'
            $customerIdAttr => 'required|integer|exists:users,id'
        ];

        $user = $this->user();

        if ($this->routeIs('orders.store')) {
            if ($user->tokenCan(Abilities::CreateOwnOrder)) {
                $rules['data.relationships.customer.data.id'] .= '|size:' . $user->id;
            }
        if ($user->tokenCan(Abilities::CreateOwnOrder)) {
            $rules[$customerIdAttr] .= '|size:' . $user->id;
        }

        return $rules;
    }

    /**
     * Prepare the data for validation.
     */
    protected function prepareForValidation()
    {
        if ($this->routeIs('customers.orders.store')) {
            $this->merge([
                'customer' => $this->route('customer')
            ]);
        }
    }
}

app/Policies/V1/OrderPolicy.php — التغييرات

تنكمش replace() إلى التعبير الواحد الذي كانته دوماً. ولا تمنحها إلا الصلاحية العامة ReplaceOrder، فلا يستطيع العميل استبدال طلب ولو عبر عنوانه المتداخل هو.

     */
    public function replace(User $user, Order $order)
    {
        if ($user->tokenCan(Abilities::ReplaceOrder)) {
            return true;
        }

        return false;
        return $user->tokenCan(Abilities::ReplaceOrder);
    }

    /**

التحقق

curl -i -X PUT http://127.0.0.1:8000/api/v1/customers/1/orders/5 \
  -H "Accept: application/json" -H "Content-Type: application/json" \
  -H "Authorization: Bearer MANAGER_TOKEN" \
  -d '{"data":{"attributes":{"reference":"ORD-90004","notes":"Nested replace","status":"paid"},"relationships":{"customer":{"data":{"id":1}}}}}'

curl -i -X POST http://127.0.0.1:8000/api/v1/customers/1/orders \
  -H "Accept: application/json" -H "Content-Type: application/json" \
  -H "Authorization: Bearer MANAGER_TOKEN" \
  -d '{"data":{"attributes":{"reference":"ORD-90005","notes":"Nested create","status":"pending"}}}'

إن لم يكن الطلب 5 عائداً للعميل 1 فستحصل على 404 رغم وجود الطلب. وأبقِ علاقة customer في جسم PUT: فما تزال ReplaceOrderRequest تشترطها، والتحقق يسبق المتحكم، ومن ثمّ فحذفها يعطيك 422 لا 404 التي تبحث عنها.

أما POST فهو الجدير بالمتابعة. لا يحمل جسم الطلب معرّف عميل؛ بل يأخذه من URL ويعيد 201.


21. إدارة المستخدمين بأمان

مورد users كامل للرموز الحاملة لصلاحيات المستخدمين. وcustomers يصبح للقراءة فقط.

توليد الأصناف

php artisan make:controller Api/V1/UserController --api --model=User
php artisan make:request Api/V1/BaseUserRequest
php artisan make:request Api/V1/ReplaceUserRequest
php artisan make:policy V1/UserPolicy --model=User

الصنفان StoreUserRequest وUpdateUserRequest موجودان منذ القسم الثامن.

الكود

app/Http/Controllers/Api/V1/CustomersController.php — التغييرات

صار مقتصراً على index وshow، والعميل هو مستخدم أنشأ طلباً واحداً على الأقل.

     */
    public function index(CustomerFilter $filters)
    {
        return UserResource::collection(User::filter($filters)->paginate());
        return UserResource::collection(
            User::has('orders')->filter($filters)->paginate()
        );
    }

    /**

ونستخدم has('orders') لا الربط. فالربط بجدول orders مع distinct() يبدو مكافئاً وليس كذلك: إذ يجري استعلام العدّ في المُصفِّح على صفوف الربط، فيُبلغ meta.total بعدد الطلبات لا بعدد العملاء. ومع 116 طلباً موزعة على 10 عملاء تدّعي النقطة وجود 8 صفحات، وتحمل الصفحة الأولى العشرة كلها، وتعود الصفحات من 2 إلى 8 فارغة. أما has() فتقيّد باستعلام فرعي، فيبقى صف واحد لكل مستخدم، ويُعدّ ما يتصفحه العميل فعلاً.

app/Http/Controllers/Api/V1/UserController.php

متحكم عمليات المستخدمين، مع فحص سياسة في كل إجراء.

<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Filters\V1\CustomerFilter;
use App\Http\Requests\Api\V1\ReplaceUserRequest;
use App\Models\User;
use App\Http\Requests\Api\V1\StoreUserRequest;
use App\Http\Requests\Api\V1\UpdateUserRequest;
use App\Http\Resources\V1\UserResource;
use App\Policies\V1\UserPolicy;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;

class UserController extends ApiController
{
    protected $policyClass = UserPolicy::class;
    /**
     * Display a listing of the resource.
     */
    public function index(CustomerFilter $filters)
    {
        return UserResource::collection(
            User::filter($filters)->paginate()
        );
    }

    /**
     * Store a newly created resource in storage.
     */
    public function store(StoreUserRequest $request)
    {
        try {
            // policy
             $this->isAble('store', User::class);

             return new UserResource(User::create($request->mappedAttributes()));
         } catch (AuthorizationException $ex) {
             return $this->error('You are not authorized to create that resource', 403);
         }
    }

    /**
     * Display the specified resource.
     */
    public function show(User $user)
    {
        if ($this->include('orders')) {
            return new UserResource($user->load('orders'));
        }

        return new UserResource($user);
    }

    /**
     * Update the specified resource in storage.
     */
    public function update(UpdateUserRequest $request, $user_id)
    {
        try {
            $user = User::findOrFail($user_id);

            // policy
            $this->isAble('update', $user);

            $user->update($request->mappedAttributes());

            return new UserResource($user);
        } catch (ModelNotFoundException $exception) {
            return $this->error('User cannot be found.', 404);
        } catch (AuthorizationException $ex) {
            return $this->error('You are not authorized to update that resource', 403);
        }
    }

    /**
     * Replace the specified resource in storage.
     */
    public function replace(ReplaceUserRequest $request, $user_id)
    {
        // PUT
        try {
            $user = User::findOrFail($user_id);

            // policy
            $this->isAble('replace', $user);

            $user->update($request->mappedAttributes());

            return new UserResource($user);
        } catch (ModelNotFoundException $exception) {
            return $this->error('User cannot be found.', 404);
        } catch (AuthorizationException $ex) {
            return $this->error('You are not authorized to replace that resource', 403);
        }
    }

    /**
     * Remove the specified resource from storage.
     */
    public function destroy($user_id)
    {
        try {
            $user = User::findOrFail($user_id);

            // policy
            $this->isAble('delete', $user);

            $user->delete();

            return $this->ok('User successfully deleted');
        } catch (ModelNotFoundException $exception) {
            return $this->error('User cannot be found.', 404);
        } catch (AuthorizationException $ex) {
            return $this->error('You are not authorized to delete that resource', 403);
        }
    }
}

وتحتاج replace() وdestroy() إلى معالجة AuthorizationException بقدر ما تحتاجها update(). فبدونها يفلت الاستثناء من المتحكم، وبينما يظل Laravel يعرض 403 فإنه يفعل ذلك بالشكل {"message": "This action is unauthorized."} — أي بصيغة إطار العمل لا بغلاف message/status الذي تستعمله كل أخطاء هذه الواجهة.

app/Http/Requests/Api/V1/BaseUserRequest.php

تمرّر mappedAttributes() كلمة المرور عبر bcrypt()، فلا تصل قيمة صريحة إلى الجدول.

<?php

namespace App\Http\Requests\Api\V1;

use Illuminate\Foundation\Http\FormRequest;

class BaseUserRequest extends FormRequest
{
    /**
     * Map the request attributes to their database columns.
     */
    public function mappedAttributes(array $otherAttributes = [])
    {
        $attributeMap = array_merge([
            'data.attributes.name' => 'name',
            'data.attributes.email' => 'email',
            'data.attributes.isManager' => 'is_manager',
            'data.attributes.password' => 'password',
        ], $otherAttributes);

        $attributesToUpdate = [];
        foreach ($attributeMap as $key => $attribute) {
            if ($this->has($key)) {
                $value = $this->input($key);

                if ($attribute === 'password') {
                    $value = bcrypt($value);
                }

                $attributesToUpdate[$attribute] = $value;
            }
        }

        return $attributesToUpdate;
    }
}

app/Http/Requests/Api/V1/ReplaceUserRequest.php

كل الحقول مطلوبة، كما في طلب استبدال الطلبات.

<?php

namespace App\Http\Requests\Api\V1;

class ReplaceUserRequest extends BaseUserRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        $rules = [
            'data.attributes.name' => 'required|string',
            'data.attributes.email' => 'required|email',
            'data.attributes.isManager' => 'required|boolean',
            'data.attributes.password' => 'required|string',
        ];

        return $rules;
    }
}

app/Http/Requests/Api/V1/StoreUserRequest.php

الملف كاملاً، مع تعليم تغييرات هذه الخطوة.

<?php

namespace App\Http\Requests\Api\V1;

use Illuminate\Foundation\Http\FormRequest;

class StoreUserRequest extends FormRequest
class StoreUserRequest extends BaseUserRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return false;
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            //
            'data.attributes.name' => 'required|string',
            'data.attributes.email' => 'required|email',
            'data.attributes.isManager' => 'required|boolean',
            'data.attributes.password' => 'required|string',
        ];
    }
}

app/Http/Requests/Api/V1/UpdateUserRequest.php

الملف كاملاً، مع تعليم تغييرات هذه الخطوة.

<?php

namespace App\Http\Requests\Api\V1;

use Illuminate\Foundation\Http\FormRequest;

class UpdateUserRequest extends FormRequest
class UpdateUserRequest extends BaseUserRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return false;
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            //
            'data.attributes.name' => 'sometimes|string',
            'data.attributes.email' => 'sometimes|email',
            'data.attributes.isManager' => 'sometimes|boolean',
            'data.attributes.password' => 'sometimes|string',
        ];
    }
}

app/Http/Resources/V1/UserResource.php — التغييرات

يضيف isManager إلى الاستجابة.

            'attributes' => [
                'name' => $this->name,
                'email' => $this->email,
                'isManager' => $this->is_manager,
                $this->mergeWhen($request->routeIs('customers.*'), [
                    'emailVerifiedAt' => $this->email_verified_at,
                    'createdAt' => $this->created_at,

app/Models/User.php — التغييرات

يصبح is_manager قابلاً للإسناد ويُحوَّل إلى قيمة منطقية. أما password فكان محوَّلاً إلى hashed أصلاً.

#[Fillable(['name', 'email', 'password'])]
#[Fillable(['name', 'email', 'password', 'is_manager'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{

    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
            'is_manager' => 'boolean',
        ];
    }

ولم يكن is_manager يصل إلى الجدول حتى الآن إلا عبر الزارع الذي يعمل بلا حراسة إسناد. أما POST /api/v1/users فيمرّ عبر User::create() في معالجة طلب عادية، ولذلك لولا إدراجه في #[Fillable] لسقطت الراية ولخرج كل مستخدم يُنشأ عبر الواجهة غيرَ مدير.

app/Policies/V1/UserPolicy.php

من يجوز له إنشاء المستخدم وتحديثه واستبداله وحذفه.

<?php

namespace App\Policies\V1;

use App\Models\User;
use App\Permissions\V1\Abilities;

class UserPolicy
{
    /**
     * Create a new policy instance.
     */
    public function __construct()
    {
        //
    }

    /**
     * Determine whether the user can delete the given user account.
     */
    public function delete(User $user, User $model)
    {
        return $user->tokenCan(Abilities::DeleteUser);
    }

    /**
     * Determine whether the user can replace the given user account.
     */
    public function replace(User $user, User $model)
    {
        return $user->tokenCan(Abilities::ReplaceUser);
    }

    /**
     * Determine whether the user can create the given user account.
     */
    public function store(User $user)
    {
        return $user->tokenCan(Abilities::CreateUser);
    }

    /**
     * Determine whether the user can update the given user account.
     */
    public function update(User $user, User $model)
    {
        return $user->tokenCan(Abilities::UpdateUser);
    }
}

app/Providers/AppServiceProvider.php — التغييرات

يسجّل سياسة المستخدمين إلى جانب سياسة الطلبات.

namespace App\Providers;

use App\Models\Order;
use App\Models\User;
use App\Policies\V1\OrderPolicy;
use App\Policies\V1\UserPolicy;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;

    public function boot(): void
    {
        Gate::policy(Order::class, OrderPolicy::class);
        Gate::policy(User::class, UserPolicy::class);
    }
}

routes/api_v1.php — التغييرات

ينال users الفصل نفسه بين PUT وPATCH.

use App\Http\Controllers\Api\V1\OrderController;
use App\Http\Controllers\Api\V1\CustomersController;
use App\Http\Controllers\Api\V1\CustomerOrdersController;
use App\Http\Controllers\Api\V1\UserController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

    Route::put('orders/{order}', [OrderController::class, 'replace']);
    Route::patch('orders/{order}', [OrderController::class, 'update']);

    Route::apiResource('customers', CustomersController::class);
    Route::apiResource('users', UserController::class)->except(['update']);
    Route::put('users/{user}', [UserController::class, 'replace']);
    Route::patch('users/{user}', [UserController::class, 'update']);

    Route::apiResource('customers', CustomersController::class)->except(['store','update','destroy']);
    Route::apiResource('customers.orders', CustomerOrdersController::class)->except(['show', 'update']);
    Route::put('customers/{customer}/orders/{order}', [CustomerOrdersController::class, 'replace']);
    Route::patch('customers/{customer}/orders/{order}', [CustomerOrdersController::class, 'update']);

العملاء والمستخدمون جدول واحد من زاويتين: customers ما يقرأه عميل الطلبات، وusers واجهة الإدارة.

التحقق

php artisan route:list --path=api/v1/users

curl -i -X POST http://127.0.0.1:8000/api/v1/users \
  -H "Accept: application/json" -H "Content-Type: application/json" \
  -H "Authorization: Bearer MANAGER_TOKEN" \
  -d '{"data":{"attributes":{"name":"New User","email":"new@example.com","isManager":false,"password":"password"}}}'

رمز المدير ينشئ المستخدم ولا تحمل الاستجابة كلمة مرور — إذ تُبقيها #[Hidden] في النموذج خارجها، وتعني bcrypt() في mappedAttributes() أن المخزَّن بصمة لا نص. وأرسِل "isManager": true فيخرج المستخدم مديراً فعلاً؛ وهذا ما يشتريه إدراجه الجديد في #[Fillable].

ورمز العميل يحصل على 403 في POST وPATCH وPUT وDELETE، كلٌّ بصيغة أخطاء الواجهة نفسها. أما GET فشأن آخر: فأي رمز موثَّق ما يزال يقرأ /api/v1/users بما فيه راية isManager لكل حساب. والقسم الثاني والعشرون هو موضع تشديد ذلك.

وcustomers صار للقراءة فقط من هنا: إذ يجيب POST /api/v1/customers وDELETE /api/v1/customers/{id} كلاهما بـ 405.


22. تطبيق مبدأ الحد الأدنى من الصلاحيات

نقاط النهاية نفسها بإعدادات أضيق. الإجراء المرفوض يعيد 403 نظيفة، وكتابة العميل مقيّدة بسجلاته.

الكود

app/Http/Controllers/Api/V1/ApiController.php — التغييرات

تعيد isAble() القيمة true أو false بدل رمي استثناء.

use App\Http\Controllers\Controller;
use App\Traits\ApiResponses;
use Illuminate\Auth\Access\AuthorizationException;

class ApiController extends Controller
{

     */
    public function isAble($ability, $targetModel)
    {
        return $this->authorize($ability, [$targetModel, $this->policyClass]);
        try {
            $this->authorize($ability, [$targetModel, $this->policyClass]);
            return true;
        } catch (AuthorizationException $ex) {
            return false;
        }
    }
}

app/Http/Controllers/Api/V1/CustomerOrdersController.php — التغييرات

تُستبدل كتل try بـif ($this->isAble(...)).

use App\Http\Resources\V1\OrderResource;
use App\Models\Order;
use App\Policies\V1\OrderPolicy;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;

class CustomerOrdersController extends ApiController

     */
    public function store(StoreOrderRequest $request, $customer_id)
    {
        try {
            // policy
             $this->isAble('store', Order::class);
        if ($this->isAble('store', Order::class)) {
            return new OrderResource(Order::create($request->mappedAttributes([
                'customer' => 'user_id'
            ])));
        }

             return new OrderResource(Order::create($request->mappedAttributes([
                'customer' => 'user_id'
             ])));
         } catch (AuthorizationException $ex) {
             return $this->error('You are not authorized to create that resource', 403);
         }
        return $this->error('You are not authorized to create that resource', 403);
    }

    /**

                            ->where('user_id', $customer_id)
                            ->firstOrFail();

            $this->isAble('replace', $order);
            if ($this->isAble('replace', $order)) {
                $order->update($request->mappedAttributes());
                return new OrderResource($order);
            }

            $order->update($request->mappedAttributes());
            return new OrderResource($order);
            return $this->error('You are not authorized to update that resource', 403);

        } catch (ModelNotFoundException $exception) {
            return $this->error('Order cannot be found.', 404);
        } catch (AuthorizationException $ex) {
            return $this->error('You are not authorized to update that resource', 403);
        }
    }

                            ->where('user_id', $customer_id)
                            ->firstOrFail();

            $this->isAble('update', $order);
            if ($this->isAble('update', $order)) {
                $order->update($request->mappedAttributes());
                return new OrderResource($order);
            }

            $order->update($request->mappedAttributes());
            return new OrderResource($order);
            return $this->error('You are not authorized to update that resource', 403);

        } catch (ModelNotFoundException $exception) {
            return $this->error('Order cannot be found.', 404);
        } catch (AuthorizationException $ex) {
            return $this->error('You are not authorized to update that resource', 403);
        }
    }

                            ->where('user_id', $customer_id)
                            ->firstOrFail();

            $this->isAble('delete', $order);
            if ($this->isAble('delete', $order)) {
                $order->delete();
                return $this->ok('Order successfully deleted');
            }

            $order->delete();
            return $this->ok('Order successfully deleted');
            return $this->error('You are not authorized to delete that resource', 403);
        } catch (ModelNotFoundException $exception) {
            return $this->error('Order cannot be found.', 404);
        } catch (AuthorizationException $ex) {
            return $this->error('You are not authorized to delete that resource', 403);
        }
    }
}

app/Http/Controllers/Api/V1/OrderController.php — التغييرات

إعادة الكتابة نفسها في المتحكم الأعلى.

use App\Http\Requests\Api\V1\UpdateOrderRequest;
use App\Http\Resources\V1\OrderResource;
use App\Policies\V1\OrderPolicy;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;

class OrderController extends ApiController

     */
    public function store(StoreOrderRequest $request)
    {
        try {
           // policy
            $this->isAble('store', Order::class);
        if ($this->isAble('store', Order::class)) {
            return new OrderResource(Order::create($request->mappedAttributes()));
        }

            return new OrderResource(Order::create($request->mappedAttributes()));
        } catch (AuthorizationException $ex) {
            return $this->error('You are not authorized to create that resource', 403);
        }
        return $this->error('You are not authorized to create that resource', 403);
    }

    /**

        try {
            $order = Order::findOrFail($order_id);

            // policy
            $this->isAble('update', $order);
            if ($this->isAble('update', $order)) {
                $order->update($request->mappedAttributes());

            $order->update($request->mappedAttributes());
                return new OrderResource($order);
            }

            return new OrderResource($order);
            return $this->error('You are not authorized to update that resource', 403);

        } catch (ModelNotFoundException $exception) {
            return $this->error('Order cannot be found.', 404);
        } catch (AuthorizationException $ex) {
            return $this->error('You are not authorized to update that resource', 403);
        }
    }

            $order = Order::findOrFail($order_id);

            // policy
            $this->isAble('replace', $order);
            if ($this->isAble('replace', $order)) {
                $order->update($request->mappedAttributes());
                return new OrderResource($order);
            }

            $order->update($request->mappedAttributes());
            return $this->error('You are not authorized to update that resource', 403);

            return new OrderResource($order);
        } catch (ModelNotFoundException $exception) {
            return $this->error('Order cannot be found.', 404);
        }

            $order = Order::findOrFail($order_id);

            // policy
            $this->isAble('delete', $order);
            if ($this->isAble('delete', $order)) {
                $order->delete();

            $order->delete();
                return $this->ok('Order successfully deleted');
            }

            return $this->ok('Order successfully deleted');
            return $this->error('You are not authorized to delete that resource', 403);

        } catch (ModelNotFoundException $exception) {
            return $this->error('Order cannot be found.', 404);
        }

app/Http/Controllers/Api/V1/UserController.php — التغييرات

صار كل إجراء على صيغة if ($this->isAble(...))، فاختفت كتل try المتكررة.

use App\Http\Requests\Api\V1\UpdateUserRequest;
use App\Http\Resources\V1\UserResource;
use App\Policies\V1\UserPolicy;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;

class UserController extends ApiController

     */
    public function store(StoreUserRequest $request)
    {
        try {
            // policy
             $this->isAble('store', User::class);
        if ($this->isAble('store', User::class)) {
            return new UserResource(User::create($request->mappedAttributes()));
        }

             return new UserResource(User::create($request->mappedAttributes()));
         } catch (AuthorizationException $ex) {
             return $this->error('You are not authorized to create that resource', 403);
         }
        return $this->error('You are not authorized to create that resource', 403);
    }

    /**

        try {
            $user = User::findOrFail($user_id);

            // policy
            $this->isAble('update', $user);
            if ($this->isAble('update', $user)) {
                $user->update($request->mappedAttributes());

            $user->update($request->mappedAttributes());
                return new UserResource($user);
            }

            return new UserResource($user);
            return $this->error('You are not authorized to update that resource', 403);

        } catch (ModelNotFoundException $exception) {
            return $this->error('User cannot be found.', 404);
        } catch (AuthorizationException $ex) {
            return $this->error('You are not authorized to update that resource', 403);
        }
    }

        try {
            $user = User::findOrFail($user_id);

            // policy
            $this->isAble('replace', $user);
            if ($this->isAble('replace', $user)) {
                $user->update($request->mappedAttributes());

            $user->update($request->mappedAttributes());
                return new UserResource($user);
            }

            return new UserResource($user);
            return $this->error('You are not authorized to update that resource', 403);

        } catch (ModelNotFoundException $exception) {
            return $this->error('User cannot be found.', 404);
        }

        try {
            $user = User::findOrFail($user_id);

            // policy
            $this->isAble('delete', $user);
            if ($this->isAble('delete', $user)) {
                $user->delete();

            $user->delete();
                return $this->ok('User successfully deleted');
            }

            return $this->ok('User successfully deleted');
            return $this->error('You are not authorized to delete that resource', 403);

        } catch (ModelNotFoundException $exception) {
            return $this->error('User cannot be found.', 404);
        }

app/Http/Requests/Api/V1/StoreOrderRequest.php — التغييرات

معرّف العميل مثبّت على المستدعي، ولا يوسّعه إلا رمز يحمل order:create.

    public function rules(): array
    {
        $customerIdAttr = $this->routeIs('orders.store') ? 'data.relationships.customer.data.id' : 'customer';
        $user = $this->user();
        $customerRule = 'required|integer|exists:users,id';

        $rules = [
            'data.attributes.reference' => 'required|string',
            'data.attributes.notes' => 'required|string',
            'data.attributes.status' => 'required|string|in:pending,paid,shipped,cancelled',
            $customerIdAttr => 'required|integer|exists:users,id'
            $customerIdAttr => $customerRule . '|size:' . $user->id
        ];

        $user = $this->user();

        if ($user->tokenCan(Abilities::CreateOwnOrder)) {
            $rules[$customerIdAttr] .= '|size:' . $user->id;
        if ($user->tokenCan(Abilities::CreateOrder)) {
            $rules[$customerIdAttr] = $customerRule;
        }

        return $rules;

app/Http/Requests/Api/V1/UpdateOrderRequest.php — التغييرات

معرّف العميل prohibited ما لم يحمل الرمز الصلاحية order:update.

            'data.attributes.reference' => 'sometimes|string',
            'data.attributes.notes' => 'sometimes|string',
            'data.attributes.status' => 'sometimes|string|in:pending,paid,shipped,cancelled',
            'data.relationships.customer.data.id' => 'sometimes|integer',
            'data.relationships.customer.data.id' => 'prohibited',
        ];

        if ($this->user()->tokenCan(Abilities::UpdateOwnOrder)) {
            $rules['data.relationships.customer.data.id'] = 'prohibited';
        if ($this->user()->tokenCan(Abilities::UpdateOrder)) {
            $rules['data.relationships.customer.data.id'] = 'sometimes|integer';
        }

        return $rules;

app/Permissions/V1/Abilities.php — التغييرات

القائمة النهائية للصلاحيات. ولا ينال أي رمز الصلاحية *.

     */
    public static function getAbilities(User $user)
    {
        // don't assign '*'
        if ($user->is_manager) {
            return [
                self::CreateOrder,

ابدأ من القاعدة الضيقة ووسّعها للـTokens المميزة. عند غياب صلاحية أو نسيان أحد الفحوصات، يكون السلوك الافتراضي هو رفض الوصول.

التحقق

curl -i -X DELETE http://127.0.0.1:8000/api/v1/users/2 \
  -H "Accept: application/json" -H "Authorization: Bearer CUSTOMER_TOKEN"

curl -i -X POST http://127.0.0.1:8000/api/v1/orders \
  -H "Accept: application/json" -H "Content-Type: application/json" \
  -H "Authorization: Bearer CUSTOMER_TOKEN" \
  -d '{"data":{"attributes":{"reference":"ORD-90006","notes":"Mine","status":"pending"},"relationships":{"customer":{"data":{"id":3}}}}}'

رمز العميل يحصل على 403، ورمز المدير ينفّذ الحذف.

والاستجابات الظاهرة لا تكاد تتغير — فهذه الطلبات نفسها كانت مرفوضة أصلاً في القسم التاسع عشر. والذي تغيّر هو مصدر الرفض. فلم تعد isAble() ترمي استثناءً، ومن ثمّ يجيب كل متحكم عبر $this->error()، ويصل كل 403 في غلاف message/status بدل أن يفلت بصيغة Laravel {"message": "This action is unauthorized."}.

والباقي دفاعٌ في العمق، ويظهر أثره على رمز لا يحمل أياً من صلاحيتَي الطلبات:

php artisan tinker --execute="echo App\Models\User::find(2)->createToken('bare', ['user:create'])->plainTextToken;"

أنشئ طلباً بهذا الرمز مسمّياً العميل 7 فيفشل عند التحقق بـ 422، لأن معرّف العميل صار افتراضه معرّف المستدعي نفسه ولا يوسّعه إلا order:create. وسمِّ معرّفك أنت فيمرّ التحقق ثم ترفض السياسة بـ 403. أما في القواعد السابقة فلم يكن الحقل مقيَّداً إلا إذا حمل الرمز order:own:create، فكان الرمز الخالي من الصلاحيتين يجد قاعدة مفتوحة ولا يقف خلفها إلا السياسة.


23. معالجة موحدة لأخطاء API

شكل واحد للأخطاء في الـAPI كله. أخطاء التحقق والسجلات المفقودة والرموز المنتهية تعود بالطريقة نفسها.

الكود

bootstrap/app.php — التغييرات

معالجات عرض لـValidationException وModelNotFoundException وNotFoundHttpException وAuthenticationException. ويعود كل منها مبكراً خارج api/*، فيحتفظ الجانب الويبي بسلوك Laravel.

<?php

use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(

        $exceptions->shouldRenderJsonWhen(
            fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
        );

        $exceptions->render(function (ValidationException $exception, Request $request) {
            if (! $request->is('api/*')) {
                return null;
            }

            $errors = collect($exception->errors())
                ->flatMap(fn (array $messages, string $field) =>
                    collect($messages)->map(fn (string $message) => [
                        'status' => 422,
                        'message' => $message,
                        'source' => $field,
                    ])
                )->values()->all();

            return response()->json(['errors' => $errors], 422);
        });

        $exceptions->render(function (ModelNotFoundException $exception, Request $request) {
            if (! $request->is('api/*')) {
                return null;
            }

            return response()->json(['errors' => [[
                'status' => 404,
                'message' => 'The resource cannot be found.',
                'source' => $exception->getModel(),
            ]]], 404);
        });

        $exceptions->render(function (NotFoundHttpException $exception, Request $request) {
            if (! $request->is('api/*')) {
                return null;
            }

            $previous = $exception->getPrevious();

            return response()->json(['errors' => [[
                'status' => 404,
                'message' => 'The resource cannot be found.',
                'source' => $previous instanceof ModelNotFoundException
                    ? $previous->getModel()
                    : '',
            ]]], 404);
        });

        $exceptions->render(function (AuthenticationException $exception, Request $request) {
            if (! $request->is('api/*')) {
                return null;
            }

            return response()->json(['errors' => [[
                'status' => 401,
                'message' => 'Unauthenticated.',
                'source' => '',
            ]]], 401);
        });
    })->create();

ومعالج NotFoundHttpException هو الذي يؤدي العمل فعلاً، ولا يكفي معالج ModelNotFoundException وحده. إذ يحوّل معالج Laravel استثناء ModelNotFoundException إلى NotFoundHttpException قبل أن يُستشار معالجو العرض، فلا يعمل معالج مسجَّل للأول فقط. وهذا أهمّ بعد هذا القسم منه قبله: فكل findOrFail() وكتلة try الخاصة بها على وشك أن يحلّ محلها ربط النماذج بالمسار، ومن ثمّ يصير فشل الربط هو السبيل الوحيد لحدوث 404. سجّل المعالج الأول وحده فيجيب كل سجل مفقود بصيغة Laravel {"message": "No query results for model [App\\Models\\Order] 999999"} — ومع تفعيل APP_DEBUG بأثر مكدّس كامل.

كما يلتقط اعتراضُ NotFoundHttpException أيَّ عنوان غير مطابق تحت api/*، فتعيد النقطة المكتوبة خطأً الغلافَ نفسه بدل صفحة HTML. ويبقى الاستثناء الأصلي في getPrevious()، ومنه يأتي source حين يكون السبب بحثاً عن نموذج.

routes/api_v1.php — التغييرات

يحل ربط النماذج بالمسار محل عمليات البحث اليدوية أدناه، وتحتاج المسارات المتداخلة إلى scopeBindings() للحفاظ على فحص الملكية الذي أضافه القسم العشرون.

    Route::apiResource('customers', CustomersController::class)->except(['store','update','destroy']);
    Route::apiResource('customers.orders', CustomerOrdersController::class)->except(['show', 'update']);
    Route::put('customers/{customer}/orders/{order}', [CustomerOrdersController::class, 'replace']);
    Route::patch('customers/{customer}/orders/{order}', [CustomerOrdersController::class, 'update']);
    Route::scopeBindings()->group(function () {
    Route::apiResource('customers.orders', CustomerOrdersController::class)->except(['show', 'update']);
        Route::put('customers/{customer}/orders/{order}', [CustomerOrdersController::class, 'replace']);
        Route::patch('customers/{customer}/orders/{order}', [CustomerOrdersController::class, 'update']);
    });

وبدون هذا ينقض التعديلُ أدناه القسمَ العشرين بصمت. فقد كان Order::where('id', $order_id)->where('user_id', $customer_id)->firstOrFail() يطابق بالمعرّفين معاً؛ أما ربط Order $order المجرّد فيطابق بمعرّف الطلب وحده، ولذلك يجد PATCH /customers/3/orders/{order-owned-by-2} الطلبَ عبر عنوان عميل خاطئ فيعدّله رمز المدير. وتخبر scopeBindings() إطارَ العمل أن يحلّ {order} عبر $customer->orders()، فتعود 404. ولاحظ أنها توضع على مجموعة — إذ لا تملك PendingResourceRegistration دالة scopeBindings()، وتسلسلها على apiResource(...) يرمي BadMethodCallException.

app/Http/Controllers/Api/V1/CustomerOrdersController.php

الملف كاملاً، مع تعليم تغييرات هذه الخطوة.

<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Filters\V1\OrderFilter;
use App\Http\Requests\Api\V1\ReplaceOrderRequest;
use App\Http\Requests\Api\V1\StoreOrderRequest;
use App\Http\Requests\Api\V1\UpdateOrderRequest;
use App\Http\Resources\V1\OrderResource;
use App\Models\Order;
use App\Models\User;
use App\Policies\V1\OrderPolicy;
use Illuminate\Database\Eloquent\ModelNotFoundException;

class CustomerOrdersController extends ApiController
{
    protected $policyClass = OrderPolicy::class;

    /**
     * Display a listing of the resource.
     */
    public function index($customer_id, OrderFilter $filters)
    public function index(User $customer, OrderFilter $filters)
    {
        return OrderResource::collection(
            Order::where('user_id', $customer_id)->filter($filters)->paginate()
            Order::where('user_id', $customer->id)->filter($filters)->paginate()
        );
    }

    /**
     * Store a newly created resource in storage.
     */
    public function store(StoreOrderRequest $request, $customer_id)
    public function store(StoreOrderRequest $request, User $customer)
    {
        if ($this->isAble('store', Order::class)) {
            return new OrderResource(Order::create($request->mappedAttributes([
                'customer' => 'user_id'
            ])));
        }

        return $this->error('You are not authorized to create that resource', 403);
        return $this->notAuthorized('You are not authorized to create that resource');
    }

    /**
     * Replace the specified resource in storage.
     */
    public function replace(ReplaceOrderRequest $request, $customer_id,  $order_id)
    public function replace(ReplaceOrderRequest $request, User $customer, Order $order)
    {
        // PUT
        try {
            $order = Order::where('id', $order_id)
                            ->where('user_id', $customer_id)
                            ->firstOrFail();

            if ($this->isAble('replace', $order)) {
                $order->update($request->mappedAttributes());
                return new OrderResource($order);
            }

            return $this->error('You are not authorized to update that resource', 403);

        } catch (ModelNotFoundException $exception) {
            return $this->error('Order cannot be found.', 404);
        if ($this->isAble('replace', $order)) {
            $order->update($request->mappedAttributes());
            return new OrderResource($order);
        }

        return $this->notAuthorized('You are not authorized to update that resource');

    }

    /**
     * Update the specified resource in storage.
     */
    public function update(UpdateOrderRequest $request, $customer_id,  $order_id)
    public function update(UpdateOrderRequest $request, User $customer, Order $order)
    {
        // PUT
        try {
            $order = Order::where('id', $order_id)
                            ->where('user_id', $customer_id)
                            ->firstOrFail();

            if ($this->isAble('update', $order)) {
                $order->update($request->mappedAttributes());
                return new OrderResource($order);
            }

            return $this->error('You are not authorized to update that resource', 403);

        } catch (ModelNotFoundException $exception) {
            return $this->error('Order cannot be found.', 404);
        if ($this->isAble('update', $order)) {
            $order->update($request->mappedAttributes());
            return new OrderResource($order);
        }

        return $this->notAuthorized('You are not authorized to update that resource');
    }

    /**
     * Remove the specified resource from storage.
     */
    public function destroy($customer_id, $order_id)
    public function destroy(User $customer, Order $order)
    {
        try {
            $order = Order::where('id', $order_id)
                            ->where('user_id', $customer_id)
                            ->firstOrFail();

            if ($this->isAble('delete', $order)) {
                $order->delete();
                return $this->ok('Order successfully deleted');
            }

            return $this->error('You are not authorized to delete that resource', 403);
        } catch (ModelNotFoundException $exception) {
            return $this->error('Order cannot be found.', 404);
        if ($this->isAble('delete', $order)) {
            $order->delete();
            return $this->ok('Order successfully deleted');
        }

        return $this->notAuthorized('You are not authorized to delete that resource');
    }
}

app/Http/Controllers/Api/V1/OrderController.php

يعود ربط النموذج بالمسار وتختفي كتل try من القسم الرابع عشر.

<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Filters\V1\OrderFilter;
use App\Http\Requests\Api\V1\ReplaceOrderRequest;
use App\Models\Order;
use App\Http\Requests\Api\V1\StoreOrderRequest;
use App\Http\Requests\Api\V1\UpdateOrderRequest;
use App\Http\Resources\V1\OrderResource;
use App\Policies\V1\OrderPolicy;
use Illuminate\Database\Eloquent\ModelNotFoundException;

class OrderController extends ApiController
{
    protected $policyClass = OrderPolicy::class;
    /**
     * Display a listing of the resource.
     */
    public function index(OrderFilter $filters)
    {
        return OrderResource::collection(Order::filter($filters)->paginate());
    }

    /**
     * Store a newly created resource in storage.
     */
    public function store(StoreOrderRequest $request)
    {
        if ($this->isAble('store', Order::class)) {
            return new OrderResource(Order::create($request->mappedAttributes()));
        }

        return $this->error('You are not authorized to create that resource', 403);
        return $this->notAuthorized('You are not authorized to create that resource');
    }

    /**
     * Display the specified resource.
     */
    public function show($order_id)
    public function show(Order $order)
    {
        try {
            $order = Order::findOrFail($order_id);

            if ($this->include('customer')) {
                return new OrderResource($order->load('customer'));
            }

            return new OrderResource($order);
        } catch (ModelNotFoundException $exception) {
            return $this->error('Order cannot be found.', 404);
        if ($this->include('customer')) {
            return new OrderResource($order->load('customer'));
        }

        return new OrderResource($order);
    }

    /**
     * Update the specified resource in storage.
     */
    public function update(UpdateOrderRequest $request, $order_id)
    public function update(UpdateOrderRequest $request, Order $order)
    {
        // PATCH
        try {
            $order = Order::findOrFail($order_id);

            if ($this->isAble('update', $order)) {
                $order->update($request->mappedAttributes());
        if ($this->isAble('update', $order)) {
            $order->update($request->mappedAttributes());

                return new OrderResource($order);
            }

            return $this->error('You are not authorized to update that resource', 403);

        } catch (ModelNotFoundException $exception) {
            return $this->error('Order cannot be found.', 404);
            return new OrderResource($order);
        }

        return $this->notAuthorized('You are not authorized to update that resource');

    }

    /**
     * Replace the specified resource in storage.
     */
    public function replace(ReplaceOrderRequest $request, $order_id)
    public function replace(ReplaceOrderRequest $request, Order $order)
    {
        // PUT
        try {
            $order = Order::findOrFail($order_id);

            // policy
            if ($this->isAble('replace', $order)) {
                $order->update($request->mappedAttributes());
                return new OrderResource($order);
            }

            return $this->error('You are not authorized to update that resource', 403);

        } catch (ModelNotFoundException $exception) {
            return $this->error('Order cannot be found.', 404);
        if ($this->isAble('replace', $order)) {
            $order->update($request->mappedAttributes());
            return new OrderResource($order);
        }

        return $this->notAuthorized('You are not authorized to update that resource');
    }

    /**
     * Remove the specified resource from storage.
     */
    public function destroy($order_id)
    public function destroy(Order $order)
    {
        try {
            $order = Order::findOrFail($order_id);
        // policy
        if ($this->isAble('delete', $order)) {
            $order->delete();

            // policy
            if ($this->isAble('delete', $order)) {
                $order->delete();

                return $this->ok('Order successfully deleted');
            }

            return $this->error('You are not authorized to delete that resource', 403);

        } catch (ModelNotFoundException $exception) {
            return $this->error('Order cannot be found.', 404);
            return $this->ok('Order successfully deleted');
        }

        return $this->notAuthorized('You are not authorized to delete that resource');
    }
}

app/Http/Controllers/Api/V1/UserController.php

الملف كاملاً، مع تعليم تغييرات هذه الخطوة.

<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Filters\V1\CustomerFilter;
use App\Http\Requests\Api\V1\ReplaceUserRequest;
use App\Models\User;
use App\Http\Requests\Api\V1\StoreUserRequest;
use App\Http\Requests\Api\V1\UpdateUserRequest;
use App\Http\Resources\V1\UserResource;
use App\Policies\V1\UserPolicy;
use Illuminate\Database\Eloquent\ModelNotFoundException;

class UserController extends ApiController
{
    protected $policyClass = UserPolicy::class;
    /**
     * Display a listing of the resource.
     */
    public function index(CustomerFilter $filters)
    {
        return UserResource::collection(
            User::filter($filters)->paginate()
        );
    }

    /**
     * Store a newly created resource in storage.
     */
    public function store(StoreUserRequest $request)
    {
        if ($this->isAble('store', User::class)) {
            return new UserResource(User::create($request->mappedAttributes()));
        }

        return $this->error('You are not authorized to create that resource', 403);
        return $this->notAuthorized('You are not authorized to create that resource');
    }

    /**
     * Display the specified resource.
     */
    public function show(User $user)
    {
        if ($this->include('orders')) {
            return new UserResource($user->load('orders'));
        }

        return new UserResource($user);
    }

    /**
     * Update the specified resource in storage.
     */
    public function update(UpdateUserRequest $request, $user_id)
    public function update(UpdateUserRequest $request, User $user)
    {
        try {
            $user = User::findOrFail($user_id);
        if ($this->isAble('update', $user)) {
            $user->update($request->mappedAttributes());

            if ($this->isAble('update', $user)) {
                $user->update($request->mappedAttributes());

                return new UserResource($user);
            }

            return $this->error('You are not authorized to update that resource', 403);

        } catch (ModelNotFoundException $exception) {
            return $this->error('User cannot be found.', 404);
            return new UserResource($user);
        }

        return $this->notAuthorized('You are not authorized to update that resource');
    }

    /**
     * Replace the specified resource in storage.
     */
    public function replace(ReplaceUserRequest $request, $user_id)
    public function replace(ReplaceUserRequest $request, User $user)
    {
        // PUT
        try {
            $user = User::findOrFail($user_id);
        if ($this->isAble('replace', $user)) {
            $user->update($request->mappedAttributes());

            if ($this->isAble('replace', $user)) {
                $user->update($request->mappedAttributes());

                return new UserResource($user);
            }

            return $this->error('You are not authorized to update that resource', 403);

        } catch (ModelNotFoundException $exception) {
            return $this->error('User cannot be found.', 404);
            return new UserResource($user);
        }

        return $this->notAuthorized('You are not authorized to update that resource');
    }

    /**
     * Remove the specified resource from storage.
     */
    public function destroy($user_id)
    public function destroy(User $user)
    {
        try {
            $user = User::findOrFail($user_id);
        if ($this->isAble('delete', $user)) {
            $user->delete();

            if ($this->isAble('delete', $user)) {
                $user->delete();

                return $this->ok('User successfully deleted');
            }

            return $this->error('You are not authorized to delete that resource', 403);

        } catch (ModelNotFoundException $exception) {
            return $this->error('User cannot be found.', 404);
            return $this->ok('User successfully deleted');
        }

        return $this->notAuthorized('You are not authorized to delete that resource');
    }
}

app/Traits/ApiResponses.php — التغييرات

تقبل error() رسالة أو قائمة أخطاء، وتبني notAuthorized() عنصر 403.

    /**
     * Return an error response.
     */
    protected function error(string $message, int $statusCode): JsonResponse
    protected function error(array|string $errors = [], ?int $statusCode = null): JsonResponse
    {
        if (is_string($errors)) {
            return response()->json([
                'message' => $errors,
                'status' => $statusCode
            ], $statusCode);
        }

        return response()->json([
            'message' => $message,
            'status' => $statusCode
            'errors' => $errors
        ], $statusCode);
    }

    /**
     * Return a 403 response for a forbidden request.
     */
    protected function notAuthorized(string $message): JsonResponse
    {
        return $this->error([[
            'status' => 403,
            'message' => $message,
            'source' => ''
        ]], 403);
    }
}

تُسطَّح أخطاء التحقق، فيُنتج الطلب المخالف لثلاث قواعد ثلاثة عناصر.

التحقق

curl -i http://127.0.0.1:8000/api/v1/orders/999999 \
  -H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"

curl -i -X POST http://127.0.0.1:8000/api/v1/orders \
  -H "Accept: application/json" -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" -d '{"data":{"attributes":{}}}'

curl -i http://127.0.0.1:8000/api/v1/orders -H "Accept: application/json"

curl -i -X PATCH http://127.0.0.1:8000/api/v1/customers/3/orders/ORDER_OWNED_BY_2 \
  -H "Accept: application/json" -H "Content-Type: application/json" \
  -H "Authorization: Bearer MANAGER_TOKEN" \
  -d '{"data":{"attributes":{"status":"paid"}}}'

ثلاثة طلبات وغلاف واحد: 404 للطلب المفقود، و422 بعنصر لكل قاعدة فاشلة، و401 بلا رمز. والرابع هو فحص التقييد — ويجب أن يكون 404 لا 200. فإن أعاد 200 فإن scopeBindings() مفقودة، ويكون الطلب قد عُدِّل للتوّ عبر عنوان عميل خاطئ.

وصارت كل الصيغ متطابقة:

الحالةالرمزالجسم
طلب مفقود404errors[0].source هو App\Models\Order
عنوان api/* غير معروف404errors[0].source فارغ
بلا رمز401Unauthenticated.
رفضته السياسة403عنصر notAuthorized()
جسم غير صالح422عنصر لكل قاعدة فاشلة

24. إنشاء توثيق API باستخدام Scribe

يقرأ Scribe تعليقات الـdocblock ويبني توثيقاً قابلاً للتصفح، وملف OpenAPI، ومجموعة Postman.

الكود

app/Http/Controllers/Api/AuthController.php — التغييرات

تشير @unauthenticated إلى أن الدخول مفتوح، وتعرض @response مثالاً حقيقياً.

    use ApiResponses;

    /**
     * Authenticate the user and issue an API token.
     * Login
     *
     * Authenticates the user and returns the user's API token.
     *
     * @unauthenticated
     * @group Authentication
     * @response 200 {
    "data": {
        "token": "{YOUR_AUTH_KEY}"
    },
    "message": "Authenticated",
    "status": 200
}
     */
    public function login(LoginUserRequest $request)
    {

    }

    /**
     * Revoke the API token used for the current request.
     * Logout
     *
     * Signs out the user and destroys the API token.
     *
     * @group Authentication
     * @response 200 {}
     */
    public function logout(Request $request)
    {

app/Http/Controllers/Api/V1/CustomerOrdersController.php — التغييرات

تتحول تعليقات الـdocblock إلى تعليمات Scribe: @group و@urlParam و@response.

    protected $policyClass = OrderPolicy::class;

    /**
     * Display a listing of the resource.
     * Get all orders
     *
     * Retrieves all orders created by a specific user.
     *
     * @group Managing Orders by Customer
     *
     * @urlParam customer integer required The customer's ID. No-example
     *
     * @response 200 {"data":[{"type":"user","id":3,"attributes":{"name":"Mr. Henri Beatty MD","email":"bmertz@example.net","isManager":false,"emailVerifiedAt":"2024-03-14T04:41:51.000000Z","createdAt":"2024-03-14T04:41:51.000000Z","updatedAt":"2024-03-14T04:41:51.000000Z"},"links":{"self":"http:\/\/localhost:8000\/api\/v1\/customers\/3"}}],"links":{"first":"http:\/\/localhost:8000\/api\/v1\/customers?page=1","last":"http:\/\/localhost:8000\/api\/v1\/customers?page=1","prev":null,"next":null},"meta":{"current_page":1,"from":1,"last_page":1,"links":[{"url":null,"label":"&laquo; Previous","active":false},{"url":"http:\/\/localhost:8000\/api\/v1\/customers?page=1","label":"1","active":true},{"url":null,"label":"Next &raquo;","active":false}],"path":"http:\/\/localhost:8000\/api\/v1\/customers","per_page":15,"to":1,"total":10}}
     *
     * @queryParam sort string Data field(s) to sort by. Separate multiple fields with commas. Denote descending sort with a minus sign. Example: sort=name
     * @queryParam filter[name] Filter by name. Wildcards are supported.
     * @queryParam filter[email] Filter by email. Wildcards are supported.
     */
    public function index(User $customer, OrderFilter $filters)
    {

    }

    /**
     * Store a newly created resource in storage.
     * Create an order
     *
     * Creates an order for the specified customer.
     *
     * @group Managing Orders by Customer
     *
     * @urlParam customer integer required The customer's ID. No-example
     *
     */
    public function store(StoreOrderRequest $request, User $customer)
    {

    }

    /**
     * Replace the specified resource in storage.
     * Replace a customer's order
     *
     * Replaces a customer's order.
     *
     * @group Managing Orders by Customer
     * @urlParam customer integer required The customer's ID. No-example
     * @urlParam order integer required The order ID. No-example
     * @response {"data":{"type":"order","id":107,"attributes":{"reference":"ORD-10432","notes":"Priority delivery","status":"paid","createdAt":"2024-03-26T04:40:48.000000Z","updatedAt":"2024-03-26T04:40:48.000000Z"},"relationships":{"customer":{"data":{"type":"user","id":1},"links":{"self":"http:\/\/localhost:8000\/api\/v1\/customers\/1"}}},"links":{"self":"http:\/\/localhost:8000\/api\/v1\/orders\/107"}}}
     */
    public function replace(ReplaceOrderRequest $request, User $customer, Order $order)
    {

    }

    /**
     * Update the specified resource in storage.
     * Update a customer's order
     *
     * Updates a customer's order.
     *
     * @group Managing Orders by Customer
     * @urlParam customer integer required The customer's ID. No-example
     * @urlParam order integer required The order ID. No-example
     */
    public function update(UpdateOrderRequest $request, User $customer, Order $order)
    {

    }

    /**
     * Remove the specified resource from storage.
     * Delete a customer's order
     *
     * Deletes a customer's order.
     *
     * @group Managing Orders by Customer
     * @urlParam customer integer required The customer's ID. No-example
     * @urlParam order integer required The order ID. No-example
     * @response {}
     */
    public function destroy(User $customer, Order $order)
    {
        if ($this->isAble('delete', $order)) {
            $order->delete();

app/Http/Controllers/Api/V1/CustomersController.php

الملف كاملاً، مع تعليم تغييرات هذه الخطوة.

<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Filters\V1\CustomerFilter;
use App\Models\User;
use App\Http\Requests\Api\V1\StoreUserRequest;
use App\Http\Requests\Api\V1\UpdateUserRequest;
use App\Http\Resources\V1\UserResource;

class CustomersController extends ApiController
{
    /**
     * Display a listing of the resource.
     * Get customers.
     *
     * Retrieves all customers who have created an order.
     *
     * @group Showing Customers
     */
    public function index(CustomerFilter $filters)
    {
        return UserResource::collection(
            User::has('orders')->filter($filters)->paginate()
        );
    }

    /**
     * Store a newly created resource in storage.
     */
    public function store(StoreUserRequest $request)
    {
        //
    }

    /**
     * Display the specified resource.
     * Get a customer.
     *
     * Retrieves a customer who has created an order.
     *
     * @group Showing Customers
     */
    public function show(User $customer)
    {
        if ($this->include('orders')) {
            return new UserResource($customer->load('orders'));
        }

        return new UserResource($customer);
    }

    /**
     * Update the specified resource in storage.
     */
    public function update(UpdateUserRequest $request, User $user)
    {
        //
    }

    /**
     * Remove the specified resource from storage.
     */
    public function destroy(User $user)
    {
        //
    }
}

app/Http/Controllers/Api/V1/OrderController.php — التغييرات

توثّق @queryParam المرشّحات ومفاتيح الترتيب.

{
    protected $policyClass = OrderPolicy::class;
    /**
     * Display a listing of the resource.
     * Get all orders
     *
     * @group Managing Orders
     * @queryParam sort string Data field(s) to sort by. Separate multiple fields with commas. Denote descending sort with a minus sign. Example: sort=reference,-createdAt
     * @queryParam filter[status] Filter by status: pending, paid, shipped, cancelled. No-example
     * @queryParam filter[reference] Filter by reference. Wildcards are supported. Example: *ORD-1*
     */
    public function index(OrderFilter $filters)
    {

    }

    /**
     * Store a newly created resource in storage.
     * Create an order
     *
     * Creates a new order record. Users can only create orders for themselves. Managers can create orders for any user.
     *
     * @group Managing Orders
     *
     * @response {"data":{"type":"order","id":107,"attributes":{"reference":"ORD-10432","notes":"Priority delivery","status":"paid","createdAt":"2024-03-26T04:40:48.000000Z","updatedAt":"2024-03-26T04:40:48.000000Z"},"relationships":{"customer":{"data":{"type":"user","id":1},"links":{"self":"http:\/\/localhost:8000\/api\/v1\/customers\/1"}}},"links":{"self":"http:\/\/localhost:8000\/api\/v1\/orders\/107"}}}
     */
    public function store(StoreOrderRequest $request)
    {

    }

    /**
     * Display the specified resource.
     * Show a specific order.
     *
     * Display an individual order.
     *
     * @group Managing Orders
     *
     */
    public function show(Order $order)
    {

    }

    /**
     * Update the specified resource in storage.
     * Update Order
     *
     * Update the specified order in storage.
     *
     * @group Managing Orders
     *
     */
    public function update(UpdateOrderRequest $request, Order $order)
    {

    }

    /**
     * Replace the specified resource in storage.
     * Replace Order
     *
     * Replace the specified order in storage.
     *
     * @group Managing Orders
     *
     */
    public function replace(ReplaceOrderRequest $request, Order $order)
    {

    }

    /**
     * Delete order.
     *
     * Remove the specified resource from storage.
     *
     * @group Managing Orders
     *
     */
    public function destroy(Order $order)
    {

app/Http/Controllers/Api/V1/UserController.php — التغييرات

التعليمات نفسها على نقاط نهاية المستخدمين.

{
    protected $policyClass = UserPolicy::class;
    /**
     * Display a listing of the resource.
     * Get all users
     *
     * @group Managing Users
     *
     * @queryParam sort string Data field(s) to sort by. Separate multiple fields with commas. Denote descending sort with a minus sign. Example: sort=name
     * @queryParam filter[name] Filter by status name. Wildcards are supported. No-example
     * @queryParam filter[email] Filter by email. Wildcards are supported. No-example
     *
     */
    public function index(CustomerFilter $filters)
    {

    }

    /**
     * Store a newly created resource in storage.
     * Create a user
     *
     * @group Managing Users
     *
     * @response 200 {"data":{"type":"user","id":16,"attributes":{"name":"My User","email":"user@user.com","isManager":false},"links":{"self":"http:\/\/localhost:8000\/api\/v1\/customers\/16"}}}
     */
    public function store(StoreUserRequest $request)
    {

        return $this->notAuthorized('You are not authorized to create that resource');
    }

    /**
     * Display the specified resource.
     /**
     * Display a user
     *
     * @group Managing Users
     *
     *
     */
    public function show(User $user)
    {

        return new UserResource($user);
    }

    /**
     * Update the specified resource in storage.
     /**
     * Update a user
     *
     * @group Managing Users
     *
     * @response 200 {"data":{"type":"user","id":16,"attributes":{"name":"My User","email":"user@user.com","isManager":false},"links":{"self":"http:\/\/localhost:8000\/api\/v1\/customers\/16"}}}
     */
    public function update(UpdateUserRequest $request, User $user)
    {

        return $this->notAuthorized('You are not authorized to update that resource');
    }

    /**
     * Replace the specified resource in storage.
     /**
     * Replace a user
     *
     * @group Managing Users
     *
     * @response 200 {"data":{"type":"user","id":16,"attributes":{"name":"My User","email":"user@user.com","isManager":false},"links":{"self":"http:\/\/localhost:8000\/api\/v1\/customers\/16"}}}
     */
    public function replace(ReplaceUserRequest $request, User $user)
    {

        return $this->notAuthorized('You are not authorized to update that resource');
    }

    /**
     * Remove the specified resource from storage.
     /**
     * Delete a user
     *
     * @group Managing Users
     *
     * @response 200 {}
     */
    public function destroy(User $user)
    {

app/Http/Requests/Api/V1/ReplaceOrderRequest.php — التغييرات

قواعد لمفاتيح الغلاف، فتطابق البنية الموثّقة ما يقبله الـAPI.

    public function rules(): array
    {
        $rules = [
            'data' => 'required|array',
            'data.attributes' => 'required|array',
            'data.attributes.reference' => 'required|string',
            'data.attributes.notes' => 'required|string',
            'data.attributes.status' => 'required|string|in:pending,paid,shipped,cancelled',
            'data.relationships' => 'required|array',
            'data.relationships.customer' => 'required|array',
            'data.relationships.customer.data' => 'required|array',
            'data.relationships.customer.data.id' => 'required|integer',
        ];

app/Http/Requests/Api/V1/ReplaceUserRequest.php — التغييرات

قواعد الغلاف نفسها للمستخدمين.

    public function rules(): array
    {
        $rules = [
            'data' => 'required|array',
            'data.attributes' => 'required|array',
            'data.attributes.name' => 'required|string',
            'data.attributes.email' => 'required|email',
            'data.attributes.isManager' => 'required|boolean',

app/Http/Requests/Api/V1/StoreOrderRequest.php — التغييرات

تحل Auth::user() محل $this->user()، وهو ما يحتاجه Scribe لقراءة القواعد خارج طلب حقيقي.

وفحوص null حول $user ليست حشواً احترازياً. فـ Scribe يستدعي rules() أثناء استخراج التوثيق، خارج أي طلب HTTP، فلا يوجد مستخدم موثَّق وتعيد Auth::user() قيمة null. وبدون الحارس يموت scribe:generate بالخطأ Call to a member function tokenCan() on null ولا يُنتج توثيقاً البتة. واستبدال $this->user() بـ Auth::user() لا يتفادى ذلك وحده — فكلاهما null أثناء الاستخراج. أما في الطلب الحقيقي فلا يعمل الحارس أبداً، لأن كل هذه المسارات تقع خلف auth:sanctum.

namespace App\Http\Requests\Api\V1;

use App\Permissions\V1\Abilities;
use Illuminate\Support\Facades\Auth;

class StoreOrderRequest extends BaseOrderRequest
{

     */
    public function rules(): array
    {
        $customerIdAttr = $this->routeIs('orders.store') ? 'data.relationships.customer.data.id' : 'customer';
        $user = $this->user();
        $isOrdersController = $this->routeIs('orders.store');
        $customerIdAttr = $isOrdersController ? 'data.relationships.customer.data.id' : 'customer';
        $user = Auth::user();
        $customerRule = 'required|integer|exists:users,id';

        $rules = [
            'data' => 'required|array',
            'data.attributes' => 'required|array',
            'data.attributes.reference' => 'required|string',
            'data.attributes.notes' => 'required|string',
            'data.attributes.status' => 'required|string|in:pending,paid,shipped,cancelled',
            $customerIdAttr => $customerRule . '|size:' . $user->id
        ];

        if ($isOrdersController) {
            $rules['data.relationships'] = 'required|array';
            $rules['data.relationships.customer'] = 'required|array';
            $rules['data.relationships.customer.data'] = 'required|array';
        }

        $rules[$customerIdAttr] = $user
            ? $customerRule . '|size:' . $user->id
            : $customerRule;

        if ($user && $user->tokenCan(Abilities::CreateOrder)) {
            $rules[$customerIdAttr] = $customerRule;

            ]);
        }
    }

    /**
     * Describe the body parameters for the API documentation.
     */
    public function bodyParameters()
    {
        $documentation = [
            'data.attributes.reference' => [
                'description' => "The order's reference",
                'example' => 'No-example'
            ],
            'data.attributes.notes' => [
                'description' => "The order's notes",
                'example' => 'No-example',
            ],
            'data.attributes.status' => [
                'description' => "The order's status",
                'example' => 'No-example',
            ],
        ];

        if ($this->routeIs('orders.store')) {
            $documentation['data.relationships.customer.data.id'] = [
                'description' => 'The customer the order belongs to.',
                'example' => 'No-example'
            ];
        } else {
            $documentation['customer'] = [
                'description' => 'The customer the order belongs to.',
                'example' => 'No-example'
            ];
        }

        return $documentation;

    }
}

app/Http/Requests/Api/V1/StoreUserRequest.php — التغييرات

وكذلك عند إنشاء مستخدم.

    public function rules(): array
    {
        return [
            'data' => 'required|array',
            'data.attributes' => 'required|array',
            'data.attributes.name' => 'required|string',
            'data.attributes.email' => 'required|email',
            'data.attributes.isManager' => 'required|boolean',

app/Http/Requests/Api/V1/UpdateOrderRequest.php — التغييرات

الانتقال نفسه إلى Auth::user().

namespace App\Http\Requests\Api\V1;

use App\Permissions\V1\Abilities;
use Illuminate\Support\Facades\Auth;

class UpdateOrderRequest extends BaseOrderRequest
{

            'data.relationships.customer.data.id' => 'prohibited',
        ];

        if ($this->user()->tokenCan(Abilities::UpdateOrder)) {
        $user = Auth::user();

        if ($user && $user->tokenCan(Abilities::UpdateOrder)) {
            $rules['data.relationships.customer.data.id'] = 'sometimes|integer';
        }

composer.json

يضيف الحزمة knuckleswtf/scribe.

{
    "$schema": "https://getcomposer.org/schema.json",
    "name": "laravel/laravel",
    "type": "project",
    "description": "An advanced Laravel API tutorial project.",
    "keywords": ["laravel", "api"],
    "license": "MIT",
    "require": {
        "php": "^8.3",
        "laravel/framework": "^13.0",
        "laravel/sanctum": "^4.3",
        "laravel/tinker": "^3.0"
    },
    "require-dev": {
        "fakerphp/faker": "^1.23",
        "knuckleswtf/scribe": "^5.0",
        "laravel/pail": "^1.2.5",
        "laravel/pint": "^1.27",
        "mockery/mockery": "^1.6",
        "nunomaduro/collision": "^8.6",
        "phpunit/phpunit": "^12.5"
    },
    "autoload": {
        "psr-4": {
            "App\\": "app/",
            "Database\\Factories\\": "database/factories/",
            "Database\\Seeders\\": "database/seeders/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "Tests\\": "tests/"
        }
    },
    "scripts": {
        "test": [
            "@php artisan config:clear --ansi @no_additional_args",
            "@php artisan test"
        ],
        "post-autoload-dump": [
            "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
            "@php artisan package:discover --ansi"
        ],
        "post-update-cmd": [
            "@php artisan vendor:publish --tag=laravel-assets --ansi --force"
        ],
        "post-root-package-install": [
            "@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
        ],
        "post-create-project-cmd": [
            "@php artisan key:generate --ansi",
            "@php artisan migrate --graceful --ansi"
        ],
        "pre-package-uninstall": [
            "Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
        ]
    },
    "extra": {
        "laravel": {
            "dont-discover": []
        }
    },
    "config": {
        "optimize-autoloader": true,
        "preferred-install": "dist",
        "sort-packages": true,
        "allow-plugins": {
            "pestphp/pest-plugin": true,
            "php-http/discovery": true
        }
    },
    "minimum-stability": "stable",
    "prefer-stable": true
}

config/scribe.php — التغييرات

يكتب vendor:publish الملف كاملاً، ولا يحتاج التغيير إلا حفنة إعدادات. واترك الباقي كما نُشر — فالإعدادات الافتراضية تطابق api/* أصلاً، وهو المطلوب توثيقه.

    'title' => config('app.name').' API Documentation',
    'title' => 'Orders Hub API Documentation',

    'type' => 'laravel',
    'type' => 'static',

    'try_it_out' => [
        'enabled' => true,

        'base_url' => null,
        'base_url' => 'http://localhost:8000',
    ],

    'auth' => [
        'enabled' => false,
        'enabled' => true,

        'default' => false,
        'default' => true,

        'in' => AuthIn::BEARER->value,

        'name' => 'key',
        'name' => 'Authorization',

        'use_value' => env('SCRIBE_AUTH_KEY'),
        'use_value' => '1|EXAMPLE_TOKEN_REPLACE_ME',
    ],

    // يعطّل Scribe 5 توليد OpenAPI افتراضيًا.
    'postman' => [
        'enabled' => true,
    ],

    'openapi' => [
        'enabled' => true,
    ],

ويحدد type مكان كتابة التوثيق. فـ static يضعه في public/docs/، ولذلك تنزل الصفحة القابلة للتصفح وملف OpenAPI ومجموعة Postman معاً ولا تحتاج إلى مسار. أما laravel فيقدّمها عبر التطبيق.

وكتلة auth هي ما يضع حقل «Authorization: Bearer» في لوحة التجربة، ويعلّم كل نقطة بأنها موثَّقة عدا التي تحمل @unauthenticated.

وينشر Scribe 5 هذا الملف مستخدماً AuthIn::BEARER->value وثوابت الاستراتيجيات في Defaults::. فإن كنت على إصدار أقدم فالمفاتيح نفسها موجودة كنصوص عادية ومصفوفات استراتيجيات صريحة — اضبط القيم أعلاه وتجاهل شكل الملف المحيط.

لهذا وضعت الأقسام السابقة الأوصاف في تعليقات docblock. فالمولّد يقرأ الـdocblock ولا يقرأ التعليقات السطرية.

التحقق

composer require --dev knuckleswtf/scribe
php artisan vendor:publish --tag=scribe-config
php artisan scribe:generate

ثبّته اعتماديةَ تطوير — فملف composer.json أعلاه يدرجه تحت require-dev، ولا شيء في الواجهة العاملة يستورده.

ويُفترض أن ينتهي scribe:generate بعبارة All done. وهو يطبع WARN No bodyParameters() method found لكل كلاس طلب لا يملكها، وليس ذلك إلا إخباراً بأنه رجع إلى قراءة rules()؛ والنقاط موثَّقة على كل حال.

ومع 'type' => 'static' يُكتب كل شيء في public/docs/:

php artisan serve

curl -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/docs
curl -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/docs/openapi.yaml
curl -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/docs/collection.json

ثلاثة ردود 200: الصفحة القابلة للتصفح، ومواصفة OpenAPI التي فعّلناها صراحة، ومجموعة Postman المفعّلة افتراضيًا. ومع 'type' => 'laravel' يكتب Scribe المجموعة والمواصفة في storage/app/scribe/ ويقدّمهما عبر Routes التي يتحكم بها laravel.add_routes؛ أما التوثيق القابل للتصفح نفسه فهو Blade view مولّد.

ووسوم @group هي ما ينظّم الشريط الجانبي، ويجب أن تندرج كل نقطة تحت واحدة منها:

المجموعةعدد النقاط
Authentication2
Managing Orders6
Managing Orders by Customer5
Managing Users6
Showing Customers2
Endpoints1

والمجموعة الأخيرة هي سلّة Scribe لكل ما لا يحمل @group. والـEndpoint الوحيد فيها هو دالة Closure للمسار /api/v1/user في routes/api_v1.php؛ ولأنه بلا Controller فلا يوجد docblock يمكن وسمه.


25. استخدام تنسيق واحد لجميع الاستجابات

تعيد بعض المسارات نموذج Eloquent مباشرة، بينما تعيد Resources غلاف data وتستخدم الأخطاء غلاف errors. سنوحّدها حتى يعرف العميل مسبقاً أين يجد الرسالة والحالة والبيانات.

تعديل app/Traits/ApiResponses.php

استبدل محتوى الملف بالكامل:

<?php

namespace App\Traits;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;

trait ApiResponses
{
    protected function ok(string $message, mixed $data = null): JsonResponse
    {
        return $this->success($message, $data);
    }

    protected function success(string $message, mixed $data = null, int $statusCode = 200): JsonResponse
    {
        return response()->json([
            'message' => $message,
            'status' => $statusCode,
            'data' => $data,
        ], $statusCode);
    }

    protected function resource(
        string $message,
        JsonResource $resource,
        int $statusCode = 200,
    ): JsonResponse {
        $payload = $resource->response()->getData(true);

        $data = array_key_exists('meta', $payload)
            ? [
                'items' => $payload['data'],
                'links' => $payload['links'],
                'meta' => $payload['meta'],
            ]
            : $payload['data'];

        return $this->success($message, $data, $statusCode);
    }

    protected function error(string $message, mixed $data = null, int $statusCode = 400): JsonResponse
    {
        return response()->json([
            'message' => $message,
            'status' => $statusCode,
            'data' => $data,
        ], $statusCode);
    }

    protected function notAuthorized(string $message): JsonResponse
    {
        return $this->error($message, null, 403);
    }
}

تضع resource() المورد المفرد مباشرة داخل data. وإذا كان المورد مقسماً إلى صفحات، تنقل السجلات إلى data.items وتحافظ على links وmeta.

تعديل المتحكمات

بدلاً من إعادة OrderResource مباشرة، مرّره إلى resource(). هذه هي الدوال المعدلة في OrderController:

public function index(OrderFilter $filters)
{
    return $this->resource(
        'Orders retrieved successfully.',
        OrderResource::collection(Order::filter($filters)->paginate()),
    );
}

public function store(StoreOrderRequest $request)
{
    if ($this->isAble('store', Order::class)) {
        return $this->resource(
            'Order created successfully.',
            new OrderResource(Order::create($request->mappedAttributes())),
            201,
        );
    }

    return $this->notAuthorized('You are not authorized to create that resource');
}

public function show(Order $order)
{
    $order = $this->include('customer') ? $order->load('customer') : $order;

    return $this->resource('Order retrieved successfully.', new OrderResource($order));
}

public function update(UpdateOrderRequest $request, Order $order)
{
    if ($this->isAble('update', $order)) {
        $order->update($request->mappedAttributes());

        return $this->resource('Order updated successfully.', new OrderResource($order));
    }

    return $this->notAuthorized('You are not authorized to update that resource');
}

استخدم الطريقة نفسها في UserController وCustomersController وCustomerOrdersController:

return $this->resource('Users retrieved successfully.', UserResource::collection($users));
return $this->resource('User retrieved successfully.', new UserResource($user));
return $this->resource('User created successfully.', new UserResource($user), 201);

return $this->resource('Customers retrieved successfully.', UserResource::collection($customers));
return $this->resource('Customer orders retrieved successfully.', OrderResource::collection($orders));

وفي AuthController:

if (! Auth::attempt($request->only('email', 'password'))) {
    return $this->error('Invalid credentials.', null, 401);
}

return $this->ok('Authenticated successfully.', [
    'token' => $user->createToken(
        'API token for '.$user->email,
        Abilities::getAbilities($user),
        now()->addMonth(),
    )->plainTextToken,
]);

// logout()
return $this->ok('Logged out successfully.');

غيّر مسار المستخدم الحالي في routes/api_v1.php حتى لا يعيد النموذج الخام:

use App\Http\Resources\V1\UserResource;

Route::get('/user', function (Request $request) {
    return response()->json([
        'message' => 'Authenticated user retrieved successfully.',
        'status' => 200,
        'data' => (new UserResource($request->user()))->resolve($request),
    ]);
});

تنسيق أخطاء Laravel

حدّث callback الخاص بالتحقق في bootstrap/app.php:

return response()->json([
    'message' => 'Validation failed.',
    'status' => 422,
    'data' => ['errors' => $errors],
], 422);

وأضف معالجاً لبقية أخطاء HTTP ومعالجاً أخيراً للأخطاء غير المتوقعة:

use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;

$exceptions->render(function (HttpExceptionInterface $exception, Request $request) {
    if (! $request->is('api/*')) {
        return null;
    }

    $status = $exception->getStatusCode();

    return response()->json([
        'message' => $status >= 500
            ? 'Server error.'
            : ($exception->getMessage() ?: 'Request failed.'),
        'status' => $status,
        'data' => null,
    ], $status, $exception->getHeaders());
});

$exceptions->render(function (\Throwable $exception, Request $request) {
    if (! $request->is('api/*')) {
        return null;
    }

    return response()->json([
        'message' => 'Server error.',
        'status' => 500,
        'data' => null,
    ], 500);
});

لا تعرض رسالة الاستثناء الأصلية في استجابة 500، لأنها قد تحتوي على استعلام SQL أو مسار ملف أو قيمة من الإعدادات.

إعداد Postman

أنشئ postman/Orders-Hub-Local.postman_environment.json:

{
  "name": "Orders Hub - Local",
  "values": [
    {"key": "baseUrl", "value": "http://localhost:8000", "type": "default", "enabled": true},
    {"key": "token", "value": "", "type": "secret", "enabled": true}
  ],
  "_postman_variable_scope": "environment"
}

أضف السكربت التالي إلى تبويب Scripts > Post-response في طلب تسجيل الدخول:

pm.test('Login succeeded', function () {
    pm.response.to.have.status(200);
});

const response = pm.response.json();

if (response?.data?.token) {
    pm.environment.set('token', response.data.token);
}

اختر Bearer Token للمجموعة واكتب {{token}}. واترك طلب تسجيل الدخول على No Auth.

التحقق

شغّل التطبيق، ثم سجّل الدخول من Postman وجرب /api/v1/user و/api/v1/orders:

{
  "message": "Orders retrieved successfully.",
  "status": 200,
  "data": {
    "items": [],
    "links": {},
    "meta": {}
  }
}

ستجد التوكن في data.token، والمستخدم الحالي داخل data، وصفوف الطلبات داخل data.items مع روابط ومعلومات التقسيم إلى صفحات.


26. اختبار تنسيق الاستجابة

اختبار Postman يدوي. نحتاج اختبارات تعمل بعد كل تعديل وتخبرنا فوراً إذا أعاد متحكم استجابة بصيغة مختلفة.

tests/Unit/ApiResponsesTest.php

<?php

namespace Tests\Unit;

use App\Traits\ApiResponses;
use Illuminate\Http\JsonResponse;
use Tests\TestCase;

class ApiResponsesTest extends TestCase
{
    public function test_success_response_uses_the_standard_format(): void
    {
        $response = $this->responder()->successResponse(
            'Operation completed.',
            ['id' => 10],
            201,
        );

        $this->assertSame(201, $response->getStatusCode());
        $this->assertSame([
            'message' => 'Operation completed.',
            'status' => 201,
            'data' => ['id' => 10],
        ], $response->getData(true));
    }

    private function responder(): object
    {
        return new class
        {
            use ApiResponses;

            public function successResponse(string $message, mixed $data, int $status): JsonResponse
            {
                return $this->success($message, $data, $status);
            }
        };
    }
}

tests/Feature/ApiResponseContractTest.php

<?php

namespace Tests\Feature;

use App\Models\Order;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ApiResponseContractTest extends TestCase
{
    use RefreshDatabase;

    public function test_login_returns_a_token_in_the_standard_format(): void
    {
        $user = User::factory()->create();

        $this->postJson('/api/login', [
            'email' => $user->email,
            'password' => 'password',
        ])->assertOk()
            ->assertJsonPath('status', 200)
            ->assertJsonStructure(['message', 'status', 'data' => ['token']]);
    }

    public function test_validation_errors_use_the_standard_format(): void
    {
        $this->postJson('/api/login', [])
            ->assertUnprocessable()
            ->assertJsonPath('status', 422)
            ->assertJsonStructure(['message', 'status', 'data' => ['errors']]);
    }

    public function test_paginated_orders_keep_items_links_and_meta(): void
    {
        $user = User::factory()->create();
        Order::factory()->count(2)->create(['user_id' => $user->id]);

        $this->actingAs($user, 'sanctum')
            ->getJson('/api/v1/orders')
            ->assertOk()
            ->assertJsonCount(2, 'data.items')
            ->assertJsonStructure([
                'message',
                'status',
                'data' => ['items', 'links', 'meta'],
            ]);
    }

    public function test_unauthenticated_response_uses_the_standard_format(): void
    {
        $this->getJson('/api/v1/orders')
            ->assertUnauthorized()
            ->assertExactJson([
                'message' => 'Unauthenticated.',
                'status' => 401,
                'data' => null,
            ]);
    }
}

يشمل ملف الاختبار في المستودع أيضاً /api/v1/user والمسار غير الموجود، حتى يكتشف إعادة نموذج خام أو استجابة 404 غير منسقة.

تشغيل الاختبارات

php artisan test
php artisan test --filter=ApiResponsesTest
php artisan test --filter=ApiResponseContractTest

بعد نجاحها، أعد توليد ملفات Scribe وافتح المجموعة في Postman. يجب أن تعرض الأمثلة والاستجابات الفعلية البنية نفسها.


27. قائمة ما بعد الدليل

يبني الدليل عقد API وحدود التفويض الأساسية. وعند نقل هذه الأفكار إلى مشروع فعلي، راجع العناصر التالية بصورة مستقلة؛ فقد تُركت عمدًا خارج خطوات الدليل وليست ميزات يزعم أنه نفذها:

  • طبّق Rate Limiting على API، واجعل القيود أشد على تسجيل الدخول وبقية Endpoints المصادقة.
  • ضع حدًا أقصى لحجم Pagination الذي يتحكم به العميل، حتى لا يفرض طلب واحد Query غير محدود.
  • قيّد CORS على Origins وMethods وHeaders التي تحتاجها التطبيقات المنشورة فعلًا.
  • استخدم Database Transactions عندما يجب أن تنجح عدة عمليات كتابة معًا أو تُلغى كلها.
  • أضف Idempotency للعمليات الكتابية التي قد يعيد العميل أو البنية التحتية إرسالها.
  • أرسل Logs وMetrics وتنبيهات منظمة إلى نظام Monitoring، من دون تسجيل Tokens أو كلمات المرور أو بيانات الاعتماد أو الحمولات الحساسة.
  • راجع إعدادات النشر: اضبط APP_DEBUG=false، واستخدم HTTPS، وأدر الأسرار خارج Source Control.
  • شغّل Queue Workers وراقبها عند استخدام المهام الخلفية، ولا تضف Caching إلا مع سياسة واضحة للإبطال وحداثة البيانات.
  • أضف Security Headers المناسبة في التطبيق أو طبقة Edge.
  • أتمت نسخ قاعدة البيانات الاحتياطية واختبر إجراءات الاستعادة.
  • شغّل الاختبارات الآلية في CI/CD قبل النشر، بما فيها اختبارات عقد الاستجابة والتفويض.

الخلاصة

أصبح لدينا API متعدد الإصدارات بمصادقة Sanctum وAPI Resources وعلاقات اختيارية مقيّدة وفلاتر قابلة لإعادة الاستخدام وترتيب بقائمة سماح وموارد متداخلة ودلالات منفصلة لـPUT وPATCH وPolicies وصلاحيات Token وأخطاء موحدة وتوثيق مولّد وغلاف استجابة واحد تحميه اختبارات عقد آلية. وتوضح قائمة التقوية العمل التشغيلي الذي يختلف باختلاف بيئة النشر، من دون المبالغة في ما ينفذه الدليل.


#Laravel #Laravel API #REST API #Sanctum #API Resources #Policies #Scribe #PHP #لارافيل
لنتواصل

هل لديك مشروع في بالك؟

أنا متاح للعمل الحر والتعاون. لنصنع شيئاً رائعاً معاً.

النشرة البريدية

أفكار مفيدة تصل مباشرة إلى بريدك

رسائل مختصرة من حين لآخر عن Laravel وNuxt والذكاء الاصطناعي وبناء منتجات رقمية أفضل، دون إزعاج.

Logo
akramdev

قضيت أكثر من 9 سنوات في بناء تطبيقات Laravel وأنظمة الأعمال والخدمات الخلفية التي تعتمد عليها.

تواصل معي

© 2026 Akram Ghaleb · جميع الحقوق محفوظة

بُني بواسطة