تجربتي في تحويل Laravel app إلى RESTful API لموبايل Flutter
كنت أعمل على تطبيق ويب لإدارة المخزون مبني بـ Laravel لمدة سنتين، وكان يعمل بشكل ممتاز. لكن مع تزايد طلب العملاء على تطبيق موبايل، وجدت نفسي أمام خيارين: إما بناء تطبيق موبايل منفصل بالكامل، أو تحويل التطبيق الحالي إلى API يعمل مع Flutter. اخترت الخيار الثاني، وكانت رحلة مليئة بالتحديات والدروس القيمة.
في البداية، كنت متخوفًا من تعقيدات العملية. كيف سأحول نظامًا كاملاً يعتمد على جلسات المستخدمين إلى نظام يعمل بـ Tokens؟ كيف سأتعامل مع رفع الملفات؟ وماذا عن الأمان؟ كل هذه الأسئلة كانت تدور في ذهني عندما بدأت المشروع.
لماذا التحول إلى API أساسًا؟
في تطبيقي الأصلي، كان كل شيء متشابكًا. واجهة المستخدم، المنطق البرمجي، وقواعد البيانات ــ كلها كانت مختلطة مع بعضها. هذا النموذج كان عمليًا في البداية، لكن مع نمو التطبيق أصبح كابوسًا في الصيانة والتطوير.
لاحظت أن فصل الطبقة الخلفية عن الواجهة الأمامية يوفر مرونة لا تصدق. بمجرد أن يكون لديك API قوي، يمكنك بناء أي واجهة تريدها ــ تطبيق Flutter، موقع ويب جديد، حتى تطبيقات سطح المكتب ــ كلها تتصل بنفس المصدر المركزي للبيانات.
المشروع التجريبي: نظام إدارة المنتجات
قررت بناء نظام بسيط لإدارة المنتجات كتجربة أولى. النظام يشمل:
- تسجيل المستخدمين وتسجيل الدخول
- إدارة المنتجات (إضافة، تعديل، حذف، عرض)
- رفع صور للمنتجات
- تصنيف المنتجات
البداية: التخطيط والهيكلة
قبل كتابة أي كود، قضيت أسبوعًا كاملاً في التخطيط. رسمت مخططًا للوظائف وحددت لكل وظيفة الطريقة HTTP المناسبة لها حسب مبادئ RESTful API.
هذه كانت الخريطة التي اتبعتها:
- POST /api/register → تسجيل مستخدم جديد
- POST /api/login → تسجيل الدخول
- GET /api/products → عرض جميع المنتجات
- POST /api/products → إضافة منتج جديد
- GET /api/products/{id} → عرض منتج محدد
- PUT /api/products/{id} → تعديل منتج
- DELETE /api/products/{id} → حذف منتج
- POST /api/products/{id}/images → رفع صورة للمنتج
إنشاء مسارات API منفصلة
في Laravel، من المهم فصل مسارات API عن مسارات الويب العادية. بدأت بملف routes/api.php:
Route::prefix('v1')->group(function () {
// Authentication routes
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
// Protected routes
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('products', ProductController::class);
Route::post('/products/{product}/images', [ProductController::class, 'uploadImage']);
Route::post('/logout', [AuthController::class, 'logout']);
});
});
استخدمت الـ prefix 'v1' لإصدار API، هذه ممارسة جيدة تسمح لك بتطوير API بدون كسر التطبيقات القديمة.
المصادقة: Sanctum vs Passport
واجهتني معضلة اختيار نظام المصادقة. بعد تجربة الاثنين، وجدت أن Sanctum هو الأنسب لتطبيقات الموبايل. السبب بسيط: Sanctum مصمم خصيصًا للتطبيقات التي لا تحتاج إلى تعقيدات OAuth2 الكاملة.
Sanctum يعمل بـ API Tokens بسيطة، كل token يمثل جلسة مستخدم على جهاز معين. هذا بالضبط ما تحتاجه تطبيقات الموبايل.
إعداد Laravel Sanctum
الخطوات كانت واضحة ومباشرة:
// Install Sanctum
composer require laravel/sanctum
// Publish configuration
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
// Run migrations
php artisan migrate
ثم في ملف app/Http/Kernel.php، تأكدت من إضافة middleware Sanctum:
'api' => [
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
بناء نظام التسجيل والدخول
في AuthController، بدأت بتطوير دوال التسجيل والدخول:
public function register(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:8|confirmed',
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
]);
$token = $user->createToken('auth_token')->plainTextToken;
return response()->json([
'user' => $user,
'access_token' => $token,
'token_type' => 'Bearer',
]);
}
public function login(Request $request)
{
$credentials = $request->validate([
'email' => 'required|email',
'password' => 'required',
]);
if (!Auth::attempt($credentials)) {
return response()->json([
'message' => 'Invalid credentials'
], 401);
}
$user = User::where('email', $request->email)->first();
$token = $user->createToken('auth_token')->plainTextToken;
return response()->json([
'user' => $user,
'access_token' => $token,
'token_type' => 'Bearer',
]);
}
نموذج المنتج والتحكم فيه
أنشأت نموذج Product بخصائص أساسية:
// Migration
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->decimal('price', 8, 2);
$table->integer('stock_quantity')->default(0);
$table->string('image_path')->nullable();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->timestamps();
});
// Product Model
class Product extends Model
{
protected $fillable = [
'name',
'description',
'price',
'stock_quantity',
'image_path',
'user_id'
];
public function user()
{
return $this->belongsTo(User::class);
}
}
Product Controller الكامل
هذا الكود يوضح كيفية بناء الـ controller مع جميع العمليات CRUD:
class ProductController extends Controller
{
public function index()
{
$products = Product::where('user_id', auth()->id())->get();
return ProductResource::collection($products);
}
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'price' => 'required|numeric|min:0',
'stock_quantity' => 'required|integer|min:0',
]);
$product = Product::create(array_merge($validated, [
'user_id' => auth()->id()
]));
return new ProductResource($product);
}
public function show(Product $product)
{
$this->authorize('view', $product);
return new ProductResource($product);
}
public function update(Request $request, Product $product)
{
$this->authorize('update', $product);
$validated = $request->validate([
'name' => 'sometimes|string|max:255',
'description' => 'nullable|string',
'price' => 'sometimes|numeric|min:0',
'stock_quantity' => 'sometimes|integer|min:0',
]);
$product->update($validated);
return new ProductResource($product);
}
public function destroy(Product $product)
{
$this->authorize('delete', $product);
$product->delete();
return response()->json([
'message' => 'Product deleted successfully'
]);
}
public function uploadImage(Request $request, Product $product)
{
$this->authorize('update', $product);
$request->validate([
'image' => 'required|image|mimes:jpeg,png,jpg,gif|max:2048',
]);
if ($request->hasFile('image')) {
// Delete old image if exists
if ($product->image_path) {
Storage::disk('public')->delete($product->image_path);
}
$path = $request->file('image')->store('product-images', 'public');
$product->update(['image_path' => $path]);
return response()->json([
'message' => 'Image uploaded successfully',
'image_url' => asset("storage/{$path}")
]);
}
return response()->json([
'message' => 'No image provided'
], 400);
}
}
API Resources: سر النجاح
اكتشفت أن API Resources هي من أقوى ميزات Laravel لهذا النوع من المشاريع. بدلاً من إرجاع النماذج مباشرة، يمكنك تخصيص شكل البيانات المرتجعة.
إنشاء Product Resource
class ProductResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'price' => (float) $this->price,
'stock_quantity' => $this->stock_quantity,
'image_url' => $this->image_path ? asset("storage/{$this->image_path}") : null,
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
'links' => [
'self' => route('products.show', $this->id),
'image_upload' => route('products.images.store', $this->id),
]
];
}
}
الفائدة من استخدام Resources أنها تمنحك تحكمًا كاملاً في شكل الاستجابة بدون الحاجة لتعديل النموذج الأساسي.
رفع الملفات: تحدي حقيقي
واجهت تحديات في رفع الملفات من Flutter إلى Laravel. المشكلة كانت في كيفية إرسال الصور مع البيانات الأخرى.
الحل النهائي لرفع الصور
// In Laravel - Storage setup php artisan storage:link // In Flutter - Upload function FutureuploadProductImage(String productId, File imageFile) async { var uri = Uri.parse('https://yourapi.com/api/v1/products/$productId/images'); var request = http.MultipartRequest('POST', uri); // Add authorization header request.headers['Authorization'] = 'Bearer $accessToken'; // Add image file var stream = http.ByteStream(imageFile.openRead()); var length = await imageFile.length(); var multipartFile = http.MultipartFile( 'image', stream, length, filename: imageFile.path.split('/').last, ); request.files.add(multipartFile); var response = await request.send(); if (response.statusCode == 200) { print('Image uploaded successfully'); } else { print('Upload failed: ${response.statusCode}'); } }
التحقق من الصحة ومعالجة الأخطاء
في تطبيقات API، معالجة الأخطاء مهمة جدًا لتجربة مستخدم سلسة. طورت نظامًا متكاملًا للتحقق من الصحة ومعالجة الأخطاء.
معالجة الاستثناءات
// In app/Exceptions/Handler.php
public function render($request, Throwable $exception)
{
if ($exception instanceof ModelNotFoundException) {
return response()->json([
'message' => 'Resource not found',
'error' => 'The requested resource does not exist'
], 404);
}
if ($exception instanceof ValidationException) {
return response()->json([
'message' => 'Validation failed',
'errors' => $exception->errors()
], 422);
}
if ($exception instanceof AuthenticationException) {
return response()->json([
'message' => 'Unauthenticated',
'error' => 'You must be logged in to access this resource'
], 401);
}
return parent::render($request, $exception);
}
الاختبار: مرحلة لا غنى عنها
قبل الانتقال إلى Flutter، اختبرت كل endpoint باستخدام Postman. هذه كانت بعض السيناريوهات التي اختبرتها:
- التسجيل بمستخدم جديد
- تسجيل الدخول والحصول على token
- محاولة الوصول إلى موارد محمية بدون token
- إضافة منتجات جديدة
- رفع صور للمنتجات
- اختبار التحقق من الصحة مع بيانات غير صالحة
أمثلة على اختبارات Postman
// Test registration
POST /api/v1/register
{
"name": "Test User",
"email": "test@example.com",
"password": "password123",
"password_confirmation": "password123"
}
// Test product creation
POST /api/v1/products
Headers: Authorization: Bearer {token}
{
"name": "New Product",
"description": "Product description",
"price": 29.99,
"stock_quantity": 100
}
التكامل مع Flutter
بعد التأكد من عمل API بشكل صحيح، بدأت في بناء تطبيق Flutter. استخدمت حزمة http للاتصال بالـ API.
خدمة API في Flutter
class ApiService {
static const String baseUrl = 'https://yourapi.com/api/v1';
static String? accessToken;
static Future
نموذج Product في Flutter
class Product {
final int id;
final String name;
final String? description;
final double price;
final int stockQuantity;
final String? imageUrl;
final DateTime createdAt;
final DateTime updatedAt;
Product({
required this.id,
required this.name,
this.description,
required this.price,
required this.stockQuantity,
this.imageUrl,
required this.createdAt,
required this.updatedAt,
});
factory Product.fromJson(Map json) {
return Product(
id: json['id'],
name: json['name'],
description: json['description'],
price: double.parse(json['price'].toString()),
stockQuantity: json['stock_quantity'],
imageUrl: json['image_url'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
);
}
Map toJson() {
return {
'name': name,
'description': description,
'price': price,
'stock_quantity': stockQuantity,
};
}
}
الدروس المستفادة والتحديات
خلال هذه الرحلة، تعلمت دروسًا قيمة:
التحديات التي واجهتها
واجهت عدة تحديات تقنية، أبرزها:
- إدارة حالة التوكنز في Flutter
- معالجة الأخطاء بشكل متسق
- تحسين أداء رفع الملفات
- ضمان أمان البيانات المنقولة
الحلول التي طورتها
لحل هذه التحديات، اتبعت استراتيجيات مختلفة:
// Token management in Flutter
class AuthService {
static Future saveToken(String token) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('access_token', token);
}
static Future getToken() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString('access_token');
}
static Future logout() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove('access_token');
}
}
// Error handling wrapper
Future handleApiCall(Future Function() apiCall) async {
try {
return await apiCall();
} on http.ClientException catch (e) {
throw Exception('Network error: ${e.message}');
} on FormatException catch (e) {
throw Exception('Data format error: ${e.message}');
} catch (e) {
throw Exception('Unexpected error: $e');
}
}
النتائج والتقييم النهائي
بعد شهرين من العمل، نجحت في تحويل التطبيق بالكامل. النتائج كانت مذهلة:
- تطبيق Flutter يعمل بسلاسة مع Laravel API
- أداء محسن مقارنة بالتطبيق الأصلي
- قابلية للتوسع وإضافة مميزات جديدة
- تجربة مستخدم متسقة عبر المنصات
الخاتمة: رحلة تستحق العناء
تحويل تطبيق Laravel إلى RESTful API لـ Flutter ليس مجرد عملية تقنية، بل هو تحول في طريقة التفكير. تعلمت أن فصل الطبقات ليس رفاهية، بل ضرورة للتطوير المستدام.
النصيحة الأهم التي يمكنني تقديمها: ابدأ بمشروع صغير، تعلم من الأخطاء، وكن صبورًا. التحول إلى بنية API قد يبدو معقدًا في البداية، لكن الفوائد طويلة المدى تستحق كل جهد.
اليوم، أستطيع بناء تطبيقات متعددة المنصات تستخدم نفس المصدر المركزي للبيانات، وهذا يفتح آفاقًا جديدة للتطوير والنمو. إذا كنت تفكر في هذه الخطوة، فلا تتردد ــ ابدأ اليوم وستندهش من النتائج.
