ازاي حولت Laravel Project ضخم إلى Monorepo منظم
في لحظة معينة من تطوير مشروع Laravel كبير، بدأت أشعر أن الأمور بدأت تخرج عن السيطرة. كان لدينا تطبيق ويب ضخم، يحتوي على عشرات الموديولات، خدمات خارجية، واجهات API، وواجهة إدارية، وواجهة عامة، وكلها مكتوبة داخل نفس المشروع. مع الوقت، أصبحت عملية النشر بطيئة، والاختبارات تستغرق وقتًا طويلاً، والتعاون بين الفرق صعب جدًا لأن كل تعديل صغير كان يهدد باقي النظام. لم أكن أتخيل أن الحل سيكون في اعتماد نموذج Monorepo، لكن التجربة غيرت طريقة تفكيري تمامًا في بناء التطبيقات الكبيرة.
في هذا المقال، سأشرح بالتفصيل كيف قمت بتحويل مشروع Laravel ضخم إلى هيكل Monorepo منظم، مع أمثلة عملية، أكواد فعلية، ودروس مستفادة من الأخطاء والتحديات التي واجهتني. إذا كنت تعمل على مشروع Laravel كبير أو تخطط لبناء واحد، فهذا الدليل سيساعدك على تجنب الفوضى وتحقيق بنية أكثر مرونة وقابلية للصيانة.
ليه Monorepo؟ وما الفرق بينه وبين Multi-repo؟
قبل أن أبدأ في التفاصيل التقنية، دعني أوضح أولًا الفكرة الأساسية. Monorepo (مستودع واحد) يعني أن كل مكونات مشروعك — سواء كانت تطبيقات منفصلة، حزم مخصصة، أدوات CLI، أو حتى واجهات فرونت إند — تعيش داخل مستودع كود واحد. في المقابل، Multi-repo يعني أن كل جزء له مستودعه الخاص.
في البداية، كنت أعتقد أن Multi-repo هو الحل الأمثل لفصل الاهتمامات، لكن مع نمو المشروع، واجهت مشاكل مثل:
- صعوبة تتبع التغييرات المشتركة بين المكونات.
- زيادة وقت النشر بسبب الحاجة لتحديث إصدارات الحزم يدويًا.
- صعوبة في مشاركة الكود المشترك (مثل Models، Traits، أو حتى قواعد التحقق validation).
- اختبارات مجزأة لا تغطي التكامل بين المكونات.
Monorepo حلّ كل هذه المشاكل. بإمكانك تعديل كود مشترك وتطبيقات متعددة في نفس الـ commit، وتشغيل اختبارات متكاملة بسهولة، ونشر كل شيء معًا أو بشكل انتقائي حسب الحاجة.
التحضير للتحول: تحليل المشروع الحالي
قبل أن ألمس أي ملف، قضيت أسبوعًا كاملًا في تحليل هيكل المشروع الحالي. كان المشروع يحتوي على:
- واجهة عامة (Frontend Web)
- لوحة تحكم إدارية (Admin Panel)
- واجهة API للتطبيقات الخارجية
- حزمة خاصة (Custom Package) تحتوي على منطق عمل مشترك
- أدوات CLI للصيانة والتشغيل
لاحظت أن الحزمة المشتركة كانت مُضمنة كـ composer package منفصل، لكنها كانت تُحدَّث يدويًا، مما تسبب في تأخير تحديثات المنطق المشترك. أيضًا، كل جزء كان يحتوي على نسخ مكررة من بعض الـ Models والـ Traits، مما زاد من احتمالية وجود أخطاء غير متناسقة.
الهدف من التحول إلى Monorepo كان:
- دمج كل المكونات في مستودع واحد.
- فصل كل مكون في مجلد مستقل مع هيكل Laravel مناسب.
- السماح بمشاركة الكود بين المكونات دون تكرار.
- تبسيط عملية النشر والاختبار.
اختيار الأدوات المناسبة
لبناء Monorepo فعّال مع Laravel، احتجت إلى أدوات تساعدني في إدارة التبعيات، النشر، والاختبار. بعد تجربة عدة خيارات، استقررت على:
- Laravel Packages لإنشاء مكونات قابلة لإعادة الاستخدام.
- Composer Workspaces (أو أدوات مثل
symplify/monorepo-builder) لإدارة التبعيات المشتركة. - Laravel Zero لأدوات CLI.
- Docker لعزل البيئات لكل مكون.
- GitHub Actions لأتمتة الاختبارات والنشر.
الأداة الأهم كانت Composer، لأنها تسمح لك بتعريف "حزم محلية" داخل نفس المشروع دون الحاجة لنشرها على Packagist.
هيكل Monorepo المقترح
بعد التخطيط، رسمت الهيكل التالي للمشروع:
monorepo-root/ ├── apps/ │ ├── public-frontend/ │ ├── admin-panel/ │ └── api-service/ ├── packages/ │ ├── core/ │ └── notifications/ ├── tools/ │ └── maintenance-cli/ ├── docker/ │ └── ... ├── composer.json └── README.md
هذا الهيكل يسمح لكل مكون أن يكون مستقلاً تقنيًا، لكنه يعيش في نفس المستودع ويشارك نفس الحزم الأساسية.
الخطوة الأولى: إنشاء الحزمة الأساسية (Core Package)
بدأت بفصل الكود المشترك في حزمة اسمها core. هذه الحزمة تحتوي على:
- جميع الـ Models
- الـ Traits المشتركة
- الـ Enums
- الـ Services (مثل PaymentService، NotificationService)
- الـ Validation Rules المخصصة
لإنشاء الحزمة، اتبعت الخطوات التالية:
1. إنشاء مجلد الحزمة
داخل مجلد packages/core، أنشأت هيكل حزمة Laravel تقليدي:
packages/core/ ├── src/ │ ├── Models/ │ ├── Traits/ │ ├── Services/ │ ├── Rules/ │ └── CoreServiceProvider.php ├── composer.json └── README.md
2. كتابة composer.json للحزمة
ملف packages/core/composer.json كان كالتالي:
{
"name": "myproject/core",
"description": "Core package for shared logic",
"type": "library",
"autoload": {
"psr-4": {
"MyProject\\Core\\": "src/"
}
},
"extra": {
"laravel": {
"providers": [
"MyProject\\Core\\CoreServiceProvider"
]
}
},
"require": {
"php": "^8.1",
"illuminate/support": "^10.0"
}
}
3. تسجيل الحزمة في composer الرئيسي
في الملف الرئيسي composer.json في جذر المشروع، أضفت:
{
"repositories": [
{
"type": "path",
"url": "packages/*"
}
],
"require": {
"myproject/core": "*"
}
}
باستخدام type: path، يسمح Composer بتحميل الحزم من مجلدات محلية دون الحاجة لنشرها.
4. إنشاء Service Provider
في ملف CoreServiceProvider.php، سجلت الخدمات والـ Models:
<?php
namespace MyProject\Core;
use Illuminate\Support\ServiceProvider;
class CoreServiceProvider extends ServiceProvider
{
public function register()
{
// تسجيل الخدمات هنا
}
public function boot()
{
// تحميل الـ Models تلقائيًا
$this->app->register(\MyProject\Core\Models\ModelServiceProvider::class);
}
}
بعد هذه الخطوة، أصبح بإمكاني استخدام أي Model من الحزمة الأساسية في أي تطبيق داخل Monorepo بمجرد إضافة use MyProject\Core\Models\User;.
الخطوة الثانية: فصل التطبيقات إلى مجلدات مستقلة
بعد إنشاء الحزمة الأساسية، بدأت في فصل كل تطبيق إلى مجلد مستقل داخل apps/.
كيف أنشأت تطبيق Laravel جديد داخل Monorepo؟
استخدمت الأمر التالي داخل مجلد apps/public-frontend:
composer create-project laravel/laravel . --prefer-dist
ثم قمت بتعديل ملف composer.json الخاص بالتطبيق ليشمل الحزمة الأساسية:
{
"require": {
"php": "^8.1",
"myproject/core": "*"
}
}
ولكن هنا واجهت مشكلة: كل تطبيق لديه ملف .env وconfig/app.php خاص به. هذا طبيعي، لكنني أردت تجنب التكرار في الإعدادات المشتركة.
حل مشكلة التكرار في ملفات البيئة
أنشأت ملف .env.example في الجذر يحتوي على المتغيرات المشتركة:
APP_NAME=MyProject
APP_ENV=local
APP_KEY=
DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
ثم في كل تطبيق، أضفت متغيرات خاصة:
مثال: apps/admin-panel/.env
APP_URL=http://admin.myproject.test
ADMIN_EMAIL=admin@myproject.com
وهكذا، كل تطبيق يرث الإعدادات العامة ويضيف خصوصيته.
ربط التطبيقات بالحزمة الأساسية
في كل تطبيق، أضفت CoreServiceProvider إلى ملف config/app.php:
'providers' => [
// ...
MyProject\Core\CoreServiceProvider::class,
],
الآن، كل تطبيق يستطيع استخدام الـ Models والخدمات من الحزمة الأساسية دون أي مشاكل.
الخطوة الثالثة: إدارة التبعيات والتحديثات
أحد أكبر التحديات في Monorepo هو إدارة التبعيات. إذا غيرت شيئًا في الحزمة الأساسية، كيف أتأكد أن كل التطبيقات ستستخدم الإصدار الجديد تلقائيًا؟
استخدام Composer Path Repository
كما ذكرت سابقًا، استخدام "type": "path" في repositories يجعل Composer يتعامل مع الحزم المحلية كأنها مثبتة من مصدر خارجي، لكن بأحدث كود موجود في المجلد.
هذا يعني أن أي تعديل في packages/core سيظهر فورًا في جميع التطبيقات دون الحاجة لإعادة النشر أو التحديث اليدوي.
تجنب التعارضات في إصدارات Laravel
لضمان التوافق، حددت إصدار Laravel في الحزمة الأساسية وفي جميع التطبيقات:
في packages/core/composer.json:
"require": {
"illuminate/support": "^10.0"
}
وفي كل تطبيق:
"require": {
"laravel/framework": "^10.0"
}
بهذا الشكل، لا يمكن لأي تطبيق أن يستخدم إصدارًا مختلفًا من Laravel دون كسر التوافق.
الخطوة الرابعة: أتمتة الاختبارات والنشر
بعد هيكلة المشروع، كان لا بد من تحديث سير العمل (workflow) ليناسب Monorepo.
تشغيل الاختبارات لكل مكون
أنشأت ملف phpunit.xml منفصل لكل تطبيق، وحزمة. مثال لحزمة core:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php">
<testsuites>
<testsuite name="Core">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
ثم أنشأت سكربت في الجذر لتشغيل جميع الاختبارات:
#!/bin/bash
# run-tests.sh
echo "Running Core package tests..."
cd packages/core && ./vendor/bin/phpunit
echo "Running Public Frontend tests..."
cd ../../apps/public-frontend && ./vendor/bin/phpunit
echo "Running Admin Panel tests..."
cd ../admin-panel && ./vendor/bin/phpunit
نشر التطبيقات بشكل مستقل
لنشر تطبيق واحد دون الآخر، استخدمت Docker Compose مع ملفات منفصلة:
مثال: docker-compose.admin.yml
version: '3.8'
services:
admin-app:
build:
context: ./apps/admin-panel
ports:
- "8081:80"
environment:
- APP_ENV=production
بهذا الشكل، يمكنني نشر لوحة التحكم على منفذ 8081، والواجهة العامة على 8080، والـ API على 8082 — كل على حدة.
CI/CD مع GitHub Actions
أنشأت workflow يكتشف ما إذا كان هناك تغيير في مكون معين، ويقوم بتشغيل اختباراته فقط:
name: Test Changed Apps
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Check changed files
id: changed
run: |
if git diff --name-only HEAD~1 HEAD | grep -q "^apps/public-frontend/"; then
echo "public_frontend=true" >> $GITHUB_OUTPUT
fi
if git diff --name-only HEAD~1 HEAD | grep -q "^packages/core/"; then
echo "core=true" >> $GITHUB_OUTPUT
fi
- name: Run Core Tests
if: steps.changed.outputs.core == 'true'
run: cd packages/core && composer install && ./vendor/bin/phpunit
- name: Run Public Frontend Tests
if: steps.changed.outputs.public_frontend == 'true'
run: cd apps/public-frontend && composer install && ./vendor/bin/phpunit
هذا يوفر وقتًا كبيرًا في pipelines، لأنك لا تختبر كل شيء عند كل تعديل بسيط.
التحديات اللي واجهتني (والحلول)
ماشي الأمر بسلاسة طبعًا. واجهتني مشاكل حقيقية، وأعتقد أن مشاركتها ستفيدك.
1. تضارب في ملفات RouteServiceProvider
كل تطبيق في Laravel لديه ملف RouteServiceProvider يحدد مسار الـ routes. في البداية، حاولت مشاركة ملف routes مشترك، لكن هذا كسر مبدأ الفصل بين التطبيقات.
الحل: تركت كل تطبيق يحتفظ بملف routes خاص به، لكن استخدمت الـ Controllers من الحزمة الأساسية عندما يكون المنطق مشتركًا. مثال:
// apps/public-frontend/routes/web.php
use MyProject\Core\Http\Controllers\ProfileController;
Route::get('/profile', [ProfileController::class, 'show']);
2. مشكلة في تنفيذ Migrations
المigrations كانت مكررة في كل تطبيق لأن كل واحد كان يحتوي على نسخة من الـ Models. هذا خلق فوضى في قاعدة البيانات.
الحل: نقلت جميع الـ migrations إلى الحزمة الأساسية، وجعلت كل تطبيق يستخدم نفس جدول الـ migrations. في ملف CoreServiceProvider:
public function boot()
{
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__.'/../database/migrations' => database_path('migrations'),
], 'core-migrations');
}
}
ثم في كل تطبيق، أضفت:
php artisan vendor:publish --tag=core-migrations
الآن، كل التطبيقات تستخدم نفس هيكل قاعدة البيانات.
3. صعوبة في التصحيح (Debugging)
في البداية، كان من الصعب تتبع من أين يأتي الخطأ — من التطبيق أم من الحزمة؟
الحل: استخدمت Xdebug مع إعدادات Docker مناسبة لكل تطبيق، ووضعت نقاط توقف (breakpoints) في كود الحزمة الأساسية. أيضًا، أضفت logging مفصل في الحزمة يُظهر اسم التطبيق المستدعي.
مقارنة الأداء قبل وبعد
بعد الانتهاء من التحول، لاحظت تحسنًا ملحوظًا في عدة جوانب:
- وقت النشر: انخفض من 25 دقيقة إلى 8 دقائق لأنني أستطيع نشر التطبيقات بشكل انتقائي.
- جودة الكود: تقلص عدد الأخطاء الناتجة عن عدم التزامن بنسبة 70%.
- التعاون بين الفرق: كل فريق يعمل على تطبيقه دون التأثير على الآخرين، لكنهم يستفيدون من التحديثات الفورية في الحزمة الأساسية.
- الاختبارات: أصبح بإمكاني تشغيل اختبارات تكامل بين التطبيقات بسهولة.
بالنسبة لي، كان هذا التحول استثمارًا ذكيًا في الصيانة طويلة المدى.
مشروع عملي مصغر يوضح الفكرة
لأوضح الفكرة أكثر، إليك مثالًا عمليًا مبسطًا يمكنك تجربته بنفسك.
الخطوة 1: إنشاء هيكل Monorepo
mkdir my-monorepo && cd my-monorepo
mkdir -p apps/web packages/core
الخطوة 2: إنشاء الحزمة الأساسية
داخل packages/core، أنشئ:
composer.json:
{
"name": "myapp/core",
"autoload": {
"psr-4": {
"MyApp\\Core\\": "src/"
}
},
"extra": {
"laravel": {
"providers": ["MyApp\\Core\\CoreServiceProvider"]
}
},
"require": {
"illuminate/support": "^10.0"
}
}
src/CoreServiceProvider.php:
<?php
namespace MyApp\Core;
use Illuminate\Support\ServiceProvider;
class CoreServiceProvider extends ServiceProvider
{
public function boot()
{
// يمكن إضافة منطق هنا لاحقًا
}
}
src/Helpers.php:
<?php
if (!function_exists('format_price')) {
function format_price($amount) {
return number_format($amount, 2) . ' $';
}
}
وأضف في composer.json تحت autoload:
"files": ["src/Helpers.php"]
الخطوة 3: إنشاء تطبيق Laravel
داخل apps/web:
composer create-project laravel/laravel . --prefer-dist
ثم عدل composer.json ليشمل:
"repositories": [
{
"type": "path",
"url": "../../packages/*"
}
],
"require": {
"myapp/core": "*"
}
ثم نفّذ:
composer install
الخطوة 4: استخدام الحزمة في التطبيق
في أي Controller داخل apps/web:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Controller;
class HomeController extends Controller
{
public function index()
{
$price = format_price(1500);
return view('welcome', compact('price'));
}
}
ستعمل الدالة format_price فورًا لأنها معرفة في الحزمة الأساسية!
نصائح نهائية لنجاح Monorepo مع Laravel
من تجربتي، إليك أهم النصائح:
- ابدأ صغيرًا: لا تحاول تحويل مشروع ضخم دفعة واحدة. ابدأ بفصل مكون واحد، ثم زد التدريجيًا.
- وثّق كل شيء: Monorepo معقد، لذا اكتب دليلًا واضحًا لكيفية إضافة مكون جديد، تشغيل الاختبارات، والنشر.
- استخدم أدوات مساعدة: أدوات مثل
monorepo-builderأوcomposer-bin-pluginيمكن أن توفر وقتك. - لا تبالغ في التقسيم: ليس كل شيء يحتاج أن يكون حزمة. افصل فقط ما هو مشترك حقًا.
- اختبر التكامل: أنشئ اختبارات تغطي تفاعل التطبيقات مع بعضها، وليس فقط كل تطبيق على حدة.
الخاتمة: ابدأ رحلتك نحو Monorepo اليوم
تحويل مشروع Laravel ضخم إلى Monorepo منظم لم يكن سهلًا، لكنه كان من أفضل القرارات التي اتخذتها في مسيرتي كمطور. الفوضى التي كانت تهدد المشروع تحولت إلى بنية واضحة، قابلة للتوسع، وسهلة الصيانة.
إذا كنت تشعر أن مشروعك بدأ يصبح "ثقيلًا" أو "مشتتًا"، فلا تنتظر حتى يصبح الأمر أسوأ. جرب أن تبدأ بفصل جزء بسيط كحزمة محلية، وانظر كيف سيغير ذلك طريقة عملك.
Monorepo ليس سحرًا، لكنه أداة قوية في يد المطور الواعي. استخدمها بذكاء، وستشكر نفسك بعد أشهر عندما تنظر إلى كودك وتقول: "واو، هذا منظم جدًا!"
ابدأ اليوم. حتى لو كان مشروعك صغيرًا. جرب الهيكل، عدّل عليه، وتعلّم منه. لأن أفضل وقت لبناء نظام قابل للنمو هو قبل أن تحتاجه فعليًا.
