📊 2 مشاهدة
شرح PHP-SaaS
تعمّق أعمق: بناء أنظمة إنتاجية كاملة لمشروع SaaS
الجزء الأول: بناء Service للفوترة (Billing Service)
لماذا Service منفصل؟
- فصل المسؤوليات: Controllers تتعامل مع HTTP فقط، منطق الفوترة معزول.
- قابلية الاختبار: يمكن اختبار الخدمة دون HTTP.
- إعادة الاستخدام: نفس الخدمة تُستخدم في الويب، API، وConsole.
- التبديل السهل: تغيير Stripe إلى Paddle دون لمس Controllers.
البنية
text
app/Services/Billing/
├── BillingService.php # الواجهة الرئيسية
├── Contracts/
│ └── PaymentGateway.php # عقد موحد لأي بوابة دفع
├── Gateways/
│ ├── StripeGateway.php
│ └── PaddleGateway.php
├── DTOs/
│ ├── SubscriptionData.php
│ └── InvoiceData.php
└── Exceptions/
├── PaymentFailedException.php
└── PlanLimitExceededException.php
عقد بوابة الدفع
php
namespace App\Services\Billing\Contracts;
use App\Models\User;
use App\Services\Billing\DTOs\SubscriptionData;
interface PaymentGateway
{
public function createSubscription(User $user, string $planId, string $paymentMethod): SubscriptionData;
public function cancelSubscription(User $user): void;
public function resumeSubscription(User $user): void;
public function swapPlan(User $user, string $newPlanId): SubscriptionData;
public function getInvoices(User $user): array;
public function getPaymentMethod(User $user): ?array;
}
DTO للاشتراك
php
namespace App\Services\Billing\DTOs;
use Carbon\Carbon;
readonly class SubscriptionData
{
public function __construct(
public string $id,
public string $planId,
public string $status,
public Carbon $currentPeriodEnd,
public ?Carbon $trialEnd = null,
public bool $cancelAtPeriodEnd = false,
) {}
public static function fromStripe(\Laravel\Cashier\Subscription $sub): self
{
return new self(
id: $sub->stripe_id,
planId: $sub->stripe_price,
status: $sub->stripe_status,
currentPeriodEnd: Carbon::createFromTimestamp($sub->current_period_end),
trialEnd: $sub->trial_ends_at,
cancelAtPeriodEnd: $sub->ends_at !== null,
);
}
}
Stripe Gateway
php
namespace App\Services\Billing\Gateways;
use App\Models\User;
use App\Services\Billing\Contracts\PaymentGateway;
use App\Services\Billing\DTOs\SubscriptionData;
use App\Services\Billing\Exceptions\PaymentFailedException;
use Stripe\Exception\CardException;
class StripeGateway implements PaymentGateway
{
public function createSubscription(User $user, string $planId, string $paymentMethod): SubscriptionData
{
try {
$subscription = $user->newSubscription('default', $planId)
->trialDays(config('saas.trial_days', 14))
->create($paymentMethod);
return SubscriptionData::fromStripe($subscription);
} catch (CardException $e) {
throw new PaymentFailedException($e->getMessage(), previous: $e);
}
}
public function cancelSubscription(User $user): void
{
$user->subscription('default')?->cancel();
}
public function resumeSubscription(User $user): void
{
$user->subscription('default')?->resume();
}
public function swapPlan(User $user, string $newPlanId): SubscriptionData
{
$sub = $user->subscription('default');
if (! $sub) {
throw new \RuntimeException('No active subscription');
}
// إذا كانت الترقية، استخدم proration
$sub->swap($newPlanId);
return SubscriptionData::fromStripe($sub->fresh());
}
public function getInvoices(User $user): array
{
return $user->invoices()->map(fn ($inv) => [
'id' => $inv->id,
'date' => $inv->date(),
'total' => $inv->total(),
'url' => $inv->invoicePdf(),
])->toArray();
}
public function getPaymentMethod(User $user): ?array
{
$pm = $user->defaultPaymentMethod();
if (! $pm) {
return null;
}
return [
'brand' => $pm->card->brand,
'last4' => $pm->card->last4,
'exp_month' => $pm->card->exp_month,
'exp_year' => $pm->card->exp_year,
];
}
}
Billing Service (الواجهة)
php
namespace App\Services\Billing;
use App\Models\User;
use App\Services\Billing\Contracts\PaymentGateway;
use App\Services\Billing\DTOs\SubscriptionData;
class BillingService
{
public function __construct(
private readonly PaymentGateway $gateway
) {}
public function subscribe(User $user, string $planId, string $paymentMethod): SubscriptionData
{
// تحقق من وجود اشتراك مسبق
if ($user->subscribed('default')) {
return $this->gateway->swapPlan($user, $planId);
}
return $this->gateway->createSubscription($user, $planId, $paymentMethod);
}
public function cancel(User $user): void
{
$this->gateway->cancelSubscription($user);
}
public function resume(User $user): void
{
$this->gateway->resumeSubscription($user);
}
public function upgrade(User $user, string $planId): SubscriptionData
{
return $this->gateway->swapPlan($user, $planId);
}
}
ربط الخدمة بالحاوية
php
// app/Providers/AppServiceProvider.php
use App\Services\Billing\Contracts\PaymentGateway;
use App\Services\Billing\Gateways\StripeGateway;
public function register(): void
{
$this->app->bind(PaymentGateway::class, function () {
return match (config('saas.gateway')) {
'stripe' => new StripeGateway(),
'paddle' => new \App\Services\Billing\Gateways\PaddleGateway(),
};
});
$this->app->singleton(BillingService::class, function ($app) {
return new BillingService($app->make(PaymentGateway::class));
});
}
Controller يستخدم الخدمة
php
namespace App\Http\Controllers\Billing;
use App\Http\Controllers\Controller;
use App\Services\Billing\BillingService;
use Illuminate\Http\Request;
class SubscriptionController extends Controller
{
public function __construct(
private readonly BillingService $billing
) {}
public function store(Request $request)
{
$data = $request->validate([
'plan' => 'required|string|exists:plans,id',
'payment_method' => 'required|string',
]);
$subscription = $this->billing->subscribe(
$request->user(),
$data['plan'],
$data['payment_method']
);
return response()->json([
'message' => 'تم الاشتراك بنجاح',
'subscription' => $subscription,
]);
}
public function cancel(Request $request)
{
$this->billing->cancel($request->user());
return back()->with('status', 'سيتم إلغاء الاشتراك في نهاية الدورة');
}
}
اختبار الخدمة
php
namespace Tests\Unit\Services;
use App\Models\User;
use App\Services\Billing\BillingService;
use App\Services\Billing\Contracts\PaymentGateway;
use App\Services\Billing\DTOs\SubscriptionData;
use Carbon\Carbon;
use Mockery;
use Tests\TestCase;
class BillingServiceTest extends TestCase
{
public function test_subscribe_creates_new_when_no_active_subscription(): void
{
$user = User::factory()->create();
$gateway = Mockery::mock(PaymentGateway::class);
$gateway->shouldReceive('createSubscription')
->once()
->andReturn(new SubscriptionData(
id: 'sub_123',
planId: 'price_pro',
status: 'active',
currentPeriodEnd: Carbon::now()->addMonth(),
));
$service = new BillingService($gateway);
$result = $service->subscribe($user, 'price_pro', 'pm_card');
$this->assertEquals('sub_123', $result->id);
}
}
الجزء الثاني: اختبار Webhooks باستخدام Stripe CLI
تثبيت Stripe CLI
bash
# macOS brew install stripe/stripe-cli/stripe # Linux wget https://github.com/stripe/stripe-cli/releases/latest/download/stripe_linux_x86_64.tar.gz tar -xvf stripe_linux_x86_64.tar.gz # تسجيل الدخول stripe login
تشغيل Webhook محلياً
bash
# يوجّه أحداث Stripe إلى تطبيقك المحلي stripe listen --forward-to localhost:8000/stripe/webhook
ستحصل على:
text
> Ready! Your webhook signing secret is whsec_xxxxxxxxxxxxx
انسخه إلى .env:
env
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxx
إرسال أحداث تجريبية
bash
# محاكاة اشتراك ناجح stripe trigger customer.subscription.created # محاكاة فشل دفع stripe trigger invoice.payment_failed # محاكاة إلغاء اشتراك stripe trigger customer.subscription.deleted
اختبار Webhook برمجياً (Pest)
php
use Illuminate\Support\Facades\Event;
use Laravel\Cashier\Events\WebhookReceived;
it('handles subscription created webhook', function () {
Event::fake([WebhookReceived::class]);
$payload = [
'id' => 'evt_test',
'type' => 'customer.subscription.created',
'data' => [
'object' => [
'id' => 'sub_test',
'customer' => 'cus_test',
'status' => 'active',
],
],
];
$this->postJson('/stripe/webhook', $payload, [
'Stripe-Signature' => $this->generateStripeSignature($payload),
])->assertOk();
Event::assertDispatched(WebhookReceived::class);
});
function generateStripeSignature(array $payload): string
{
$secret = config('cashier.webhook.secret');
$timestamp = time();
$signedPayload = $timestamp.'.'.json_encode($payload);
$signature = hash_hmac('sha256', $signedPayload, $secret);
return "t={$timestamp},v1={$signature}";
}
اختبارات التكامل مع Stripe Test Mode
php
use Laravel\Cashier\Testing\TestCase;
class StripeIntegrationTest extends TestCase
{
public function test_full_subscription_flow()
{
$user = User::factory()->create();
$user->createAsStripeCustomer();
// إنشاء طريقة دفع تجريبية
$paymentMethod = $user->createSetupIntent()->payment_method;
// اشتراك
$subscription = $user->newSubscription('default', 'price_test_pro')
->create($paymentMethod);
$this->assertTrue($user->subscribed('default'));
// إلغاء
$subscription->cancel();
$this->assertTrue($user->subscription('default')->onGracePeriod());
}
}
الجزء الثالث: Middleware لحدود الخطط
تعريف الخطط في الإعدادات
php
// config/saas.php
return [
'plans' => [
'free' => [
'name' => 'مجاني',
'price' => 0,
'limits' => [
'projects' => 3,
'team_members' => 2,
'api_calls' => 1000,
'storage_mb' => 100,
],
'features' => ['basic_support'],
],
'pro' => [
'name' => 'احترافي',
'price' => 29,
'limits' => [
'projects' => 50,
'team_members' => 10,
'api_calls' => 100000,
'storage_mb' => 10000,
],
'features' => ['basic_support', 'api_access', 'custom_domain'],
],
'enterprise' => [
'name' => 'مؤسسي',
'price' => 299,
'limits' => [
'projects' => PHP_INT_MAX,
'team_members' => PHP_INT_MAX,
'api_calls' => PHP_INT_MAX,
'storage_mb' => PHP_INT_MAX,
],
'features' => ['*'],
],
],
];
خدمة الخطط
php
namespace App\Services;
use App\Models\User;
class PlanService
{
public function planFor(User $user): array
{
$planId = $user->plan ?? 'free';
return config("saas.plans.{$planId}");
}
public function limit(User $user, string $key): int
{
return $this->planFor($user)['limits'][$key] ?? 0;
}
public function hasFeature(User $user, string $feature): bool
{
$features = $this->planFor($user)['features'];
return in_array('*', $features) || in_array($feature, $features);
}
public function usage(User $user, string $key): int
{
return match ($key) {
'projects' => $user->projects()->count(),
'team_members' => $user->team->members()->count(),
'api_calls' => cache()->get("api_calls:{$user->id}", 0),
'storage_mb' => $user->files()->sum('size_mb'),
default => 0,
};
}
public function can(User $user, string $key): bool
{
return $this->usage($user, $key) < $this->limit($user, $key);
}
}
Middleware للحدود
php
namespace App\Http\Middleware;
use App\Services\PlanService;
use Closure;
use Illuminate\Http\Request;
class EnforcePlanLimit
{
public function __construct(
private readonly PlanService $plans
) {}
public function handle(Request $request, Closure $next, string $limitKey)
{
$user = $request->user();
if (! $this->plans->can($user, $limitKey)) {
if ($request->expectsJson()) {
return response()->json([
'message' => "وصلت للحد الأقصى من {$limitKey}",
'limit' => $this->plans->limit($user, $limitKey),
'usage' => $this->plans->usage($user, $limitKey),
'upgrade_url' => route('billing.plans'),
], 429);
}
return redirect()->route('billing.plans')
->with('error', "وصلت للحد الأقصى من {$limitKey}");
}
return $next($request);
}
}
Middleware للميزات
php
namespace App\Http\Middleware;
use App\Services\PlanService;
use Closure;
use Illuminate\Http\Request;
class RequireFeature
{
public function __construct(
private readonly PlanService $plans
) {}
public function handle(Request $request, Closure $next, string $feature)
{
if (! $this->plans->hasFeature($request->user(), $feature)) {
abort(403, 'هذه الميزة غير متاحة في خطتك الحالية');
}
return $next($request);
}
}
تسجيل Middleware
php
// app/Http/Kernel.php
protected $middlewareAliases = [
'plan.limit' => \App\Http\Middleware\EnforcePlanLimit::class,
'plan.feature' => \App\Http\Middleware\RequireFeature::class,
];
الاستخدام في Routes
php
// routes/web.php
Route::middleware(['auth'])->group(function () {
Route::post('/projects', [ProjectController::class, 'store'])
->middleware('plan.limit:projects');
Route::get('/api-keys', [ApiKeyController::class, 'index'])
->middleware('plan.feature:api_access');
});
عدّاد API Calls
php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class CountApiCalls
{
public function handle(Request $request, Closure $next)
{
$key = "api_calls:{$request->user()->id}";
$count = cache()->increment($key);
// إعادة تعيين العداد كل شهر
if ($count === 1) {
cache()->put($key, 1, now()->addMonth());
}
return $next($request);
}
}
الجزء الرابع: إعداد CI/CD لمشروع Laravel SaaS
GitHub Actions — الاختبارات
yaml
# .github/workflows/tests.yml
name: Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: testing
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s
redis:
image: redis:7
ports:
- 6379:6379
options: --health-cmd="redis-cli ping" --health-interval=10s
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, pdo_mysql, redis, bcmath, gd
coverage: xdebug
- name: Install Composer dependencies
run: composer install --prefer-dist --no-progress
- name: Copy .env
run: cp .env.testing .env
- name: Generate key
run: php artisan key:generate
- name: Run Pint
run: vendor/bin/pint --test
- name: Run PHPStan
run: vendor/bin/phpstan analyse --memory-limit=2G
- name: Run Tests
run: vendor/bin/pest --coverage --min=80
- name: Upload coverage
uses: codecov/codecov-action@v4
GitHub Actions — النشر
yaml
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
needs: [test]
steps:
- uses: actions/checkout@v4
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts
- name: Deploy via SSH
run: |
ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'EOF'
cd /var/www/saas
git pull origin main
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan queue:restart
php artisan horizon:terminate
EOF
سكربت النشر (Deployer)
php
// deploy.php
namespace Deployer;
require 'recipe/laravel.php';
host('production')
->set('hostname', 'saas.example.com')
->set('remote_user', 'deploy')
->set('deploy_path', '/var/www/saas');
host('staging')
->set('hostname', 'staging.saas.example.com')
->set('remote_user', 'deploy')
->set('deploy_path', '/var/www/saas-staging');
// مهام مخصصة
task('tenants:migrate', function () {
run('{{release_path}}/artisan tenants:migrate --force');
});
after('artisan:migrate', 'tenants:migrate');
// إعادة تشغيل Horizon
task('horizon:restart', function () {
run('{{release_path}}/artisan horizon:terminate');
});
after('deploy:symlink', 'horizon:restart');
bash
# النشر dep deploy production # التراجع dep rollback production
الجزء الخامس: المراقبة والتنبيهات
Sentry — تتبع الأخطاء
php
// config/sentry.php
return [
'dsn' => env('SENTRY_LARAVEL_DSN'),
'traces_sample_rate' => env('SENTRY_TRACES_SAMPLE_RATE', 0.2),
'profiles_sample_rate' => 0.2,
'environment' => env('APP_ENV'),
];
php
// app/Exceptions/Handler.php
use Sentry\Laravel\Integration;
public function register(): void
{
$this->reportable(function (\Throwable $e) {
Integration::captureUnhandledException($e);
});
}
Horizon — مراقبة Queues
php
// config/horizon.php
'environments' => [
'production' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['default', 'billing', 'notifications'],
'balance' => 'auto',
'minProcesses' => 1,
'maxProcesses' => 10,
'tries' => 3,
'timeout' => 60,
],
],
],
bash
# تشغيل Horizon php artisan horizon # في Supervisor [program:horizon] command=php /var/www/saas/artisan horizon autostart=true autorestart=true user=deploy
Health Checks
php
// routes/web.php
Route::get('/health', function () {
return response()->json([
'status' => 'ok',
'database' => DB::connection()->getPdo() ? 'connected' : 'failed',
'redis' => Redis::ping() ? 'connected' : 'failed',
'queue' => Queue::size() < 1000 ? 'ok' : 'backlog',
'timestamp' => now()->toIso8601String(),
]);
});
تنبيهات Slack
php
// app/Notifications/SystemAlert.php
namespace App\Notifications;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\SlackMessage;
class SystemAlert extends Notification
{
public function __construct(
public string $title,
public string $message,
public string $level = 'warning'
) {}
public function via($notifiable): array
{
return ['slack'];
}
public function toSlack($notifiable): SlackMessage
{
return (new SlackMessage)
->{$this->level}()
->title($this->title)
->content($this->message)
->timestamp(now());
}
}
php
// إرسال تنبيه عند فشل Webhook
Notification::route('slack', config('services.slack.webhook'))
->notify(new SystemAlert(
'فشل Webhook',
"فشل معالجة حدث Stripe: {$eventId}",
'error'
));
الجزء السادس: الأمان المتقدم
قائمة تحقق أمنية
| البند | الأداة/الإجراء |
|---|---|
| حماية CSRF | مدمج في Laravel |
| XSS | Blade escaping تلقائي |
| SQL Injection | Eloquent/Query Builder |
| Rate Limiting | RateLimiter::for('api', ...) |
| 2FA | pragmarx/google2fa |
| تشفير البيانات الحساسة | Crypt::encryptString() |
| تدقيق التبعيات | composer audit |
| تحليل ثابت | nunomaduro/larastan |
| اختبار اختراق | أدوات خارجية (Burp, OWASP ZAP) |
| سجلات التدقيق | spatie/laravel-activitylog |
Rate Limiting متقدم
php
// app/Providers/RouteServiceProvider.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
protected function configureRateLimiting(): void
{
RateLimiter::for('api', function (Request $request) {
$user = $request->user();
return Limit::perMinute(match ($user?->plan) {
'enterprise' => 1000,
'pro' => 100,
default => 20,
})->by($user?->id ?: $request->ip());
});
RateLimiter::for('login', function (Request $request) {
return [
Limit::perMinute(5)->by($request->ip()),
Limit::perMinute(10)->by($request->input('email')),
];
});
}
تشفير بيانات حساسة
php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Support\Facades\Crypt;
class Tenant extends Model
{
protected function stripeSecret(): Attribute
{
return Attribute::make(
get: fn ($value) => $value ? Crypt::decryptString($value) : null,
set: fn ($value) => $value ? Crypt::encryptString($value) : null,
);
}
}
الجزء السابع: خارطة طريق 90 يوماً
الشهر الأول: الأساس
- □ تثبيت Laravel + Breeze
- □ إعداد PostgreSQL + Redis
- □ مصادقة + أدوار (Spatie Permission)
- □ تصميم قاعدة البيانات
- □ اختبارات أساسية
الشهر الثاني: الميزات الأساسية
- □ تعدد المستأجرين (stancl/tenancy)
- □ الفوترة (Cashier + Stripe)
- □ لوحة الإدارة (Filament)
- □ حدود الخطط (Pennant + Middleware)
- □ إشعارات بريدية
الشهر الثالث: الجاهزية للإنتاج
- □ اختبارات شاملة (>80%)
- □ CI/CD
- □ مراقبة (Sentry + Horizon)
- □ نسخ احتياطي (spatie/laravel-backup)
- □ اختبار حمل
- □ توثيق API (Scramble أو Scribe)
- □ إطلاق تجريبي (Beta)
الجزء الثامن: أدوات إضافية تستحق الاستكشاف
| الأداة | الوظيفة |
|---|---|
laravel/scout | بحث متقدم (Meilisearch, Algolia, TNTSearch) |
laravel/reverb | WebSockets (بديل Pusher) |
laravel/folio | صفحات Vue/React داخل Laravel |
laravel/volt | مكونات Blade تفاعلية |
spatie/laravel-typescript-transformer | توليد TypeScript من PHP |
dedoc/scramble | توثيق API تلقائي |
knuckleswtf/scribe | توثيق API شامل |
laravel/pulse | مراقبة أداء (رسمي) |
spatie/laravel-onboard | تتبع خطوات الإعداد الأولي (Onboarding) |
spatie/laravel-csp | Content Security Policy |
الجزء التاسع: أخطاء شائعة يجب تجنبها
- تجاهل Multi-tenancy مبكراً: إضافته لاحقاً مؤلمة.
- تخزين منطق الفوترة في Controllers: يصبح غير قابل للاختبار.
- عدم اختبار Webhooks: أكثر مصدر للأخطاء في الإنتاج.
- استخدام
env()خارج config: يفشل بعدconfig:cache. - عدم تتبع استخدام API: يؤدي لتجاوز الحدود.
- نسخ
.envمن التطوير للإنتاج: خطر أمني. - عدم إعداد Supervisor للـ Queues: الـ Jobs لا تعمل.
- تجاهل النسخ الاحتياطي: كارثة عند فشل DB.
- عدم مراقبة الأداء: المشاكل تُكتشف متأخراً.
- التوسع مبكراً: لا تبنِ لـ 100,000 مستخدم قبل أن تصل لـ 100.
هل تريد أن أتعمق في:
laravel/scout+ Meilisearch لبناء بحث SaaS؟laravel/reverbللإشعارات الفورية؟dedoc/scrambleلتوثيق API تلقائياً؟- اختبار الحمل (Load Testing) باستخدام k6؟
- إعداد Kubernetes لنشر SaaS؟
This response is AI-generated, for reference only.
الكود + العملاء قم بالشحن باستخدام الذكاء الاصطناعي الذي يقوم بما تتوقعه – بغض النظر عن طريقة البناء.cashier إعداد CI/CD لمشروع Laravel SaaS أمثلة كود لـ Middleware حدود الخطط؟ بناء Service للفوترة مع مثال كامل؟
الحزمة الكاملة: الكود + العملاء (Billing + Clients) + CI/CD + Middleware + AI
الجزء الأول: بناء Service الفوترة الكامل مع دعم العملاء
هيكل المشروع الكامل
text
app/
├── Services/Billing/
│ ├── BillingService.php
│ ├── Contracts/
│ │ └── PaymentGateway.php
│ ├── Gateways/
│ │ ├── StripeGateway.php
│ │ └── PaddleGateway.php
│ ├── DTOs/
│ │ ├── CustomerData.php
│ │ ├── SubscriptionData.php
│ │ └── InvoiceData.php
│ └── Exceptions/
│ ├── PaymentFailedException.php
│ └── CustomerNotFoundException.php
├── Models/
│ ├── Customer.php
│ ├── Subscription.php
│ └── Invoice.php
└── Http/Controllers/Billing/
├── CustomerController.php
├── SubscriptionController.php
└── InvoiceController.php
1.1 نموذج العميل (Customer Model)
php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Customer extends Model
{
use SoftDeletes;
protected $fillable = [
'tenant_id',
'user_id',
'email',
'name',
'company',
'vat_number',
'country',
'currency',
'stripe_id',
'paddle_id',
'metadata',
];
protected $casts = [
'metadata' => 'array',
];
public function user()
{
return $this->belongsTo(User::class);
}
public function subscriptions(): HasMany
{
return $this->hasMany(Subscription::class);
}
public function invoices(): HasMany
{
return $this->hasMany(Invoice::class);
}
public function activeSubscription(): ?Subscription
{
return $this->subscriptions()
->whereIn('status', ['active', 'trialing'])
->latest()
->first();
}
public function isSubscribed(): bool
{
return $this->activeSubscription() !== null;
}
}
1.2 نموذج الاشتراك (Subscription Model)
php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Subscription extends Model
{
protected $fillable = [
'customer_id',
'plan_id',
'gateway',
'gateway_id',
'status',
'trial_ends_at',
'current_period_start',
'current_period_end',
'canceled_at',
'ends_at',
'quantity',
'metadata',
];
protected $casts = [
'trial_ends_at' => 'datetime',
'current_period_start' => 'datetime',
'current_period_end' => 'datetime',
'canceled_at' => 'datetime',
'ends_at' => 'datetime',
'metadata' => 'array',
];
public function customer()
{
return $this->belongsTo(Customer::class);
}
public function isActive(): bool
{
return in_array($this->status, ['active', 'trialing'])
&& ($this->ends_at === null || $this->ends_at->isFuture());
}
public function onGracePeriod(): bool
{
return $this->ends_at !== null && $this->ends_at->isFuture();
}
public function isCanceled(): bool
{
return $this->canceled_at !== null;
}
}
1.3 DTOs
php
// app/Services/Billing/DTOs/CustomerData.php
namespace App\Services\Billing\DTOs;
readonly class CustomerData
{
public function __construct(
public string $id,
public string $email,
public string $name,
public ?string $company = null,
public ?string $vatNumber = null,
public string $currency = 'usd',
public array $metadata = [],
) {}
public function toArray(): array
{
return [
'id' => $this->id,
'email' => $this->email,
'name' => $this->name,
'company' => $this->company,
'vat_number' => $this->vatNumber,
'currency' => $this->currency,
'metadata' => $this->metadata,
];
}
}
php
// app/Services/Billing/DTOs/SubscriptionData.php
namespace App\Services\Billing\DTOs;
use Carbon\Carbon;
readonly class SubscriptionData
{
public function __construct(
public string $id,
public string $planId,
public string $status,
public Carbon $currentPeriodStart,
public Carbon $currentPeriodEnd,
public ?Carbon $trialEnd = null,
public ?Carbon $endsAt = null,
public bool $cancelAtPeriodEnd = false,
public int $quantity = 1,
) {}
public function toArray(): array
{
return [
'id' => $this->id,
'plan_id' => $this->planId,
'status' => $this->status,
'current_period_start' => $this->currentPeriodStart->toIso8601String(),
'current_period_end' => $this->currentPeriodEnd->toIso8601String(),
'trial_end' => $this->trialEnd?->toIso8601String(),
'ends_at' => $this->endsAt?->toIso8601String(),
'cancel_at_period_end' => $this->cancelAtPeriodEnd,
'quantity' => $this->quantity,
];
}
}
php
// app/Services/Billing/DTOs/InvoiceData.php
namespace App\Services\Billing\DTOs;
use Carbon\Carbon;
readonly class InvoiceData
{
public function __construct(
public string $id,
public string $number,
public Carbon $date,
public int $amount,
public string $currency,
public string $status,
public ?string $pdfUrl = null,
public ?string $hostedUrl = null,
) {}
public function formattedAmount(): string
{
return number_format($this->amount / 100, 2).' '.strtoupper($this->currency);
}
}
1.4 عقد بوابة الدفع الموسع
php
namespace App\Services\Billing\Contracts;
use App\Models\Customer;
use App\Services\Billing\DTOs\CustomerData;
use App\Services\Billing\DTOs\InvoiceData;
use App\Services\Billing\DTOs\SubscriptionData;
interface PaymentGateway
{
// العملاء
public function createCustomer(CustomerData $data): CustomerData;
public function updateCustomer(Customer $customer, array $data): CustomerData;
public function deleteCustomer(Customer $customer): void;
// الاشتراكات
public function createSubscription(Customer $customer, string $planId, string $paymentMethod, int $quantity = 1): SubscriptionData;
public function cancelSubscription(Customer $customer): void;
public function resumeSubscription(Customer $customer): void;
public function swapPlan(Customer $customer, string $newPlanId): SubscriptionData;
// الفواتير
public function getInvoices(Customer $customer): array;
public function getInvoice(Customer $customer, string $invoiceId): InvoiceData;
public function downloadInvoice(Customer $customer, string $invoiceId): string;
// طرق الدفع
public function getPaymentMethod(Customer $customer): ?array;
public function updatePaymentMethod(Customer $customer, string $paymentMethodId): void;
}
1.5 Stripe Gateway الكامل
php
namespace App\Services\Billing\Gateways;
use App\Models\Customer;
use App\Services\Billing\Contracts\PaymentGateway;
use App\Services\Billing\DTOs\CustomerData;
use App\Services\Billing\DTOs\InvoiceData;
use App\Services\Billing\DTOs\SubscriptionData;
use App\Services\Billing\Exceptions\PaymentFailedException;
use Carbon\Carbon;
use Stripe\Exception\CardException;
use Stripe\StripeClient;
class StripeGateway implements PaymentGateway
{
private StripeClient $stripe;
public function __construct()
{
$this->stripe = new StripeClient(config('cashier.secret'));
}
public function createCustomer(CustomerData $data): CustomerData
{
$customer = $this->stripe->customers->create([
'email' => $data->email,
'name' => $data->name,
'metadata' => array_merge($data->metadata, [
'company' => $data->company,
'vat_number' => $data->vatNumber,
]),
]);
return new CustomerData(
id: $customer->id,
email: $customer->email,
name: $customer->name,
company: $data->company,
vatNumber: $data->vatNumber,
currency: $data->currency,
metadata: $customer->metadata->toArray(),
);
}
public function updateCustomer(Customer $customer, array $data): CustomerData
{
$updated = $this->stripe->customers->update($customer->stripe_id, $data);
return new CustomerData(
id: $updated->id,
email: $updated->email,
name: $updated->name,
company: $updated->metadata['company'] ?? null,
vatNumber: $updated->metadata['vat_number'] ?? null,
currency: $customer->currency,
metadata: $updated->metadata->toArray(),
);
}
public function deleteCustomer(Customer $customer): void
{
$this->stripe->customers->delete($customer->stripe_id);
}
public function createSubscription(Customer $customer, string $planId, string $paymentMethod, int $quantity = 1): SubscriptionData
{
try {
// إرفاق طريقة الدفع
$this->stripe->paymentMethods->attach($paymentMethod, [
'customer' => $customer->stripe_id,
]);
$this->stripe->customers->update($customer->stripe_id, [
'invoice_settings' => ['default_payment_method' => $paymentMethod],
]);
$subscription = $this->stripe->subscriptions->create([
'customer' => $customer->stripe_id,
'items' => [['price' => $planId, 'quantity' => $quantity]],
'trial_period_days' => config('saas.trial_days', 14),
'expand' => ['latest_invoice.payment_intent'],
]);
return $this->mapSubscription($subscription);
} catch (CardException $e) {
throw new PaymentFailedException($e->getMessage(), previous: $e);
}
}
public function cancelSubscription(Customer $customer): void
{
$sub = $customer->activeSubscription();
if (! $sub) {
return;
}
$this->stripe->subscriptions->update($sub->gateway_id, [
'cancel_at_period_end' => true,
]);
}
public function resumeSubscription(Customer $customer): void
{
$sub = $customer->activeSubscription();
if (! $sub) {
return;
}
$this->stripe->subscriptions->update($sub->gateway_id, [
'cancel_at_period_end' => false,
]);
}
public function swapPlan(Customer $customer, string $newPlanId): SubscriptionData
{
$sub = $customer->activeSubscription();
if (! $sub) {
throw new \RuntimeException('No active subscription');
}
$stripeSub = $this->stripe->subscriptions->retrieve($sub->gateway_id);
$updated = $this->stripe->subscriptions->update($sub->gateway_id, [
'items' => [[
'id' => $stripeSub->items->data[0]->id,
'price' => $newPlanId,
]],
'proration_behavior' => 'create_prorations',
]);
return $this->mapSubscription($updated);
}
public function getInvoices(Customer $customer): array
{
$invoices = $this->stripe->invoices->all([
'customer' => $customer->stripe_id,
'limit' => 100,
]);
return array_map(fn ($inv) => $this->mapInvoice($inv), $invoices->data);
}
public function getInvoice(Customer $customer, string $invoiceId): InvoiceData
{
$invoice = $this->stripe->invoices->retrieve($invoiceId);
return $this->mapInvoice($invoice);
}
public function downloadInvoice(Customer $customer, string $invoiceId): string
{
$invoice = $this->stripe->invoices->retrieve($invoiceId);
return $invoice->invoice_pdf;
}
public function getPaymentMethod(Customer $customer): ?array
{
$methods = $this->stripe->paymentMethods->all([
'customer' => $customer->stripe_id,
'type' => 'card',
]);
if (empty($methods->data)) {
return null;
}
$pm = $methods->data[0];
return [
'id' => $pm->id,
'brand' => $pm->card->brand,
'last4' => $pm->card->last4,
'exp_month' => $pm->card->exp_month,
'exp_year' => $pm->card->exp_year,
];
}
public function updatePaymentMethod(Customer $customer, string $paymentMethodId): void
{
$this->stripe->customers->update($customer->stripe_id, [
'invoice_settings' => ['default_payment_method' => $paymentMethodId],
]);
}
private function mapSubscription($subscription): SubscriptionData
{
return new SubscriptionData(
id: $subscription->id,
planId: $subscription->items->data[0]->price->id,
status: $subscription->status,
currentPeriodStart: Carbon::createFromTimestamp($subscription->current_period_start),
currentPeriodEnd: Carbon::createFromTimestamp($subscription->current_period_end),
trialEnd: $subscription->trial_end ? Carbon::createFromTimestamp($subscription->trial_end) : null,
endsAt: $subscription->cancel_at ? Carbon::createFromTimestamp($subscription->cancel_at) : null,
cancelAtPeriodEnd: $subscription->cancel_at_period_end,
quantity: $subscription->items->data[0]->quantity,
);
}
private function mapInvoice($invoice): InvoiceData
{
return new InvoiceData(
id: $invoice->id,
number: $invoice->number ?? '',
date: Carbon::createFromTimestamp($invoice->created),
amount: $invoice->total,
currency: $invoice->currency,
status: $invoice->status,
pdfUrl: $invoice->invoice_pdf,
hostedUrl: $invoice->hosted_invoice_url,
);
}
}
1.6 Billing Service الكامل
php
namespace App\Services\Billing;
use App\Models\Customer;
use App\Models\Subscription;
use App\Models\User;
use App\Services\Billing\Contracts\PaymentGateway;
use App\Services\Billing\DTOs\CustomerData;
use App\Services\Billing\DTOs\InvoiceData;
use App\Services\Billing\DTOs\SubscriptionData;
use Illuminate\Support\Facades\DB;
class BillingService
{
public function __construct(
private readonly PaymentGateway $gateway
) {}
/**
* إنشاء عميل جديد (محلياً + في بوابة الدفع)
*/
public function createCustomer(User $user, array $data): Customer
{
return DB::transaction(function () use ($user, $data) {
$customerData = new CustomerData(
id: '',
email: $data['email'] ?? $user->email,
name: $data['name'] ?? $user->name,
company: $data['company'] ?? null,
vatNumber: $data['vat_number'] ?? null,
currency: $data['currency'] ?? config('saas.currency', 'usd'),
metadata: ['user_id' => $user->id, 'tenant_id' => $user->tenant_id],
);
$gatewayCustomer = $this->gateway->createCustomer($customerData);
return Customer::create([
'user_id' => $user->id,
'tenant_id' => $user->tenant_id,
'email' => $gatewayCustomer->email,
'name' => $gatewayCustomer->name,
'company' => $gatewayCustomer->company,
'vat_number' => $gatewayCustomer->vatNumber,
'currency' => $gatewayCustomer->currency,
'stripe_id' => $gatewayCustomer->id,
'metadata' => $gatewayCustomer->metadata,
]);
});
}
/**
* اشتراك جديد
*/
public function subscribe(Customer $customer, string $planId, string $paymentMethod, int $quantity = 1): Subscription
{
return DB::transaction(function () use ($customer, $planId, $paymentMethod, $quantity) {
// إذا كان لديه اشتراك نشط، قم بالترقية
if ($active = $customer->activeSubscription()) {
return $this->swapPlan($customer, $planId);
}
$data = $this->gateway->createSubscription($customer, $planId, $paymentMethod, $quantity);
return Subscription::create([
'customer_id' => $customer->id,
'plan_id' => $data->planId,
'gateway' => config('saas.gateway', 'stripe'),
'gateway_id' => $data->id,
'status' => $data->status,
'trial_ends_at' => $data->trialEnd,
'current_period_start' => $data->currentPeriodStart,
'current_period_end' => $data->currentPeriodEnd,
'ends_at' => $data->endsAt,
'quantity' => $data->quantity,
]);
});
}
/**
* إلغاء (في نهاية الدورة)
*/
public function cancel(Customer $customer): void
{
$sub = $customer->activeSubscription();
if (! $sub) {
return;
}
$this->gateway->cancelSubscription($customer);
$sub->update([
'canceled_at' => now(),
'ends_at' => $sub->current_period_end,
]);
}
/**
* استئناف بعد الإلغاء
*/
public function resume(Customer $customer): void
{
$sub = $customer->subscriptions()
->whereNotNull('ends_at')
->where('ends_at', '>', now())
->latest()
->first();
if (! $sub) {
return;
}
$this->gateway->resumeSubscription($customer);
$sub->update([
'canceled_at' => null,
'ends_at' => null,
]);
}
/**
* ترقية/تغيير خطة
*/
public function swapPlan(Customer $customer, string $newPlanId): Subscription
{
$sub = $customer->activeSubscription();
if (! $sub) {
throw new \RuntimeException('No active subscription to swap');
}
$data = $this->gateway->swapPlan($customer, $newPlanId);
$sub->update([
'plan_id' => $data->planId,
'status' => $data->status,
'current_period_start' => $data->currentPeriodStart,
'current_period_end' => $data->currentPeriodEnd,
'quantity' => $data->quantity,
]);
return $sub->fresh();
}
/**
* جلب الفواتير
*/
public function invoices(Customer $customer): array
{
return $this->gateway->getInvoices($customer);
}
/**
* تحميل فاتورة PDF
*/
public function downloadInvoice(Customer $customer, string $invoiceId): string
{
return $this->gateway->downloadInvoice($customer, $invoiceId);
}
/**
* طريقة الدفع الحالية
*/
public function paymentMethod(Customer $customer): ?array
{
return $this->gateway->getPaymentMethod($customer);
}
/**
* تحديث طريقة الدفع
*/
public function updatePaymentMethod(Customer $customer, string $paymentMethodId): void
{
$this->gateway->updatePaymentMethod($customer, $paymentMethodId);
}
}
1.7 Controllers
php
// app/Http/Controllers/Billing/CustomerController.php
namespace App\Http\Controllers\Billing;
use App\Http\Controllers\Controller;
use App\Services\Billing\BillingService;
use Illuminate\Http\Request;
class CustomerController extends Controller
{
public function __construct(
private readonly BillingService $billing
) {}
public function store(Request $request)
{
$data = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:customers,email',
'company' => 'nullable|string|max:255',
'vat_number' => 'nullable|string|max:50',
'currency' => 'nullable|string|size:3',
]);
$customer = $this->billing->createCustomer($request->user(), $data);
return response()->json($customer, 201);
}
}
php
// app/Http/Controllers/Billing/SubscriptionController.php
namespace App\Http\Controllers\Billing;
use App\Http\Controllers\Controller;
use App\Services\Billing\BillingService;
use Illuminate\Http\Request;
class SubscriptionController extends Controller
{
public function __construct(
private readonly BillingService $billing
) {}
public function store(Request $request)
{
$data = $request->validate([
'plan_id' => 'required|string',
'payment_method' => 'required|string',
'quantity' => 'nullable|integer|min:1',
]);
$customer = $request->user()->customer;
if (! $customer) {
return response()->json(['message' => 'Customer not found'], 404);
}
$subscription = $this->billing->subscribe(
$customer,
$data['plan_id'],
$data['payment_method'],
$data['quantity'] ?? 1
);
return response()->json($subscription, 201);
}
public function cancel(Request $request)
{
$this->billing->cancel($request->user()->customer);
return response()->json(['message' => 'Subscription will cancel at period end']);
}
public function resume(Request $request)
{
$this->billing->resume($request->user()->customer);
return response()->json(['message' => 'Subscription resumed']);
}
public function swap(Request $request)
{
$data = $request->validate(['plan_id' => 'required|string']);
$subscription = $this->billing->swapPlan(
$request->user()->customer,
$data['plan_id']
);
return response()->json($subscription);
}
}
php
// app/Http/Controllers/Billing/InvoiceController.php
namespace App\Http\Controllers\Billing;
use App\Http\Controllers\Controller;
use App\Services\Billing\BillingService;
use Illuminate\Http\Request;
class InvoiceController extends Controller
{
public function __construct(
private readonly BillingService $billing
) {}
public function index(Request $request)
{
$invoices = $this->billing->invoices($request->user()->customer);
return response()->json($invoices);
}
public function download(Request $request, string $invoiceId)
{
$url = $this->billing->downloadInvoice(
$request->user()->customer,
$invoiceId
);
return redirect($url);
}
}
الجزء الثاني: دمج الذكاء الاصطناعي (AI) في الفوترة والعملاء
2.1 خدمة AI للتنبؤ بالسلوك
php
// app/Services/AI/CustomerInsightsService.php
namespace App\Services\AI;
use App\Models\Customer;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
class CustomerInsightsService
{
/**
* التنبؤ بخطر إلغاء الاشتراك (Churn Prediction)
*/
public function churnRisk(Customer $customer): array
{
return Cache::remember("churn:{$customer->id}", now()->addHours(6), function () use ($customer) {
$features = $this->extractFeatures($customer);
$response = Http::withHeaders([
'Authorization' => 'Bearer '.config('services.openai.key'),
])->post('https://api.openai.com/v1/chat/completions', [
'model' => 'gpt-4o-mini',
'messages' => [
[
'role' => 'system',
'content' => 'You are a SaaS churn prediction expert. Return JSON: {"risk_score": 0-1, "reasons": [], "recommendations": []}',
],
[
'role' => 'user',
'content' => json_encode($features),
],
],
'response_format' => ['type' => 'json_object'],
]);
return json_decode($response->json('choices.0.message.content'), true);
});
}
/**
* استخراج الميزات لتحليل AI
*/
private function extractFeatures(Customer $customer): array
{
$sub = $customer->activeSubscription();
return [
'plan' => $sub?->plan_id,
'days_since_signup' => $customer->created_at->diffInDays(now()),
'days_since_last_login' => optional($customer->user->last_login_at)->diffInDays(now()) ?? 999,
'failed_payments' => $customer->invoices()->where('status', 'failed')->count(),
'total_invoices' => $customer->invoices()->count(),
'has_trial' => $sub?->trial_ends_at !== null,
'subscription_age_days' => $sub?->created_at->diffInDays(now()) ?? 0,
'usage_trend' => $this->usageTrend($customer),
];
}
private function usageTrend(Customer $customer): string
{
// مقارنة استخدام آخر 30 يوم بالسابق
$recent = cache()->get("usage:{$customer->id}:recent", 0);
$previous = cache()->get("usage:{$customer->id}:previous", 0);
if ($previous === 0) {
return 'unknown';
}
$ratio = $recent / $previous;
return match (true) {
$ratio > 1.2 => 'growing',
$ratio < 0.8 => 'declining',
default => 'stable',
};
}
/**
* توصية بخطة مناسبة
*/
public function recommendPlan(Customer $customer): array
{
$usage = [
'projects' => $customer->user->projects()->count(),
'team_members' => $customer->user->team?->members()->count() ?? 1,
'api_calls' => cache()->get("api_calls:{$customer->user_id}", 0),
];
$plans = config('saas.plans');
$bestPlan = null;
$bestScore = PHP_INT_MAX;
foreach ($plans as $planId => $plan) {
$score = 0;
foreach ($usage as $key => $value) {
$limit = $plan['limits'][$key] ?? 0;
if ($value > $limit) {
$score += ($value - $limit) * 100; // عقوبة تجاوز
} else {
$score += ($limit - $value) / 10; // كلما زاد الفراغ قلّت الجدارة
}
}
if ($score < $bestScore) {
$bestScore = $score;
$bestPlan = $planId;
}
}
return [
'recommended_plan' => $bestPlan,
'current_usage' => $usage,
'reason' => 'Based on current usage patterns',
];
}
}
2.2 AI Agent لخدمة العملاء (Billing Assistant)
php
// app/Services/AI/BillingAssistant.php
namespace App\Services\AI;
use App\Models\Customer;
use App\Services\Billing\BillingService;
use Illuminate\Support\Facades\Http;
class BillingAssistant
{
public function __construct(
private readonly BillingService $billing,
private readonly CustomerInsightsService $insights
) {}
/**
* مساعد ذكي يجيب على أسئلة الفوترة ويقترح إجراءات
*/
public function ask(Customer $customer, string $question): array
{
$context = $this->buildContext($customer);
$response = Http::withHeaders([
'Authorization' => 'Bearer '.config('services.openai.key'),
])->post('https://api.openai.com/v1/chat/completions', [
'model' => 'gpt-4o-mini',
'messages' => [
[
'role' => 'system',
'content' => 'أنت مساعد فوترة ذكي. أجب بالعربية. أرجع JSON: {"answer": "...", "suggested_action": "...", "action_payload": {}}',
],
[
'role' => 'user',
'content' => "سياق العميل: ".json_encode($context)."\n\nسؤال العميل: {$question}",
],
],
'response_format' => ['type' => 'json_object'],
]);
return json_decode($response->json('choices.0.message.content'), true);
}
private function buildContext(Customer $customer): array
{
$sub = $customer->activeSubscription();
return [
'name' => $customer->name,
'plan' => $sub?->plan_id ?? 'free',
'status' => $sub?->status ?? 'none',
'current_period_end' => $sub?->current_period_end?->toDateString(),
'invoices_count' => $customer->invoices()->count(),
'churn_risk' => $this->insights->churnRisk($customer)['risk_score'] ?? null,
'recommended_plan' => $this->insights->recommendPlan($customer)['recommended_plan'] ?? null,
];
}
/**
* تنفيذ إجراء مقترح من AI (بعد موافقة المستخدم)
*/
public function executeAction(Customer $customer, string $action, array $payload): mixed
{
return match ($action) {
'cancel_subscription' => $this->billing->cancel($customer),
'resume_subscription' => $this->billing->resume($customer),
'upgrade_plan' => $this->billing->swapPlan($customer, $payload['plan_id']),
'download_invoice' => $this->billing->downloadInvoice($customer, $payload['invoice_id']),
default => throw new \InvalidArgumentException("Unknown action: {$action}"),
};
}
}
2.3 استخراج بيانات الفواتير بالـ AI (OCR + LLM)
php
// app/Services/AI/InvoiceParserService.php
namespace App\Services\AI;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\UploadedFile;
class InvoiceParserService
{
/**
* استخراج بيانات من فاتورة PDF/صورة
*/
public function parse(UploadedFile $file): array
{
// 1. رفع الملف إلى OpenAI Vision
$base64 = base64_encode(file_get_contents($file->getRealPath()));
$response = Http::withHeaders([
'Authorization' => 'Bearer '.config('services.openai.key'),
])->post('https://api.openai.com/v1/chat/completions', [
'model' => 'gpt-4o',
'messages' => [
[
'role' => 'user',
'content' => [
[
'type' => 'text',
'text' => 'استخرج بيانات الفاتورة كـ JSON: {"vendor": "", "invoice_number": "", "date": "", "amount": 0, "currency": "", "tax": 0, "items": []}',
],
[
'type' => 'image_url',
'image_url' => ['url' => "data:{$file->getMimeType()};base64,{$base64}"],
],
],
],
],
'response_format' => ['type' => 'json_object'],
]);
return json_decode($response->json('choices.0.message.content'), true);
}
}
2.4 AI Chatbot مدمج مع Filament
php
// app/Filament/Pages/BillingAssistant.php
namespace App\Filament\Pages;
use App\Services\AI\BillingAssistant;
use Filament\Pages\Page;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Forms\Form;
class BillingAssistantPage extends Page implements HasForms
{
use InteractsWithForms;
protected static ?string $navigationIcon = 'heroicon-o-sparkles';
protected static ?string $navigationLabel = 'مساعد الفوترة AI';
protected static string $view = 'filament.pages.billing-assistant';
public ?array $data = [];
public array $messages = [];
public function mount(): void
{
$this->form->fill();
}
public function form(Form $form): Form
{
return $form->schema([
Textarea::make('question')
->label('اسأل عن أي شيء يخص الفوترة')
->required()
->rows(3),
])->statePath('data');
}
public function ask(BillingAssistant $assistant): void
{
$customer = auth()->user()->customer;
$result = $assistant->ask($customer, $this->data['question']);
$this->messages[] = [
'role' => 'user',
'content' => $this->data['question'],
];
$this->messages[] = [
'role' => 'assistant',
'content' => $result['answer'],
'action' => $result['suggested_action'] ?? null,
'payload' => $result['action_payload'] ?? null,
];
$this->data['question'] = '';
}
public function executeSuggested(string $action, array $payload, BillingAssistant $assistant): void
{
$assistant->executeAction(auth()->user()->customer, $action, $payload);
$this->messages[] = ['role' => 'system', 'content' => 'تم تنفيذ الإجراء بنجاح'];
}
}
الجزء الثالث: Middleware حدود الخطط (أمثلة كود كاملة)
3.1 Middleware للحدود العامة
php
// app/Http/Middleware/EnforcePlanLimit.php
namespace App\Http\Middleware;
use App\Services\PlanService;
use Closure;
use Illuminate\Http\Request;
class EnforcePlanLimit
{
public function __construct(
private readonly PlanService $plans
) {}
public function handle(Request $request, Closure $next, string $limitKey)
{
$user = $request->user();
if (! $user) {
return $next($request);
}
if (! $this->plans->can($user, $limitKey)) {
$limit = $this->plans->limit($user, $limitKey);
$usage = $this->plans->usage($user, $limitKey);
if ($request->expectsJson()) {
return response()->json([
'error' => 'plan_limit_exceeded',
'message' => "وصلت للحد الأقصى من {$limitKey}",
'limit' => $limit,
'usage' => $usage,
'upgrade_url' => route('billing.plans'),
], 429);
}
return redirect()->route('billing.plans')
->with('error', "وصلت للحد الأقصى من {$limitKey} ({$usage}/{$limit})");
}
return $next($request);
}
}
3.2 Middleware للميزات
php
// app/Http/Middleware/RequireFeature.php
namespace App\Http\Middleware;
use App\Services\PlanService;
use Closure;
use Illuminate\Http\Request;
class RequireFeature
{
public function __construct(
private readonly PlanService $plans
) {}
public function handle(Request $request, Closure $next, string $feature)
{
if (! $this->plans->hasFeature($request->user(), $feature)) {
abort(403, 'هذه الميزة غير متاحة في خطتك الحالية');
}
return $next($request);
}
}
3.3 Middleware لعدّاد API
php
// app/Http/Middleware/CountApiCalls.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
class CountApiCalls
{
public function handle(Request $request, Closure $next)
{
$user = $request->user();
if (! $user) {
return $next($request);
}
$key = "api_calls:{$user->id}:".now()->format('Y-m');
Redis::incr($key);
Redis::expire($key, 60 * 60 * 24 * 40); // 40 يوم
return $next($request);
}
}
3.4 Middleware للتحقق من الاشتراك
php
// app/Http/Middleware/RequireActiveSubscription.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class RequireActiveSubscription
{
public function handle(Request $request, Closure $next, ?string $plan = null)
{
$customer = $request->user()?->customer;
if (! $customer || ! $customer->isSubscribed()) {
if ($request->expectsJson()) {
return response()->json(['error' => 'subscription_required'], 403);
}
return redirect()->route('billing.plans')
->with('error', 'يجب الاشتراك للوصول لهذه الصفحة');
}
if ($plan && $customer->activeSubscription()->plan_id !== $plan) {
abort(403, "هذه الصفحة تتطلب خطة {$plan}");
}
return $next($request);
}
}
3.5 تسجيل كل Middleware
php
// app/Http/Kernel.php
protected $middlewareAliases = [
'plan.limit' => \App\Http\Middleware\EnforcePlanLimit::class,
'plan.feature' => \App\Http\Middleware\RequireFeature::class,
'plan.subscription' => \App\Http\Middleware\RequireActiveSubscription::class,
'api.counter' => \App\Http\Middleware\CountApiCalls::class,
];
protected $middlewareGroups = [
'api' => [
'throttle:api',
\App\Http\Middleware\CountApiCalls::class,
],
];
3.6 الاستخدام في Routes
php
// routes/web.php
Route::middleware(['auth', 'plan.subscription'])->group(function () {
Route::get('/dashboard', DashboardController::class);
Route::post('/projects', [ProjectController::class, 'store'])
->middleware('plan.limit:projects');
Route::get('/api-keys', [ApiKeyController::class, 'index'])
->middleware('plan.feature:api_access');
Route::get('/team/invite', [TeamController::class, 'invite'])
->middleware('plan.limit:team_members');
});
// routes/api.php
Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
Route::get('/user', fn (Request $r) => $r->user());
});
3.7 اختبار Middleware (Pest)
php
use App\Models\User;
use App\Services\PlanService;
it('blocks project creation when limit reached', function () {
$user = User::factory()->create(['plan' => 'free']);
$user->projects()->createMany(array_fill(0, 3, ['name' => 'Project']));
$this->actingAs($user)
->postJson('/projects', ['name' => 'New Project'])
->assertStatus(429)
->assertJsonPath('error', 'plan_limit_exceeded');
});
it('allows project creation under limit', function () {
$user = User::factory()->create(['plan' => 'pro']);
$this->actingAs($user)
->postJson('/projects', ['name' => 'New Project'])
->assertStatus(201);
});
it('blocks API access on free plan', function () {
$user = User::factory()->create(['plan' => 'free']);
$this->actingAs($user)
->getJson('/api-keys')
->assertStatus(403);
});
الجزء الرابع: إعداد CI/CD كامل
4.1 .github/workflows/ci.yml
yaml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
name: Code Style (Pint)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, pdo, pdo_mysql, bcmath, gd, redis
- run: composer install --prefer-dist --no-progress
- run: vendor/bin/pint --test
static-analysis:
name: Static Analysis (PHPStan)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, pdo, pdo_mysql, bcmath, gd, redis
- run: composer install --prefer-dist --no-progress
- run: vendor/bin/phpstan analyse --memory-limit=2G
test:
name: Tests (PHP ${{ matrix.php }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.2', '8.3']
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: testing
ports: [3306:3306]
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
redis:
image: redis:7
ports: [6379:6379]
options: --health-cmd="redis-cli ping" --health-interval=10s
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: mbstring, pdo, pdo_mysql, bcmath, gd, redis, xdebug
coverage: xdebug
- run: composer install --prefer-dist --no-progress
- name: Prepare environment
run: |
cp .env.testing .env
php artisan key:generate
- name: Run migrations
run: php artisan migrate --force
env:
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_PORT: 3306
DB_DATABASE: testing
DB_USERNAME: root
DB_PASSWORD: root
- name: Run tests
run: vendor/bin/pest --parallel --coverage --min=80
env:
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_DATABASE: testing
DB_USERNAME: root
DB_PASSWORD: root
REDIS_HOST: 127.0.0.1
- name: Upload coverage
if: matrix.php == '8.3'
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xml
security:
name: Security Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
- run: composer install --prefer-dist --no-progress
- run: composer audit --format=json || true
4.2 .github/workflows/deploy.yml
yaml
name: Deploy
on:
push:
branches: [main]
workflow_run:
workflows: [CI]
types: [completed]
branches: [main]
jobs:
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
if: github.event.workflow_run.conclusion == 'success' || github.event_name == 'push'
environment: staging
steps:
- uses: actions/checkout@v4
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts
- name: Deploy
run: |
ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'EOF'
set -e
cd /var/www/saas-staging
git fetch origin main
git reset --hard origin/main
composer install --no-dev --optimize-autoloader --no-interaction
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
php artisan queue:restart
php artisan horizon:terminate
EOF
deploy-production:
name: Deploy to Production
runs-on: ubuntu-latest
needs: deploy-staging
environment: production
steps:
- uses: actions/checkout@v4
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.PROD_SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H ${{ secrets.PROD_SSH_HOST }} >> ~/.ssh/known_hosts
- name: Deploy
run: |
ssh ${{ secrets.PROD_SSH_USER }}@${{ secrets.PROD_SSH_HOST }} << 'EOF'
set -e
cd /var/www/saas
# نسخ احتياطي قبل النشر
php artisan backup:run --only-db
git fetch origin main
git reset --hard origin/main
composer install --no-dev --optimize-autoloader --no-interaction
php artisan down --retry=60 || true
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
php artisan up
php artisan queue:restart
php artisan horizon:terminate
EOF
4.3 deploy.php (Deployer)
php
<?php
namespace Deployer;
require 'recipe/laravel.php';
// إعداد المضيفين
host('staging')
->setHostname('staging.saas.example.com')
->setRemoteUser('deploy')
->setDeployPath('/var/www/saas-staging')
->set('branch', 'develop');
host('production')
->setHostname('saas.example.com')
->setRemoteUser('deploy')
->setDeployPath('/var/www/saas')
->set('branch', 'main');
// مهام مخصصة
task('tenants:migrate', function () {
run('{{release_path}}/artisan tenants:migrate --force');
});
task('horizon:restart', function () {
run('{{release_path}}/artisan horizon:terminate');
});
task('backup:database', function () {
run('{{release_path}}/artisan backup:run --only-db');
});
// خطوات النشر
after('deploy:update_code', 'backup:database');
after('artisan:migrate', 'tenants:migrate');
after('deploy:symlink', 'horizon:restart');
// إعدادات
set('keep_releases', 5);
set('application', 'saas');
set('repository', 'git@github.com:your-org/saas.git');
4.4 Dockerfile للإنتاج
dockerfile
# Dockerfile
FROM php:8.3-fpm-alpine AS base
RUN apk add --no-cache \
git curl libpng-dev libzip-dev oniguruma-dev \
mysql-client supervisor nginx \
&& docker-php-ext-install pdo pdo_mysql bcmath gd zip opcache
# Redis extension
RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& apk del .build-deps
# Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
# نسخ الكود
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
COPY . .
RUN composer dump-autoload --optimize \
&& php artisan config:cache \
&& php artisan route:cache \
&& php artisan view:cache
RUN chown -R www-data:www-data storage bootstrap/cache
USER www-data
EXPOSE 9000
CMD ["php-fpm"]
4.5 docker-compose.yml للتطوير
yaml
version: '3.8'
services:
app:
build:
context: .
target: base
volumes:
- .:/var/www/html
depends_on:
- mysql
- redis
environment:
DB_HOST: mysql
REDIS_HOST: redis
nginx:
image: nginx:alpine
ports:
- "8000:80"
volumes:
- .:/var/www/html
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf
depends_on:
- app
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: saas
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
redis:
image: redis:7-alpine
ports:
- "6379:6379"
horizon:
build:
context: .
target: base
command: php artisan horizon
volumes:
- .:/var/www/html
depends_on:
- redis
- mysql
mailhog:
image: mailhog/mailhog
ports:
- "8025:8025"
volumes:
mysql_data:
4.6 اختبارات الحمل (k6)
javascript
// load-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '2m', target: 50 },
{ duration: '5m', target: 100 },
{ duration: '2m', target: 200 },
{ duration: '5m', target: 200 },
{ duration: '2m', target: 0 },
],
thresholds: {
http_req_duration: ['p(95)<500'],
http_req_failed: ['rate<0.01'],
},
};
const BASE_URL = __ENV.BASE_URL || 'https://staging.saas.example.com';
const TOKEN = __ENV.API_TOKEN;
export default function () {
const res = http.get(`${BASE_URL}/api/projects`, {
headers: { Authorization: `Bearer ${TOKEN}` },
});
check(res, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
});
sleep(1);
}
bash
k6 run load-test.js
الجزء الخامس: ملخص الحزمة الكاملة
| المكوّن | الملف | الوظيفة |
|---|---|---|
| Customer Model | app/Models/Customer.php | إدارة العملاء محلياً |
| Subscription Model | app/Models/Subscription.php | تتبع الاشتراكات |
| DTOs | app/Services/Billing/DTOs/* | نقل بيانات نظيف |
| PaymentGateway | app/Services/Billing/Contracts/PaymentGateway.php | عقد موحد |
| StripeGateway | app/Services/Billing/Gateways/StripeGateway.php | تنفيذ Stripe |
| BillingService | app/Services/Billing/BillingService.php | منطق الأعمال |
| Controllers | app/Http/Controllers/Billing/* | واجهات HTTP |
| AI Insights | app/Services/AI/CustomerInsightsService.php | تنبؤ Churn + توصيات |
| AI Assistant | app/Services/AI/BillingAssistant.php | مساعد ذكي |
| AI Parser | app/Services/AI/InvoiceParserService.php | استخراج فواتير |
| Middleware | app/Http/Middleware/* | حدود + ميزات + عدّاد |
| CI | .github/workflows/ci.yml | فحص + اختبار |
| CD | .github/workflows/deploy.yml + deploy.php | نشر تلقائي |
| Docker | Dockerfile + docker-compose.yml | بيئة موحدة |
| Load Test | load-test.js | اختبار حمل |
الجزء السادس: خطوات التشغيل السريع
bash
# 1. استنساخ المشروع git clone git@github.com:your-org/saas.git && cd saas # 2. تثبيت الحزم composer install # 3. إعداد البيئة cp .env.example .env php artisan key:generate # 4. قاعدة البيانات docker-compose up -d mysql redis php artisan migrate # 5. تشغيل التطوير docker-compose up -d php artisan serve php artisan horizon # 6. اختبار الـ Webhooks stripe listen --forward-to localhost:8000/stripe/webhook # 7. الاختبارات vendor/bin/pest vendor/bin/pint vendor/bin/phpstan analyse # 8. النشر dep deploy production
هل تريد أن أتعمق في:
laravel/scout+ Meilisearch للبحث في العملاء والفواتير؟laravel/reverbللإشعارات الفورية عند نجاح/فشل الدفع؟- إعداد Kubernetes (Deployment + HPA + Ingress) لـ SaaS؟
- اختبارات E2E باستخدام Playwright لتدفق الاشتراك الكامل؟